mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
perf(plugins): dock open latency and tab strip UX fixes
This commit is contained in:
@@ -4,9 +4,9 @@ import { createApp, defineComponent, h, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn(), invokePlugin: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.listPlugins }));
|
||||
vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.listPlugins, invokePlugin: mocks.invokePlugin }));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) }));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
@@ -116,6 +116,32 @@ describe("PluginBottomDock workbench contribution guard", () => {
|
||||
expect(second).toMatchObject({ commandId: "sample.open", instanceKey: "local", icon: "terminal" });
|
||||
});
|
||||
|
||||
it("skips the launch options fetch when the panel command has no options_action", async () => {
|
||||
// options_action is optional on open-workbench panel commands; without the
|
||||
// guard the dock still fired a doomed invokePlugin(undefined) round trip
|
||||
// and warned on every picker open / entry switch.
|
||||
const plugin = installedPlugin("sample.panel");
|
||||
plugin.manifest.contributions!.push({
|
||||
type: "command",
|
||||
id: "sample.open",
|
||||
label: "Open sample",
|
||||
action: { type: "open-workbench", workbench: "sample.panel", presentation: "panel" },
|
||||
});
|
||||
mocks.listPlugins.mockResolvedValue([plugin]);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
|
||||
await mountDock();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(renderError).toBeUndefined();
|
||||
expect(mocks.invokePlugin).not.toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls.filter((call) => String(call[0]).includes("[DBX][plugin:dock]"))).toEqual([]);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("degrades to an empty frame instead of crashing when the plugin no longer declares the entry's workbench contribution", async () => {
|
||||
// Plugin upgrade/downgrade replaced the workbench id the entry was opened with.
|
||||
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.renamed")]);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useDockResize } from "@/composables/useDockResize";
|
||||
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import { getOrLoadPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
@@ -96,24 +97,46 @@ const activeCommand = computed(() => {
|
||||
return createFrontendPluginRegistry(plugins.value).findCommand(entry.pluginId, entry.commandId)?.contribution ?? null;
|
||||
});
|
||||
|
||||
watch(entries, () => void loadPluginData(), { deep: true, immediate: true });
|
||||
// The plugin list is loaded once at mount and refreshed only on the
|
||||
// dbx:plugins-changed event. It must NOT be re-fetched from an entries deep
|
||||
// watch: every dock entry mutation (open/rename/reorder/close) would reassign
|
||||
// `plugins.value`, and the transient state churn re-runs loadLaunchOptions and
|
||||
// re-patches every PluginWorkbenchHost prop — the visible "everything blinks
|
||||
// when one connection opens" bug.
|
||||
void loadPluginData();
|
||||
watch([activeEntry, activeCommand], () => void loadLaunchOptions());
|
||||
const onPluginsChanged = () => void loadPluginData();
|
||||
window.addEventListener("dbx:plugins-changed", onPluginsChanged);
|
||||
onScopeDispose(() => window.removeEventListener("dbx:plugins-changed", onPluginsChanged));
|
||||
|
||||
async function loadPluginData() {
|
||||
if (!entries.value.length) {
|
||||
plugins.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
plugins.value = await api.listPlugins();
|
||||
schedulePanelUiWarm(plugins.value);
|
||||
} catch {
|
||||
plugins.value = [];
|
||||
// Keep the previous list: clearing it flips every panel's
|
||||
// `v-if="definitionFor(...)"` false and unmounts live terminal webviews.
|
||||
}
|
||||
}
|
||||
|
||||
// Generic first-open warm (any plugin, any entry point): plugins that declare
|
||||
// an open-workbench command with presentation:"panel" get their multi-megabyte
|
||||
// ui html read/inlined at idle time, so the FIRST panel the user opens — from
|
||||
// the "+" picker, a command menu, or a toolbar placement — mounts on a warm
|
||||
// cache instead of paying the pipeline after the click. Idle-scheduled so it
|
||||
// never competes with startup traffic; the module LRU bounds memory and
|
||||
// getOrLoadPluginUiHtml coalesces with real opens racing the warm.
|
||||
function schedulePanelUiWarm(definitions: InstalledPlugin[]) {
|
||||
const candidates = definitions.filter((plugin) => (plugin.manifest.contributions || []).some((contribution) => contribution.type === "command" && contribution.action.type === "open-workbench" && contribution.action.presentation === "panel"));
|
||||
if (!candidates.length) return;
|
||||
const idle: (callback: () => void) => void = typeof window.requestIdleCallback === "function" ? window.requestIdleCallback : (callback) => void window.setTimeout(callback, 2000);
|
||||
idle(() => {
|
||||
for (const plugin of candidates) {
|
||||
void getOrLoadPluginUiHtml(`${plugin.manifest.id}:${plugin.manifest.version}`, plugin.manifest.id).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function definitionFor(pluginId: string): InstalledPlugin | undefined {
|
||||
return plugins.value.find((candidate) => candidate.manifest.id === pluginId);
|
||||
}
|
||||
@@ -135,6 +158,10 @@ function workbenchContributionFor(entry: PluginDockEntry): PluginWorkbenchContri
|
||||
// - when connection_targets is on, the plugin's own saved connections.
|
||||
const connectionStore = useConnectionStore();
|
||||
const launchOptionEntries = ref<Array<{ key: string; label: string; description?: string; context?: Record<string, unknown> }>>([]);
|
||||
// Launch options per `${pluginId}:${commandId}`: sidecar options_action round
|
||||
// trips are pure overhead when re-opening the same picker, and re-fetching
|
||||
// with a pre-cleared list is what makes the open picker flash.
|
||||
const launchOptionsCache = new Map<string, Array<{ key: string; label: string; description?: string; context?: Record<string, unknown> }>>();
|
||||
const launchOptionsLoading = ref(false);
|
||||
|
||||
const activeAction = computed(() => (activeCommand.value?.action.type === "open-workbench" ? activeCommand.value.action : null));
|
||||
@@ -147,15 +174,31 @@ const activePluginProviders = computed(() => {
|
||||
async function loadLaunchOptions() {
|
||||
const action = activeAction.value;
|
||||
const pluginId = activeEntry.value?.pluginId;
|
||||
launchOptionEntries.value = [];
|
||||
if (!action || !pluginId || !action.options_action) return;
|
||||
launchOptionsLoading.value = true;
|
||||
const commandId = activeCommand.value?.id;
|
||||
// options_action is OPTIONAL on open-workbench commands: without it there is
|
||||
// nothing to fetch — proceeding fired a doomed invokePlugin(undefined) IPC
|
||||
// round trip and a console.warn on every picker open / entry switch.
|
||||
const cacheKey = action?.options_action && pluginId && commandId ? `${pluginId}:${commandId}` : "";
|
||||
if (!cacheKey) {
|
||||
launchOptionEntries.value = [];
|
||||
return;
|
||||
}
|
||||
// Stale-while-revalidate: paint the cached list immediately (same content,
|
||||
// zero visual churn) and swap in the fresh one when it arrives. Clearing the
|
||||
// list up front made the open "+" picker flash empty on every entry switch.
|
||||
const cached = launchOptionsCache.get(cacheKey);
|
||||
launchOptionEntries.value = cached ?? [];
|
||||
launchOptionsLoading.value = !cached;
|
||||
try {
|
||||
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId, action.options_action, {});
|
||||
launchOptionEntries.value = (result?.entries ?? []).map((entry, index) => ({ key: `opt:${index}`, label: entry.label, description: entry.description, context: entry.context }));
|
||||
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId!, action!.options_action!, {});
|
||||
const next = (result?.entries ?? []).map((entry, index) => ({ key: `opt:${index}`, label: entry.label, description: entry.description, context: entry.context }));
|
||||
launchOptionsCache.set(cacheKey, next);
|
||||
// A stale response (entry switched while fetching) must not overwrite the
|
||||
// now-active entry's list.
|
||||
if (activeEntry.value?.pluginId === pluginId && activeCommand.value?.id === commandId) launchOptionEntries.value = next;
|
||||
} catch (cause) {
|
||||
console.warn("[DBX][plugin:dock] launch options unavailable", cause);
|
||||
launchOptionEntries.value = [];
|
||||
if (!cached && activeEntry.value?.pluginId === pluginId && activeCommand.value?.id === commandId) launchOptionEntries.value = [];
|
||||
} finally {
|
||||
launchOptionsLoading.value = false;
|
||||
}
|
||||
@@ -168,6 +211,9 @@ const connectionTargets = computed(() => {
|
||||
});
|
||||
|
||||
function onPlusAction(value: string) {
|
||||
// Any selection closes the picker (documented behavior) before side effects:
|
||||
// staying open let the entry switch refetch repaint it mid-air.
|
||||
closePlusMenu();
|
||||
if (value === "replay") {
|
||||
rerunActiveCommand();
|
||||
return;
|
||||
@@ -289,6 +335,18 @@ function hideDock() {
|
||||
setDockVisible(false);
|
||||
}
|
||||
|
||||
// Maximize and collapse are mutually exclusive dock states (the height style
|
||||
// reads maximized first): collapsing while maximized used to hide the content
|
||||
// behind a stuck 80vh frame ("only the content hid, the dock didn't move").
|
||||
function toggleDockMaximize() {
|
||||
if (!maximized.value) collapsed.value = false;
|
||||
setDockMaximized(!maximized.value);
|
||||
}
|
||||
function toggleDockCollapse() {
|
||||
if (!collapsed.value) setDockMaximized(false);
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
// Drag the top edge to resize the height (min 140px, up to the shared maximize
|
||||
// bound). Dragging always exits maximized/collapsed: the start height is the
|
||||
// currently rendered pixel height, so the transition is seamless. The
|
||||
@@ -332,7 +390,22 @@ const plusRoot = ref<HTMLElement>();
|
||||
function togglePlusMenu() {
|
||||
plusOpen.value = !plusOpen.value;
|
||||
plusFilter.value = "";
|
||||
if (plusOpen.value) void loadLaunchOptions();
|
||||
if (plusOpen.value) {
|
||||
void loadLaunchOptions();
|
||||
warmActivePluginPanelHtml();
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the first-open html pipeline under the time the user spends reading the
|
||||
// "+" picker: the read/decode/inline of a multi-megabyte ui build runs while
|
||||
// the menu is open, so whichever panel the user picks mounts on a warm cache
|
||||
// instead of paying the pipeline serially after the click. Coalesced and
|
||||
// idempotent — a panel mounting mid-warm joins the same in-flight promise.
|
||||
function warmActivePluginPanelHtml() {
|
||||
const pluginId = activeEntry.value?.pluginId;
|
||||
const plugin = pluginId ? definitionFor(pluginId) : undefined;
|
||||
if (!plugin) return;
|
||||
void getOrLoadPluginUiHtml(`${plugin.manifest.id}:${plugin.manifest.version}`, plugin.manifest.id).catch(() => undefined);
|
||||
}
|
||||
function closePlusMenu() {
|
||||
plusOpen.value = false;
|
||||
@@ -342,7 +415,20 @@ const onPlusMenuOutsidePointerDown = (event: PointerEvent) => {
|
||||
if (plusOpen.value && root && !root.contains(event.target as Node)) closePlusMenu();
|
||||
};
|
||||
window.addEventListener("pointerdown", onPlusMenuOutsidePointerDown, true);
|
||||
onScopeDispose(() => window.removeEventListener("pointerdown", onPlusMenuOutsidePointerDown, true));
|
||||
// Plugin terminal panels live in cross-document iframes: a click inside one
|
||||
// never reaches the host window's pointerdown closer above, but it does move
|
||||
// focus out of the host document — close on blur. Escape covers keyboard users.
|
||||
const onPlusMenuWindowBlur = () => closePlusMenu();
|
||||
const onPlusMenuKeydown = (event: KeyboardEvent) => {
|
||||
if (plusOpen.value && event.key === "Escape") closePlusMenu();
|
||||
};
|
||||
window.addEventListener("blur", onPlusMenuWindowBlur);
|
||||
window.addEventListener("keydown", onPlusMenuKeydown, true);
|
||||
onScopeDispose(() => {
|
||||
window.removeEventListener("pointerdown", onPlusMenuOutsidePointerDown, true);
|
||||
window.removeEventListener("blur", onPlusMenuWindowBlur);
|
||||
window.removeEventListener("keydown", onPlusMenuKeydown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -352,12 +438,15 @@ onScopeDispose(() => window.removeEventListener("pointerdown", onPlusMenuOutside
|
||||
<div v-show="visible" ref="dockRoot" data-plugin-bottom-dock class="relative z-10 flex shrink-0 flex-col overflow-hidden border-t bg-background" :style="{ height: maximized ? `${DOCK_MAX_VIEWPORT_RATIO * 100}vh` : collapsed ? '2.25rem' : `${dockHeight}px` }">
|
||||
<div data-plugin-dock-resize-handle class="absolute inset-x-0 top-0 z-10 h-1.5 cursor-row-resize hover:bg-primary/30" @pointerdown="onResizeHandlePointerDown" />
|
||||
<div class="flex h-9 shrink-0 items-center gap-1 border-b bg-muted/30 pl-2 pr-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto" data-plugin-dock-tabs>
|
||||
<!-- Tabs shrink to their content so the "+" can sit flush after them;
|
||||
the scroll container ends before the "+" picker — an absolutely
|
||||
positioned menu inside an overflow-x-auto scroller gets clipped. -->
|
||||
<div class="flex min-w-0 shrink items-center gap-1 overflow-x-auto" data-plugin-dock-tabs>
|
||||
<button
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="group flex h-7 min-w-0 max-w-40 shrink items-center gap-1 overflow-hidden rounded-md px-2 text-xs"
|
||||
:class="[entry.id === activeEntryId ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground', dragEntryId && dragEntryId !== entry.id ? 'opacity-60' : '']"
|
||||
class="group flex h-7 min-w-0 max-w-40 shrink items-center gap-1 overflow-hidden rounded-md border px-2 text-xs"
|
||||
:class="[entry.id === activeEntryId ? 'border-border bg-accent font-medium text-foreground' : 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground', dragEntryId && dragEntryId !== entry.id ? 'opacity-60' : '']"
|
||||
:title="entry.title"
|
||||
:draggable="renamingEntryId !== entry.id"
|
||||
@click="activatePluginDockEntry(entry.id)"
|
||||
@@ -381,12 +470,26 @@ onScopeDispose(() => window.removeEventListener("pointerdown", onPlusMenuOutside
|
||||
@blur="commitRename"
|
||||
/>
|
||||
<span v-else class="min-w-0 flex-1 truncate text-left">{{ entry.title }}</span>
|
||||
<span v-if="renamingEntryId !== entry.id" class="ml-0.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-background/80 group-hover:opacity-100" role="button" :aria-label="t('pluginDock.close')" @click.stop="closeEntry(entry.id)">
|
||||
<!-- Close affordance is always visible on the active tab, on hover
|
||||
for the rest — the screenshot's interaction model: the active
|
||||
terminal is the one you close most, hunting for a hover-only X
|
||||
is friction. -->
|
||||
<span
|
||||
v-if="renamingEntryId !== entry.id"
|
||||
class="ml-0.5 shrink-0 rounded p-0.5 transition-opacity hover:bg-background/80 group-hover:opacity-100"
|
||||
:class="entry.id === activeEntryId ? 'opacity-70' : 'opacity-0'"
|
||||
role="button"
|
||||
:aria-label="t('pluginDock.close')"
|
||||
@click.stop="closeEntry(entry.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="activeCommand" ref="plusRoot" class="relative">
|
||||
<!-- The "+" sits flush after the tab list (new-terminal affordance where
|
||||
the tabs end), not pushed to the far right edge; it lives OUTSIDE
|
||||
the tabs' scroll container so its dropdown menu renders unclipped. -->
|
||||
<div v-if="activeCommand" ref="plusRoot" class="relative shrink-0">
|
||||
<Tooltip :delay-duration="200">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('pluginDock.newTerminal')" :aria-expanded="plusOpen" @click="togglePlusMenu">
|
||||
@@ -414,16 +517,17 @@ onScopeDispose(() => window.removeEventListener("pointerdown", onPlusMenuOutside
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<Tooltip :delay-duration="200">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" :aria-label="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" @click="setDockMaximized(!maximized)">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" :aria-label="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" @click="toggleDockMaximize">
|
||||
<Minimize2 v-if="maximized" class="h-3.5 w-3.5" />
|
||||
<Maximize2 v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ maximized ? t("pluginDock.restore") : t("pluginDock.maximize") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" :aria-label="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" @click="collapsed = !collapsed">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" :aria-label="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" @click="toggleDockCollapse">
|
||||
<ChevronUp v-if="collapsed" class="h-3.5 w-3.5" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
type PluginSaveFileResult,
|
||||
type PluginWorkbenchContext,
|
||||
} from "@/lib/plugins/pluginHostBridge";
|
||||
import { getCachedPluginUiHtml, getOrLoadPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache";
|
||||
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 { getCachedPluginUiHtml, setCachedPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -60,6 +60,10 @@ let bridge: PluginHostBridge | undefined;
|
||||
let unsubscribeEvents: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let loadGeneration = 0;
|
||||
// Boot timing (§8.3 loading lifecycle): one console.debug line per iframe load
|
||||
// so panel-open latency can be attributed (doc cache / sandbox doc / parse+exec).
|
||||
let bootStartedAt = 0;
|
||||
let bootCacheHit = false;
|
||||
|
||||
// --- Plugin file-transfer bridge (native dialogs + OS file drops) ---------
|
||||
// The sandboxed iframe cannot reach local files, so handles live here: Tauri
|
||||
@@ -477,65 +481,6 @@ async function savePluginFile(request: PluginSaveFileRequest, data: Uint8Array):
|
||||
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 }> {
|
||||
// Shipped ui builds usually inline every asset into one HTML document.
|
||||
// Parsing and re-serializing a multi-megabyte document is pure overhead when
|
||||
// there is nothing local to inline — pre-check before touching DOMParser.
|
||||
if (!/<script\b[^>]*\bsrc=/i.test(html) && !/<link\b[^>]*rel=["']?stylesheet/i.test(html)) {
|
||||
return { html, entryDirectory: "" };
|
||||
}
|
||||
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 = "";
|
||||
// Fetch every referenced asset concurrently — these are bridge round-trips
|
||||
// into the sidecar, and panels reopen this path on every workbench (re)load.
|
||||
const fetched = await Promise.all(
|
||||
resources.map((resource) => {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) return Promise.resolve({ resource, content: null });
|
||||
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
|
||||
return api.readPluginUiAsset(pluginId, path).then((asset) => ({
|
||||
resource,
|
||||
content: new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0))),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const { resource, content } of fetched) {
|
||||
if (content === null) continue;
|
||||
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
|
||||
@@ -548,8 +493,6 @@ function pluginUiBaseUrl(pluginId: string, entryDirectory: string): string | und
|
||||
return entryDirectory ? `${origin}${entryDirectory}/` : origin;
|
||||
}
|
||||
|
||||
// Inlined plugin ui html per `${pluginId}:${version}` (see loadWorkbench).
|
||||
|
||||
async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge?.dispose();
|
||||
@@ -557,28 +500,32 @@ async function loadWorkbench() {
|
||||
loading.value = true;
|
||||
frameReady.value = false;
|
||||
error.value = "";
|
||||
bootStartedAt = performance.now();
|
||||
bootCacheHit = true;
|
||||
try {
|
||||
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
|
||||
// The read/decode/inline pipeline over a multi-megabyte ui build dominates
|
||||
// workbench open time; cache the inlined html per plugin id+version so
|
||||
// reopening panels (new dock entries, workbench reloads) skips it. Theme
|
||||
// is applied per load via the sandbox document, so the cache never pins a
|
||||
// stale appearance.
|
||||
// stale appearance. getOrLoadPluginUiHtml coalesces with an in-flight
|
||||
// warm (dock "+" picker) so the panel never duplicates a running pipeline.
|
||||
const htmlCacheKey = `${props.plugin.manifest.id}:${props.plugin.manifest.version}`;
|
||||
let cachedHtml = getCachedPluginUiHtml(htmlCacheKey);
|
||||
if (!cachedHtml) {
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
bootCacheHit = false;
|
||||
cachedHtml = await getOrLoadPluginUiHtml(htmlCacheKey, props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const inlined = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
cachedHtml = inlined;
|
||||
setCachedPluginUiHtml(htmlCacheKey, cachedHtml);
|
||||
}
|
||||
const { html, entryDirectory } = cachedHtml;
|
||||
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme(), {
|
||||
baseUrl: pluginUiBaseUrl(props.plugin.manifest.id, entryDirectory),
|
||||
});
|
||||
// The final sandbox document is cached alongside the html: generating it
|
||||
// re-runs megabyte-scale string surgery on every boot.
|
||||
if (!cachedHtml.sandboxDoc) {
|
||||
cachedHtml.sandboxDoc = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme(), {
|
||||
baseUrl: pluginUiBaseUrl(props.plugin.manifest.id, entryDirectory),
|
||||
});
|
||||
}
|
||||
source.value = cachedHtml.sandboxDoc;
|
||||
await nextTick();
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
createBridge();
|
||||
@@ -596,6 +543,13 @@ function onMessage(event: MessageEvent) {
|
||||
}
|
||||
|
||||
function onFrameLoad() {
|
||||
if (bootStartedAt) {
|
||||
const endedAt = performance.now();
|
||||
const bootMs = Math.round(endedAt - bootStartedAt);
|
||||
performance.measure(`pluginUi:boot:${props.plugin.manifest.id}`, { start: bootStartedAt, end: endedAt });
|
||||
bootStartedAt = 0;
|
||||
console.debug(`[plugin-ui-boot] ${props.plugin.manifest.id}@${props.plugin.manifest.version} iframeLoad=${bootMs}ms cacheHit=${bootCacheHit}`);
|
||||
}
|
||||
// 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
|
||||
|
||||
+10
-7
@@ -13,25 +13,28 @@ describe("pluginBottomDock", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("numbers same-command entries beyond the highest live suffix, never duplicating after a close", async () => {
|
||||
it("suffixes only on a title collision, taking the lowest free number; distinct titles stay verbatim", async () => {
|
||||
const dock = await loadModule();
|
||||
// Distinct titles (e.g. named connections) never get a number.
|
||||
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "connection", commandId: "open-connection", title: "Prod" });
|
||||
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "connection", commandId: "open-connection", title: "Staging" });
|
||||
const first = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
|
||||
const second = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
|
||||
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
|
||||
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["Local terminal", "Local terminal 2", "Local terminal 3"]);
|
||||
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["Prod", "Staging", "Local terminal", "Local terminal 2", "Local terminal 3"]);
|
||||
|
||||
// Close the middle entry, then open another: the new title must not
|
||||
// collide with the surviving "Local terminal 3".
|
||||
// Close the middle entry, then open another: "Local terminal 2" is the
|
||||
// lowest free slot again (numbers belong to no entry once closed); the
|
||||
// reopened entry appends at the strip's end.
|
||||
dock.closePluginDockEntry(second);
|
||||
const fourth = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
|
||||
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["Local terminal", "Local terminal 3", "Local terminal 4"]);
|
||||
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
|
||||
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["Prod", "Staging", "Local terminal", "Local terminal 3", "Local terminal 2"]);
|
||||
|
||||
// Different commands number independently.
|
||||
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "other.command", title: "Other" });
|
||||
const titles = dock.usePluginBottomDock().entries.value.map((entry) => entry.title);
|
||||
expect(titles[titles.length - 1]).toBe("Other");
|
||||
dock.closePluginDockEntry(first);
|
||||
dock.closePluginDockEntry(fourth);
|
||||
});
|
||||
|
||||
it("finds singleton reuse candidates only inside the same plugin+command+instance-key group (§4.1)", async () => {
|
||||
|
||||
@@ -51,17 +51,20 @@ export interface AddPluginDockEntryPayload {
|
||||
/** Creates (and activates) a dock terminal entry; returns its stable id. */
|
||||
export function addPluginDockEntry(payload: AddPluginDockEntryPayload): string {
|
||||
const id = uuid();
|
||||
// Same-command entries get a sequence suffix in the title (generic: "Local terminal 1 / 2 ..."),
|
||||
// keeps multi-instance panels distinguishable in the tab strip. Numbers are assigned at creation
|
||||
// and advance beyond the highest live suffix — never backfilled after a close, so titles stay
|
||||
// unique and stable (same convention as the plugin tabs in queryStore).
|
||||
// A sequence suffix is only added on a title collision within the same
|
||||
// command family ("Local terminal", "Local terminal 2", ...). Distinct
|
||||
// titles — e.g. named SSH connections — keep their name verbatim. Numbers
|
||||
// pick the lowest free slot, so a closed entry's number is reused instead
|
||||
// of climbing forever (titles stay unique among live entries, which is all
|
||||
// the tab strip needs to keep panels distinguishable).
|
||||
const siblings = dockEntries.value.filter((entry) => entry.pluginId === payload.pluginId && entry.commandId === payload.commandId);
|
||||
const nextNumber =
|
||||
siblings.reduce((highest, entry) => {
|
||||
const suffix = / (\d+)$/.exec(entry.title);
|
||||
return suffix ? Math.max(highest, Number(suffix[1])) : highest;
|
||||
}, 1) + 1;
|
||||
const title = siblings.length ? `${payload.title} ${nextNumber}` : payload.title;
|
||||
const taken = new Set(siblings.map((entry) => entry.title));
|
||||
let title = payload.title;
|
||||
if (taken.has(title)) {
|
||||
let suffix = 2;
|
||||
while (taken.has(`${payload.title} ${suffix}`)) suffix += 1;
|
||||
title = `${payload.title} ${suffix}`;
|
||||
}
|
||||
dockEntries.value.push({
|
||||
id,
|
||||
pluginId: payload.pluginId,
|
||||
|
||||
+51
-2
@@ -1,9 +1,26 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearPluginUiHtmlCache, getCachedPluginUiHtml, setCachedPluginUiHtml } from "./pluginUiHtmlCache";
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readPluginUiEntry: vi.fn(),
|
||||
readPluginUiAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
readPluginUiEntry: mocks.readPluginUiEntry,
|
||||
readPluginUiAsset: mocks.readPluginUiAsset,
|
||||
}));
|
||||
|
||||
import { clearPluginUiHtmlCache, getCachedPluginUiHtml, getOrLoadPluginUiHtml, setCachedPluginUiHtml } from "./pluginUiHtmlCache";
|
||||
|
||||
function uiEntryPayload(html: string): { dataBase64: string } {
|
||||
return { dataBase64: btoa(html) };
|
||||
}
|
||||
|
||||
describe("pluginUiHtmlCache", () => {
|
||||
beforeEach(() => {
|
||||
clearPluginUiHtmlCache();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("returns the stored document for the same plugin id+version key", () => {
|
||||
@@ -33,4 +50,36 @@ describe("pluginUiHtmlCache", () => {
|
||||
setCachedPluginUiHtml("io.dbx.ssh@0.4.88", { html: "old", entryDirectory: "" });
|
||||
expect(getCachedPluginUiHtml("io.dbx.ssh@0.4.89")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getOrLoad resolves from the cache without touching the bridge", async () => {
|
||||
setCachedPluginUiHtml("io.dbx.ssh@0.4.88", { html: "cached", entryDirectory: "" });
|
||||
await expect(getOrLoadPluginUiHtml("io.dbx.ssh@0.4.88", "io.dbx.ssh")).resolves.toEqual({ html: "cached", entryDirectory: "" });
|
||||
expect(mocks.readPluginUiEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces concurrent misses into one pipeline run and caches the document", async () => {
|
||||
let release!: (value: { dataBase64: string }) => void;
|
||||
mocks.readPluginUiEntry.mockReturnValue(new Promise((resolve) => (release = resolve)));
|
||||
const first = getOrLoadPluginUiHtml("io.dbx.ssh@0.4.88", "io.dbx.ssh");
|
||||
const second = getOrLoadPluginUiHtml("io.dbx.ssh@0.4.88", "io.dbx.ssh");
|
||||
release(uiEntryPayload("<html>doc</html>"));
|
||||
await expect(first).resolves.toEqual({ html: "<html>doc</html>", entryDirectory: "" });
|
||||
await expect(second).resolves.toEqual({ html: "<html>doc</html>", entryDirectory: "" });
|
||||
// One bridge read served both callers (dock warm + panel mount).
|
||||
expect(mocks.readPluginUiEntry).toHaveBeenCalledTimes(1);
|
||||
// Subsequent callers hit the LRU without a new read.
|
||||
await expect(getOrLoadPluginUiHtml("io.dbx.ssh@0.4.88", "io.dbx.ssh")).resolves.toEqual({ html: "<html>doc</html>", entryDirectory: "" });
|
||||
expect(mocks.readPluginUiEntry).toHaveBeenCalledTimes(1);
|
||||
expect(getCachedPluginUiHtml("io.dbx.ssh@0.4.88")).toEqual({ html: "<html>doc</html>", entryDirectory: "" });
|
||||
});
|
||||
|
||||
it("keeps the cache untouched on failure and lets the next caller retry", async () => {
|
||||
mocks.readPluginUiEntry.mockRejectedValueOnce(new Error("bridge down"));
|
||||
await expect(getOrLoadPluginUiHtml("io.dbx.ssh@0.4.88", "io.dbx.ssh")).rejects.toThrow("bridge down");
|
||||
expect(getCachedPluginUiHtml("io.dbx.ssh@0.4.88")).toBeUndefined();
|
||||
// The failed in-flight slot is cleared, so a retry re-runs the pipeline.
|
||||
mocks.readPluginUiEntry.mockResolvedValueOnce(uiEntryPayload("<html>retry</html>"));
|
||||
await expect(getOrLoadPluginUiHtml("io.dbx.ssh@0.4.88", "io.dbx.ssh")).resolves.toEqual({ html: "<html>retry</html>", entryDirectory: "" });
|
||||
expect(mocks.readPluginUiEntry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,22 @@
|
||||
// the bridge and re-parses it each time. Module scope is the point: the cache
|
||||
// is shared by every PluginWorkbenchHost instance, so only the first boot of a
|
||||
// plugin version pays the read/decode/inline pipeline.
|
||||
const cache = new Map<string, { html: string; entryDirectory: string }>();
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
export interface PluginUiHtml {
|
||||
html: string;
|
||||
entryDirectory: string;
|
||||
/** Final sandbox document (html + CSP/SDK/theme injection); built lazily once
|
||||
* per plugin version — regenerating it re-runs megabyte-scale string surgery
|
||||
* on every panel/tab boot. The embedded appearance only affects the pre-init
|
||||
* first paint; the init message pushes the live theme right after. */
|
||||
sandboxDoc?: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, PluginUiHtml>();
|
||||
const LIMIT = 4;
|
||||
|
||||
export function getCachedPluginUiHtml(key: string): { html: string; entryDirectory: string } | undefined {
|
||||
export function getCachedPluginUiHtml(key: string): PluginUiHtml | undefined {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return undefined;
|
||||
// Map iterates oldest-first; re-inserting makes the just-read entry the
|
||||
@@ -16,7 +28,7 @@ export function getCachedPluginUiHtml(key: string): { html: string; entryDirecto
|
||||
return hit;
|
||||
}
|
||||
|
||||
export function setCachedPluginUiHtml(key: string, value: { html: string; entryDirectory: string }): void {
|
||||
export function setCachedPluginUiHtml(key: string, value: PluginUiHtml): void {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
while (cache.size > LIMIT) cache.delete(cache.keys().next().value as string);
|
||||
@@ -25,4 +37,103 @@ export function setCachedPluginUiHtml(key: string, value: { html: string; entryD
|
||||
/** Test and plugin-uninstall escape hatch. */
|
||||
export function clearPluginUiHtmlCache(): void {
|
||||
cache.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
// --- First-boot pipeline (read → decode → inline local assets) -------------
|
||||
|
||||
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<PluginUiHtml> {
|
||||
// Shipped ui builds usually inline every asset into one HTML document.
|
||||
// Parsing and re-serializing a multi-megabyte document is pure overhead when
|
||||
// there is nothing local to inline — pre-check before touching DOMParser.
|
||||
if (!/<script\b[^>]*\bsrc=/i.test(html) && !/<link\b[^>]*rel=["']?stylesheet/i.test(html)) {
|
||||
return { html, entryDirectory: "" };
|
||||
}
|
||||
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 = "";
|
||||
// Fetch every referenced asset concurrently — these are bridge round-trips
|
||||
// into the sidecar, and panels reopen this path on every workbench (re)load.
|
||||
const fetched = await Promise.all(
|
||||
resources.map((resource) => {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) return Promise.resolve({ resource, content: null });
|
||||
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
|
||||
return api.readPluginUiAsset(pluginId, path).then((asset) => ({
|
||||
resource,
|
||||
content: new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0))),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const { resource, content } of fetched) {
|
||||
if (content === null) continue;
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Full first-boot pipeline: read the ui entry through the bridge, decode, inline local assets. */
|
||||
export async function loadPluginUiHtml(pluginId: string): Promise<PluginUiHtml> {
|
||||
const asset = await api.readPluginUiEntry(pluginId);
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
return inlineLocalUiAssets(new TextDecoder().decode(bytes), pluginId);
|
||||
}
|
||||
|
||||
// --- In-flight coalescing ---------------------------------------------------
|
||||
|
||||
const inFlight = new Map<string, Promise<PluginUiHtml>>();
|
||||
|
||||
/**
|
||||
* Cache-or-coalesced-load: a hit resolves synchronously-ish from the LRU;
|
||||
* a miss starts (or JOINS) the single in-flight pipeline for the key. Callers
|
||||
* that warm ahead of user intent (the dock "+" picker) and the panel mounting
|
||||
* moments later share one bridge read instead of racing duplicate
|
||||
* multi-megabyte pipelines. A failed load settles the cache untouched and
|
||||
* clears its in-flight slot, so the next caller retries cleanly.
|
||||
*/
|
||||
export function getOrLoadPluginUiHtml(key: string, pluginId: string): Promise<PluginUiHtml> {
|
||||
const hit = getCachedPluginUiHtml(key);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
let load = inFlight.get(key);
|
||||
if (!load) {
|
||||
load = loadPluginUiHtml(pluginId)
|
||||
.then((value) => {
|
||||
setCachedPluginUiHtml(key, value);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(key);
|
||||
});
|
||||
inFlight.set(key, load);
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user