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

+3
View File
@@ -8,6 +8,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import AppToolbar from "@/components/layout/AppToolbar.vue";
import AppTabBar from "@/components/layout/AppTabBar.vue";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "@/components/layout/groupTabBarPortal";
import PluginShortcutBar from "@/components/plugins/PluginShortcutBar.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
@@ -4154,6 +4155,7 @@ onUnmounted(() => {
/>
<div :class="isDetachedWindowContext ? 'flex-1 flex min-h-0' : isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
<PluginShortcutBar v-if="!isDetachedWindowContext && !isZenMode && settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position.startsWith('left-')" :position="settingsStore.editorSettings.pluginShortcuts.position" />
<AppSidebar
v-if="!isDetachedWindowContext"
v-show="sidebarOpen && !isZenMode"
@@ -4506,6 +4508,7 @@ onUnmounted(() => {
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
</div>
</div>
<PluginShortcutBar v-if="!isDetachedWindowContext && !isZenMode && settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position.startsWith('right-')" :position="settingsStore.editorSettings.pluginShortcuts.position" />
</div>
<AppDialogs
@@ -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";
@@ -0,0 +1,136 @@
// @vitest-environment happy-dom
import { createApp, effectScope, h, nextTick, ref } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePluginShortcutHeight } from "../usePluginShortcutHeight";
import { isPanelResizing } from "@/lib/app/panelResizeState";
let app: ReturnType<typeof createApp>;
let resizeObserver: () => void;
let root: HTMLElement;
afterEach(() => {
app?.unmount();
root?.remove();
vi.unstubAllGlobals();
});
async function setup() {
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: () => void) {
resizeObserver = callback;
}
observe() {}
disconnect() {}
},
);
root = document.createElement("div");
root.innerHTML = '<div></div><nav><div data-shortcut-resize></div><div style="padding:4px"><div style="gap:0px"><span data-shortcut-id="a"></span></div></div></nav>';
document.body.append(root);
const tree = root.children[0] as HTMLElement;
const section = root.children[1] as HTMLElement;
const handle = section.children[0] as HTMLElement;
const scroll = section.children[1] as HTMLElement;
let width = 184;
let total = 600;
const count = ref(10);
const savedHeight = ref<number | null>(null);
const sectionRef = ref<HTMLElement | null>(null);
const scrollRef = ref<HTMLElement | null>(null);
let sizing!: ReturnType<typeof usePluginShortcutHeight>;
const save = vi.fn(async (height: number | null) => {
savedHeight.value = height;
});
app = createApp({
setup() {
sizing = usePluginShortcutHeight({ section: sectionRef, scroll: scrollRef, sidebarList: ref(true), count, savedHeight, save });
return () => h("div");
},
});
app.mount(document.createElement("div"));
Object.defineProperty(tree, "offsetHeight", { get: () => total - sizing.height.value - 6 });
Object.defineProperty(section, "offsetHeight", { get: () => sizing.height.value + 6 });
Object.defineProperty(handle, "offsetHeight", { get: () => 6 });
Object.defineProperty(scroll, "clientWidth", { get: () => width });
section.getBoundingClientRect = () => ({ height: sizing.height.value + 6 }) as DOMRect;
sectionRef.value = section;
scrollRef.value = scroll;
await nextTick();
await nextTick();
handle.addEventListener("pointerdown", sizing.start);
const pointer = (target: EventTarget, type: string, y: number) => target.dispatchEvent(new PointerEvent(type, { pointerId: 1, button: 0, clientY: y, bubbles: true, cancelable: true }));
return {
sizing,
savedHeight,
count,
save,
handle,
pointer,
setWidth: (next: number) => {
width = next;
resizeObserver();
},
setTotal: (next: number) => {
total = next;
resizeObserver();
},
};
}
describe("shortcut sidebar height", () => {
it("does not attach a resize observer after disposal during a pending measurement", async () => {
const observe = vi.fn();
vi.stubGlobal(
"ResizeObserver",
class {
observe = observe;
disconnect() {}
},
);
const section = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => usePluginShortcutHeight({ section, scroll: ref(null), sidebarList: ref(true), count: ref(1), savedHeight: ref(null), save: vi.fn() }));
root = document.createElement("div");
root.innerHTML = "<nav></nav>";
section.value = root.firstElementChild as HTMLElement;
await nextTick();
scope.stop();
await nextTick();
expect(observe).not.toHaveBeenCalled();
});
it("keeps one entry per row regardless of width, remembers manual height and resets to automatic", async () => {
const { sizing, count, savedHeight, setWidth } = await setup();
expect(sizing.height.value).toBe(148);
setWidth(112);
expect(sizing.height.value).toBe(148);
count.value = 40;
expect(sizing.height.value).toBe(148);
savedHeight.value = 250;
expect(sizing.height.value).toBe(250);
count.value = 2;
expect(sizing.height.value).toBe(250);
await sizing.reset();
expect(savedHeight.value).toBeNull();
expect(sizing.height.value).toBe(64);
});
it("saves only on release and reserves space for the tree", async () => {
const { sizing, handle, pointer, save, setTotal } = await setup();
pointer(handle, "pointerdown", 500);
pointer(window, "pointermove", 400);
expect(sizing.height.value).toBe(248);
expect(save).not.toHaveBeenCalled();
pointer(window, "pointerup", 400);
await nextTick();
expect(save).toHaveBeenCalledWith(248);
expect(isPanelResizing.value).toBe(false);
setTotal(250);
expect(sizing.height.value).toBe(124);
});
it.each(["blur", "pointercancel", "escape"])("restores the original height on %s", async (reason) => {
const { sizing, handle, pointer, save } = await setup();
pointer(handle, "pointerdown", 500);
pointer(window, "pointermove", 400);
window.dispatchEvent(reason === "escape" ? new KeyboardEvent("keydown", { key: "Escape" }) : new Event(reason));
expect(sizing.height.value).toBe(148);
expect(save).not.toHaveBeenCalled();
expect(isPanelResizing.value).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { reactive } from "vue";
import { describe, expect, it, vi } from "vitest";
import { usePluginShortcutPreferences } from "../usePluginShortcutPreferences";
import { normalizePluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
const mocks = vi.hoisted(() => ({ settings: null as any }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => mocks.settings }));
describe("shortcut preference writes", () => {
it("merges queued writes from different controls without replacing unrelated preferences", async () => {
const state = reactive({ pluginShortcuts: normalizePluginShortcutSettings({}) });
mocks.settings = {
editorSettings: state,
updateEditorSettingsAndPersist: vi.fn(async (patch) => {
state.pluginShortcuts = patch.pluginShortcuts;
}),
};
const bar = usePluginShortcutPreferences();
const page = usePluginShortcutPreferences();
await Promise.all([bar.update({ order: ["a", "b"] }), page.update({ hiddenPluginIds: ["p"] }), bar.update({ sidebarHeight: 132 })]);
expect(state.pluginShortcuts).toMatchObject({ order: ["a", "b"], hiddenPluginIds: ["p"], sidebarHeight: 132 });
});
it("continues after a failed write without carrying its unpersisted patch forward", async () => {
const state = reactive({ pluginShortcuts: normalizePluginShortcutSettings({}) });
mocks.settings = {
editorSettings: state,
updateEditorSettingsAndPersist: vi
.fn()
.mockRejectedValueOnce(new Error("disk full"))
.mockImplementation(async (patch) => {
state.pluginShortcuts = patch.pluginShortcuts;
}),
};
const preferences = usePluginShortcutPreferences();
const failed = preferences.update({ order: ["lost"] });
const next = preferences.update({ hiddenPluginIds: ["p"] });
await expect(failed).rejects.toThrow("disk full");
await next;
expect(state.pluginShortcuts).toMatchObject({ order: [], hiddenPluginIds: ["p"] });
});
});
@@ -0,0 +1,187 @@
// @vitest-environment happy-dom
import { createApp, h, reactive, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { usePluginShortcuts } from "../usePluginShortcuts";
import { addPluginDockEntry, usePluginBottomDock } from "@/lib/plugins/pluginBottomDock";
const mocks = vi.hoisted(() => ({ list: vi.fn(), query: null as any, locale: null as any, settings: null as any }));
vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.list }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => mocks.query }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => mocks.settings }));
vi.mock("vue-i18n", async (original) => ({ ...(await original<typeof import("vue-i18n")>()), useI18n: () => ({ locale: mocks.locale, t: (key: string) => key }) }));
const fixture = (id: string, presentation = "panel") => ({
compatibility: { compatible: true },
manifest: {
id,
name: id,
version: "1.0.0",
drivers: [],
contributions: [
{ type: "workbench", id: "workbench", label: "Workbench" },
{ type: "command", id: "open", label: "Open", action: { type: "open-workbench", workbench: "workbench", presentation } },
],
},
});
let app: ReturnType<typeof createApp>;
let shortcuts: ReturnType<typeof usePluginShortcuts>;
async function mount() {
app = createApp({
setup() {
shortcuts = usePluginShortcuts();
return () => h("div");
},
});
app.mount(document.createElement("div"));
await vi.waitFor(() => expect(shortcuts.entries.value.length).toBeGreaterThan(0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.locale = ref("en");
mocks.settings = reactive({ editorSettings: { pluginShortcuts: { order: [], hiddenPluginIds: [] } } });
mocks.query = reactive({ tabs: [], activeTabId: null, openPluginWorkbench: vi.fn(), openPluginFilesystem: vi.fn() });
mocks.list.mockResolvedValue([fixture("a"), fixture("b")]);
const dock = usePluginBottomDock();
dock.entries.value = [];
dock.visible.value = false;
dock.activeEntryId.value = null;
});
afterEach(() => app?.unmount());
describe("shortcut dispatch", () => {
it.each([
{ source: undefined, restored: false },
{ source: undefined, restored: true },
{ source: "open", restored: true },
{ source: "other", restored: true },
])("preserves a reused tab and its context (source=$source, restored=$restored)", async ({ source, restored }) => {
setActivePinia(createPinia());
const { useQueryStore } = await vi.importActual<typeof import("@/stores/queryStore")>("@/stores/queryStore");
const query = useQueryStore();
mocks.query = query;
const oldId = query.openPluginWorkbench("tab", "workbench", {
commandId: source,
title: "Existing session",
context: { workbenchId: "existing-session", retained: { value: 1 } },
});
// Restored tabs contain serialized legacy metadata, without commandId.
if (restored) query.tabs = JSON.parse(JSON.stringify(query.tabs));
const metadata = query.tabs[0].pluginWorkbench!;
const context = metadata.context;
const plugin = fixture("tab", "tab");
plugin.manifest.contributions.push({ ...(plugin.manifest.contributions[1] as any), id: "other", label: "Other function" });
plugin.manifest.contributions.push({ type: "menus", id: "menus", items: ["open", "other"].map((command) => ({ command, location: "appToolbar", group: "navigation", order: 0, default_visible: true })) } as any);
mocks.list.mockResolvedValue([plugin]);
await mount();
const entry = shortcuts.entries.value.find((entry) => entry.targetId === "open")!;
const other = shortcuts.entries.value.find((entry) => entry.targetId === "other")!;
shortcuts.open(entry);
expect(query.activeTabId).toBe(oldId);
expect(query.tabs).toHaveLength(1);
expect(query.tabs[0].pluginWorkbench).toBe(metadata);
expect(metadata.context).toBe(context);
expect(metadata.context).toEqual({ workbenchId: "existing-session", retained: { value: 1 } });
expect(metadata.commandId).toBe(source ?? "open");
expect(shortcuts.isActive(entry)).toBe(source !== "other");
expect(shortcuts.isActive(other)).toBe(source === "other");
// Another command sharing this workbench must not steal its provenance.
shortcuts.open(other);
expect(query.tabs).toHaveLength(1);
expect(query.activeTabId).toBe(oldId);
expect(metadata.commandId).toBe(source ?? "open");
expect(metadata.context).toBe(context);
expect(shortcuts.isActive(entry)).toBe(source !== "other");
expect(shortcuts.isActive(other)).toBe(source === "other");
});
it.each(["command", "connection"] as const)("highlights additional %s sessions only for their originating shortcut", async (kind) => {
const plugin = fixture("a");
(plugin.manifest.contributions[1] as any).action.instance_key = "local";
mocks.list.mockResolvedValue([plugin]);
await mount();
const entry = shortcuts.entries.value[0];
shortcuts.open(entry);
const secondId = addPluginDockEntry({ pluginId: entry.pluginId, commandId: entry.targetId, instanceKey: "local", workbenchContributionId: "workbench", kind, title: "Second session" });
expect(shortcuts.isActive(entry)).toBe(true);
shortcuts.open(entry);
expect(shortcuts.isActive(entry)).toBe(false);
shortcuts.open(entry);
expect(shortcuts.isActive(entry)).toBe(true);
expect(usePluginBottomDock().activeEntryId.value).toBe(secondId);
expect(usePluginBottomDock().entries.value).toHaveLength(2);
addPluginDockEntry({ pluginId: entry.pluginId, commandId: "another-command", instanceKey: "local", workbenchContributionId: "workbench", kind, title: "Other function" });
expect(shortcuts.isActive(entry)).toBe(false);
});
it.each(["singleton", "new"])("restores the selected instance after hiding a command with multiple panels (reuse=%s)", async (reuse) => {
const plugin = fixture("a");
(plugin.manifest.contributions[1] as any).action.reuse = reuse;
mocks.list.mockResolvedValue([plugin]);
await mount();
const entry = shortcuts.entries.value[0];
shortcuts.open(entry);
const secondId = addPluginDockEntry({ pluginId: entry.pluginId, commandId: entry.targetId, workbenchContributionId: "workbench", kind: "command", title: "Second terminal" });
shortcuts.open(entry);
expect(usePluginBottomDock().visible.value).toBe(false);
shortcuts.open(entry);
expect(usePluginBottomDock().activeEntryId.value).toBe(secondId);
expect(usePluginBottomDock().entries.value).toHaveLength(2);
});
it("filters entire plugins without losing their saved order or running sessions", async () => {
await mount();
const [a, b] = shortcuts.entries.value;
shortcuts.open(a);
mocks.settings.editorSettings.pluginShortcuts.order = [b.id, a.id];
mocks.settings.editorSettings.pluginShortcuts.hiddenPluginIds = ["a"];
expect(shortcuts.entries.value.map((entry) => entry.id)).toEqual([b.id]);
expect(shortcuts.allEntries.value.map((entry) => entry.id)).toEqual([b.id, a.id]);
expect(usePluginBottomDock().entries.value).toHaveLength(1);
mocks.settings.editorSettings.pluginShortcuts.hiddenPluginIds = [];
expect(shortcuts.entries.value.map((entry) => entry.id)).toEqual([b.id, a.id]);
});
it("toggles only the matching panel, preserves sessions and restores the correct command", async () => {
await mount();
const [a, b] = shortcuts.entries.value;
const dock = usePluginBottomDock();
shortcuts.open(a);
const firstId = dock.activeEntryId.value;
shortcuts.open(b);
expect(dock.entries.value).toHaveLength(2);
expect(shortcuts.isActive(a)).toBe(false);
expect(shortcuts.isActive(b)).toBe(true);
shortcuts.open(a);
expect(dock.activeEntryId.value).toBe(firstId);
expect(dock.visible.value).toBe(true);
shortcuts.open(a);
expect(dock.visible.value).toBe(false);
expect(dock.entries.value).toHaveLength(2);
shortcuts.open(a);
expect(dock.entries.value).toHaveLength(2);
expect(dock.activeEntryId.value).toBe(firstId);
});
it("opens tab commands and legacy workbenches/filesystems with their proper context", async () => {
const legacy = fixture("legacy");
legacy.manifest.contributions = [
{ type: "workbench", id: "workbench", label: "Workbench" },
{ type: "filesystem-provider", id: "files", label: "Files", schemes: ["file"], root_uri: "file:///" },
] as any;
mocks.list.mockResolvedValue([fixture("tab", "tab"), legacy]);
await mount();
for (const entry of shortcuts.entries.value) shortcuts.open(entry);
expect(mocks.query.openPluginWorkbench).toHaveBeenCalledWith("tab", "workbench", expect.objectContaining({ commandId: "open", forceNew: false, context: expect.objectContaining({ surface: "tab" }) }));
expect(mocks.query.openPluginWorkbench).toHaveBeenCalledWith("legacy", "workbench", { title: "Workbench" });
expect(mocks.query.openPluginFilesystem).toHaveBeenCalledWith("legacy", "files", { title: "Files", rootUri: "file:///" });
});
it("refreshes on uninstall and locale changes and refuses stale entries", async () => {
await mount();
const old = shortcuts.entries.value[0];
mocks.list.mockResolvedValue([fixture("b")]);
window.dispatchEvent(new Event("dbx:plugins-changed"));
await vi.waitFor(() => expect(shortcuts.entries.value).toHaveLength(1));
shortcuts.open(old);
expect(usePluginBottomDock().entries.value).toHaveLength(0);
mocks.locale.value = "zh-CN";
await vi.waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(3));
app.unmount();
window.dispatchEvent(new Event("dbx:plugins-changed"));
expect(mocks.list).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,139 @@
import { computed, nextTick, onScopeDispose, ref, watch, type Ref } from "vue";
import { clampPluginShortcutHeight, pluginShortcutListHeight } from "@/lib/plugins/pluginShortcuts";
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
export function usePluginShortcutHeight(options: { section: Ref<HTMLElement | null>; scroll: Ref<HTMLElement | null>; sidebarList: Ref<boolean>; count: Ref<number>; savedHeight: Ref<number | null>; save: (height: number | null) => Promise<void> }) {
const available = ref(0);
const itemSize = ref(28);
const gap = ref(0);
const padding = ref(8);
const minimum = computed(() => itemSize.value + padding.value);
const draft = ref<number | null>(null);
const resizing = ref(false);
const height = computed(() => clampPluginShortcutHeight(draft.value ?? options.savedHeight.value ?? pluginShortcutListHeight(options.count.value, itemSize.value, gap.value, padding.value), available.value, minimum.value));
let observer: ResizeObserver | null = null;
let handle: HTMLElement | null = null;
let pointerId: number | null = null;
let startY = 0;
let startHeight = 0;
let scale = 1;
function measure() {
const section = options.section.value;
const scroll = options.scroll.value;
if (!section || !scroll || !options.sidebarList.value) return;
const tree = section.previousElementSibling as HTMLElement | null;
const divider = section.querySelector<HTMLElement>("[data-shortcut-resize]");
available.value = (tree?.offsetHeight ?? 0) + section.offsetHeight - (divider?.offsetHeight ?? 0);
const item = scroll.querySelector<HTMLElement>("[data-shortcut-id]");
const grid = scroll.firstElementChild;
itemSize.value = item?.offsetHeight || 28;
gap.value = grid ? parseFloat(getComputedStyle(grid).gap) || 0 : 0;
const style = getComputedStyle(scroll);
padding.value = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
}
watch(
[options.section, options.sidebarList],
async (_value, _previous, onCleanup) => {
let cancelled = false;
onCleanup(() => {
cancelled = true;
observer?.disconnect();
});
observer?.disconnect();
if (!options.section.value || !options.sidebarList.value) cancel();
await nextTick();
if (cancelled || !options.sidebarList.value) return;
measure();
if (typeof ResizeObserver !== "undefined" && options.section.value?.parentElement) {
observer = new ResizeObserver(measure);
observer.observe(options.section.value.parentElement);
if (options.scroll.value) observer.observe(options.scroll.value);
// Sidebar font changes can resize rows without resizing the viewport.
if (options.scroll.value?.firstElementChild) observer.observe(options.scroll.value.firstElementChild);
}
},
{ flush: "post" },
);
watch(options.count, () => void nextTick(measure));
function cleanup() {
window.removeEventListener("pointermove", move, true);
window.removeEventListener("pointerup", finish, true);
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("blur", cancel);
window.removeEventListener("keydown", keydown, true);
handle?.removeEventListener("lostpointercapture", cancel);
if (pointerId !== null && handle?.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
pointerId = null;
handle = null;
if (resizing.value) endPanelResize();
resizing.value = false;
}
function cancel() {
cleanup();
draft.value = null;
}
function keydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancel();
}
}
function move(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
event.preventDefault();
draft.value = clampPluginShortcutHeight(startHeight + (startY - event.clientY) / scale, available.value, minimum.value);
}
async function finish(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
move(event);
const next = draft.value;
cleanup();
try {
if (next !== null && next !== startHeight) await options.save(next);
} finally {
draft.value = null;
}
}
function start(event: PointerEvent) {
if (event.button !== 0 || resizing.value) return;
event.preventDefault();
measure();
startY = event.clientY;
startHeight = height.value;
const section = options.section.value;
scale = section?.offsetHeight ? section.getBoundingClientRect().height / section.offsetHeight : 1;
scale ||= 1;
handle = event.currentTarget as HTMLElement;
pointerId = event.pointerId;
handle.setPointerCapture?.(pointerId);
handle.addEventListener("lostpointercapture", cancel);
resizing.value = true;
beginPanelResize();
window.addEventListener("pointermove", move, { capture: true, passive: false });
window.addEventListener("pointerup", finish, true);
window.addEventListener("pointercancel", cancel);
window.addEventListener("blur", cancel);
window.addEventListener("keydown", keydown, true);
}
async function reset() {
cancel();
await options.save(null);
}
async function resizeWithKeyboard(event: KeyboardEvent) {
if (event.key === "Home") {
event.preventDefault();
await reset();
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
await options.save(clampPluginShortcutHeight(height.value + (event.key === "ArrowUp" ? 1 : -1) * (itemSize.value + gap.value), available.value, minimum.value));
}
}
onScopeDispose(() => {
observer?.disconnect();
cancel();
});
return { height, resizing, start, reset, resizeWithKeyboard };
}
@@ -0,0 +1,30 @@
import { computed, ref, type Ref } from "vue";
import { useSettingsStore } from "@/stores/settingsStore";
import { normalizePluginShortcutSettings, type PluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
const queues = new WeakMap<object, { tail: Promise<void>; pending: Ref<number> }>();
/** Serialize partial updates across the settings page, sorting and the resize handle. */
export function usePluginShortcutPreferences() {
const settings = useSettingsStore();
let queue = queues.get(settings);
if (!queue) {
queue = { tail: Promise.resolve(), pending: ref(0) };
queues.set(settings, queue);
}
const state = queue;
function update(patch: Partial<PluginShortcutSettings> | ((current: PluginShortcutSettings) => Partial<PluginShortcutSettings>)) {
state.pending.value++;
const operation = state.tail.then(async () => {
const current = settings.editorSettings.pluginShortcuts;
await settings.updateEditorSettingsAndPersist({ pluginShortcuts: normalizePluginShortcutSettings({ ...current, ...(typeof patch === "function" ? patch(current) : patch) }) });
});
state.tail = operation
.catch(() => {})
.finally(() => {
state.pending.value--;
});
return operation;
}
return { update, saving: computed(() => state.pending.value > 0) };
}
@@ -0,0 +1,135 @@
import { onScopeDispose, reactive, watch, type Ref } from "vue";
interface SortOptions {
container: Ref<HTMLElement | null>;
/** Optional portalled vertical lists that share the same persisted order. */
extraContainers?: Ref<HTMLElement[]>;
ids: Ref<string[]>;
horizontal: Ref<boolean>;
disabled: Ref<boolean>;
commit: (source: string, target: string, after: boolean) => void;
}
/** Internal pointer sorting deliberately avoids Windows WebView's native file-drop pipeline. */
export function usePluginShortcutSort(options: SortOptions) {
const drag = reactive({ source: null as string | null, active: false, target: null as string | null, after: false, x: 0, y: 0 });
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let frame = 0;
let suppressClickUntil = 0;
let capture: HTMLElement | null = null;
function hitContainer() {
return [options.container.value, ...(options.extraContainers?.value ?? [])].find((container) => {
if (!container) return false;
const bounds = container.getBoundingClientRect();
return drag.x >= bounds.left && drag.x <= bounds.right && drag.y >= bounds.top && drag.y <= bounds.bottom;
});
}
function locate() {
const container = hitContainer();
drag.target = null;
if (!container) return;
const bounds = container.getBoundingClientRect();
if (drag.x < bounds.left || drag.x > bounds.right || drag.y < bounds.top || drag.y > bounds.bottom) return;
let distance = Infinity;
for (const item of container.querySelectorAll<HTMLElement>("[data-shortcut-id]")) {
const rect = item.getBoundingClientRect();
if (rect.bottom <= bounds.top || rect.top >= bounds.bottom) continue;
const dx = Math.max(rect.left - drag.x, 0, drag.x - rect.right);
const dy = Math.max(rect.top - drag.y, 0, drag.y - rect.bottom);
const next = dx * dx + dy * dy;
if (next >= distance) continue;
distance = next;
drag.target = item.dataset.shortcutId ?? null;
drag.after = container === options.container.value && options.horizontal.value ? drag.x > rect.left + rect.width / 2 : drag.y > rect.top + rect.height / 2;
}
}
function autoScroll() {
if (!drag.active) return;
const container = hitContainer();
if (container) {
const bounds = container.getBoundingClientRect();
const edge = Math.min(24, bounds.height / 3);
if (drag.x >= bounds.left && drag.x <= bounds.right && drag.y >= bounds.top && drag.y <= bounds.bottom) {
const direction = drag.y < bounds.top + edge ? -1 : drag.y > bounds.bottom - edge ? 1 : 0;
if (direction) {
container.scrollTop += direction * 6;
locate();
}
}
}
frame = requestAnimationFrame(autoScroll);
}
function cancel() {
if (drag.active) suppressClickUntil = Date.now() + 250;
drag.active = false;
drag.source = null;
drag.target = null;
cancelAnimationFrame(frame);
window.removeEventListener("pointermove", move, true);
window.removeEventListener("pointerup", finish, true);
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("keydown", keydown, true);
window.removeEventListener("blur", cancel);
capture?.removeEventListener("lostpointercapture", cancel);
if (pointerId !== null && capture?.hasPointerCapture?.(pointerId)) capture.releasePointerCapture(pointerId);
pointerId = null;
capture = null;
}
function keydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancel();
}
}
function move(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
drag.x = event.clientX;
drag.y = event.clientY;
if (!drag.active) {
if (Math.hypot(drag.x - startX, drag.y - startY) <= 5) return;
drag.active = true;
capture = options.container.value;
capture?.setPointerCapture?.(event.pointerId);
capture?.addEventListener("lostpointercapture", cancel);
frame = requestAnimationFrame(autoScroll);
}
event.preventDefault();
locate();
}
function finish(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
const active = drag.active;
if (active) {
event.preventDefault();
event.stopPropagation();
drag.x = event.clientX;
drag.y = event.clientY;
locate();
}
const { source, target, after } = drag;
cancel();
if (active && source && target && source !== target) options.commit(source, target, after);
}
function start(event: PointerEvent, id: string) {
if (event.button !== 0 || options.disabled.value || pointerId !== null) return;
pointerId = event.pointerId;
drag.source = id;
drag.x = startX = event.clientX;
drag.y = startY = event.clientY;
window.addEventListener("pointermove", move, { capture: true, passive: false });
window.addEventListener("pointerup", finish, true);
window.addEventListener("pointercancel", cancel);
window.addEventListener("keydown", keydown, true);
window.addEventListener("blur", cancel);
}
watch([options.ids, options.horizontal], cancel);
onScopeDispose(cancel);
return { drag, start, cancel, suppressClick: () => drag.active || Date.now() < suppressClickUntil };
}
@@ -0,0 +1,74 @@
import { computed, onScopeDispose, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import * as api from "@/lib/backend/api";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { collectPluginShortcuts, orderPluginShortcuts, type PluginShortcutEntry } from "@/lib/plugins/pluginShortcuts";
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
import { setDockVisible, usePluginBottomDock } from "@/lib/plugins/pluginBottomDock";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
export function usePluginShortcuts() {
const { locale } = useI18n();
const settings = useSettingsStore();
const query = useQueryStore();
const dock = usePluginBottomDock();
const registry = shallowRef(createFrontendPluginRegistry([]));
const allEntries = computed(() => orderPluginShortcuts(collectPluginShortcuts(registry.value), settings.editorSettings.pluginShortcuts.order));
const entries = computed(() => {
const hidden = new Set(settings.editorSettings.pluginShortcuts.hiddenPluginIds);
return allEntries.value.filter((entry) => !hidden.has(entry.pluginId));
});
let sequence = 0;
async function refresh() {
const current = ++sequence;
try {
const plugins = await api.listPlugins();
if (current === sequence) registry.value = createFrontendPluginRegistry(plugins, locale.value);
} catch (error) {
if (current === sequence) registry.value = createFrontendPluginRegistry([]);
console.warn("[DBX][plugin:shortcuts]", error);
}
}
function hasSelectedPanel(entry: PluginShortcutEntry): boolean {
const active = dock.entries.value.find((item) => item.id === dock.activeEntryId.value);
return entry.command?.action.presentation === "panel" && active?.pluginId === entry.pluginId && active.workbenchContributionId === entry.command.action.workbench && active.commandId === entry.targetId && active.instanceKey === entry.command.action.instance_key;
}
function isActive(entry: PluginShortcutEntry): boolean {
if (entry.command?.action.presentation === "panel") return dock.visible.value && hasSelectedPanel(entry);
const tab = query.tabs.find((tab) => tab.id === query.activeTabId);
if (entry.kind === "filesystem") return tab?.pluginFilesystem?.pluginId === entry.pluginId && tab.pluginFilesystem.providerId === entry.targetId;
const target = entry.command?.action.workbench ?? entry.targetId;
return tab?.pluginWorkbench?.pluginId === entry.pluginId && tab.pluginWorkbench.contributionId === target && (!entry.command || tab.pluginWorkbench.commandId === entry.targetId);
}
function open(entry: PluginShortcutEntry) {
// Re-resolve before dispatch so removed/disabled entries cannot be executed.
const current = entries.value.find((item) => item.id === entry.id);
if (!current || current.disabled) return;
if (current.kind === "command") {
if (hasSelectedPanel(current)) {
// Restoring chrome must keep the selected session, even when the
// command's normal execution creates a new instance.
setDockVisible(!dock.visible.value);
return;
}
const result = executePluginCommand(registry.value, query, current.pluginId, current.targetId);
if (result.error) throw new Error(result.error);
} else if (current.kind === "workbench") {
query.openPluginWorkbench(current.pluginId, current.targetId, { title: current.label });
} else {
query.openPluginFilesystem(current.pluginId, current.targetId, { title: current.label, rootUri: current.rootUri });
}
}
const onChange = () => void refresh();
watch(locale, onChange);
window.addEventListener("dbx:plugins-changed", onChange);
window.addEventListener("focus", onChange);
onScopeDispose(() => {
sequence++;
window.removeEventListener("dbx:plugins-changed", onChange);
window.removeEventListener("focus", onChange);
});
void refresh();
return { entries, allEntries, open, isActive };
}
+13
View File
@@ -80,6 +80,19 @@ const consul = {
};
export default withEnglishFallback({
pluginPlatform: {
shortcutsPluginCenter: "Plagin mərkəzinin yanında (açılan menyu)",
shortcutsToolbar: "Yeniləmə düyməsinin solunda (üzən)",
shortcutsToolbarCount: "Görünən nişanların sayı",
shortcutsToolbarHint: "0 bütün girişləri menyuya yerləşdirir. Yer çatmadıqda qalanları da menyuya keçir.",
shortcutsMore: "Digər plagin qısayolları ({count})",
shortcutsLeftTop: "Sol · Yuxarı",
shortcutsLeftBottom: "Sol · Aşağı",
shortcutsRightTop: "Sağ · Yuxarı",
shortcutsRightBottom: "Sağ · Aşağı",
shortcutsVisiblePlugins: "Görünən plaginlər",
shortcutsResize: "Hündürlüyü dəyişmək üçün sürükləyin; avtomatik hündürlük üçün iki dəfə klikləyin",
},
cellTransform: {
title: "Çevirmə önizləməsi",
description: "Cari dəyəri baxmaq və ya köçürmək üçün çevirir. İlkin dəyər və redaktə qaralaması dəyişmir.",
+18
View File
@@ -166,6 +166,24 @@ export default {
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "Beside Plugin Center (dropdown)",
shortcutsToolbar: "Before Update (floating)",
shortcutsToolbarCount: "Visible toolbar icons",
shortcutsToolbarHint: "0 puts all entries in the menu. Limited space moves more entries into the menu.",
shortcutsMore: "More plugin shortcuts ({count})",
shortcutsLeftTop: "Left · Top first",
shortcutsLeftBottom: "Left · Bottom first",
shortcutsRightTop: "Right · Top first",
shortcutsRightBottom: "Right · Bottom first",
shortcutsVisiblePlugins: "Visible plugins",
shortcutsResize: "Drag to resize; double-click for automatic height",
globalSettingsTitle: "Global settings",
shortcutsTitle: "Plugin shortcuts",
shortcutsDescription: "Choose where plugin shortcuts appear. Drag icons to reorder them.",
shortcutsEnabled: "Show plugin shortcuts",
shortcutsPosition: "Position",
shortcutsBottom: "Connection tree bottom",
shortcutsSaveFailed: "Could not save plugin shortcuts",
openResultView: "Open with {label}",
centerTitle: "Plugin Center",
centerDescription: "Discover optional DBX capabilities in one marketplace, then manage installed plugins and repositories separately.",
+18
View File
@@ -169,6 +169,24 @@ export default withEnglishFallback({
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "Junto al centro de plugins (desplegable)",
shortcutsToolbar: "Antes de Actualizar (flotante)",
shortcutsToolbarCount: "Iconos visibles",
shortcutsToolbarHint: "0 coloca todos los accesos en el menú. Se agrupan más si falta espacio.",
shortcutsMore: "Más accesos a complementos ({count})",
shortcutsLeftTop: "Izquierda · Arriba",
shortcutsLeftBottom: "Izquierda · Abajo",
shortcutsRightTop: "Derecha · Arriba",
shortcutsRightBottom: "Derecha · Abajo",
shortcutsVisiblePlugins: "Complementos visibles",
shortcutsResize: "Arrastra para ajustar la altura; haz doble clic para restaurar la altura automática",
globalSettingsTitle: "Configuración global",
shortcutsTitle: "Accesos a complementos",
shortcutsDescription: "Elige la ubicación de los accesos. Arrastra los iconos para reordenarlos.",
shortcutsEnabled: "Mostrar accesos a complementos",
shortcutsPosition: "Ubicación",
shortcutsBottom: "Debajo del árbol de conexiones",
shortcutsSaveFailed: "No se pudieron guardar los accesos",
openResultView: "Abrir con {label}",
centerTitle: "Centro de plugins",
centerDescription: "Descubre capacidades opcionales de DBX en un marketplace unificado y administra por separado los plugins instalados y los repositorios.",
+18
View File
@@ -168,6 +168,24 @@ export default withEnglishFallback({
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "Accanto al centro plugin (menu a discesa)",
shortcutsToolbar: "Prima di Aggiorna (flottante)",
shortcutsToolbarCount: "Icone visibili",
shortcutsToolbarHint: "0 sposta tutte le voci nel menu. Altre voci vengono raggruppate se lo spazio è limitato.",
shortcutsMore: "Altre scorciatoie dei plugin ({count})",
shortcutsLeftTop: "Sinistra · In alto",
shortcutsLeftBottom: "Sinistra · In basso",
shortcutsRightTop: "Destra · In alto",
shortcutsRightBottom: "Destra · In basso",
shortcutsVisiblePlugins: "Plugin visibili",
shortcutsResize: "Trascina per ridimensionare; doppio clic per l’altezza automatica",
globalSettingsTitle: "Impostazioni globali",
shortcutsTitle: "Scorciatoie dei plugin",
shortcutsDescription: "Scegli la posizione delle scorciatoie. Trascina le icone per riordinarle.",
shortcutsEnabled: "Mostra scorciatoie dei plugin",
shortcutsPosition: "Posizione",
shortcutsBottom: "Sotto l’albero delle connessioni",
shortcutsSaveFailed: "Impossibile salvare le scorciatoie",
openResultView: "Apri con {label}",
centerTitle: "Centro plugin",
centerDescription: "Scopri funzionalità DBX opzionali in un marketplace unico e gestisci separatamente plugin installati e repository.",
+18
View File
@@ -169,6 +169,24 @@ export default withEnglishFallback({
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "プラグインセンターの右側(ドロップダウン)",
shortcutsToolbar: "更新ボタンの左側(フローティング)",
shortcutsToolbarCount: "表示するアイコン数",
shortcutsToolbarHint: "0 はすべてメニューに表示します。幅が足りない場合も自動的に収納します。",
shortcutsMore: "その他のプラグイン({count})",
shortcutsLeftTop: "左側・上寄せ",
shortcutsLeftBottom: "左側・下寄せ",
shortcutsRightTop: "右側・上寄せ",
shortcutsRightBottom: "右側・下寄せ",
shortcutsVisiblePlugins: "表示するプラグイン",
shortcutsResize: "ドラッグで高さを変更、ダブルクリックで自動調整に戻す",
globalSettingsTitle: "グローバル設定",
shortcutsTitle: "プラグインショートカット",
shortcutsDescription: "表示位置を選択します。アイコンをドラッグして並べ替えできます。",
shortcutsEnabled: "プラグインショートカットを表示",
shortcutsPosition: "表示位置",
shortcutsBottom: "接続ツリーの下部",
shortcutsSaveFailed: "ショートカット設定を保存できませんでした",
openResultView: "{label} で開く",
centerTitle: "プラグインセンター",
centerDescription: "統合マーケットプレイスで DBX の追加機能を探し、インストール済みプラグインとリポジトリを個別に管理します。",
+18
View File
@@ -168,6 +168,24 @@ export default withEnglishFallback({
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "플러그인 센터 오른쪽 (드롭다운)",
shortcutsToolbar: "업데이트 버튼 왼쪽 (플로팅)",
shortcutsToolbarCount: "표시할 아이콘 수",
shortcutsToolbarHint: "0은 모든 항목을 메뉴에 표시합니다. 공간이 부족하면 자동으로 메뉴에 넣습니다.",
shortcutsMore: "더 많은 플러그인 바로가기 ({count})",
shortcutsLeftTop: "왼쪽 · 위쪽 정렬",
shortcutsLeftBottom: "왼쪽 · 아래쪽 정렬",
shortcutsRightTop: "오른쪽 · 위쪽 정렬",
shortcutsRightBottom: "오른쪽 · 아래쪽 정렬",
shortcutsVisiblePlugins: "표시할 플러그인",
shortcutsResize: "드래그하여 높이 조절, 두 번 클릭하여 자동 높이 복원",
globalSettingsTitle: "전역 설정",
shortcutsTitle: "플러그인 바로가기",
shortcutsDescription: "표시 위치를 선택하세요. 아이콘을 드래그하여 순서를 변경할 수 있습니다.",
shortcutsEnabled: "플러그인 바로가기 표시",
shortcutsPosition: "표시 위치",
shortcutsBottom: "연결 트리 하단",
shortcutsSaveFailed: "바로가기 설정을 저장하지 못했습니다",
openResultView: "{label}(으)로 열기",
centerTitle: "플러그인 센터",
centerDescription: "하나의 마켓플레이스에서 선택적 DBX 기능을 찾고, 설치된 플러그인과 저장소를 별도로 관리하세요.",
+18
View File
@@ -169,6 +169,24 @@ export default withEnglishFallback({
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "Ao lado da central de plugins (menu suspenso)",
shortcutsToolbar: "Antes de Atualizar (flutuante)",
shortcutsToolbarCount: "Ícones visíveis",
shortcutsToolbarHint: "0 coloca todos os atalhos no menu. Mais atalhos são agrupados quando falta espaço.",
shortcutsMore: "Mais atalhos de plugins ({count})",
shortcutsLeftTop: "Esquerda · Superior",
shortcutsLeftBottom: "Esquerda · Inferior",
shortcutsRightTop: "Direita · Superior",
shortcutsRightBottom: "Direita · Inferior",
shortcutsVisiblePlugins: "Plugins visíveis",
shortcutsResize: "Arraste para ajustar a altura; clique duas vezes para altura automática",
globalSettingsTitle: "Configurações globais",
shortcutsTitle: "Atalhos de plugins",
shortcutsDescription: "Escolha a posição dos atalhos. Arraste os ícones para reordená-los.",
shortcutsEnabled: "Mostrar atalhos de plugins",
shortcutsPosition: "Posição",
shortcutsBottom: "Abaixo da árvore de conexões",
shortcutsSaveFailed: "Não foi possível salvar os atalhos",
openResultView: "Abrir com {label}",
centerTitle: "Central de plugins",
centerDescription: "Descubra recursos opcionais do DBX em um marketplace unificado e gerencie separadamente plugins instalados e repositórios.",
+18
View File
@@ -116,6 +116,24 @@ export default withEnglishFallback({
name: "DBX",
},
pluginPlatform: {
shortcutsPluginCenter: "Справа от центра плагинов (меню)",
shortcutsToolbar: "Слева от обновления (плавающая)",
shortcutsToolbarCount: "Число видимых значков",
shortcutsToolbarHint: "0 помещает все пункты в меню. При нехватке места остальные также перемещаются в меню.",
shortcutsMore: "Другие плагины ({count})",
shortcutsLeftTop: "Слева · Сверху",
shortcutsLeftBottom: "Слева · Снизу",
shortcutsRightTop: "Справа · Сверху",
shortcutsRightBottom: "Справа · Снизу",
shortcutsVisiblePlugins: "Видимые плагины",
shortcutsResize: "Перетащите для изменения высоты; двойной щелчок — автоматическая высота",
globalSettingsTitle: "Общие настройки",
shortcutsTitle: "Быстрый доступ к плагинам",
shortcutsDescription: "Выберите расположение панели. Перетаскивайте значки, чтобы изменить их порядок.",
shortcutsEnabled: "Показывать быстрый доступ к плагинам",
shortcutsPosition: "Расположение",
shortcutsBottom: "Под деревом подключений",
shortcutsSaveFailed: "Не удалось сохранить настройки быстрого доступа",
openResultView: "Открыть с помощью {label}",
centerTitle: "Центр плагинов",
centerDescription: "Открывайте дополнительные возможности DBX в едином магазине, а затем управляйте установленными плагинами и репозиториями отдельно.",
+13
View File
@@ -82,6 +82,19 @@ const consul = {
};
export default withEnglishFallback({
pluginPlatform: {
shortcutsPluginCenter: "Eklenti merkezinin yanında (açılır menü)",
shortcutsToolbar: "Güncelle düğmesinin solunda (kayan)",
shortcutsToolbarCount: "Görünür simge sayısı",
shortcutsToolbarHint: "0 tüm girişleri menüye taşır. Alan daraldığında diğerleri de menüye taşınır.",
shortcutsMore: "Diğer eklenti kısayolları ({count})",
shortcutsLeftTop: "Sol · Üst",
shortcutsLeftBottom: "Sol · Alt",
shortcutsRightTop: "Sağ · Üst",
shortcutsRightBottom: "Sağ · Alt",
shortcutsVisiblePlugins: "Görünür eklentiler",
shortcutsResize: "Yüksekliği ayarlamak için sürükleyin; otomatik yükseklik için çift tıklayın",
},
cellTransform: {
title: "Dönüştürme önizlemesi",
description: "Geçerli değeri görüntülemek veya kopyalamak için dönüştürür. Özgün değer ve düzenleme taslağı değişmez.",
+18
View File
@@ -91,6 +91,24 @@ export default withEnglishFallback({
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "插件中心右侧(下拉)",
shortcutsToolbar: "更新按钮左侧(浮动)",
shortcutsToolbarCount: "主栏显示数量",
shortcutsToolbarHint: "0 表示全部放入下拉菜单;空间不足时自动收纳。",
shortcutsMore: "更多插件入口({count})",
shortcutsLeftTop: "左侧·顶部优先",
shortcutsLeftBottom: "左侧·底部优先",
shortcutsRightTop: "右侧·顶部优先",
shortcutsRightBottom: "右侧·底部优先",
shortcutsVisiblePlugins: "显示的插件",
shortcutsResize: "拖动调整高度,双击恢复自动高度",
globalSettingsTitle: "全局配置",
shortcutsTitle: "插件快捷入口",
shortcutsDescription: "选择插件快捷入口的显示位置,拖动图标可调整顺序。",
shortcutsEnabled: "显示插件快捷入口",
shortcutsPosition: "显示位置",
shortcutsBottom: "连接树底部",
shortcutsSaveFailed: "插件快捷入口配置保存失败",
openResultView: "用 {label} 打开",
centerTitle: "插件中心",
centerDescription: "在统一商店中发现可选的 DBX 能力,并分别管理已安装插件和插件仓库。",
+18
View File
@@ -169,6 +169,24 @@ export default withEnglishFallback({
},
docs,
pluginPlatform: {
shortcutsPluginCenter: "外掛中心右側(下拉)",
shortcutsToolbar: "更新按鈕左側(浮動)",
shortcutsToolbarCount: "主列顯示數量",
shortcutsToolbarHint: "0 表示全部放入下拉選單;空間不足時自動收納。",
shortcutsMore: "更多外掛程式入口({count})",
shortcutsLeftTop: "左側·頂部優先",
shortcutsLeftBottom: "左側·底部優先",
shortcutsRightTop: "右側·頂部優先",
shortcutsRightBottom: "右側·底部優先",
shortcutsVisiblePlugins: "顯示的外掛程式",
shortcutsResize: "拖曳調整高度,按兩下恢復自動高度",
globalSettingsTitle: "全域設定",
shortcutsTitle: "外掛程式快捷入口",
shortcutsDescription: "選擇外掛程式快捷入口的顯示位置,拖曳圖示可調整順序。",
shortcutsEnabled: "顯示外掛程式快捷入口",
shortcutsPosition: "顯示位置",
shortcutsBottom: "連線樹底部",
shortcutsSaveFailed: "外掛程式快捷入口設定儲存失敗",
openResultView: "用 {label} 開啟",
centerTitle: "外掛中心",
centerDescription: "在統一商店中探索選用的 DBX 能力,並分別管理已安裝外掛與外掛倉庫。",
@@ -80,6 +80,7 @@ export function executePluginCommand(registry: FrontendPluginRegistry, queryStor
};
// singleton (default) reuses the existing instance tab; new forces a fresh one.
queryStore.openPluginWorkbench(pluginId, action.workbench, {
commandId: command.id,
title: command.label,
context,
forceNew: action.reuse === "new",
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { createFrontendPluginRegistry } from "./frontendPlugin";
import { pluginShortcutToolbarCount, pluginShortcutToolbarWidth, clampPluginShortcutHeight, pluginShortcutListHeight, collectPluginShortcuts, movePluginShortcut, normalizePluginShortcutSettings, orderPluginShortcuts } from "./pluginShortcuts";
import type { InstalledPlugin } from "@/types/database";
function plugin(id: string, contributions: unknown[], compatible = true): InstalledPlugin {
return { compatibility: { compatible }, manifest: { id, name: id, version: "1.0.0", drivers: [], contributions } } as InstalledPlugin;
}
const workbench = { type: "workbench", id: "wb", label: "Workbench" };
const command = { type: "command", id: "open", label: "Open", action: { type: "open-workbench", workbench: "wb", presentation: "panel" } };
const placement = { location: "appToolbar", command: "open", group: "navigation", order: 0, default_visible: true };
const provider = { type: "connection-provider", id: "connection", label: "Connection", database_type: "ssh", fields: [], workbench: "wb" };
describe("plugin shortcuts", () => {
it("reserves room for overflow and caps toolbar preferences", () => {
expect(normalizePluginShortcutSettings({ position: "toolbar" })).toMatchObject({ position: "toolbar", toolbarCount: 3 });
expect(normalizePluginShortcutSettings({ toolbarCount: -1 }).toolbarCount).toBe(0);
expect(normalizePluginShortcutSettings({ toolbarCount: 50 }).toolbarCount).toBe(10);
expect(normalizePluginShortcutSettings({ toolbarCount: NaN }).toolbarCount).toBe(3);
expect(pluginShortcutToolbarWidth(0, 3)).toBe(0);
expect(pluginShortcutToolbarWidth(5, 3)).toBe(124);
expect(pluginShortcutToolbarCount(5, 3, 124)).toBe(3);
expect(pluginShortcutToolbarCount(3, 3, 94)).toBe(3);
expect(pluginShortcutToolbarCount(3, 3, 64)).toBe(1);
expect(pluginShortcutToolbarCount(5, 3, 34)).toBe(0);
expect(pluginShortcutToolbarCount(5, 0, 34)).toBe(0);
});
it("migrates old positions and sanitizes new visibility and height preferences", () => {
expect(normalizePluginShortcutSettings({ position: "left" }).position).toBe("left-top");
expect(normalizePluginShortcutSettings({ position: "right" }).position).toBe("right-top");
for (const position of ["left-top", "left-bottom", "right-top", "right-bottom", "sidebar-bottom", "toolbar", "plugin-center"]) expect(normalizePluginShortcutSettings({ position }).position).toBe(position);
expect(normalizePluginShortcutSettings({ hiddenPluginIds: ["a", "a", null, "", "b"], sidebarHeight: NaN })).toMatchObject({ hiddenPluginIds: ["a", "b"], sidebarHeight: null });
expect(normalizePluginShortcutSettings({ sidebarHeight: -1 }).sidebarHeight).toBeNull();
expect(normalizePluginShortcutSettings({ sidebarHeight: 156 }).sidebarHeight).toBe(156);
});
it("fits one entry per row with a five-row automatic limit", () => {
expect(pluginShortcutListHeight(0)).toBe(0);
expect(pluginShortcutListHeight(1)).toBe(36);
expect(pluginShortcutListHeight(3)).toBe(92);
expect(pluginShortcutListHeight(5)).toBe(148);
expect(pluginShortcutListHeight(10)).toBe(148);
expect(pluginShortcutListHeight(3, 32)).toBe(104);
expect(clampPluginShortcutHeight(500, 400)).toBe(280);
expect(clampPluginShortcutHeight(1, 400)).toBe(40);
expect(clampPluginShortcutHeight(40, 130)).toBe(10);
});
it("normalizes old and malformed preferences with independent default arrays", () => {
expect(normalizePluginShortcutSettings(null)).toEqual({ enabled: true, position: "right-top", order: [], hiddenPluginIds: [], sidebarHeight: null, toolbarCount: 3 });
expect(normalizePluginShortcutSettings({ enabled: "false", position: "top", order: [null, "a", "", "a", "b"] })).toEqual({ enabled: true, position: "right-top", order: ["a", "b"], hiddenPluginIds: [], sidebarHeight: null, toolbarCount: 3 });
expect(normalizePluginShortcutSettings({ enabled: false, position: "sidebar-bottom" }).enabled).toBe(false);
});
it("includes legacy standalone workbenches and filesystems but not connection-bound surfaces", () => {
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("legacy", [workbench, { type: "filesystem-provider", id: "files", label: "Files", schemes: ["file"], root_uri: "file:///" }]), plugin("ssh", [provider, workbench]), plugin("bad", [workbench], false)]));
expect(entries.map((entry) => entry.kind)).toEqual(["workbench", "filesystem"]);
expect(entries[1].rootUri).toBe("file:///");
});
it("keeps SSH global commands, deduplicates placements and never invents a connection workbench", () => {
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("ssh", [provider, workbench, command, { type: "menus", id: "menus", items: [placement, placement] }])]));
expect(entries).toHaveLength(1);
expect(entries[0].kind).toBe("command");
});
it("uses commands for standalone workbenches and honors explicit toolbar visibility", () => {
const registry = createFrontendPluginRegistry([
plugin("fallback", [workbench, command]),
plugin("hidden", [workbench, command, { type: "menus", id: "menus", items: [{ ...placement, default_visible: false }] }]),
plugin("conditional", [workbench, command, { type: "menus", id: "menus", items: [{ ...placement, when: { all: [{ key: "connection.state", equals: "connected" }] } }] }]),
plugin("missing", [command, { type: "menus", id: "menus", items: [placement] }]),
]);
expect(collectPluginShortcuts(registry).map((entry) => entry.pluginId)).toEqual(["fallback"]);
});
it("keeps disabled commands disabled and excludes incompatible declared commands", () => {
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("disabled", [workbench, { ...command, enablement: { all: [{ key: "connection.state", equals: "connected" }] } }]), plugin("bad", [workbench, command, { type: "menus", id: "menus", items: [placement] }], false)]));
expect(entries).toHaveLength(1);
expect(entries[0].disabled).toBe(true);
});
it("uses collision-safe IDs independent of labels and preserves unavailable positions on reorder", () => {
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("a", [workbench]), plugin("b", [workbench]), plugin("c", [workbench])]));
const [a, b, c] = entries.map((entry) => entry.id);
const saved = movePluginShortcut([a, "uninstalled", b], [a, b, c], c, a, false);
expect(saved).toEqual([c, "uninstalled", a, b]);
expect(orderPluginShortcuts(entries, saved).map((entry) => entry.id)).toEqual([c, a, b]);
expect(movePluginShortcut(saved, [c, a, b], a, a, true)).toBe(saved);
expect(movePluginShortcut(saved, [c, a, b], "external", a, true)).toBe(saved);
const renamed = collectPluginShortcuts(createFrontendPluginRegistry([plugin("a", [{ ...workbench, label: "工作台" }])]));
expect(renamed[0].id).toBe(a);
});
});
@@ -0,0 +1,124 @@
import { evaluatePluginCommandConditions, type FrontendPluginRegistry } from "./frontendPlugin";
import type { PluginCommandContribution } from "@/types/database";
export type PluginShortcutPosition = "left-top" | "left-bottom" | "right-top" | "right-bottom" | "sidebar-bottom" | "toolbar" | "plugin-center";
export interface PluginShortcutSettings {
enabled: boolean;
position: PluginShortcutPosition;
order: string[];
hiddenPluginIds: string[];
sidebarHeight: number | null;
toolbarCount: number;
}
export function normalizePluginShortcutSettings(value: unknown): PluginShortcutSettings {
const input = (value && typeof value === "object" ? value : {}) as Partial<Omit<PluginShortcutSettings, "position">> & { position?: unknown };
const position = input.position;
const strings = (values: unknown): string[] => (Array.isArray(values) ? [...new Set(values.filter((id): id is string => typeof id === "string" && id.length > 0))] : []);
return {
enabled: typeof input.enabled === "boolean" ? input.enabled : true,
position: position === "left" ? "left-top" : position === "left-top" || position === "left-bottom" || position === "right-bottom" || position === "sidebar-bottom" || position === "toolbar" || position === "plugin-center" ? position : "right-top",
order: strings(input.order),
hiddenPluginIds: strings(input.hiddenPluginIds),
sidebarHeight: typeof input.sidebarHeight === "number" && Number.isFinite(input.sidebarHeight) && input.sidebarHeight > 0 ? input.sidebarHeight : null,
toolbarCount: typeof input.toolbarCount === "number" && Number.isFinite(input.toolbarCount) ? Math.max(0, Math.min(10, Math.floor(input.toolbarCount))) : 3,
};
}
/** 28px buttons, 2px gaps, 2px padding and a 1px border on either side. */
export function pluginShortcutToolbarWidth(count: number, requested: number): number {
return count ? 4 + 30 * (Math.min(count, requested) + (count > requested ? 1 : 0)) : 0;
}
export function pluginShortcutToolbarCount(count: number, requested: number, width: number): number {
const slots = Math.max(1, Math.floor((width - 4) / 30));
return count <= requested && count <= slots ? count : Math.min(requested, slots - 1);
}
/** All sizes are CSS pixels; the caller measures its actual scroll viewport. */
export function pluginShortcutListHeight(count: number, itemSize = 28, gap = 0, padding = 8): number {
const rows = Math.min(5, count);
return rows ? padding + rows * itemSize + (rows - 1) * gap : 0;
}
export function clampPluginShortcutHeight(requested: number, availableHeight: number, minimum = 40): number {
const maximum = Math.max(0, availableHeight - 120);
return Math.min(maximum, Math.max(minimum, requested));
}
export interface PluginShortcutEntry {
id: string;
pluginId: string;
pluginName: string;
kind: "command" | "workbench" | "filesystem";
targetId: string;
label: string;
icon?: string;
rootUri?: string;
command?: PluginCommandContribution;
disabled: boolean;
}
export function collectPluginShortcuts(registry: FrontendPluginRegistry): PluginShortcutEntry[] {
const result = new Map<string, PluginShortcutEntry>();
const add = (pluginId: string, kind: PluginShortcutEntry["kind"], targetId: string, label: string, icon?: string, command?: PluginCommandContribution, rootUri?: string) => {
const plugin = registry.findPlugin(pluginId)?.plugin;
if (!plugin?.compatibility.compatible) return;
if (command && !registry.findWorkbench(pluginId, command.action.workbench)) return;
const id = JSON.stringify([pluginId, kind, targetId]);
result.set(id, {
id,
pluginId,
pluginName: plugin.manifest.name,
kind,
targetId,
label,
icon: icon || plugin.manifest.icon,
command,
rootUri,
disabled: command
? !evaluatePluginCommandConditions(command.enablement?.all, {
surface: command.action.presentation === "panel" ? "panel" : "tab",
"connection.state": "none",
readOnly: false,
})
: false,
});
};
const declared = registry.listToolbarMenuCommands().sort((a, b) => a.order - b.order || `${a.plugin.manifest.id}:${a.command.id}`.localeCompare(`${b.plugin.manifest.id}:${b.command.id}`));
for (const { plugin, command } of declared) add(plugin.manifest.id, "command", command.id, command.label, command.icon, command);
const connected = new Set(registry.listConnectionProviders().map(({ plugin }) => plugin.manifest.id));
for (const { plugin, contribution } of registry.listWorkbenches()) {
const pluginId = plugin.manifest.id;
if (connected.has(pluginId)) continue;
const command = registry.findCommandTargetingWorkbench(pluginId, contribution.id);
if (command) {
// A declared toolbar visibility rule must not be bypassed by the legacy fallback.
const hasPlacement = registry.findPlugin(pluginId)?.contributions.some((item) => item.type === "menus" && item.items.some((placement) => placement.location === "appToolbar" && placement.command === command.id));
if (!hasPlacement) add(pluginId, "command", command.id, command.label, command.icon, command);
} else add(pluginId, "workbench", contribution.id, contribution.label, contribution.icon);
}
const commands = new Set(registry.listCommands().map(({ plugin }) => plugin.manifest.id));
for (const { plugin, contribution } of registry.listFilesystemProviders()) {
if (!connected.has(plugin.manifest.id) && !commands.has(plugin.manifest.id)) {
add(plugin.manifest.id, "filesystem", contribution.id, contribution.label, contribution.icon, undefined, contribution.root_uri);
}
}
return [...result.values()];
}
export function orderPluginShortcuts(entries: PluginShortcutEntry[], order: string[]): PluginShortcutEntry[] {
const indices = new Map(order.map((id, index) => [id, index]));
return [...entries].sort((a, b) => (indices.get(a.id) ?? order.length) - (indices.get(b.id) ?? order.length));
}
/** Reorder visible slots while retaining unavailable plugins at their saved positions. */
export function movePluginShortcut(order: string[], visibleIds: string[], source: string, target: string, after: boolean): string[] {
if (source === target || !visibleIds.includes(source) || !visibleIds.includes(target)) return order;
const moved = visibleIds.filter((id) => id !== source);
moved.splice(moved.indexOf(target) + (after ? 1 : 0), 0, source);
const complete = [...new Set([...order, ...visibleIds])];
const visible = new Set(visibleIds);
let index = 0;
return complete.map((id) => (visible.has(id) ? moved[index++] : id));
}
@@ -0,0 +1,31 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ load: vi.fn(), save: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({ loadEditorSettings: mocks.load, saveEditorSettings: mocks.save }));
import { useSettingsStore } from "../settingsStore";
beforeEach(() => {
setActivePinia(createPinia());
mocks.load.mockResolvedValue({});
mocks.save.mockResolvedValue(undefined);
});
describe("plugin shortcut persistence", () => {
it.each(["sidebar-bottom", "plugin-center"] as const)("restores %s preferences after restart and rolls back failed writes", async (position) => {
let saved: unknown = {};
mocks.load.mockImplementation(async () => saved);
mocks.save.mockImplementation(async (value) => {
saved = JSON.parse(JSON.stringify(value));
});
const first = useSettingsStore();
await first.initEditorSettings();
const pluginShortcuts = { enabled: false, position, order: ["b", "missing", "a"], hiddenPluginIds: ["hidden"], sidebarHeight: 132, toolbarCount: 6 };
await first.updateEditorSettingsAndPersist({ pluginShortcuts });
setActivePinia(createPinia());
const second = useSettingsStore();
await second.initEditorSettings();
expect(second.editorSettings.pluginShortcuts).toEqual(pluginShortcuts);
mocks.save.mockRejectedValueOnce(new Error("disk full"));
await expect(second.updateEditorSettingsAndPersist({ pluginShortcuts: { ...pluginShortcuts, enabled: true } })).rejects.toThrow("disk full");
expect(second.editorSettings.pluginShortcuts).toEqual(pluginShortcuts);
});
});
+7 -1
View File
@@ -3739,12 +3739,17 @@ export const useQueryStore = defineStore("query", () => {
return undefined;
}
function openPluginWorkbench(pluginId: string, contributionId: string, options: { title?: string; connectionId?: string; database?: string; context?: Record<string, unknown>; forceNew?: boolean; refreshContextOnReuse?: boolean } = {}) {
function openPluginWorkbench(pluginId: string, contributionId: string, options: { commandId?: string; title?: string; connectionId?: string; database?: string; context?: Record<string, unknown>; forceNew?: boolean; refreshContextOnReuse?: boolean } = {}) {
const contextConnectionId = typeof options.context?.connectionId === "string" ? options.context.connectionId : "";
const connectionId = options.connectionId || contextConnectionId;
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && tab.pluginWorkbench?.contributionId === contributionId && pluginTabConnectionId(tab) === connectionId);
if (existing) {
// Adopt legacy/bridge-created tabs without changing their live context.
// A shared workbench keeps an existing command's provenance on reuse.
if (existing.pluginWorkbench!.commandId == null && options.commandId) {
existing.pluginWorkbench!.commandId = options.commandId;
}
if (options.refreshContextOnReuse && options.context) {
existing.pluginWorkbench = {
...existing.pluginWorkbench!,
@@ -3792,6 +3797,7 @@ export const useQueryStore = defineStore("query", () => {
isExplaining: false,
mode: "plugin-workbench",
pluginWorkbench: {
commandId: options.commandId,
pluginId,
contributionId,
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
+5
View File
@@ -1,3 +1,4 @@
import { normalizePluginShortcutSettings, type PluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { aiConfigToItem, generateId, getConfigKey } from "@/lib/ai/aiConfigList";
@@ -963,6 +964,7 @@ export interface EditorSettings {
queryExportKeysetOptimizationEnabled: boolean;
updateDownloadSource: UpdateDownloadSource;
ignoredUpdateVersion: string;
pluginShortcuts: PluginShortcutSettings;
toolbarItems: ToolbarItems;
objectBrowserShowCheckbox: boolean;
objectBrowserViewMode: "list" | "grid";
@@ -1235,6 +1237,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
queryExportKeysetOptimizationEnabled: true,
updateDownloadSource: "official",
ignoredUpdateVersion: "",
pluginShortcuts: normalizePluginShortcutSettings(undefined),
toolbarItems: { ...DEFAULT_TOOLBAR_ITEMS },
objectBrowserShowCheckbox: false,
objectBrowserViewMode: "list",
@@ -1840,6 +1843,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled,
updateDownloadSource: normalizeUpdateDownloadSource(settings.updateDownloadSource),
ignoredUpdateVersion: typeof settings.ignoredUpdateVersion === "string" ? settings.ignoredUpdateVersion : DEFAULT_EDITOR_SETTINGS.ignoredUpdateVersion,
pluginShortcuts: normalizePluginShortcutSettings(settings.pluginShortcuts),
toolbarItems: normalizeToolbarItems(settings.toolbarItems),
objectBrowserShowCheckbox: typeof settings.objectBrowserShowCheckbox === "boolean" ? settings.objectBrowserShowCheckbox : DEFAULT_EDITOR_SETTINGS.objectBrowserShowCheckbox,
objectBrowserViewMode: settings.objectBrowserViewMode === "grid" ? "grid" : DEFAULT_EDITOR_SETTINGS.objectBrowserViewMode,
@@ -2628,6 +2632,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;
if (partial.updateDownloadSource !== undefined) editorSettings.value.updateDownloadSource = normalizeUpdateDownloadSource(partial.updateDownloadSource);
if (partial.ignoredUpdateVersion !== undefined) editorSettings.value.ignoredUpdateVersion = typeof partial.ignoredUpdateVersion === "string" ? partial.ignoredUpdateVersion : "";
if (partial.pluginShortcuts !== undefined) editorSettings.value.pluginShortcuts = normalizePluginShortcutSettings(partial.pluginShortcuts);
if (partial.toolbarItems !== undefined) editorSettings.value.toolbarItems = normalizeToolbarItems(partial.toolbarItems);
if (partial.objectBrowserShowCheckbox !== undefined) editorSettings.value.objectBrowserShowCheckbox = partial.objectBrowserShowCheckbox === true;
if (partial.objectBrowserViewMode !== undefined) editorSettings.value.objectBrowserViewMode = partial.objectBrowserViewMode === "grid" ? "grid" : "list";
+2
View File
@@ -1948,6 +1948,8 @@ export interface QueryTab {
| "plugin-workbench"
| "plugin-filesystem";
pluginWorkbench?: {
/** Host command that created this tab; distinct commands can share a workbench. */
commandId?: string;
pluginId: string;
contributionId: string;
context?: Record<string, unknown>;