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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -193,6 +193,8 @@ export default withEnglishFallback({
|
||||
pluginHomepage: "ホームページ",
|
||||
marketplaceStatus: { install: "インストール", installed: "インストール済み", update: "更新", unsupported: "非対応" },
|
||||
updateSuccess: "{name} を {version} に更新しました",
|
||||
pinPlugin: "プラグインをピン留め",
|
||||
unpinPlugin: "ピン留めを解除",
|
||||
noInstalledPlugins: "プラグインはまだありません",
|
||||
browseMarketplace: "マーケットプレイスを見る",
|
||||
localInstallTitle: "プラグインパッケージをインストール",
|
||||
|
||||
@@ -197,6 +197,8 @@ export default withEnglishFallback({
|
||||
unsupported: "지원되지 않음",
|
||||
},
|
||||
updateSuccess: "{name} 업데이트 완료: {version}",
|
||||
pinPlugin: "플러그인 고정",
|
||||
unpinPlugin: "고정 해제",
|
||||
noInstalledPlugins: "설치된 플러그인이 없습니다",
|
||||
browseMarketplace: "마켓플레이스 둘러보기",
|
||||
localInstallTitle: "플러그인 패키지 설치",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -118,6 +118,8 @@ export default withEnglishFallback({
|
||||
updateBlockedByConnections: "请先关闭以下连接后再更新或回滚插件:{labels}",
|
||||
updateBlockedByOperations: "该插件仍有操作正在执行,请等待完成后再更新或回滚。",
|
||||
updateInProgress: "该插件正在更新,请等待完成后重试。",
|
||||
pinPlugin: "置顶插件",
|
||||
unpinPlugin: "取消置顶",
|
||||
noInstalledPlugins: "尚未安装插件",
|
||||
browseMarketplace: "浏览插件商店",
|
||||
localInstallTitle: "安装插件包",
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
Reference in new issue
Block a user