feat(plugins): add command and menu contributions with bottom dock

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-23 19:52:11 +08:00
1 parent 67dd9a6229
commit aefecd3841
37 files changed
+2911 -34

No files matched your search

@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useDockResize } from "@/composables/useDockResize";
function pointerEvent(type: string, clientY: number, options: { pointerId?: number; button?: number; buttons?: number } = {}): MouseEvent {
const event = new MouseEvent(type, { bubbles: true, clientY, button: options.button ?? 0 });
Object.defineProperty(event, "pointerId", { value: options.pointerId ?? 1 });
Object.defineProperty(event, "buttons", { value: options.buttons ?? (type === "pointermove" ? 1 : 0) });
return event;
}
function stubWindowHeight(height: number) {
vi.spyOn(window, "innerHeight", "get").mockReturnValue(height);
}
describe("useDockResize", () => {
beforeEach(() => {
stubWindowHeight(1000);
document.body.replaceChildren();
});
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
function setup(rootHeight = 320) {
const dockHeight = ref(rootHeight);
const root = document.createElement("div");
document.body.append(root);
const handle = document.createElement("div");
root.append(handle);
const { startResize } = useDockResize({
dockHeight,
minHeight: 140,
dockElement: () => root,
});
handle.addEventListener("pointerdown", startResize);
return { dockHeight, root, handle };
}
it("grows when dragging the top edge upward and commits the height on pointerup", () => {
const { dockHeight, root, handle } = setup(320);
handle.dispatchEvent(pointerEvent("pointerdown", 680));
document.dispatchEvent(pointerEvent("pointermove", 580));
expect(root.style.height).toBe("420px");
document.dispatchEvent(pointerEvent("pointerup", 580));
expect(dockHeight.value).toBe(420);
});
it("shrinks when dragging downward and clamps at the minimum height", () => {
const { dockHeight, root, handle } = setup(320);
handle.dispatchEvent(pointerEvent("pointerdown", 680));
document.dispatchEvent(pointerEvent("pointermove", 800));
expect(root.style.height).toBe("200px");
document.dispatchEvent(pointerEvent("pointermove", 2000));
expect(root.style.height).toBe("140px");
document.dispatchEvent(pointerEvent("pointerup", 2000));
expect(dockHeight.value).toBe(140);
});
it("caps the height at the viewport fraction shared with the maximize button", () => {
const { dockHeight, handle } = setup(320);
handle.dispatchEvent(pointerEvent("pointerdown", 900));
document.dispatchEvent(pointerEvent("pointermove", 0));
document.dispatchEvent(pointerEvent("pointerup", 0));
expect(dockHeight.value).toBe(800);
});
it("finishes the drag on pointercancel instead of leaking the listeners", () => {
const { dockHeight, root, handle } = setup(320);
handle.dispatchEvent(pointerEvent("pointerdown", 680));
document.dispatchEvent(pointerEvent("pointermove", 500));
expect(root.style.height).toBe("500px");
document.dispatchEvent(pointerEvent("pointercancel", 500));
expect(dockHeight.value).toBe(500);
expect(document.body.querySelector('[aria-hidden="true"]')).toBeNull();
// A later buttonless move must not ghost-drive the dock.
document.dispatchEvent(pointerEvent("pointermove", 200));
expect(dockHeight.value).toBe(500);
expect(root.style.height).toBe("500px");
});
it("finishes the drag when the window loses focus", () => {
const { dockHeight, handle } = setup(320);
handle.dispatchEvent(pointerEvent("pointerdown", 680));
window.dispatchEvent(new Event("blur"));
expect(dockHeight.value).toBe(320);
expect(document.body.querySelector('[aria-hidden="true"]')).toBeNull();
});
it("ignores non-primary-button presses", () => {
const { dockHeight, root, handle } = setup(320);
handle.dispatchEvent(pointerEvent("pointerdown", 680, { button: 2, buttons: 2 }));
document.dispatchEvent(pointerEvent("pointermove", 400, { buttons: 2 }));
expect(root.style.height).toBe("");
document.dispatchEvent(pointerEvent("pointerup", 400));
expect(dockHeight.value).toBe(320);
});
it("starts from the rendered height so a drag out of the maximized state is seamless", () => {
const { dockHeight, root, handle } = setup(320);
vi.spyOn(root, "getBoundingClientRect").mockReturnValue({ height: 700, top: 300 } as DOMRect);
handle.dispatchEvent(pointerEvent("pointerdown", 300));
document.dispatchEvent(pointerEvent("pointermove", 200));
document.dispatchEvent(pointerEvent("pointerup", 200));
expect(dockHeight.value).toBe(800);
});
it("toggles the global resize freeze flag for heavy subtrees", () => {
const { handle } = setup(320);
handle.dispatchEvent(pointerEvent("pointerdown", 680));
expect(document.body.classList.contains("dbx-is-resizing")).toBe(true);
document.dispatchEvent(pointerEvent("pointerup", 600));
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
});
});
@@ -0,0 +1,108 @@
import { getCurrentScope, onScopeDispose, type Ref } from "vue";
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
/**
* Vertical counterpart of `usePanelResize` for the global bottom dock, which
* grows upward from the window's bottom edge (height delta = negated clientY
* delta). Same contract as the horizontal original: pointer capture on the
* handle, rAF-coalesced direct style writes while dragging, reactive commit on
* release, and the global measurement freeze for heavy subtrees.
*
* Pointer capture is not an optimization here: the dock body is plugin
* iframes, and without capture those iframes swallow pointermove/pointerup —
* the height freezes mid-drag and a release over an iframe leaks the
* listeners, letting later buttonless moves ghost-drive the dock.
*/
export interface DockResizeOptions {
/** Reactive dock height in px; committed on drag end. */
dockHeight: Ref<number>;
minHeight: number;
/** Fraction of the window height the dock may occupy. Keep this identical to the maximize height so maximizing never shrinks a dragged dock. */
maxHeightRatio?: number;
/** The dock root element, read for the seamless start height and written during the drag. */
dockElement: () => HTMLElement | null;
}
export function useDockResize({ dockHeight, minHeight, maxHeightRatio = 0.8, dockElement }: DockResizeOptions) {
const maxHeight = () => Math.max(minHeight, Math.floor(window.innerHeight * maxHeightRatio));
let rafId: number | null = null;
let finish: (() => void) | null = null;
function startResize(event: PointerEvent) {
if (event.button !== 0 || finish) return;
event.preventDefault();
const handle = event.currentTarget as HTMLElement | null;
handle?.setPointerCapture(event.pointerId);
beginPanelResize();
const overlay = document.createElement("div");
overlay.setAttribute("aria-hidden", "true");
Object.assign(overlay.style, {
position: "fixed",
inset: "0",
zIndex: "2147483647",
cursor: "row-resize",
userSelect: "none",
touchAction: "none",
});
document.body.append(overlay);
const rendered = dockElement()?.getBoundingClientRect().height ?? 0;
const startHeight = Math.min(maxHeight(), Math.max(minHeight, rendered > 0 ? rendered : dockHeight.value));
const startY = event.clientY;
const root = dockElement();
let currentHeight = startHeight;
const applyHeight = () => {
root?.style.setProperty("height", `${currentHeight}px`);
rafId = null;
};
const onPointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.buttons === 0) {
// The press was lost without a matching pointerup (gesture/iframe
// edge): release instead of ghost-resizing on buttonless moves.
finishResize();
return;
}
const next = startHeight - (moveEvent.clientY - startY);
currentHeight = Math.max(minHeight, Math.min(maxHeight(), next));
// happy-dom unit tests assert styles synchronously (no frame advance),
// so apply immediately under the test mode instead of via rAF.
if (import.meta.env.MODE === "test") {
applyHeight();
return;
}
if (rafId !== null) return;
rafId = requestAnimationFrame(applyHeight);
};
const finishResize = () => {
if (!finish) return;
finish = null;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
root?.style.setProperty("height", `${currentHeight}px`);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", finishResize);
document.removeEventListener("pointercancel", finishResize);
window.removeEventListener("blur", finishResize);
if (handle?.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
endPanelResize();
overlay.remove();
dockHeight.value = currentHeight;
};
finish = finishResize;
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", finishResize);
document.addEventListener("pointercancel", finishResize);
window.addEventListener("blur", finishResize, { once: true });
}
// A dock unmounted mid-drag must not leave the window listeners behind.
if (getCurrentScope()) onScopeDispose(() => finish?.());
return { startResize };
}
+18 -6
View File
@@ -1,4 +1,4 @@
import { computed, ref, watch } from "vue";
import { computed, ref, watch, type Ref } from "vue";
import type { ConnectionConfig } from "@/types/database";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
@@ -29,7 +29,7 @@ const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["r
export interface QuickOpenItem {
id: string;
type: "connection" | "plugin_workbench" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file" | "content_match";
type: "connection" | "plugin_workbench" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file" | "content_match" | "plugin_command";
label: string;
description?: string;
connectionId: string;
@@ -47,11 +47,21 @@ export interface QuickOpenItem {
matchText?: string; // For content matches: matched slice
lineText?: string; // For content matches: full matching line
highlightIndices?: [number, number]; // For content matches: [start, end) chars into lineText to highlight
pluginId?: string; // For plugin workbenches: plugin manifest id
pluginId?: string; // For plugin commands and plugin workbenches: source plugin manifest id
commandId?: string; // For plugin commands: short id of the command contribution
pluginName?: string; // For plugin commands: source plugin display name (provenance hint)
contributionId?: string; // For plugin workbenches: workbench contribution id
pluginIcon?: string; // For plugin workbenches: contribution icon path, falling back to the manifest icon
}
export interface UseQuickOpenOptions {
/**
* External extra items (e.g. plugin commandPalette commands): merged into the initial list and the search result pool,
* refreshed by the caller; with no query they are appended after the built-in entries in injection order.
*/
extraItems?: Ref<QuickOpenItem[]>;
}
export type QuickOpenMatchKind = "exact" | "initials" | "prefix" | "word-prefix" | "substring" | "fuzzy";
export interface QuickOpenMatch {
@@ -272,9 +282,10 @@ function collectSqlFileEntries(entries: SqlFileEntry[], results: SqlFileEntry[])
}
}
export function useQuickOpen() {
export function useQuickOpen(options: UseQuickOpenOptions = {}) {
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
const extraItems = options.extraItems;
const searchQuery = ref("");
const selectedIndex = ref(0);
const remoteItems = ref<QuickOpenItem[]>([]);
@@ -921,7 +932,7 @@ export function useQuickOpen() {
const filteredItems = computed((): MatchedItem[] => {
if (!searchQuery.value.trim()) {
// Show all tree items plus a limited set of recent SQL library files and external SQL files
return [...allItems.value, ...sqlLibraryRecentItems.value, ...sqlFileRecentItems.value].map((item) => ({
return [...allItems.value, ...sqlLibraryRecentItems.value, ...sqlFileRecentItems.value, ...(extraItems?.value ?? [])].map((item) => ({
...item,
matchScore: Infinity,
matchIndices: [],
@@ -932,7 +943,7 @@ export function useQuickOpen() {
const seen = new Set<string>();
// When searching, include ALL SQL library files and external SQL files
for (const item of [...allItems.value, ...sqlLibraryAllItems.value, ...sqlFileItems.value, ...remoteItems.value]) {
for (const item of [...allItems.value, ...sqlLibraryAllItems.value, ...sqlFileItems.value, ...remoteItems.value, ...(extraItems?.value ?? [])]) {
const key = quickOpenItemKey(item);
if (seen.has(key)) continue;
seen.add(key);
@@ -970,6 +981,7 @@ export function useQuickOpen() {
sql_library_file: 12,
sql_file: 13,
content_match: 14,
plugin_command: 15,
};
const typeDifference = typeOrder[a.type] - typeOrder[b.type];
if (typeDifference !== 0) return typeDifference;