feat(plugins): pin installed plugins at the top
This commit is contained in:
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">
|
||||
|
||||
+54
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user