feat(plugins): pin installed plugins at the top

This commit is contained in:
U1 authored and GitHub committed 2026-09-18 20:05:02 +08:00
1 parent 8454560f66
commit d2fd783592
12 files changed
+205 -22

No files matched your search

@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FrontendPluginDefinition } from "@/lib/plugins/frontendPlugin";
import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from "@/lib/plugins/pluginPinning";
function definition(id: string): FrontendPluginDefinition {
return { plugin: { manifest: { id } } } as unknown as FrontendPluginDefinition;
}
describe("sortPluginsPinnedFirst", () => {
const defs = [definition("a"), definition("b"), definition("c"), definition("d")];
it("moves pinned plugins to the front while keeping relative order", () => {
expect(sortPluginsPinnedFirst(defs, ["c", "a"]).map((d) => d.plugin.manifest.id)).toEqual(["a", "c", "b", "d"]);
});
it("returns the original order when nothing is pinned", () => {
expect(sortPluginsPinnedFirst(defs, [])).toEqual(defs);
});
it("ignores pinned ids that no longer match an installed plugin", () => {
expect(sortPluginsPinnedFirst(defs, ["gone", "d"]).map((d) => d.plugin.manifest.id)).toEqual(["d", "a", "b", "c"]);
});
it("returns the input array identity when no pin applies", () => {
expect(sortPluginsPinnedFirst(defs, ["gone"])).toBe(defs);
});
});
describe("pinned plugin storage", () => {
const backing = new Map<string, string>();
beforeEach(() => {
vi.stubGlobal("localStorage", {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => void backing.set(key, value),
removeItem: (key: string) => void backing.delete(key),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("round-trips ids through localStorage", () => {
savePinnedPluginIds(["io.example.a", "io.example.b"]);
expect(loadPinnedPluginIds()).toEqual(["io.example.a", "io.example.b"]);
savePinnedPluginIds([]);
expect(loadPinnedPluginIds()).toEqual([]);
});
it("tolerates corrupted stored values", () => {
localStorage.setItem("dbx-plugin-pinned-ids", "{not json");
expect(loadPinnedPluginIds()).toEqual([]);
localStorage.setItem("dbx-plugin-pinned-ids", JSON.stringify([1, "ok", null]));
expect(loadPinnedPluginIds()).toEqual(["ok"]);
localStorage.removeItem("dbx-plugin-pinned-ids");
});
});
@@ -0,0 +1,33 @@
import type { FrontendPluginDefinition } from "@/lib/plugins/frontendPlugin";
const PINNED_PLUGINS_STORAGE_KEY = "dbx-plugin-pinned-ids";
export function loadPinnedPluginIds(): string[] {
try {
if (typeof localStorage === "undefined") return [];
const saved = localStorage.getItem(PINNED_PLUGINS_STORAGE_KEY);
const ids = saved ? JSON.parse(saved) : [];
return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
export function savePinnedPluginIds(ids: string[]): void {
try {
if (typeof localStorage === "undefined") return;
localStorage.setItem(PINNED_PLUGINS_STORAGE_KEY, JSON.stringify(ids));
} catch {
// Storage may be unavailable (private mode); pinning just won't persist.
}
}
/** Stable partition: pinned plugins keep their relative order first, then
* the rest follow in their original order. Unknown ids in the pinned list
* are ignored so uninstalling a plugin leaves no phantom pins. */
export function sortPluginsPinnedFirst<T extends FrontendPluginDefinition>(definitions: T[], pinnedIds: string[]): T[] {
const pinned = new Set(pinnedIds);
const pinnedPart = definitions.filter((definition) => pinned.has(definition.plugin.manifest.id));
if (pinnedPart.length === 0) return definitions;
return [...pinnedPart, ...definitions.filter((definition) => !pinned.has(definition.plugin.manifest.id))];
}