From ccfb108aa1f2e8a9ec3a25b26d08cd01c1885d1b Mon Sep 17 00:00:00 2001 From: Jinpy Date: Thu, 24 Sep 2026 22:43:24 +0800 Subject: [PATCH] perf(plugins): dock open latency and tab strip UX fixes --- .../plugins/PluginBottomDock.spec.ts | 30 +++- .../components/plugins/PluginBottomDock.vue | 146 +++++++++++++++--- .../plugins/PluginWorkbenchHost.vue | 98 ++++-------- .../src/lib/plugins/pluginBottomDock.spec.ts | 17 +- .../src/lib/plugins/pluginBottomDock.ts | 23 +-- .../src/lib/plugins/pluginUiHtmlCache.spec.ts | 53 ++++++- .../src/lib/plugins/pluginUiHtmlCache.ts | 117 +++++++++++++- 7 files changed, 367 insertions(+), 117 deletions(-) diff --git a/apps/desktop/src/components/plugins/PluginBottomDock.spec.ts b/apps/desktop/src/components/plugins/PluginBottomDock.spec.ts index ab44b78539..ee4a830b1a 100644 --- a/apps/desktop/src/components/plugins/PluginBottomDock.spec.ts +++ b/apps/desktop/src/components/plugins/PluginBottomDock.spec.ts @@ -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")]); diff --git a/apps/desktop/src/components/plugins/PluginBottomDock.vue b/apps/desktop/src/components/plugins/PluginBottomDock.vue index 8f61d1e881..ec40eaaac2 100644 --- a/apps/desktop/src/components/plugins/PluginBottomDock.vue +++ b/apps/desktop/src/components/plugins/PluginBottomDock.vue @@ -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 }>>([]); +// 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 }>>(); 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 }> }>(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 }> }>(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(); 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); +});