feat(plugins): configurable plugin shortcut dock positions

This commit is contained in:
ezreal authored and GitHub committed 2026-09-24 17:55:23 +08:00
1 parent b03d51db31
commit dc22e8c974
41 files changed
+2172 -41

No files matched your search

@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createPinia } from "pinia";
const mocks = vi.hoisted(() => ({
store: null as any,
@@ -175,6 +176,7 @@ async function mountSidebar() {
setup: () => () => h(AppSidebar, { sidebarWidth: 260 }),
}),
);
app.use(createPinia());
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
await nextTick();
@@ -8,6 +8,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import PluginShortcutBar from "@/components/plugins/PluginShortcutBar.vue";
import { useSettingsStore } from "@/stores/settingsStore";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
@@ -33,6 +35,7 @@ type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
const { t } = useI18n();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const connectionTreeRef = ref<InstanceType<typeof ConnectionTree>>();
const showDeleteSelectedConfirm = ref(false);
@@ -296,6 +299,7 @@ defineExpose({ focusSearch, locateTabInSidebar });
<div class="flex-1 min-h-0">
<ConnectionTree ref="connectionTreeRef" @open-settings="(initialTab) => emit('open-settings', initialTab)" @add-to-ai="(nodes) => emit('add-to-ai', nodes)" />
</div>
<PluginShortcutBar v-if="settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position === 'sidebar-bottom'" position="sidebar-bottom" />
</div>
<div class="panel-resize-handle panel-resize-handle--right" @pointerdown="emit('startResize', $event)" />
<Dialog v-model:open="showDeleteSelectedConfirm">
@@ -9,11 +9,9 @@ import LightDropdown from "@/components/ui/LightDropdown.vue";
import WindowControls from "@/components/layout/WindowControls.vue";
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import ToolbarUpdateIcon from "@/components/layout/ToolbarUpdateIcon.vue";
import PluginShortcutToolbar from "@/components/plugins/PluginShortcutToolbar.vue";
import { MAC_TRAFFIC_LIGHT_X, macTrafficLightInsetPaddingForScale, shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
import { useToast } from "@/composables/useToast";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import { setDockVisible, usePluginBottomDock } from "@/lib/plugins/pluginBottomDock";
import { usePluginToolbarCommands, type PluginToolbarCommandEntry } from "@/lib/plugins/pluginCommandRegistry";
import { useSettingsStore } from "@/stores/settingsStore";
import { isSystemAppThemeMode, type AppThemeMode } from "@/lib/app/appTheme";
@@ -80,27 +78,6 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
// PR-A4 appToolbar surface (HOST_PLUGIN_UI_SPEC §5.1): plugin commands render as icons
// surface (next to Settings/AI); clicking runs the command (presentation: panel -> the global bottom dock),
// clicking again collapses an open dock command.
const { entries: pluginCommandEntries, open: openPluginCommand } = usePluginToolbarCommands();
const { entries: pluginDockEntries, visible: dockVisible } = usePluginBottomDock();
// The toolbar icon toggles panel visibility (panel hide keeps the webviews
// mounted, so sessions and height survive); with a hidden-but-populated dock
// the first click just restores it, and only an empty dock runs the command
// (otherwise "getting the panel back" would keep spawning new terminals).
function togglePluginCommand(entry: PluginToolbarCommandEntry) {
if (dockVisible.value) {
setDockVisible(false);
return;
}
if (pluginDockEntries.value.length) {
setDockVisible(true);
return;
}
const result = openPluginCommand(entry);
if (result.error) toast(result.error, 5000);
}
const settingsStore = useSettingsStore();
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
const showToolbarUpdateEntry = computed(() => toolbarItems.value.checkUpdates || props.hasUpdateAvailable);
@@ -186,6 +163,8 @@ function onToolbarDblClick(e: MouseEvent) {
const toolbarEl = ref<HTMLElement>();
const newConnectionLabelEl = ref<HTMLElement>();
const toolbarCollapsed = ref(false);
const pluginCenterGroup = ref<HTMLElement | null>(null);
const showPluginCenterShortcuts = computed(() => settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position === "plugin-center");
const shouldReserveTrafficLightInset = computed(() => shouldReserveMacTrafficLightInset(isMac, isFullscreen.value, isDesktop));
function checkToolbarWidth() {
@@ -611,10 +590,13 @@ const toolbarStyle = computed(() => {
<!-- 小圆点仅提示"有可更新驱动",具体数量交给对话框内标签页红点展示,避免工具栏长期挂红数字。 -->
<span v-if="agentDriverUpdateCount > 0" class="ml-0.5 inline-block h-2 w-2 rounded-full bg-red-500" :aria-label="t('toolbar.updatableDriverCount')" :title="t('toolbar.updatableDriverCount')" />
</Button>
<Button v-if="toolbarItems.pluginCenter" variant="ghost" size="sm" :class="[toolbarTextButtonClass, { 'bg-accent': showPluginCenter }]" @click="emit('open-plugin-center')">
<PlugZap class="h-3.5 w-3.5" />
<span :class="toolbarTextLabelClass">{{ t("toolbar.pluginCenter") }}</span>
</Button>
<div v-if="toolbarItems.pluginCenter || showPluginCenterShortcuts" ref="pluginCenterGroup" class="flex shrink-0 items-center rounded-md" :class="{ 'bg-accent': showPluginCenter }">
<Button v-if="toolbarItems.pluginCenter" variant="ghost" size="sm" :class="[toolbarTextButtonClass, { 'rounded-r-none': showPluginCenterShortcuts }]" @click="emit('open-plugin-center')">
<PlugZap class="h-3.5 w-3.5" />
<span :class="toolbarTextLabelClass">{{ t("toolbar.pluginCenter") }}</span>
</Button>
<PluginShortcutToolbar v-if="showPluginCenterShortcuts" dropdown-only :menu-anchor="pluginCenterGroup" @layout-change="scheduleToolbarLayout" />
</div>
<LightDropdown
v-if="showMoreDropdown"
@@ -632,6 +614,7 @@ const toolbarStyle = computed(() => {
</template>
<template v-if="toolbarCollapsed">
<PluginShortcutToolbar v-if="showPluginCenterShortcuts" dropdown-only @layout-change="scheduleToolbarLayout" />
<LightDropdown
v-if="collapsedItems.length > 0"
model-value=""
@@ -651,6 +634,7 @@ const toolbarStyle = computed(() => {
<!-- Right-side items wrapped in overflow-aware container -->
<div ref="rightWrapper" class="flex min-w-0 items-center gap-1 overflow-hidden">
<PluginShortcutToolbar v-if="settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position === 'toolbar'" @layout-change="scheduleToolbarLayout" />
<template v-if="showToolbarUpdateEntry">
<Tooltip>
<TooltipTrigger as-child>
@@ -796,16 +780,6 @@ const toolbarStyle = computed(() => {
</TooltipTrigger>
<TooltipContent>GitHub</TooltipContent>
</Tooltip>
<Tooltip v-for="entry in pluginCommandEntries" :key="`${entry.pluginId}.${entry.commandId}`">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :class="{ 'toolbar-action-button--active bg-accent': dockVisible }" :aria-label="entry.label" @click="togglePluginCommand(entry)">
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" class="toolbar-action-icon h-4 w-4" />
<span v-if="dockVisible" class="toolbar-panel-status" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ entry.label }} · {{ entry.pluginName }}</TooltipContent>
</Tooltip>
</div>
<!-- /rightWrapper -->
@@ -45,8 +45,9 @@ vi.mock("./PluginWorkbenchHost.vue", () => ({
contribution: { type: Object, required: true },
context: { type: Object, default: undefined },
},
setup(props) {
return () => h("div", { "data-workbench-host-stub": "" }, `${props.plugin.manifest.name} · ${props.contribution.label}`);
emits: ["open-workbench"],
setup(props, { emit }) {
return () => h("div", { "data-workbench-host-stub": "", onClick: () => emit("open-workbench", props.contribution.id, { commandId: "untrusted" }) }, `${props.plugin.manifest.name} · ${props.contribution.label}`);
},
}),
}));
@@ -102,6 +103,19 @@ describe("PluginBottomDock workbench contribution guard", () => {
expect(root.querySelector("[data-workbench-host-stub]")?.textContent).toBe("Sample · Sample panel");
});
it("preserves host command provenance when a plugin opens a second session", async () => {
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.panel")]);
const first = dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", instanceKey: "local", icon: "terminal", title: "Terminal" });
entryIds.push(first);
await mountDock();
(root.querySelector("[data-workbench-host-stub]") as HTMLElement).click();
const state = dock.usePluginBottomDock();
const second = state.entries.value.find((entry) => entry.id === state.activeEntryId.value)!;
entryIds.push(second.id);
expect(second.id).not.toBe(first);
expect(second).toMatchObject({ commandId: "sample.open", instanceKey: "local", icon: "terminal" });
});
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")]);
@@ -184,6 +184,7 @@ function onPlusAction(value: string) {
workbenchContributionId: entry.workbenchContributionId,
kind: "command",
commandId: command.id,
instanceKey: command.action.instance_key,
title: option.label,
icon: command.icon,
commandContext: option.context ?? {},
@@ -203,6 +204,7 @@ function onPlusAction(value: string) {
workbenchContributionId: entry.workbenchContributionId,
kind: "connection",
commandId: command.id,
instanceKey: command.action.instance_key,
title: connection.name || connection.id,
icon: activeCommand.value?.icon,
commandContext: {
@@ -250,6 +252,9 @@ function onPanelOpenWorkbench(entry: (typeof entries.value)[number], _contributi
pluginId: entry.pluginId,
workbenchContributionId: entry.workbenchContributionId,
kind: "command",
commandId: entry.commandId,
instanceKey: entry.instanceKey,
icon: entry.icon,
title: entry.title,
commandContext: payload,
});
@@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/composables/useToast";
import PluginShortcutSettings from "./PluginShortcutSettings.vue";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import * as api from "@/lib/backend/api";
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
@@ -1385,6 +1386,7 @@ onBeforeUnmount(() => {
<TabsContent value="settings" class="m-0 min-h-0 flex-1 overflow-y-auto">
<div class="space-y-4 pb-2">
<PluginShortcutSettings />
<section class="space-y-3 rounded-xl border p-4">
<div class="flex items-start gap-3">
<div class="rounded-md bg-primary/10 p-2 text-primary"><FileUp class="size-4" /></div>
@@ -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: "<span />" } }));
vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: { props: ["text", "disabled"], template: "<span :data-tooltip='text'><slot /></span>" } }));
let app: ReturnType<typeof createApp>;
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<HTMLElement>("[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();
});
});
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import PluginIcon from "./PluginIcon.vue";
import { usePluginShortcuts } from "@/composables/usePluginShortcuts";
import { usePluginShortcutPreferences } from "@/composables/usePluginShortcutPreferences";
import { usePluginShortcutSort } from "@/composables/usePluginShortcutSort";
import { usePluginShortcutHeight } from "@/composables/usePluginShortcutHeight";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { movePluginShortcut, type PluginShortcutEntry, type PluginShortcutPosition } from "@/lib/plugins/pluginShortcuts";
const props = defineProps<{ position: PluginShortcutPosition }>();
const { t } = useI18n();
const settings = useSettingsStore();
const { toast } = useToast();
const { entries, open, isActive } = usePluginShortcuts();
const { update, saving } = usePluginShortcutPreferences();
const section = ref<HTMLElement | null>(null);
const scroll = ref<HTMLElement | null>(null);
const sidebarList = computed(() => props.position === "sidebar-bottom");
const left = computed(() => props.position.startsWith("left-"));
const bottom = computed(() => !sidebarList.value && props.position.endsWith("-bottom"));
const ids = computed(() => entries.value.map((entry) => entry.id));
function saveError(error: unknown) {
toast(`${t("pluginPlatform.shortcutsSaveFailed")}: ${String(error)}`, 5000);
}
const sizing = usePluginShortcutHeight({
section,
scroll,
sidebarList,
count: computed(() => entries.value.length),
savedHeight: computed(() => settings.editorSettings.pluginShortcuts.sidebarHeight),
save: async (sidebarHeight) => {
try {
await update({ sidebarHeight });
} catch (error) {
saveError(error);
}
},
});
const sorting = usePluginShortcutSort({
container: scroll,
ids,
horizontal: ref(false),
disabled: computed(() => saving.value || sizing.resizing.value),
commit: async (source, target, after) => {
try {
await update((current) => ({ order: movePluginShortcut(current.order, ids.value, source, target, after) }));
} catch (error) {
saveError(error);
}
},
});
const { drag } = sorting;
const draggedEntry = computed(() => entries.value.find((entry) => entry.id === drag.source));
watch(() => props.position, sorting.cancel);
function activate(entry: PluginShortcutEntry) {
if (sorting.suppressClick()) return;
try {
open(entry);
} catch (error) {
toast(String(error), 5000);
}
}
</script>
<template>
<nav
v-if="settings.isEditorSettingsLoaded && settings.editorSettings.pluginShortcuts.enabled && entries.length"
ref="section"
:aria-label="t('pluginPlatform.shortcutsTitle')"
:data-plugin-shortcut-position="position"
class="plugin-shortcut-bar flex shrink-0 flex-col"
:class="sidebarList ? 'min-w-0' : ['min-h-0 w-10 self-stretch bg-muted/30', left ? 'border-r' : 'border-l']"
@dragstart.prevent
>
<div
v-if="sidebarList"
data-shortcut-resize
role="separator"
tabindex="0"
aria-orientation="horizontal"
:aria-label="t('pluginPlatform.shortcutsResize')"
:aria-valuenow="Math.round(sizing.height.value)"
:title="t('pluginPlatform.shortcutsResize')"
class="h-1.5 shrink-0 cursor-row-resize touch-none border-t border-border transition-colors hover:bg-primary/30 focus-visible:bg-primary/30 focus-visible:outline-none"
@pointerdown="!saving && sizing.start($event)"
@dblclick="!saving && sizing.reset()"
@keydown="!saving && sizing.resizeWithKeyboard($event)"
/>
<div ref="scroll" data-shortcut-scroll class="min-h-0 overflow-y-auto overflow-x-hidden p-1" :class="sidebarList ? 'shrink-0' : 'flex-1'" :style="sidebarList ? { height: `${sizing.height.value}px` } : undefined">
<div class="shortcut-items flex flex-col" :class="sidebarList ? 'gap-0' : ['min-h-full gap-1', { 'shortcut-items--bottom': bottom }]">
<LightTooltip v-for="(entry, index) in entries" :key="entry.id" :text="entry.label" :disabled="drag.active || sizing.resizing.value" :side="sidebarList ? 'top' : left ? 'right' : 'left'" content-class="shortcut-tooltip">
<span
class="shortcut-item relative flex shrink-0 touch-none"
:class="{
'w-full min-w-0': sidebarList,
'mt-auto': bottom && index === 0,
'drop-before': drag.target === entry.id && !drag.after && drag.source !== entry.id,
'drop-after': drag.target === entry.id && drag.after && drag.source !== entry.id,
'opacity-40': drag.active && drag.source === entry.id,
}"
:data-shortcut-id="entry.id"
@pointerdown="sorting.start($event, entry.id)"
>
<Button
variant="ghost"
size="icon"
class="shortcut-button relative shrink-0"
:class="[sidebarList ? 'shortcut-list-button h-auto min-h-7 w-full min-w-0 justify-start gap-2 rounded-none px-2 py-1 text-left font-normal' : 'size-8', { 'shortcut-button--active': isActive(entry) }]"
:style="sidebarList ? { fontSize: `${settings.editorSettings.sidebarFontSize}px` } : undefined"
:aria-label="entry.label"
:aria-pressed="isActive(entry)"
:disabled="entry.disabled"
@click="activate(entry)"
>
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" :class="sidebarList ? 'size-3.5' : 'size-4'" class="shrink-0 [&_svg]:text-current [&_img]:pointer-events-none" />
<span v-if="sidebarList" data-shortcut-label class="min-w-0 flex-1 truncate">{{ entry.label }}</span>
<span v-if="isActive(entry)" data-shortcut-active-indicator :class="sidebarList ? 'size-1.5 shrink-0 rounded-full bg-green-500' : 'absolute left-0 h-3 w-0.5 rounded-full bg-primary'" aria-hidden="true" />
</Button>
</span>
</LightTooltip>
</div>
</div>
<Teleport to="body">
<div v-if="drag.active || sizing.resizing.value" class="shortcut-pointer-overlay fixed inset-0 select-none touch-none" :style="{ cursor: drag.active ? 'grabbing' : 'row-resize' }" aria-hidden="true" />
<div
v-if="drag.active && draggedEntry"
class="shortcut-drag-preview pointer-events-none fixed flex max-w-xs items-center gap-2 rounded-md border border-primary/50 bg-popover px-2 py-1.5 text-xs text-popover-foreground shadow-md"
:style="{ left: `${drag.x + 12}px`, top: `${drag.y + 12}px` }"
aria-hidden="true"
>
<PluginIcon :plugin-id="draggedEntry.pluginId" :icon="draggedEntry.icon" class="size-4 [&_svg]:text-current" />
<span class="truncate">{{ draggedEntry.label }}</span>
</div>
</Teleport>
</nav>
</template>
<style scoped>
/* The first actual flex item uses auto margin, keeping overflowing rails reachable. */
.shortcut-button {
border: 1px solid transparent;
color: var(--muted-foreground);
transition:
background-color 120ms,
color 120ms,
border-color 120ms;
}
.shortcut-button:hover {
background: color-mix(in srgb, var(--primary) 14%, var(--background));
border-color: color-mix(in srgb, var(--primary) 40%, transparent);
color: var(--foreground);
}
.shortcut-button--active {
background: var(--accent);
color: var(--accent-foreground);
border-color: color-mix(in srgb, var(--primary) 35%, transparent);
}
.shortcut-button:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.shortcut-item.drop-before::before,
.shortcut-item.drop-after::after {
content: "";
position: absolute;
z-index: 1;
pointer-events: none;
background: var(--primary);
height: 2px;
left: 0;
right: 0;
}
.shortcut-item.drop-before::before {
top: -3px;
}
.shortcut-item.drop-after::after {
bottom: -3px;
}
.shortcut-list-button {
border-width: 0;
color: var(--foreground);
}
.shortcut-list-button:hover {
background: var(--sidebar-accent);
}
/* Match TreeItem's unfocused and focused selection, including theme overrides. */
.shortcut-list-button.shortcut-button--active {
background: var(--tree-connection-active-bg, rgb(235 235 235));
}
:root.dark .shortcut-list-button.shortcut-button--active {
background: var(--tree-connection-active-bg, rgb(36 36 36));
}
.shortcut-list-button.shortcut-button--active:focus {
background: var(--tree-connection-active-focus-bg, rgb(211 227 245));
}
:root.dark .shortcut-list-button.shortcut-button--active:focus {
background: var(--tree-connection-active-focus-bg, rgb(33 60 89));
}
.shortcut-pointer-overlay {
z-index: 2147483646;
}
.shortcut-drag-preview {
z-index: 2147483647;
}
</style>
<style>
.shortcut-tooltip {
width: max-content;
max-width: calc(100vw - 24px);
white-space: normal;
overflow-wrap: anywhere;
}
</style>
@@ -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: "<span />" } }));
let app: ReturnType<typeof createApp>;
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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();
});
});
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Settings2 } from "@lucide/vue";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { usePluginShortcuts } from "@/composables/usePluginShortcuts";
import { usePluginShortcutPreferences } from "@/composables/usePluginShortcutPreferences";
import PluginIcon from "./PluginIcon.vue";
import type { PluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
const { t } = useI18n();
const settings = useSettingsStore();
const { toast } = useToast();
const { allEntries } = usePluginShortcuts();
const plugins = computed(() => [...new Map(allEntries.value.map((entry) => [entry.pluginId, entry])).values()]);
const { saving, update: persist } = usePluginShortcutPreferences();
async function update(patch: Partial<PluginShortcutSettings>) {
try {
await persist(patch);
} catch (error) {
toast(`${t("pluginPlatform.shortcutsSaveFailed")}: ${String(error)}`, 5000);
}
}
async function togglePlugin(pluginId: string, visible: boolean) {
try {
await persist((current) => ({ hiddenPluginIds: visible ? current.hiddenPluginIds.filter((id) => id !== pluginId) : [...new Set([...current.hiddenPluginIds, pluginId])] }));
} catch (error) {
toast(`${t("pluginPlatform.shortcutsSaveFailed")}: ${String(error)}`, 5000);
}
}
</script>
<template>
<section class="space-y-4 rounded-xl border p-4" data-plugin-shortcut-settings>
<div class="flex items-start gap-3">
<div class="rounded-md bg-primary/10 p-2 text-primary"><Settings2 class="size-4" /></div>
<div>
<div class="text-sm font-medium">{{ t("pluginPlatform.globalSettingsTitle") }}</div>
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.shortcutsDescription") }}</div>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<Label for="plugin-shortcuts-enabled" class="text-xs">{{ t("pluginPlatform.shortcutsEnabled") }}</Label>
<Switch id="plugin-shortcuts-enabled" :model-value="settings.editorSettings.pluginShortcuts.enabled" :disabled="saving || !settings.isEditorSettingsLoaded" @update:model-value="update({ enabled: $event })" />
</div>
<div v-if="settings.editorSettings.pluginShortcuts.enabled" class="flex items-center justify-between gap-4">
<Label for="plugin-shortcuts-position" class="text-xs">{{ t("pluginPlatform.shortcutsPosition") }}</Label>
<Select :model-value="settings.editorSettings.pluginShortcuts.position" :disabled="saving || !settings.isEditorSettingsLoaded" @update:model-value="update({ position: String($event) as PluginShortcutSettings['position'] })">
<SelectTrigger id="plugin-shortcuts-position" class="h-8 w-52 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left-top">{{ t("pluginPlatform.shortcutsLeftTop") }}</SelectItem>
<SelectItem value="left-bottom">{{ t("pluginPlatform.shortcutsLeftBottom") }}</SelectItem>
<SelectItem value="right-top">{{ t("pluginPlatform.shortcutsRightTop") }}</SelectItem>
<SelectItem value="right-bottom">{{ t("pluginPlatform.shortcutsRightBottom") }}</SelectItem>
<SelectItem value="sidebar-bottom">{{ t("pluginPlatform.shortcutsBottom") }}</SelectItem>
<SelectItem value="plugin-center">{{ t("pluginPlatform.shortcutsPluginCenter") }}</SelectItem>
<SelectItem value="toolbar">{{ t("pluginPlatform.shortcutsToolbar") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="settings.editorSettings.pluginShortcuts.enabled && settings.editorSettings.pluginShortcuts.position === 'toolbar'" class="flex items-center justify-between gap-4">
<div>
<Label for="plugin-shortcuts-toolbar-count" class="text-xs">{{ t("pluginPlatform.shortcutsToolbarCount") }}</Label>
<p class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.shortcutsToolbarHint") }}</p>
</div>
<Select :model-value="String(settings.editorSettings.pluginShortcuts.toolbarCount)" :disabled="saving || !settings.isEditorSettingsLoaded" @update:model-value="update({ toolbarCount: Number($event) })">
<SelectTrigger id="plugin-shortcuts-toolbar-count" class="h-8 w-24 shrink-0 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="count in 11" :key="count" :value="String(count - 1)">{{ count - 1 }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="plugins.length" class="space-y-2">
<div class="text-xs font-medium">{{ t("pluginPlatform.shortcutsVisiblePlugins") }}</div>
<div class="max-h-64 divide-y overflow-y-auto rounded-md border">
<div v-for="(plugin, index) in plugins" :key="plugin.pluginId" class="flex items-center justify-between gap-3 px-3 py-2">
<Label :for="`plugin-shortcut-visible-${index}`" class="flex min-w-0 items-center gap-2 text-xs">
<PluginIcon :plugin-id="plugin.pluginId" :icon="plugin.icon" class="size-4 [&_svg]:text-current" />
<span class="truncate">{{ plugin.pluginName }}</span>
</Label>
<Switch :id="`plugin-shortcut-visible-${index}`" :model-value="!settings.editorSettings.pluginShortcuts.hiddenPluginIds.includes(plugin.pluginId)" :disabled="saving || !settings.isEditorSettingsLoaded" @update:model-value="togglePlugin(plugin.pluginId, $event)" />
</div>
</div>
</div>
</section>
</template>
@@ -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: '<span data-plugin-icon :data-plugin-id="pluginId" />' } }));
vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: { template: "<slot />" } }));
let app: ReturnType<typeof createApp>;
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<HTMLElement>('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<HTMLElement>('[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<HTMLElement>("[data-plugin-shortcut-toolbar]")!;
const menu = document.querySelector<HTMLElement>("[data-plugin-shortcut-overflow]")!;
root.getBoundingClientRect = () => rect(0, 0, 28, 32);
menu.getBoundingClientRect = () => rect(0, 40, 240, 160);
const rows = menu.querySelectorAll<HTMLElement>("[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<HTMLElement>('[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<HTMLElement>("[data-plugin-shortcut-toolbar]")!;
const menu = document.querySelector<HTMLElement>("[data-plugin-shortcut-overflow]")!;
root.getBoundingClientRect = () => rect(0, 0, 94, 32);
menu.getBoundingClientRect = () => rect(0, 40, 240, 100);
root.querySelectorAll<HTMLElement>("[data-shortcut-id]").forEach((item, i) => {
item.getBoundingClientRect = () => rect(i * 30, 0, 28, 28);
});
const rows = menu.querySelectorAll<HTMLElement>("[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();
});
});
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, nextTick, onScopeDispose, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ChevronDown } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import PluginIcon from "./PluginIcon.vue";
import { usePluginShortcuts } from "@/composables/usePluginShortcuts";
import { usePluginShortcutPreferences } from "@/composables/usePluginShortcutPreferences";
import { usePluginShortcutSort } from "@/composables/usePluginShortcutSort";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { movePluginShortcut, pluginShortcutToolbarCount, pluginShortcutToolbarWidth, type PluginShortcutEntry } from "@/lib/plugins/pluginShortcuts";
const props = defineProps<{ dropdownOnly?: boolean; menuAnchor?: HTMLElement | null }>();
const emit = defineEmits<{ "layout-change": [] }>();
const { t } = useI18n();
const settings = useSettingsStore();
const { toast } = useToast();
const { entries, open, isActive } = usePluginShortcuts();
const { update, saving } = usePluginShortcutPreferences();
const root = ref<HTMLElement | null>(null);
const menuList = ref<HTMLElement | null>(null);
const moreTrigger = ref<HTMLElement | null>(null);
const menuOpen = ref(false);
const measuredWidth = ref<number | null>(null);
const requested = computed(() => (props.dropdownOnly ? 0 : settings.editorSettings.pluginShortcuts.toolbarCount));
const preferredWidth = computed(() => pluginShortcutToolbarWidth(entries.value.length, requested.value));
const count = computed(() => pluginShortcutToolbarCount(entries.value.length, requested.value, measuredWidth.value ?? preferredWidth.value));
const inlineEntries = computed(() => entries.value.slice(0, count.value));
const overflowEntries = computed(() => entries.value.slice(count.value));
const ids = computed(() => entries.value.map((entry) => entry.id));
const sorting = usePluginShortcutSort({
container: root,
extraContainers: computed(() => (menuList.value ? [menuList.value] : [])),
horizontal: ref(true),
ids,
disabled: saving,
commit: async (source, target, after) => {
try {
await update((current) => ({ order: movePluginShortcut(current.order, ids.value, source, target, after) }));
} catch (error) {
toast(`${t("pluginPlatform.shortcutsSaveFailed")}: ${String(error)}`, 5000);
}
},
});
const { drag } = sorting;
const draggedEntry = computed(() => entries.value.find((entry) => entry.id === drag.source));
let observer: ResizeObserver | null = null;
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function clearHoverTimer() {
if (hoverTimer !== null) clearTimeout(hoverTimer);
hoverTimer = null;
}
function measure() {
measuredWidth.value = root.value?.offsetWidth ?? null;
emit("layout-change");
}
watch(
root,
(element) => {
observer?.disconnect();
if (element && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(measure);
observer.observe(element);
}
measure();
},
{ flush: "post" },
);
watch(preferredWidth, () => void nextTick(measure));
watch(overflowEntries, (entries) => {
if (!entries.length) menuOpen.value = false;
});
watch(menuOpen, (open) => {
if (!open) sorting.cancel();
});
// A held icon over the arrow opens the menu, allowing moves across the inline/menu boundary.
watch(
() => [drag.active, drag.x, drag.y] as const,
() => {
const bounds = moreTrigger.value?.getBoundingClientRect();
const hovering = drag.active && !menuOpen.value && bounds && drag.x >= bounds.left && drag.x <= bounds.right && drag.y >= bounds.top && drag.y <= bounds.bottom;
if (!hovering) {
clearHoverTimer();
return;
}
if (hoverTimer === null)
hoverTimer = setTimeout(() => {
hoverTimer = null;
if (drag.active) menuOpen.value = true;
}, 350);
},
);
function outside(event: CustomEvent<{ originalEvent: Event }>) {
if (drag.active || (event.detail.originalEvent.target instanceof Node && root.value?.contains(event.detail.originalEvent.target))) event.preventDefault();
}
function preventDragFocus(event: Event) {
if (drag.active) event.preventDefault();
}
function activate(entry: PluginShortcutEntry, event?: Event) {
if (sorting.suppressClick()) {
event?.preventDefault();
return;
}
try {
open(entry);
menuOpen.value = false;
} catch (error) {
toast(String(error), 5000);
}
}
onScopeDispose(() => {
observer?.disconnect();
clearHoverTimer();
});
</script>
<template>
<nav
v-if="settings.isEditorSettingsLoaded && entries.length"
ref="root"
data-plugin-shortcut-toolbar
:aria-label="t('pluginPlatform.shortcutsTitle')"
class="shortcut-toolbar flex h-8 items-center"
:class="dropdownOnly ? 'shrink-0' : 'min-w-[34px] shrink gap-0.5 rounded-md border border-border/70 bg-background/80 px-0.5 py-0 shadow-xs'"
:style="dropdownOnly ? undefined : { width: `${preferredWidth}px` }"
@dragstart.prevent
>
<LightTooltip v-for="entry in inlineEntries" :key="entry.id" :text="entry.label" :disabled="drag.active || menuOpen" side="bottom" content-class="shortcut-toolbar-tooltip">
<span
class="shortcut-inline-item relative flex shrink-0 touch-none"
:data-shortcut-id="entry.id"
:class="{ 'drop-before': drag.target === entry.id && !drag.after && drag.source !== entry.id, 'drop-after': drag.target === entry.id && drag.after && drag.source !== entry.id, 'opacity-40': drag.active && drag.source === entry.id }"
@pointerdown="sorting.start($event, entry.id)"
>
<Button variant="ghost" size="icon" class="shortcut-toolbar-button relative size-7 shrink-0" :class="{ 'bg-accent text-accent-foreground': isActive(entry) }" :aria-label="entry.label" :aria-pressed="isActive(entry)" :disabled="entry.disabled" @click="activate(entry)">
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" class="size-4 [&_svg]:text-current [&_img]:pointer-events-none" />
<span v-if="isActive(entry)" class="absolute bottom-0 h-0.5 w-3 rounded-full bg-primary" aria-hidden="true" />
</Button>
</span>
</LightTooltip>
<DropdownMenu v-if="overflowEntries.length" v-model:open="menuOpen" :modal="false">
<span ref="moreTrigger" class="flex shrink-0">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="shortcut-toolbar-button shrink-0" :class="[dropdownOnly ? 'h-8 w-7 rounded-l-none' : 'size-7', { 'bg-accent text-accent-foreground': menuOpen }]" :aria-label="t('pluginPlatform.shortcutsMore', { count: overflowEntries.length })">
<ChevronDown class="size-3.5 transition-transform" :class="{ 'rotate-180': menuOpen }" />
</Button>
</DropdownMenuTrigger>
</span>
<DropdownMenuContent :reference="dropdownOnly ? (menuAnchor ?? undefined) : undefined" :align="dropdownOnly ? 'start' : 'end'" :side-offset="dropdownOnly ? 4 : 8" class="w-60 max-w-[calc(100vw-16px)] p-1" @interact-outside="outside" @open-auto-focus="preventDragFocus" @dragstart.prevent>
<div ref="menuList" data-plugin-shortcut-overflow class="max-h-[min(320px,var(--reka-dropdown-menu-content-available-height))] overflow-y-auto overflow-x-hidden">
<DropdownMenuItem
v-for="entry in overflowEntries"
:key="entry.id"
:data-shortcut-id="entry.id"
:text-value="entry.label"
:disabled="entry.disabled"
class="shortcut-menu-item relative min-h-8 touch-none gap-2 rounded-sm text-xs"
:class="{ 'bg-accent text-accent-foreground': isActive(entry), 'drop-before': drag.target === entry.id && !drag.after && drag.source !== entry.id, 'drop-after': drag.target === entry.id && drag.after && drag.source !== entry.id, 'opacity-40': drag.active && drag.source === entry.id }"
@pointerdown="sorting.start($event, entry.id)"
@select="activate(entry, $event)"
>
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" class="size-4 shrink-0 [&_svg]:text-current [&_img]:pointer-events-none" />
<span class="min-w-0 flex-1 truncate" :title="entry.label">{{ entry.label }}</span>
<span v-if="isActive(entry)" class="size-1.5 shrink-0 rounded-full bg-green-500" aria-hidden="true" />
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
<Teleport to="body">
<div v-if="drag.active" class="fixed inset-0 z-[2147483646] cursor-grabbing touch-none select-none" aria-hidden="true" />
<div
v-if="drag.active && draggedEntry"
class="pointer-events-none fixed z-[2147483647] flex max-w-xs items-center gap-2 rounded-md border border-primary/50 bg-popover px-2 py-1.5 text-xs text-popover-foreground shadow-md"
:style="{ left: `${drag.x + 12}px`, top: `${drag.y + 12}px` }"
aria-hidden="true"
>
<PluginIcon :plugin-id="draggedEntry.pluginId" :icon="draggedEntry.icon" class="size-4 [&_svg]:text-current" />
<span class="truncate">{{ draggedEntry.label }}</span>
</div>
</Teleport>
</nav>
</template>
<style scoped>
.shortcut-toolbar-button {
border: 1px solid transparent;
color: var(--muted-foreground);
}
.shortcut-toolbar-button:hover,
.shortcut-toolbar-button[aria-pressed="true"] {
background: color-mix(in srgb, var(--primary) 14%, var(--background));
border-color: color-mix(in srgb, var(--primary) 40%, transparent);
color: var(--foreground);
}
.drop-before::before,
.drop-after::after {
content: "";
position: absolute;
background: var(--primary);
pointer-events: none;
z-index: 1;
}
.shortcut-inline-item.drop-before::before {
width: 2px;
inset: 2px auto 2px -2px;
}
.shortcut-inline-item.drop-after::after {
width: 2px;
inset: 2px -2px 2px auto;
}
.shortcut-menu-item.drop-before::before {
height: 2px;
inset: 0 2px auto;
}
.shortcut-menu-item.drop-after::after {
height: 2px;
inset: auto 2px 0;
}
</style>
<style>
.shortcut-toolbar-tooltip {
width: max-content;
max-width: calc(100vw - 24px);
white-space: normal;
overflow-wrap: anywhere;
}
</style>
@@ -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";