feat(plugins): configurable plugin shortcut dock positions
This commit is contained in:
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>
|
||||
+3
@@ -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";
|
||||
|
||||
|
||||
Reference in new issue
Block a user