diff --git a/apps/desktop/src/components/plugins/PluginShortcutBar.spec.ts b/apps/desktop/src/components/plugins/PluginShortcutBar.spec.ts
new file mode 100644
index 0000000000..3677a8aa36
--- /dev/null
+++ b/apps/desktop/src/components/plugins/PluginShortcutBar.spec.ts
@@ -0,0 +1,137 @@
+// @vitest-environment happy-dom
+import { createApp, h, nextTick, reactive, ref } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import PluginShortcutBar from "./PluginShortcutBar.vue";
+import { normalizePluginShortcutSettings, type PluginShortcutPosition } from "@/lib/plugins/pluginShortcuts";
+
+const mocks = vi.hoisted(() => ({ open: vi.fn(), save: vi.fn(), toast: vi.fn(), state: null as any, entries: null as any }));
+vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
+vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => mocks.state }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
+vi.mock("@/composables/usePluginShortcuts", () => ({ usePluginShortcuts: () => ({ entries: mocks.entries, open: mocks.open, isActive: (entry: any) => entry.id === "b" }) }));
+vi.mock("./PluginIcon.vue", () => ({ default: { template: "
" } }));
+vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: { props: ["text", "disabled"], template: "
" } }));
+
+let app: ReturnType
;
+let container: HTMLDivElement;
+const flush = async () => {
+ for (let i = 0; i < 6; i++) {
+ await Promise.resolve();
+ await nextTick();
+ }
+};
+function box(x: number, y: number, width: number, height: number) {
+ return { x, y, left: x, top: y, right: x + width, bottom: y + height, width, height, toJSON() {} };
+}
+function mount(position: PluginShortcutPosition) {
+ container = document.createElement("div");
+ document.body.append(container);
+ app = createApp({ render: () => h(PluginShortcutBar, { position }) });
+ app.mount(container);
+ const scroll = container.querySelector("[data-shortcut-scroll]") as HTMLElement;
+ scroll.getBoundingClientRect = () => box(0, 0, 100, 200);
+ const items = container.querySelectorAll("[data-shortcut-id]");
+ items.forEach((item, i) => {
+ item.getBoundingClientRect = () => box(0, i * 36, position === "sidebar-bottom" ? 100 : 32, 32);
+ });
+ return items;
+}
+function pointer(element: EventTarget, type: string, x: number, y: number) {
+ element.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, button: 0, clientX: x, clientY: y }));
+}
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.state = reactive({ isEditorSettingsLoaded: true, editorSettings: { pluginShortcuts: normalizePluginShortcutSettings({}) }, updateEditorSettingsAndPersist: mocks.save });
+ mocks.entries = ref(["a", "b", "c"].map((id) => ({ id, pluginId: id, label: `Function ${id}`, pluginName: `Plugin ${id}`, kind: "workbench", targetId: id, disabled: false })));
+ mocks.save.mockResolvedValue(undefined);
+});
+afterEach(() => {
+ app?.unmount();
+ container?.remove();
+});
+
+describe("plugin shortcut bar", () => {
+ it.each(["left-top", "left-bottom", "right-top", "right-bottom", "sidebar-bottom"] as const)("sorts with pointer events at %s without launching on release", async (position) => {
+ const items = mount(position);
+ await nextTick();
+ const from = items[2].getBoundingClientRect();
+ pointer(items[2], "pointerdown", from.x + 16, from.y + 16);
+ pointer(window, "pointermove", 1, 1);
+ await nextTick();
+ expect(items[0].classList.contains("drop-before")).toBe(true);
+ expect(document.querySelector(".shortcut-drag-preview")).not.toBeNull();
+ pointer(window, "pointerup", 1, 1);
+ await flush();
+ expect(mocks.save).toHaveBeenCalledWith({ pluginShortcuts: { ...normalizePluginShortcutSettings({}), order: ["c", "a", "b"] } });
+ (items[2].querySelector("button") as HTMLElement).click();
+ expect(mocks.open).not.toHaveBeenCalled();
+ expect(document.querySelector(".shortcut-drag-preview")).toBeNull();
+ });
+ it("preserves ordinary clicks below the drag threshold", async () => {
+ const items = mount("right-top");
+ pointer(items[0], "pointerdown", 16, 16);
+ pointer(window, "pointermove", 19, 18);
+ pointer(window, "pointerup", 19, 18);
+ (items[0].querySelector("button") as HTMLElement).click();
+ expect(mocks.open).toHaveBeenCalledTimes(1);
+ expect(mocks.save).not.toHaveBeenCalled();
+ });
+ it.each(["escape", "blur", "pointercancel", "outside"])("cancels via %s", async (reason) => {
+ const items = mount("right-top");
+ await nextTick();
+ pointer(items[2], "pointerdown", 16, 88);
+ pointer(window, "pointermove", 1, 1);
+ if (reason === "escape") window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ else if (reason === "outside") pointer(window, "pointerup", 150, 1);
+ else window.dispatchEvent(new Event(reason));
+ pointer(window, "pointerup", 1, 1);
+ await flush();
+ expect(mocks.save).not.toHaveBeenCalled();
+ expect(document.querySelector(".shortcut-pointer-overlay")).toBeNull();
+ });
+ it("reports save failures and shows only the function name in the tooltip", async () => {
+ const items = mount("right-top");
+ await nextTick();
+ expect(container.querySelector("[data-tooltip]")?.getAttribute("data-tooltip")).toBe("Function a");
+ mocks.save.mockRejectedValueOnce(new Error("disk full"));
+ pointer(items[2], "pointerdown", 16, 88);
+ pointer(window, "pointermove", 1, 1);
+ pointer(window, "pointerup", 1, 1);
+ await flush();
+ expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining("disk full"), 5000);
+ });
+ it("auto-scrolls near the edge and cleans up after unmount", async () => {
+ const items = mount("right-top");
+ await nextTick();
+ const callbacks: FrameRequestCallback[] = [];
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
+ callbacks.push(callback);
+ return callbacks.length;
+ });
+ pointer(items[0], "pointerdown", 16, 16);
+ pointer(window, "pointermove", 16, 198);
+ callbacks.shift()?.(16);
+ expect((container.querySelector("[data-shortcut-scroll]") as HTMLElement).scrollTop).toBeGreaterThan(0);
+ app.unmount();
+ pointer(window, "pointerup", 1, 1);
+ expect(mocks.save).not.toHaveBeenCalled();
+ vi.restoreAllMocks();
+ });
+ it("reflects activity and hides empty or disabled bars and dividers", async () => {
+ mount("sidebar-bottom");
+ expect([...container.querySelectorAll("[data-shortcut-label]")].map((label) => label.textContent)).toEqual(["Function a", "Function b", "Function c"]);
+ const buttons = container.querySelectorAll("button");
+ expect(buttons[0].getAttribute("aria-pressed")).toBe("false");
+ expect(buttons[1].getAttribute("aria-pressed")).toBe("true");
+ expect(buttons[0].querySelector("[data-shortcut-active-indicator]")).toBeNull();
+ expect(buttons[1].querySelector("[data-shortcut-active-indicator]")?.classList.contains("bg-green-500")).toBe(true);
+ expect(container.querySelector('[role="separator"]')).not.toBeNull();
+ mocks.state.editorSettings.pluginShortcuts.enabled = false;
+ await nextTick();
+ expect(container.querySelector("nav")).toBeNull();
+ mocks.state.editorSettings.pluginShortcuts.enabled = true;
+ mocks.entries.value = [];
+ await nextTick();
+ expect(container.querySelector("nav")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/components/plugins/PluginShortcutBar.vue b/apps/desktop/src/components/plugins/PluginShortcutBar.vue
new file mode 100644
index 0000000000..a4f24ca759
--- /dev/null
+++ b/apps/desktop/src/components/plugins/PluginShortcutBar.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/plugins/PluginShortcutSettings.spec.ts b/apps/desktop/src/components/plugins/PluginShortcutSettings.spec.ts
new file mode 100644
index 0000000000..a522dcc1da
--- /dev/null
+++ b/apps/desktop/src/components/plugins/PluginShortcutSettings.spec.ts
@@ -0,0 +1,74 @@
+// @vitest-environment happy-dom
+import { createApp, h, nextTick, reactive } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { normalizePluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
+import PluginShortcutSettings from "./PluginShortcutSettings.vue";
+const mocks = vi.hoisted(() => ({ state: null as any, save: vi.fn(), toast: vi.fn() }));
+vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
+vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => mocks.state }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
+vi.mock("@/composables/usePluginShortcuts", () => ({
+ usePluginShortcuts: () => ({
+ allEntries: {
+ value: [
+ { pluginId: "p", pluginName: "Plugin", id: "one" },
+ { pluginId: "p", pluginName: "Plugin", id: "two" },
+ ],
+ },
+ }),
+}));
+vi.mock("./PluginIcon.vue", () => ({ default: { template: "" } }));
+let app: ReturnType;
+let container: HTMLDivElement;
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.state = reactive({ isEditorSettingsLoaded: true, editorSettings: { pluginShortcuts: normalizePluginShortcutSettings({ order: ["saved"] }) }, updateEditorSettingsAndPersist: mocks.save });
+ mocks.save.mockImplementation(async ({ pluginShortcuts }) => {
+ mocks.state.editorSettings.pluginShortcuts = pluginShortcuts;
+ });
+ container = document.createElement("div");
+ document.body.append(container);
+ app = createApp({ render: () => h(PluginShortcutSettings) });
+ app.mount(container);
+});
+afterEach(() => {
+ app.unmount();
+ container.remove();
+});
+describe("plugin shortcut settings", () => {
+ it("selects the Plugin Center dropdown and preserves unrelated preferences", async () => {
+ const trigger = container.querySelector('[role="combobox"]')!;
+ trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
+ await vi.waitFor(() => expect(document.querySelector('[role="option"]')).not.toBeNull());
+ const option = [...document.querySelectorAll('[role="option"]')].find((item) => item.textContent?.includes("shortcutsPluginCenter"))!;
+ option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
+ await vi.waitFor(() => expect(mocks.state.editorSettings.pluginShortcuts.position).toBe("plugin-center"));
+ expect(mocks.state.editorSettings.pluginShortcuts.order).toEqual(["saved"]);
+ expect(container.querySelector("#plugin-shortcuts-toolbar-count")).toBeNull();
+ });
+ it("shows one toggle per plugin and preserves choices under the global switch", async () => {
+ const switches = container.querySelectorAll('[role="switch"]');
+ expect(switches).toHaveLength(2);
+ switches[1].click();
+ await vi.waitFor(() => expect(mocks.state.editorSettings.pluginShortcuts.hiddenPluginIds).toEqual(["p"]));
+ await vi.waitFor(() => expect(switches[0].hasAttribute("disabled")).toBe(false));
+ switches[0].click();
+ await vi.waitFor(() => expect(mocks.state.editorSettings.pluginShortcuts.enabled).toBe(false));
+ expect(mocks.state.editorSettings.pluginShortcuts.hiddenPluginIds).toEqual(["p"]);
+ expect(mocks.state.editorSettings.pluginShortcuts.order).toEqual(["saved"]);
+ });
+ it("hides the position choice when switched off and preserves position and order", async () => {
+ (container.querySelector('[role="switch"]') as HTMLElement).click();
+ await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledOnce());
+ await nextTick();
+ expect(mocks.state.editorSettings.pluginShortcuts).toEqual(normalizePluginShortcutSettings({ enabled: false, order: ["saved"] }));
+ expect(container.querySelector('[role="combobox"]')).toBeNull();
+ });
+ it("keeps the current value and shows errors when persistence fails", async () => {
+ mocks.save.mockRejectedValueOnce(new Error("disk full"));
+ (container.querySelector('[role="switch"]') as HTMLElement).click();
+ await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalled());
+ expect(mocks.state.editorSettings.pluginShortcuts.enabled).toBe(true);
+ expect(container.querySelector('[role="combobox"]')).not.toBeNull();
+ });
+});
diff --git a/apps/desktop/src/components/plugins/PluginShortcutSettings.vue b/apps/desktop/src/components/plugins/PluginShortcutSettings.vue
new file mode 100644
index 0000000000..60bf815c67
--- /dev/null
+++ b/apps/desktop/src/components/plugins/PluginShortcutSettings.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
{{ t("pluginPlatform.globalSettingsTitle") }}
+
{{ t("pluginPlatform.shortcutsDescription") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t("pluginPlatform.shortcutsToolbarHint") }}
+
+
+
+
+
{{ t("pluginPlatform.shortcutsVisiblePlugins") }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/plugins/PluginShortcutToolbar.spec.ts b/apps/desktop/src/components/plugins/PluginShortcutToolbar.spec.ts
new file mode 100644
index 0000000000..fb61db4b19
--- /dev/null
+++ b/apps/desktop/src/components/plugins/PluginShortcutToolbar.spec.ts
@@ -0,0 +1,160 @@
+// @vitest-environment happy-dom
+import { createApp, h, nextTick, reactive, ref } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import PluginShortcutToolbar from "./PluginShortcutToolbar.vue";
+import { normalizePluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
+
+const mocks = vi.hoisted(() => ({ open: vi.fn(), save: vi.fn(), toast: vi.fn(), state: null as any, entries: null as any }));
+vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
+vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => mocks.state }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
+vi.mock("@/composables/usePluginShortcuts", () => ({ usePluginShortcuts: () => ({ entries: mocks.entries, open: mocks.open, isActive: (entry: any) => entry.id === "a" }) }));
+vi.mock("./PluginIcon.vue", () => ({ default: { props: ["pluginId"], template: '' } }));
+vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: { template: "" } }));
+
+let app: ReturnType;
+let host: HTMLDivElement;
+const dropdownOnly = ref(false);
+let width: number | undefined;
+let resize: () => void;
+const flush = async () => {
+ for (let i = 0; i < 6; i++) {
+ await nextTick();
+ await Promise.resolve();
+ }
+};
+beforeEach(() => {
+ vi.clearAllMocks();
+ width = undefined;
+ dropdownOnly.value = false;
+ mocks.state = reactive({ isEditorSettingsLoaded: true, editorSettings: { pluginShortcuts: normalizePluginShortcutSettings({ position: "toolbar", toolbarCount: 2 }) }, updateEditorSettingsAndPersist: mocks.save });
+ mocks.entries = ref(["a", "b", "c", "d", "e"].map((id) => ({ id, pluginId: id, label: `Function ${id}`, pluginName: `Plugin ${id}`, kind: "workbench", targetId: id, disabled: false })));
+ mocks.save.mockResolvedValue(undefined);
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) {
+ return this.hasAttribute("data-plugin-shortcut-toolbar") ? (width ?? parseFloat(this.style.width)) : 28;
+ });
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(callback: () => void) {
+ resize = callback;
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ host = document.createElement("div");
+ document.body.append(host);
+ app = createApp({ render: () => h(PluginShortcutToolbar, { dropdownOnly: dropdownOnly.value }) });
+ app.mount(host);
+});
+afterEach(() => {
+ app.unmount();
+ host.remove();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+async function openMenu() {
+ await flush();
+ const trigger = host.querySelector('button[aria-haspopup="menu"]')!;
+ trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
+ await vi.waitFor(() => expect(document.querySelector("[data-plugin-shortcut-overflow]")).not.toBeNull());
+ await flush();
+}
+function rect(x: number, y: number, w: number, h: number): DOMRect {
+ return { x, y, width: w, height: h, left: x, top: y, right: x + w, bottom: y + h, toJSON() {} };
+}
+function pointer(target: EventTarget, type: string, x: number, y: number) {
+ target.dispatchEvent(new PointerEvent(type, { pointerId: 1, button: 0, clientX: x, clientY: y, bubbles: true, cancelable: true }));
+}
+
+describe("floating plugin shortcuts", () => {
+ it("shows all shortcuts beside Plugin Center regardless of the floating icon count", async () => {
+ dropdownOnly.value = true;
+ mocks.state.editorSettings.pluginShortcuts.toolbarCount = 10;
+ await flush();
+ expect(host.querySelectorAll("[data-shortcut-id]")).toHaveLength(0);
+ await openMenu();
+ const rows = document.querySelectorAll('[data-plugin-shortcut-overflow] [role="menuitem"]');
+ expect([...rows].map((row) => row.textContent?.trim())).toEqual(["Function a", "Function b", "Function c", "Function d", "Function e"]);
+ rows[1].click();
+ expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ id: "b" }));
+ await flush();
+ expect(document.querySelector("[data-plugin-shortcut-overflow]")).toBeNull();
+ });
+ it("sorts within the Plugin Center dropdown and retains unavailable entry positions", async () => {
+ dropdownOnly.value = true;
+ mocks.state.editorSettings.pluginShortcuts.position = "plugin-center";
+ mocks.state.editorSettings.pluginShortcuts.order = ["a", "hidden", "b", "c", "d", "e"];
+ await openMenu();
+ const root = host.querySelector("[data-plugin-shortcut-toolbar]")!;
+ const menu = document.querySelector("[data-plugin-shortcut-overflow]")!;
+ root.getBoundingClientRect = () => rect(0, 0, 28, 32);
+ menu.getBoundingClientRect = () => rect(0, 40, 240, 160);
+ const rows = menu.querySelectorAll("[data-shortcut-id]");
+ rows.forEach((row, i) => {
+ row.getBoundingClientRect = () => rect(0, 40 + i * 32, 240, 32);
+ });
+ pointer(rows[2], "pointerdown", 20, 120);
+ pointer(window, "pointermove", 20, 41);
+ pointer(window, "pointerup", 20, 41);
+ await flush();
+ expect(mocks.save).toHaveBeenCalledWith({ pluginShortcuts: expect.objectContaining({ position: "plugin-center", order: ["c", "hidden", "a", "b", "d", "e"] }) });
+ expect(mocks.open).not.toHaveBeenCalled();
+ });
+
+ it("shows the requested icons and renders remaining entries as named menu rows", async () => {
+ await flush();
+ expect(host.querySelectorAll("[data-shortcut-id]")).toHaveLength(2);
+ await openMenu();
+ const items = document.querySelectorAll('[data-plugin-shortcut-overflow] [role="menuitem"]');
+ expect([...items].map((item) => item.textContent?.trim())).toEqual(["Function c", "Function d", "Function e"]);
+ expect(items[0].querySelector("[data-plugin-icon]")).not.toBeNull();
+ items[0].click();
+ expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ id: "c" }));
+ });
+ it("supports all-in-menu and hides the arrow when everything fits", async () => {
+ mocks.state.editorSettings.pluginShortcuts.toolbarCount = 0;
+ await flush();
+ expect(host.querySelectorAll("[data-shortcut-id]")).toHaveLength(0);
+ expect(host.querySelector('button[aria-haspopup="menu"]')).not.toBeNull();
+ mocks.state.editorSettings.pluginShortcuts.toolbarCount = 10;
+ await flush();
+ expect(host.querySelectorAll("[data-shortcut-id]")).toHaveLength(5);
+ expect(host.querySelector('button[aria-haspopup="menu"]')).toBeNull();
+ });
+ it("automatically collapses into a menu when width is limited", async () => {
+ await flush();
+ width = 34;
+ resize();
+ await flush();
+ expect(host.querySelectorAll("[data-shortcut-id]")).toHaveLength(0);
+ expect(host.querySelector('button[aria-haspopup="menu"]')).not.toBeNull();
+ });
+ it("sorts from the portalled menu into the inline strip without launching", async () => {
+ await openMenu();
+ const root = host.querySelector("[data-plugin-shortcut-toolbar]")!;
+ const menu = document.querySelector("[data-plugin-shortcut-overflow]")!;
+ root.getBoundingClientRect = () => rect(0, 0, 94, 32);
+ menu.getBoundingClientRect = () => rect(0, 40, 240, 100);
+ root.querySelectorAll("[data-shortcut-id]").forEach((item, i) => {
+ item.getBoundingClientRect = () => rect(i * 30, 0, 28, 28);
+ });
+ const rows = menu.querySelectorAll("[data-shortcut-id]");
+ rows.forEach((item, i) => {
+ item.getBoundingClientRect = () => rect(0, 40 + i * 32, 240, 32);
+ });
+ pointer(rows[0], "pointerdown", 20, 56);
+ pointer(window, "pointermove", 1, 1);
+ pointer(window, "pointerup", 1, 1);
+ await flush();
+ expect(mocks.save).toHaveBeenCalledWith({ pluginShortcuts: expect.objectContaining({ order: ["c", "a", "b", "d", "e"] }) });
+ expect(mocks.open).not.toHaveBeenCalled();
+ });
+ it("does not leave an empty floating shell after all plugins are hidden", async () => {
+ mocks.entries.value = [];
+ await flush();
+ expect(host.querySelector("nav")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/components/plugins/PluginShortcutToolbar.vue b/apps/desktop/src/components/plugins/PluginShortcutToolbar.vue
new file mode 100644
index 0000000000..96dbcc5877
--- /dev/null
+++ b/apps/desktop/src/components/plugins/PluginShortcutToolbar.vue
@@ -0,0 +1,230 @@
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts
index 3d4ec7aea1..87b245b48c 100644
--- a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts
+++ b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts
@@ -56,6 +56,9 @@ vi.mock("@/components/ui/tooltip", async () => {
return { Tooltip: stub, TooltipContent: stub, TooltipTrigger: stub };
});
vi.mock("@/components/plugins/PluginIcon.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("PluginIcon") }));
+// Shortcut preferences have their own component/store tests. Keep this batch
+// harness scoped to plugin mutations and their exact backend call counts.
+vi.mock("@/components/plugins/PluginShortcutSettings.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("PluginShortcutSettings") }));
import PluginContributionsPanel from "@/components/plugins/PluginContributionsPanel.vue";
diff --git a/apps/desktop/src/composables/__tests__/usePluginShortcutHeight.spec.ts b/apps/desktop/src/composables/__tests__/usePluginShortcutHeight.spec.ts
new file mode 100644
index 0000000000..dbbd711cc8
--- /dev/null
+++ b/apps/desktop/src/composables/__tests__/usePluginShortcutHeight.spec.ts
@@ -0,0 +1,136 @@
+// @vitest-environment happy-dom
+import { createApp, effectScope, h, nextTick, ref } from "vue";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { usePluginShortcutHeight } from "../usePluginShortcutHeight";
+import { isPanelResizing } from "@/lib/app/panelResizeState";
+
+let app: ReturnType;
+let resizeObserver: () => void;
+let root: HTMLElement;
+afterEach(() => {
+ app?.unmount();
+ root?.remove();
+ vi.unstubAllGlobals();
+});
+async function setup() {
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(callback: () => void) {
+ resizeObserver = callback;
+ }
+ observe() {}
+ disconnect() {}
+ },
+ );
+ root = document.createElement("div");
+ root.innerHTML = '';
+ document.body.append(root);
+ const tree = root.children[0] as HTMLElement;
+ const section = root.children[1] as HTMLElement;
+ const handle = section.children[0] as HTMLElement;
+ const scroll = section.children[1] as HTMLElement;
+ let width = 184;
+ let total = 600;
+ const count = ref(10);
+ const savedHeight = ref(null);
+ const sectionRef = ref(null);
+ const scrollRef = ref(null);
+ let sizing!: ReturnType;
+ const save = vi.fn(async (height: number | null) => {
+ savedHeight.value = height;
+ });
+ app = createApp({
+ setup() {
+ sizing = usePluginShortcutHeight({ section: sectionRef, scroll: scrollRef, sidebarList: ref(true), count, savedHeight, save });
+ return () => h("div");
+ },
+ });
+ app.mount(document.createElement("div"));
+ Object.defineProperty(tree, "offsetHeight", { get: () => total - sizing.height.value - 6 });
+ Object.defineProperty(section, "offsetHeight", { get: () => sizing.height.value + 6 });
+ Object.defineProperty(handle, "offsetHeight", { get: () => 6 });
+ Object.defineProperty(scroll, "clientWidth", { get: () => width });
+ section.getBoundingClientRect = () => ({ height: sizing.height.value + 6 }) as DOMRect;
+ sectionRef.value = section;
+ scrollRef.value = scroll;
+ await nextTick();
+ await nextTick();
+ handle.addEventListener("pointerdown", sizing.start);
+ const pointer = (target: EventTarget, type: string, y: number) => target.dispatchEvent(new PointerEvent(type, { pointerId: 1, button: 0, clientY: y, bubbles: true, cancelable: true }));
+ return {
+ sizing,
+ savedHeight,
+ count,
+ save,
+ handle,
+ pointer,
+ setWidth: (next: number) => {
+ width = next;
+ resizeObserver();
+ },
+ setTotal: (next: number) => {
+ total = next;
+ resizeObserver();
+ },
+ };
+}
+describe("shortcut sidebar height", () => {
+ it("does not attach a resize observer after disposal during a pending measurement", async () => {
+ const observe = vi.fn();
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe = observe;
+ disconnect() {}
+ },
+ );
+ const section = ref(null);
+ const scope = effectScope();
+ scope.run(() => usePluginShortcutHeight({ section, scroll: ref(null), sidebarList: ref(true), count: ref(1), savedHeight: ref(null), save: vi.fn() }));
+ root = document.createElement("div");
+ root.innerHTML = "";
+ section.value = root.firstElementChild as HTMLElement;
+ await nextTick();
+ scope.stop();
+ await nextTick();
+ expect(observe).not.toHaveBeenCalled();
+ });
+ it("keeps one entry per row regardless of width, remembers manual height and resets to automatic", async () => {
+ const { sizing, count, savedHeight, setWidth } = await setup();
+ expect(sizing.height.value).toBe(148);
+ setWidth(112);
+ expect(sizing.height.value).toBe(148);
+ count.value = 40;
+ expect(sizing.height.value).toBe(148);
+ savedHeight.value = 250;
+ expect(sizing.height.value).toBe(250);
+ count.value = 2;
+ expect(sizing.height.value).toBe(250);
+ await sizing.reset();
+ expect(savedHeight.value).toBeNull();
+ expect(sizing.height.value).toBe(64);
+ });
+ it("saves only on release and reserves space for the tree", async () => {
+ const { sizing, handle, pointer, save, setTotal } = await setup();
+ pointer(handle, "pointerdown", 500);
+ pointer(window, "pointermove", 400);
+ expect(sizing.height.value).toBe(248);
+ expect(save).not.toHaveBeenCalled();
+ pointer(window, "pointerup", 400);
+ await nextTick();
+ expect(save).toHaveBeenCalledWith(248);
+ expect(isPanelResizing.value).toBe(false);
+ setTotal(250);
+ expect(sizing.height.value).toBe(124);
+ });
+ it.each(["blur", "pointercancel", "escape"])("restores the original height on %s", async (reason) => {
+ const { sizing, handle, pointer, save } = await setup();
+ pointer(handle, "pointerdown", 500);
+ pointer(window, "pointermove", 400);
+ window.dispatchEvent(reason === "escape" ? new KeyboardEvent("keydown", { key: "Escape" }) : new Event(reason));
+ expect(sizing.height.value).toBe(148);
+ expect(save).not.toHaveBeenCalled();
+ expect(isPanelResizing.value).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/composables/__tests__/usePluginShortcutPreferences.spec.ts b/apps/desktop/src/composables/__tests__/usePluginShortcutPreferences.spec.ts
new file mode 100644
index 0000000000..b1edbdd3cd
--- /dev/null
+++ b/apps/desktop/src/composables/__tests__/usePluginShortcutPreferences.spec.ts
@@ -0,0 +1,41 @@
+import { reactive } from "vue";
+import { describe, expect, it, vi } from "vitest";
+import { usePluginShortcutPreferences } from "../usePluginShortcutPreferences";
+import { normalizePluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
+
+const mocks = vi.hoisted(() => ({ settings: null as any }));
+vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => mocks.settings }));
+
+describe("shortcut preference writes", () => {
+ it("merges queued writes from different controls without replacing unrelated preferences", async () => {
+ const state = reactive({ pluginShortcuts: normalizePluginShortcutSettings({}) });
+ mocks.settings = {
+ editorSettings: state,
+ updateEditorSettingsAndPersist: vi.fn(async (patch) => {
+ state.pluginShortcuts = patch.pluginShortcuts;
+ }),
+ };
+ const bar = usePluginShortcutPreferences();
+ const page = usePluginShortcutPreferences();
+ await Promise.all([bar.update({ order: ["a", "b"] }), page.update({ hiddenPluginIds: ["p"] }), bar.update({ sidebarHeight: 132 })]);
+ expect(state.pluginShortcuts).toMatchObject({ order: ["a", "b"], hiddenPluginIds: ["p"], sidebarHeight: 132 });
+ });
+ it("continues after a failed write without carrying its unpersisted patch forward", async () => {
+ const state = reactive({ pluginShortcuts: normalizePluginShortcutSettings({}) });
+ mocks.settings = {
+ editorSettings: state,
+ updateEditorSettingsAndPersist: vi
+ .fn()
+ .mockRejectedValueOnce(new Error("disk full"))
+ .mockImplementation(async (patch) => {
+ state.pluginShortcuts = patch.pluginShortcuts;
+ }),
+ };
+ const preferences = usePluginShortcutPreferences();
+ const failed = preferences.update({ order: ["lost"] });
+ const next = preferences.update({ hiddenPluginIds: ["p"] });
+ await expect(failed).rejects.toThrow("disk full");
+ await next;
+ expect(state.pluginShortcuts).toMatchObject({ order: [], hiddenPluginIds: ["p"] });
+ });
+});
diff --git a/apps/desktop/src/composables/__tests__/usePluginShortcuts.spec.ts b/apps/desktop/src/composables/__tests__/usePluginShortcuts.spec.ts
new file mode 100644
index 0000000000..2795362393
--- /dev/null
+++ b/apps/desktop/src/composables/__tests__/usePluginShortcuts.spec.ts
@@ -0,0 +1,187 @@
+// @vitest-environment happy-dom
+import { createApp, h, reactive, ref } from "vue";
+import { createPinia, setActivePinia } from "pinia";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { usePluginShortcuts } from "../usePluginShortcuts";
+import { addPluginDockEntry, usePluginBottomDock } from "@/lib/plugins/pluginBottomDock";
+
+const mocks = vi.hoisted(() => ({ list: vi.fn(), query: null as any, locale: null as any, settings: null as any }));
+vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.list }));
+vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => mocks.query }));
+vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => mocks.settings }));
+vi.mock("vue-i18n", async (original) => ({ ...(await original()), useI18n: () => ({ locale: mocks.locale, t: (key: string) => key }) }));
+
+const fixture = (id: string, presentation = "panel") => ({
+ compatibility: { compatible: true },
+ manifest: {
+ id,
+ name: id,
+ version: "1.0.0",
+ drivers: [],
+ contributions: [
+ { type: "workbench", id: "workbench", label: "Workbench" },
+ { type: "command", id: "open", label: "Open", action: { type: "open-workbench", workbench: "workbench", presentation } },
+ ],
+ },
+});
+let app: ReturnType;
+let shortcuts: ReturnType;
+async function mount() {
+ app = createApp({
+ setup() {
+ shortcuts = usePluginShortcuts();
+ return () => h("div");
+ },
+ });
+ app.mount(document.createElement("div"));
+ await vi.waitFor(() => expect(shortcuts.entries.value.length).toBeGreaterThan(0));
+}
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.locale = ref("en");
+ mocks.settings = reactive({ editorSettings: { pluginShortcuts: { order: [], hiddenPluginIds: [] } } });
+ mocks.query = reactive({ tabs: [], activeTabId: null, openPluginWorkbench: vi.fn(), openPluginFilesystem: vi.fn() });
+ mocks.list.mockResolvedValue([fixture("a"), fixture("b")]);
+ const dock = usePluginBottomDock();
+ dock.entries.value = [];
+ dock.visible.value = false;
+ dock.activeEntryId.value = null;
+});
+afterEach(() => app?.unmount());
+describe("shortcut dispatch", () => {
+ it.each([
+ { source: undefined, restored: false },
+ { source: undefined, restored: true },
+ { source: "open", restored: true },
+ { source: "other", restored: true },
+ ])("preserves a reused tab and its context (source=$source, restored=$restored)", async ({ source, restored }) => {
+ setActivePinia(createPinia());
+ const { useQueryStore } = await vi.importActual("@/stores/queryStore");
+ const query = useQueryStore();
+ mocks.query = query;
+ const oldId = query.openPluginWorkbench("tab", "workbench", {
+ commandId: source,
+ title: "Existing session",
+ context: { workbenchId: "existing-session", retained: { value: 1 } },
+ });
+ // Restored tabs contain serialized legacy metadata, without commandId.
+ if (restored) query.tabs = JSON.parse(JSON.stringify(query.tabs));
+ const metadata = query.tabs[0].pluginWorkbench!;
+ const context = metadata.context;
+ const plugin = fixture("tab", "tab");
+ plugin.manifest.contributions.push({ ...(plugin.manifest.contributions[1] as any), id: "other", label: "Other function" });
+ plugin.manifest.contributions.push({ type: "menus", id: "menus", items: ["open", "other"].map((command) => ({ command, location: "appToolbar", group: "navigation", order: 0, default_visible: true })) } as any);
+ mocks.list.mockResolvedValue([plugin]);
+ await mount();
+ const entry = shortcuts.entries.value.find((entry) => entry.targetId === "open")!;
+ const other = shortcuts.entries.value.find((entry) => entry.targetId === "other")!;
+ shortcuts.open(entry);
+ expect(query.activeTabId).toBe(oldId);
+ expect(query.tabs).toHaveLength(1);
+ expect(query.tabs[0].pluginWorkbench).toBe(metadata);
+ expect(metadata.context).toBe(context);
+ expect(metadata.context).toEqual({ workbenchId: "existing-session", retained: { value: 1 } });
+ expect(metadata.commandId).toBe(source ?? "open");
+ expect(shortcuts.isActive(entry)).toBe(source !== "other");
+ expect(shortcuts.isActive(other)).toBe(source === "other");
+ // Another command sharing this workbench must not steal its provenance.
+ shortcuts.open(other);
+ expect(query.tabs).toHaveLength(1);
+ expect(query.activeTabId).toBe(oldId);
+ expect(metadata.commandId).toBe(source ?? "open");
+ expect(metadata.context).toBe(context);
+ expect(shortcuts.isActive(entry)).toBe(source !== "other");
+ expect(shortcuts.isActive(other)).toBe(source === "other");
+ });
+ it.each(["command", "connection"] as const)("highlights additional %s sessions only for their originating shortcut", async (kind) => {
+ const plugin = fixture("a");
+ (plugin.manifest.contributions[1] as any).action.instance_key = "local";
+ mocks.list.mockResolvedValue([plugin]);
+ await mount();
+ const entry = shortcuts.entries.value[0];
+ shortcuts.open(entry);
+ const secondId = addPluginDockEntry({ pluginId: entry.pluginId, commandId: entry.targetId, instanceKey: "local", workbenchContributionId: "workbench", kind, title: "Second session" });
+ expect(shortcuts.isActive(entry)).toBe(true);
+ shortcuts.open(entry);
+ expect(shortcuts.isActive(entry)).toBe(false);
+ shortcuts.open(entry);
+ expect(shortcuts.isActive(entry)).toBe(true);
+ expect(usePluginBottomDock().activeEntryId.value).toBe(secondId);
+ expect(usePluginBottomDock().entries.value).toHaveLength(2);
+ addPluginDockEntry({ pluginId: entry.pluginId, commandId: "another-command", instanceKey: "local", workbenchContributionId: "workbench", kind, title: "Other function" });
+ expect(shortcuts.isActive(entry)).toBe(false);
+ });
+ it.each(["singleton", "new"])("restores the selected instance after hiding a command with multiple panels (reuse=%s)", async (reuse) => {
+ const plugin = fixture("a");
+ (plugin.manifest.contributions[1] as any).action.reuse = reuse;
+ mocks.list.mockResolvedValue([plugin]);
+ await mount();
+ const entry = shortcuts.entries.value[0];
+ shortcuts.open(entry);
+ const secondId = addPluginDockEntry({ pluginId: entry.pluginId, commandId: entry.targetId, workbenchContributionId: "workbench", kind: "command", title: "Second terminal" });
+ shortcuts.open(entry);
+ expect(usePluginBottomDock().visible.value).toBe(false);
+ shortcuts.open(entry);
+ expect(usePluginBottomDock().activeEntryId.value).toBe(secondId);
+ expect(usePluginBottomDock().entries.value).toHaveLength(2);
+ });
+ it("filters entire plugins without losing their saved order or running sessions", async () => {
+ await mount();
+ const [a, b] = shortcuts.entries.value;
+ shortcuts.open(a);
+ mocks.settings.editorSettings.pluginShortcuts.order = [b.id, a.id];
+ mocks.settings.editorSettings.pluginShortcuts.hiddenPluginIds = ["a"];
+ expect(shortcuts.entries.value.map((entry) => entry.id)).toEqual([b.id]);
+ expect(shortcuts.allEntries.value.map((entry) => entry.id)).toEqual([b.id, a.id]);
+ expect(usePluginBottomDock().entries.value).toHaveLength(1);
+ mocks.settings.editorSettings.pluginShortcuts.hiddenPluginIds = [];
+ expect(shortcuts.entries.value.map((entry) => entry.id)).toEqual([b.id, a.id]);
+ });
+ it("toggles only the matching panel, preserves sessions and restores the correct command", async () => {
+ await mount();
+ const [a, b] = shortcuts.entries.value;
+ const dock = usePluginBottomDock();
+ shortcuts.open(a);
+ const firstId = dock.activeEntryId.value;
+ shortcuts.open(b);
+ expect(dock.entries.value).toHaveLength(2);
+ expect(shortcuts.isActive(a)).toBe(false);
+ expect(shortcuts.isActive(b)).toBe(true);
+ shortcuts.open(a);
+ expect(dock.activeEntryId.value).toBe(firstId);
+ expect(dock.visible.value).toBe(true);
+ shortcuts.open(a);
+ expect(dock.visible.value).toBe(false);
+ expect(dock.entries.value).toHaveLength(2);
+ shortcuts.open(a);
+ expect(dock.entries.value).toHaveLength(2);
+ expect(dock.activeEntryId.value).toBe(firstId);
+ });
+ it("opens tab commands and legacy workbenches/filesystems with their proper context", async () => {
+ const legacy = fixture("legacy");
+ legacy.manifest.contributions = [
+ { type: "workbench", id: "workbench", label: "Workbench" },
+ { type: "filesystem-provider", id: "files", label: "Files", schemes: ["file"], root_uri: "file:///" },
+ ] as any;
+ mocks.list.mockResolvedValue([fixture("tab", "tab"), legacy]);
+ await mount();
+ for (const entry of shortcuts.entries.value) shortcuts.open(entry);
+ expect(mocks.query.openPluginWorkbench).toHaveBeenCalledWith("tab", "workbench", expect.objectContaining({ commandId: "open", forceNew: false, context: expect.objectContaining({ surface: "tab" }) }));
+ expect(mocks.query.openPluginWorkbench).toHaveBeenCalledWith("legacy", "workbench", { title: "Workbench" });
+ expect(mocks.query.openPluginFilesystem).toHaveBeenCalledWith("legacy", "files", { title: "Files", rootUri: "file:///" });
+ });
+ it("refreshes on uninstall and locale changes and refuses stale entries", async () => {
+ await mount();
+ const old = shortcuts.entries.value[0];
+ mocks.list.mockResolvedValue([fixture("b")]);
+ window.dispatchEvent(new Event("dbx:plugins-changed"));
+ await vi.waitFor(() => expect(shortcuts.entries.value).toHaveLength(1));
+ shortcuts.open(old);
+ expect(usePluginBottomDock().entries.value).toHaveLength(0);
+ mocks.locale.value = "zh-CN";
+ await vi.waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(3));
+ app.unmount();
+ window.dispatchEvent(new Event("dbx:plugins-changed"));
+ expect(mocks.list).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/apps/desktop/src/composables/usePluginShortcutHeight.ts b/apps/desktop/src/composables/usePluginShortcutHeight.ts
new file mode 100644
index 0000000000..dd91984777
--- /dev/null
+++ b/apps/desktop/src/composables/usePluginShortcutHeight.ts
@@ -0,0 +1,139 @@
+import { computed, nextTick, onScopeDispose, ref, watch, type Ref } from "vue";
+import { clampPluginShortcutHeight, pluginShortcutListHeight } from "@/lib/plugins/pluginShortcuts";
+import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
+
+export function usePluginShortcutHeight(options: { section: Ref; scroll: Ref; sidebarList: Ref; count: Ref; savedHeight: Ref; save: (height: number | null) => Promise }) {
+ const available = ref(0);
+ const itemSize = ref(28);
+ const gap = ref(0);
+ const padding = ref(8);
+ const minimum = computed(() => itemSize.value + padding.value);
+ const draft = ref(null);
+ const resizing = ref(false);
+ const height = computed(() => clampPluginShortcutHeight(draft.value ?? options.savedHeight.value ?? pluginShortcutListHeight(options.count.value, itemSize.value, gap.value, padding.value), available.value, minimum.value));
+ let observer: ResizeObserver | null = null;
+ let handle: HTMLElement | null = null;
+ let pointerId: number | null = null;
+ let startY = 0;
+ let startHeight = 0;
+ let scale = 1;
+
+ function measure() {
+ const section = options.section.value;
+ const scroll = options.scroll.value;
+ if (!section || !scroll || !options.sidebarList.value) return;
+ const tree = section.previousElementSibling as HTMLElement | null;
+ const divider = section.querySelector("[data-shortcut-resize]");
+ available.value = (tree?.offsetHeight ?? 0) + section.offsetHeight - (divider?.offsetHeight ?? 0);
+ const item = scroll.querySelector("[data-shortcut-id]");
+ const grid = scroll.firstElementChild;
+ itemSize.value = item?.offsetHeight || 28;
+ gap.value = grid ? parseFloat(getComputedStyle(grid).gap) || 0 : 0;
+ const style = getComputedStyle(scroll);
+ padding.value = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
+ }
+ watch(
+ [options.section, options.sidebarList],
+ async (_value, _previous, onCleanup) => {
+ let cancelled = false;
+ onCleanup(() => {
+ cancelled = true;
+ observer?.disconnect();
+ });
+ observer?.disconnect();
+ if (!options.section.value || !options.sidebarList.value) cancel();
+ await nextTick();
+ if (cancelled || !options.sidebarList.value) return;
+ measure();
+ if (typeof ResizeObserver !== "undefined" && options.section.value?.parentElement) {
+ observer = new ResizeObserver(measure);
+ observer.observe(options.section.value.parentElement);
+ if (options.scroll.value) observer.observe(options.scroll.value);
+ // Sidebar font changes can resize rows without resizing the viewport.
+ if (options.scroll.value?.firstElementChild) observer.observe(options.scroll.value.firstElementChild);
+ }
+ },
+ { flush: "post" },
+ );
+ watch(options.count, () => void nextTick(measure));
+
+ function cleanup() {
+ window.removeEventListener("pointermove", move, true);
+ window.removeEventListener("pointerup", finish, true);
+ window.removeEventListener("pointercancel", cancel);
+ window.removeEventListener("blur", cancel);
+ window.removeEventListener("keydown", keydown, true);
+ handle?.removeEventListener("lostpointercapture", cancel);
+ if (pointerId !== null && handle?.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
+ pointerId = null;
+ handle = null;
+ if (resizing.value) endPanelResize();
+ resizing.value = false;
+ }
+ function cancel() {
+ cleanup();
+ draft.value = null;
+ }
+ function keydown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ cancel();
+ }
+ }
+ function move(event: PointerEvent) {
+ if (event.pointerId !== pointerId) return;
+ event.preventDefault();
+ draft.value = clampPluginShortcutHeight(startHeight + (startY - event.clientY) / scale, available.value, minimum.value);
+ }
+ async function finish(event: PointerEvent) {
+ if (event.pointerId !== pointerId) return;
+ move(event);
+ const next = draft.value;
+ cleanup();
+ try {
+ if (next !== null && next !== startHeight) await options.save(next);
+ } finally {
+ draft.value = null;
+ }
+ }
+ function start(event: PointerEvent) {
+ if (event.button !== 0 || resizing.value) return;
+ event.preventDefault();
+ measure();
+ startY = event.clientY;
+ startHeight = height.value;
+ const section = options.section.value;
+ scale = section?.offsetHeight ? section.getBoundingClientRect().height / section.offsetHeight : 1;
+ scale ||= 1;
+ handle = event.currentTarget as HTMLElement;
+ pointerId = event.pointerId;
+ handle.setPointerCapture?.(pointerId);
+ handle.addEventListener("lostpointercapture", cancel);
+ resizing.value = true;
+ beginPanelResize();
+ window.addEventListener("pointermove", move, { capture: true, passive: false });
+ window.addEventListener("pointerup", finish, true);
+ window.addEventListener("pointercancel", cancel);
+ window.addEventListener("blur", cancel);
+ window.addEventListener("keydown", keydown, true);
+ }
+ async function reset() {
+ cancel();
+ await options.save(null);
+ }
+ async function resizeWithKeyboard(event: KeyboardEvent) {
+ if (event.key === "Home") {
+ event.preventDefault();
+ await reset();
+ } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
+ event.preventDefault();
+ await options.save(clampPluginShortcutHeight(height.value + (event.key === "ArrowUp" ? 1 : -1) * (itemSize.value + gap.value), available.value, minimum.value));
+ }
+ }
+ onScopeDispose(() => {
+ observer?.disconnect();
+ cancel();
+ });
+ return { height, resizing, start, reset, resizeWithKeyboard };
+}
diff --git a/apps/desktop/src/composables/usePluginShortcutPreferences.ts b/apps/desktop/src/composables/usePluginShortcutPreferences.ts
new file mode 100644
index 0000000000..0794458349
--- /dev/null
+++ b/apps/desktop/src/composables/usePluginShortcutPreferences.ts
@@ -0,0 +1,30 @@
+import { computed, ref, type Ref } from "vue";
+import { useSettingsStore } from "@/stores/settingsStore";
+import { normalizePluginShortcutSettings, type PluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
+
+const queues = new WeakMap