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

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { BadgeCheck, Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, Info, LayoutGrid, Link2, List, Loader2, PackageCheck, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
import { BadgeCheck, Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, Info, LayoutGrid, Link2, List, Loader2, PackageCheck, Pencil, Pin, PinOff, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -13,6 +13,7 @@ import { useToast } from "@/composables/useToast";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import * as api from "@/lib/backend/api";
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from "@/lib/plugins/pluginPinning";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
@@ -106,7 +107,13 @@ const batchRunning = ref(false);
const mutationRunning = computed(() => batchRunning.value || !!marketplaceInstallingKey.value || installing.value || urlInstalling.value || operating.value);
const registry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
const definitions = computed(() => registry.value.listPlugins());
const pinnedPluginIds = ref<string[]>(loadPinnedPluginIds());
const definitions = computed(() => sortPluginsPinnedFirst(registry.value.listPlugins(), pinnedPluginIds.value));
const isPluginPinned = (pluginId: string) => pinnedPluginIds.value.includes(pluginId);
const togglePluginPinned = (pluginId: string) => {
pinnedPluginIds.value = isPluginPinned(pluginId) ? pinnedPluginIds.value.filter((id) => id !== pluginId) : [...pinnedPluginIds.value, pluginId];
savePinnedPluginIds(pinnedPluginIds.value);
};
const connectionProviders = computed(() => registry.value.listConnectionProviders());
const selectedEntry = computed(() => connectionProviders.value.find((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value) || null);
const selectedDefinition = computed(() => definitions.value.find((definition) => definition.plugin.manifest.id === selectedPluginId.value) || null);
@@ -984,31 +991,47 @@ onBeforeUnmount(() => {
</div>
<div class="grid min-h-0 flex-1 gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="space-y-1 rounded-lg border bg-muted/10 p-2">
<button
<div
v-for="definition in definitions"
:key="definition.plugin.manifest.id"
type="button"
class="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-muted"
:data-plugin-id="definition.plugin.manifest.id"
class="group/plugin-row flex w-full items-start rounded-md hover:bg-muted"
:class="batchMode ? (isInstalledSelected(definition.plugin.manifest.id) ? 'bg-muted ring-1 ring-primary/30' : '') : selectedPluginId === definition.plugin.manifest.id ? 'bg-muted ring-1 ring-primary/30' : ''"
:disabled="batchMode && batchRunning"
@click="batchMode ? toggleInstalledSelection(definition.plugin.manifest.id) : selectPlugin(definition.plugin.manifest.id)"
>
<span
v-if="batchMode"
class="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded border transition-colors"
:class="isInstalledSelected(definition.plugin.manifest.id) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 bg-background'"
><Check v-if="isInstalledSelected(definition.plugin.manifest.id)" class="size-3"
/></span>
<Check v-else-if="selectedPluginId === definition.plugin.manifest.id" class="mt-0.5 size-3.5 shrink-0 text-primary" /><span v-else class="mt-0.5 size-3.5 shrink-0" />
<PluginIcon :plugin-id="definition.plugin.manifest.id" :icon="definition.plugin.manifest.icon" class="size-8 rounded-md border bg-background p-1" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ definition.plugin.manifest.name }}</span>
<span class="mt-1 flex flex-wrap gap-1">
<Badge variant="outline" class="h-4 px-1.5 text-[10px]">v{{ definition.plugin.manifest.version || "-" }}</Badge>
<Badge :variant="definition.plugin.compatibility.compatible ? 'secondary' : 'destructive'" class="h-4 px-1.5 text-[10px]">{{ definition.plugin.compatibility.compatible ? t("pluginPlatform.compatible") : t("pluginPlatform.blocked") }}</Badge>
<button type="button" class="flex min-w-0 flex-1 items-start gap-2 px-2 py-2 text-left" :disabled="batchMode && batchRunning" @click="batchMode ? toggleInstalledSelection(definition.plugin.manifest.id) : selectPlugin(definition.plugin.manifest.id)">
<span
v-if="batchMode"
class="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded border transition-colors"
:class="isInstalledSelected(definition.plugin.manifest.id) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 bg-background'"
><Check v-if="isInstalledSelected(definition.plugin.manifest.id)" class="size-3"
/></span>
<Check v-else-if="selectedPluginId === definition.plugin.manifest.id" class="mt-0.5 size-3.5 shrink-0 text-primary" /><span v-else class="mt-0.5 size-3.5 shrink-0" />
<PluginIcon :plugin-id="definition.plugin.manifest.id" :icon="definition.plugin.manifest.icon" class="size-8 rounded-md border bg-background p-1" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ definition.plugin.manifest.name }}</span>
<span class="mt-1 flex flex-wrap gap-1">
<Badge variant="outline" class="h-4 px-1.5 text-[10px]">v{{ definition.plugin.manifest.version || "-" }}</Badge>
<Badge :variant="definition.plugin.compatibility.compatible ? 'secondary' : 'destructive'" class="h-4 px-1.5 text-[10px]">{{ definition.plugin.compatibility.compatible ? t("pluginPlatform.compatible") : t("pluginPlatform.blocked") }}</Badge>
</span>
</span>
</span>
</button>
</button>
<button
v-if="!batchMode"
type="button"
class="mr-2 mt-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
:class="isPluginPinned(definition.plugin.manifest.id) ? 'text-primary' : 'opacity-0 group-hover/plugin-row:opacity-100 focus-visible:opacity-100'"
:aria-pressed="isPluginPinned(definition.plugin.manifest.id)"
:aria-label="isPluginPinned(definition.plugin.manifest.id) ? t('pluginPlatform.unpinPlugin') : t('pluginPlatform.pinPlugin')"
:title="isPluginPinned(definition.plugin.manifest.id) ? t('pluginPlatform.unpinPlugin') : t('pluginPlatform.pinPlugin')"
@click.stop="togglePluginPinned(definition.plugin.manifest.id)"
@keydown.enter.stop.prevent="togglePluginPinned(definition.plugin.manifest.id)"
@keydown.space.stop.prevent="togglePluginPinned(definition.plugin.manifest.id)"
>
<Pin v-if="isPluginPinned(definition.plugin.manifest.id)" class="size-3.5" />
<PinOff v-else class="size-3.5" />
</button>
<span v-else class="mr-2 mt-2 size-6 shrink-0" aria-hidden="true" />
</div>
</div>
<div class="space-y-4 rounded-lg border p-4">
@@ -240,6 +240,60 @@ describe("PluginContributionsPanel batch source validation", () => {
});
});
describe("PluginContributionsPanel installed plugin pin controls", () => {
it("renders selection and pin actions as sibling native buttons", async () => {
state.batchMode = false;
await nextTick();
const pinButton = [...host.querySelectorAll<HTMLElement>("[title]")].find((element) => element.title.startsWith("pluginPlatform.pinPlugin:"));
expect(pinButton).toBeInstanceOf(HTMLButtonElement);
expect(pinButton?.parentElement?.tagName).toBe("DIV");
expect(pinButton?.parentElement?.querySelectorAll(":scope > button")).toHaveLength(2);
expect(pinButton?.parentElement?.querySelector("button [role='button']")).toBeNull();
expect(pinButton?.getAttribute("aria-pressed")).toBe("false");
expect(pinButton?.getAttribute("aria-label")).toBe(pinButton?.title);
state.batchMode = true;
await nextTick();
expect(host.querySelector("[title^='pluginPlatform.pinPlugin:'], [title^='pluginPlatform.unpinPlugin:']")).toBeNull();
expect(host.querySelector("[data-plugin-id='a']")?.querySelectorAll(":scope > button")).toHaveLength(1);
});
it("keeps pin click and keyboard activation isolated from row selection", async () => {
state.batchMode = false;
state.selectedPluginId = "a";
await nextTick();
const row = host.querySelector<HTMLElement>("[data-plugin-id='b']")!;
const [selectButton, pinButton] = [...row.querySelectorAll<HTMLButtonElement>(":scope > button")];
expect(selectButton).toBeInstanceOf(HTMLButtonElement);
expect(pinButton).toBeInstanceOf(HTMLButtonElement);
expect(selectButton.tabIndex).toBe(0);
expect(pinButton.tabIndex).toBe(0);
pinButton.click();
await nextTick();
expect(state.selectedPluginId).toBe("a");
expect(localStorage.getItem("dbx-plugin-pinned-ids")).toBe('["b"]');
expect(pinButton.getAttribute("aria-pressed")).toBe("true");
pinButton.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await nextTick();
expect(state.selectedPluginId).toBe("a");
expect(localStorage.getItem("dbx-plugin-pinned-ids")).toBe("[]");
pinButton.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }));
await nextTick();
expect(state.selectedPluginId).toBe("a");
expect(localStorage.getItem("dbx-plugin-pinned-ids")).toBe('["b"]');
selectButton.click();
await nextTick();
expect(state.selectedPluginId).toBe("b");
expect(localStorage.getItem("dbx-plugin-pinned-ids")).toBe('["b"]');
});
});
const batches = ["install", "uninstall"] as const;
type Batch = (typeof batches)[number];
const singles = ["marketplace", "uninstall", "rollback", "package", "url"] as const;
+2
View File
@@ -198,6 +198,8 @@ export default {
updateBlockedByConnections: "Close these connections before updating or rolling back the plugin: {labels}",
updateBlockedByOperations: "Wait for this plugin's active operations to finish before updating or rolling back.",
updateInProgress: "This plugin is being updated. Try again after the update finishes.",
pinPlugin: "Pin plugin",
unpinPlugin: "Unpin plugin",
noInstalledPlugins: "No plugins installed",
browseMarketplace: "Browse Marketplace",
localInstallTitle: "Install a plugin package",
+2
View File
@@ -193,6 +193,8 @@ export default withEnglishFallback({
pluginHomepage: "Página principal",
marketplaceStatus: { install: "Instalar", installed: "Instalado", update: "Actualizar", unsupported: "No compatible" },
updateSuccess: "Se actualizó {name} a {version}",
pinPlugin: "Fijar plugin",
unpinPlugin: "Quitar fijación",
noInstalledPlugins: "No hay plugins instalados",
browseMarketplace: "Explorar Marketplace",
localInstallTitle: "Instalar un paquete de plugin",
+2
View File
@@ -192,6 +192,8 @@ export default withEnglishFallback({
pluginHomepage: "Sito web",
marketplaceStatus: { install: "Installa", installed: "Installato", update: "Aggiorna", unsupported: "Non supportato" },
updateSuccess: "{name} aggiornato alla versione {version}",
pinPlugin: "Fissa il plugin",
unpinPlugin: "Togli fissaggio",
noInstalledPlugins: "Nessun plugin installato",
browseMarketplace: "Sfoglia Marketplace",
localInstallTitle: "Installa un pacchetto plugin",
+2
View File
@@ -193,6 +193,8 @@ export default withEnglishFallback({
pluginHomepage: "ホームページ",
marketplaceStatus: { install: "インストール", installed: "インストール済み", update: "更新", unsupported: "非対応" },
updateSuccess: "{name} を {version} に更新しました",
pinPlugin: "プラグインをピン留め",
unpinPlugin: "ピン留めを解除",
noInstalledPlugins: "プラグインはまだありません",
browseMarketplace: "マーケットプレイスを見る",
localInstallTitle: "プラグインパッケージをインストール",
+2
View File
@@ -197,6 +197,8 @@ export default withEnglishFallback({
unsupported: "지원되지 않음",
},
updateSuccess: "{name} 업데이트 완료: {version}",
pinPlugin: "플러그인 고정",
unpinPlugin: "고정 해제",
noInstalledPlugins: "설치된 플러그인이 없습니다",
browseMarketplace: "마켓플레이스 둘러보기",
localInstallTitle: "플러그인 패키지 설치",
+2
View File
@@ -193,6 +193,8 @@ export default withEnglishFallback({
pluginHomepage: "Página inicial",
marketplaceStatus: { install: "Instalar", installed: "Instalado", update: "Atualizar", unsupported: "Não compatível" },
updateSuccess: "{name} atualizado para {version}",
pinPlugin: "Fixar plugin",
unpinPlugin: "Desafixar",
noInstalledPlugins: "Nenhum plugin instalado",
browseMarketplace: "Explorar Marketplace",
localInstallTitle: "Instalar pacote de plugin",
+2
View File
@@ -118,6 +118,8 @@ export default withEnglishFallback({
updateBlockedByConnections: "请先关闭以下连接后再更新或回滚插件:{labels}",
updateBlockedByOperations: "该插件仍有操作正在执行,请等待完成后再更新或回滚。",
updateInProgress: "该插件正在更新,请等待完成后重试。",
pinPlugin: "置顶插件",
unpinPlugin: "取消置顶",
noInstalledPlugins: "尚未安装插件",
browseMarketplace: "浏览插件商店",
localInstallTitle: "安装插件包",
+2
View File
@@ -196,6 +196,8 @@ export default withEnglishFallback({
updateBlockedByConnections: "請先關閉以下連線後再更新或回復外掛:{labels}",
updateBlockedByOperations: "此外掛仍有操作正在執行,請等待完成後再更新或回復。",
updateInProgress: "此外掛正在更新,請等待完成後重試。",
pinPlugin: "置頂外掛程式",
unpinPlugin: "取消置頂",
noInstalledPlugins: "尚未安裝外掛",
browseMarketplace: "瀏覽外掛商店",
localInstallTitle: "安裝外掛套件",
@@ -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))];
}