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
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user