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
@@ -80,6 +80,7 @@ export function executePluginCommand(registry: FrontendPluginRegistry, queryStor
|
||||
};
|
||||
// singleton (default) reuses the existing instance tab; new forces a fresh one.
|
||||
queryStore.openPluginWorkbench(pluginId, action.workbench, {
|
||||
commandId: command.id,
|
||||
title: command.label,
|
||||
context,
|
||||
forceNew: action.reuse === "new",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFrontendPluginRegistry } from "./frontendPlugin";
|
||||
import { pluginShortcutToolbarCount, pluginShortcutToolbarWidth, clampPluginShortcutHeight, pluginShortcutListHeight, collectPluginShortcuts, movePluginShortcut, normalizePluginShortcutSettings, orderPluginShortcuts } from "./pluginShortcuts";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
|
||||
function plugin(id: string, contributions: unknown[], compatible = true): InstalledPlugin {
|
||||
return { compatibility: { compatible }, manifest: { id, name: id, version: "1.0.0", drivers: [], contributions } } as InstalledPlugin;
|
||||
}
|
||||
const workbench = { type: "workbench", id: "wb", label: "Workbench" };
|
||||
const command = { type: "command", id: "open", label: "Open", action: { type: "open-workbench", workbench: "wb", presentation: "panel" } };
|
||||
const placement = { location: "appToolbar", command: "open", group: "navigation", order: 0, default_visible: true };
|
||||
const provider = { type: "connection-provider", id: "connection", label: "Connection", database_type: "ssh", fields: [], workbench: "wb" };
|
||||
|
||||
describe("plugin shortcuts", () => {
|
||||
it("reserves room for overflow and caps toolbar preferences", () => {
|
||||
expect(normalizePluginShortcutSettings({ position: "toolbar" })).toMatchObject({ position: "toolbar", toolbarCount: 3 });
|
||||
expect(normalizePluginShortcutSettings({ toolbarCount: -1 }).toolbarCount).toBe(0);
|
||||
expect(normalizePluginShortcutSettings({ toolbarCount: 50 }).toolbarCount).toBe(10);
|
||||
expect(normalizePluginShortcutSettings({ toolbarCount: NaN }).toolbarCount).toBe(3);
|
||||
expect(pluginShortcutToolbarWidth(0, 3)).toBe(0);
|
||||
expect(pluginShortcutToolbarWidth(5, 3)).toBe(124);
|
||||
expect(pluginShortcutToolbarCount(5, 3, 124)).toBe(3);
|
||||
expect(pluginShortcutToolbarCount(3, 3, 94)).toBe(3);
|
||||
expect(pluginShortcutToolbarCount(3, 3, 64)).toBe(1);
|
||||
expect(pluginShortcutToolbarCount(5, 3, 34)).toBe(0);
|
||||
expect(pluginShortcutToolbarCount(5, 0, 34)).toBe(0);
|
||||
});
|
||||
it("migrates old positions and sanitizes new visibility and height preferences", () => {
|
||||
expect(normalizePluginShortcutSettings({ position: "left" }).position).toBe("left-top");
|
||||
expect(normalizePluginShortcutSettings({ position: "right" }).position).toBe("right-top");
|
||||
for (const position of ["left-top", "left-bottom", "right-top", "right-bottom", "sidebar-bottom", "toolbar", "plugin-center"]) expect(normalizePluginShortcutSettings({ position }).position).toBe(position);
|
||||
expect(normalizePluginShortcutSettings({ hiddenPluginIds: ["a", "a", null, "", "b"], sidebarHeight: NaN })).toMatchObject({ hiddenPluginIds: ["a", "b"], sidebarHeight: null });
|
||||
expect(normalizePluginShortcutSettings({ sidebarHeight: -1 }).sidebarHeight).toBeNull();
|
||||
expect(normalizePluginShortcutSettings({ sidebarHeight: 156 }).sidebarHeight).toBe(156);
|
||||
});
|
||||
it("fits one entry per row with a five-row automatic limit", () => {
|
||||
expect(pluginShortcutListHeight(0)).toBe(0);
|
||||
expect(pluginShortcutListHeight(1)).toBe(36);
|
||||
expect(pluginShortcutListHeight(3)).toBe(92);
|
||||
expect(pluginShortcutListHeight(5)).toBe(148);
|
||||
expect(pluginShortcutListHeight(10)).toBe(148);
|
||||
expect(pluginShortcutListHeight(3, 32)).toBe(104);
|
||||
expect(clampPluginShortcutHeight(500, 400)).toBe(280);
|
||||
expect(clampPluginShortcutHeight(1, 400)).toBe(40);
|
||||
expect(clampPluginShortcutHeight(40, 130)).toBe(10);
|
||||
});
|
||||
it("normalizes old and malformed preferences with independent default arrays", () => {
|
||||
expect(normalizePluginShortcutSettings(null)).toEqual({ enabled: true, position: "right-top", order: [], hiddenPluginIds: [], sidebarHeight: null, toolbarCount: 3 });
|
||||
expect(normalizePluginShortcutSettings({ enabled: "false", position: "top", order: [null, "a", "", "a", "b"] })).toEqual({ enabled: true, position: "right-top", order: ["a", "b"], hiddenPluginIds: [], sidebarHeight: null, toolbarCount: 3 });
|
||||
expect(normalizePluginShortcutSettings({ enabled: false, position: "sidebar-bottom" }).enabled).toBe(false);
|
||||
});
|
||||
it("includes legacy standalone workbenches and filesystems but not connection-bound surfaces", () => {
|
||||
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("legacy", [workbench, { type: "filesystem-provider", id: "files", label: "Files", schemes: ["file"], root_uri: "file:///" }]), plugin("ssh", [provider, workbench]), plugin("bad", [workbench], false)]));
|
||||
expect(entries.map((entry) => entry.kind)).toEqual(["workbench", "filesystem"]);
|
||||
expect(entries[1].rootUri).toBe("file:///");
|
||||
});
|
||||
it("keeps SSH global commands, deduplicates placements and never invents a connection workbench", () => {
|
||||
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("ssh", [provider, workbench, command, { type: "menus", id: "menus", items: [placement, placement] }])]));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].kind).toBe("command");
|
||||
});
|
||||
it("uses commands for standalone workbenches and honors explicit toolbar visibility", () => {
|
||||
const registry = createFrontendPluginRegistry([
|
||||
plugin("fallback", [workbench, command]),
|
||||
plugin("hidden", [workbench, command, { type: "menus", id: "menus", items: [{ ...placement, default_visible: false }] }]),
|
||||
plugin("conditional", [workbench, command, { type: "menus", id: "menus", items: [{ ...placement, when: { all: [{ key: "connection.state", equals: "connected" }] } }] }]),
|
||||
plugin("missing", [command, { type: "menus", id: "menus", items: [placement] }]),
|
||||
]);
|
||||
expect(collectPluginShortcuts(registry).map((entry) => entry.pluginId)).toEqual(["fallback"]);
|
||||
});
|
||||
it("keeps disabled commands disabled and excludes incompatible declared commands", () => {
|
||||
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("disabled", [workbench, { ...command, enablement: { all: [{ key: "connection.state", equals: "connected" }] } }]), plugin("bad", [workbench, command, { type: "menus", id: "menus", items: [placement] }], false)]));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].disabled).toBe(true);
|
||||
});
|
||||
it("uses collision-safe IDs independent of labels and preserves unavailable positions on reorder", () => {
|
||||
const entries = collectPluginShortcuts(createFrontendPluginRegistry([plugin("a", [workbench]), plugin("b", [workbench]), plugin("c", [workbench])]));
|
||||
const [a, b, c] = entries.map((entry) => entry.id);
|
||||
const saved = movePluginShortcut([a, "uninstalled", b], [a, b, c], c, a, false);
|
||||
expect(saved).toEqual([c, "uninstalled", a, b]);
|
||||
expect(orderPluginShortcuts(entries, saved).map((entry) => entry.id)).toEqual([c, a, b]);
|
||||
expect(movePluginShortcut(saved, [c, a, b], a, a, true)).toBe(saved);
|
||||
expect(movePluginShortcut(saved, [c, a, b], "external", a, true)).toBe(saved);
|
||||
const renamed = collectPluginShortcuts(createFrontendPluginRegistry([plugin("a", [{ ...workbench, label: "工作台" }])]));
|
||||
expect(renamed[0].id).toBe(a);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { evaluatePluginCommandConditions, type FrontendPluginRegistry } from "./frontendPlugin";
|
||||
import type { PluginCommandContribution } from "@/types/database";
|
||||
|
||||
export type PluginShortcutPosition = "left-top" | "left-bottom" | "right-top" | "right-bottom" | "sidebar-bottom" | "toolbar" | "plugin-center";
|
||||
export interface PluginShortcutSettings {
|
||||
enabled: boolean;
|
||||
position: PluginShortcutPosition;
|
||||
order: string[];
|
||||
hiddenPluginIds: string[];
|
||||
sidebarHeight: number | null;
|
||||
toolbarCount: number;
|
||||
}
|
||||
|
||||
export function normalizePluginShortcutSettings(value: unknown): PluginShortcutSettings {
|
||||
const input = (value && typeof value === "object" ? value : {}) as Partial<Omit<PluginShortcutSettings, "position">> & { position?: unknown };
|
||||
const position = input.position;
|
||||
const strings = (values: unknown): string[] => (Array.isArray(values) ? [...new Set(values.filter((id): id is string => typeof id === "string" && id.length > 0))] : []);
|
||||
return {
|
||||
enabled: typeof input.enabled === "boolean" ? input.enabled : true,
|
||||
position: position === "left" ? "left-top" : position === "left-top" || position === "left-bottom" || position === "right-bottom" || position === "sidebar-bottom" || position === "toolbar" || position === "plugin-center" ? position : "right-top",
|
||||
order: strings(input.order),
|
||||
hiddenPluginIds: strings(input.hiddenPluginIds),
|
||||
sidebarHeight: typeof input.sidebarHeight === "number" && Number.isFinite(input.sidebarHeight) && input.sidebarHeight > 0 ? input.sidebarHeight : null,
|
||||
toolbarCount: typeof input.toolbarCount === "number" && Number.isFinite(input.toolbarCount) ? Math.max(0, Math.min(10, Math.floor(input.toolbarCount))) : 3,
|
||||
};
|
||||
}
|
||||
|
||||
/** 28px buttons, 2px gaps, 2px padding and a 1px border on either side. */
|
||||
export function pluginShortcutToolbarWidth(count: number, requested: number): number {
|
||||
return count ? 4 + 30 * (Math.min(count, requested) + (count > requested ? 1 : 0)) : 0;
|
||||
}
|
||||
|
||||
export function pluginShortcutToolbarCount(count: number, requested: number, width: number): number {
|
||||
const slots = Math.max(1, Math.floor((width - 4) / 30));
|
||||
return count <= requested && count <= slots ? count : Math.min(requested, slots - 1);
|
||||
}
|
||||
|
||||
/** All sizes are CSS pixels; the caller measures its actual scroll viewport. */
|
||||
export function pluginShortcutListHeight(count: number, itemSize = 28, gap = 0, padding = 8): number {
|
||||
const rows = Math.min(5, count);
|
||||
return rows ? padding + rows * itemSize + (rows - 1) * gap : 0;
|
||||
}
|
||||
|
||||
export function clampPluginShortcutHeight(requested: number, availableHeight: number, minimum = 40): number {
|
||||
const maximum = Math.max(0, availableHeight - 120);
|
||||
return Math.min(maximum, Math.max(minimum, requested));
|
||||
}
|
||||
|
||||
export interface PluginShortcutEntry {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
kind: "command" | "workbench" | "filesystem";
|
||||
targetId: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
rootUri?: string;
|
||||
command?: PluginCommandContribution;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function collectPluginShortcuts(registry: FrontendPluginRegistry): PluginShortcutEntry[] {
|
||||
const result = new Map<string, PluginShortcutEntry>();
|
||||
const add = (pluginId: string, kind: PluginShortcutEntry["kind"], targetId: string, label: string, icon?: string, command?: PluginCommandContribution, rootUri?: string) => {
|
||||
const plugin = registry.findPlugin(pluginId)?.plugin;
|
||||
if (!plugin?.compatibility.compatible) return;
|
||||
if (command && !registry.findWorkbench(pluginId, command.action.workbench)) return;
|
||||
const id = JSON.stringify([pluginId, kind, targetId]);
|
||||
result.set(id, {
|
||||
id,
|
||||
pluginId,
|
||||
pluginName: plugin.manifest.name,
|
||||
kind,
|
||||
targetId,
|
||||
label,
|
||||
icon: icon || plugin.manifest.icon,
|
||||
command,
|
||||
rootUri,
|
||||
disabled: command
|
||||
? !evaluatePluginCommandConditions(command.enablement?.all, {
|
||||
surface: command.action.presentation === "panel" ? "panel" : "tab",
|
||||
"connection.state": "none",
|
||||
readOnly: false,
|
||||
})
|
||||
: false,
|
||||
});
|
||||
};
|
||||
const declared = registry.listToolbarMenuCommands().sort((a, b) => a.order - b.order || `${a.plugin.manifest.id}:${a.command.id}`.localeCompare(`${b.plugin.manifest.id}:${b.command.id}`));
|
||||
for (const { plugin, command } of declared) add(plugin.manifest.id, "command", command.id, command.label, command.icon, command);
|
||||
const connected = new Set(registry.listConnectionProviders().map(({ plugin }) => plugin.manifest.id));
|
||||
for (const { plugin, contribution } of registry.listWorkbenches()) {
|
||||
const pluginId = plugin.manifest.id;
|
||||
if (connected.has(pluginId)) continue;
|
||||
const command = registry.findCommandTargetingWorkbench(pluginId, contribution.id);
|
||||
if (command) {
|
||||
// A declared toolbar visibility rule must not be bypassed by the legacy fallback.
|
||||
const hasPlacement = registry.findPlugin(pluginId)?.contributions.some((item) => item.type === "menus" && item.items.some((placement) => placement.location === "appToolbar" && placement.command === command.id));
|
||||
if (!hasPlacement) add(pluginId, "command", command.id, command.label, command.icon, command);
|
||||
} else add(pluginId, "workbench", contribution.id, contribution.label, contribution.icon);
|
||||
}
|
||||
const commands = new Set(registry.listCommands().map(({ plugin }) => plugin.manifest.id));
|
||||
for (const { plugin, contribution } of registry.listFilesystemProviders()) {
|
||||
if (!connected.has(plugin.manifest.id) && !commands.has(plugin.manifest.id)) {
|
||||
add(plugin.manifest.id, "filesystem", contribution.id, contribution.label, contribution.icon, undefined, contribution.root_uri);
|
||||
}
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
export function orderPluginShortcuts(entries: PluginShortcutEntry[], order: string[]): PluginShortcutEntry[] {
|
||||
const indices = new Map(order.map((id, index) => [id, index]));
|
||||
return [...entries].sort((a, b) => (indices.get(a.id) ?? order.length) - (indices.get(b.id) ?? order.length));
|
||||
}
|
||||
|
||||
/** Reorder visible slots while retaining unavailable plugins at their saved positions. */
|
||||
export function movePluginShortcut(order: string[], visibleIds: string[], source: string, target: string, after: boolean): string[] {
|
||||
if (source === target || !visibleIds.includes(source) || !visibleIds.includes(target)) return order;
|
||||
const moved = visibleIds.filter((id) => id !== source);
|
||||
moved.splice(moved.indexOf(target) + (after ? 1 : 0), 0, source);
|
||||
const complete = [...new Set([...order, ...visibleIds])];
|
||||
const visible = new Set(visibleIds);
|
||||
let index = 0;
|
||||
return complete.map((id) => (visible.has(id) ? moved[index++] : id));
|
||||
}
|
||||
Reference in new issue
Block a user