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;