feat(plugins): add command and menu contributions with bottom dock
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user