feat(plugins): configurable plugin shortcut dock positions

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

No files matched your search

@@ -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 };
}