feat(plugins): configurable plugin shortcut dock positions
This commit is contained in:
1 parent
b03d51db31
commit
dc22e8c974
41 files changed
+2172
-41
No files matched your search
@@ -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>
|
||||
+3
@@ -56,6 +56,9 @@ vi.mock("@/components/ui/tooltip", async () => {
|
||||
return { Tooltip: stub, TooltipContent: stub, TooltipTrigger: stub };
|
||||
});
|
||||
vi.mock("@/components/plugins/PluginIcon.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("PluginIcon") }));
|
||||
// Shortcut preferences have their own component/store tests. Keep this batch
|
||||
// harness scoped to plugin mutations and their exact backend call counts.
|
||||
vi.mock("@/components/plugins/PluginShortcutSettings.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("PluginShortcutSettings") }));
|
||||
|
||||
import PluginContributionsPanel from "@/components/plugins/PluginContributionsPanel.vue";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 の追加機能を探し、インストール済みプラグインとリポジトリを個別に管理します。",
|
||||
|
||||
@@ -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 기능을 찾고, 설치된 플러그인과 저장소를 별도로 관리하세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 в едином магазине, а затем управляйте установленными плагинами и репозиториями отдельно.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 能力,并分别管理已安装插件和插件仓库。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user