Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/components/settings/sections/PluginsSection.enableToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { test, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { PluginManifest } from "@/plugins/api";

const manifest = (id: string, permissions: string[]): PluginManifest =>
({ id, name: id, version: "1.0.0", description: "", permissions } as PluginManifest);

const DOCKER = manifest("plugin-docker", ["docker:read"]);
const THEME = manifest("emerald-night", ["themes"]);

const loaded = vi.hoisted(() => ({ list: [] as PluginManifest[] }));
const runtime = vi.hoisted(() => ({ setPluginActive: vi.fn() }));
vi.mock("@/plugins/runtime", () => ({
getLoadedPlugins: () => loaded.list,
setPluginActive: runtime.setPluginActive,
pluginStorageGet: vi.fn(async () => null),
pluginStorageSet: vi.fn(async () => {}),
}));
const marketplaceState = {
installedMeta: [] as unknown[], catalog: [] as unknown[],
installing: new Set<string>(),
uninstallPlugin: vi.fn(async () => {}),
uninstallSeededPlugin: vi.fn(async () => {}),
reloadPlugin: vi.fn(async () => {}),
scanLocal: vi.fn(async () => {}),
installPlugin: vi.fn(async () => {}),
fetchManifest: vi.fn(async () => ({ manifest: { permissions: [] }, manifestText: "" })),
appVersion: null as string | null,
loadAppVersion: vi.fn(async () => {}),
};
const FIRST_PARTY_SOURCE = vi.hoisted(() => ({ id: "voltius", name: "Voltius Marketplace", url: "", enabled: true, deletable: false }));
vi.mock("@/stores/marketplaceStore", () => ({
useMarketplaceStore: (selector?: (s: typeof marketplaceState) => unknown) =>
selector ? selector(marketplaceState) : marketplaceState,
FIRST_PARTY_SOURCE,
}));
vi.mock("@/stores/notificationStore", () => ({
useNotificationStore: Object.assign(() => ({ push: vi.fn() }), { getState: () => ({ push: vi.fn() }) }),
}));
vi.mock("@/stores/toggleSettingsStore", () => ({ getToggle: () => false, useToggle: () => false }));
vi.mock("@/components/shared/ToolbarViewControls", () => ({ useFilterShortcut: () => {} }));
vi.mock("@/components/shared/Toggle", () => ({
Toggle: ({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
<button data-testid="enable-toggle" data-checked={String(checked)} onClick={onChange} />
),
}));
vi.mock("@/components/settings/sections/PluginPermissionModal", () => ({ PluginPermissionModal: () => null }));
vi.mock("@/utils/platform", () => ({ useIsAndroid: () => false }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => {}) }));
vi.mock("@/stores/seededTombstoneStore", () => ({
useSeededTombstoneStore: Object.assign((sel?: (s: { removed: string[] }) => unknown) => sel ? sel({ removed: [] }) : { removed: [] }, {
getState: () => ({ removed: [], isRemoved: () => false }),
}),
loadSeededEntries: vi.fn(async () => new Map()),
}));

import { InstalledTab } from "@/components/settings/sections/PluginsSection";
import { usePluginStore } from "@/stores/pluginStore";
import { usePluginRegistryStore } from "@/stores/pluginRegistryStore";

/** Puts a plugin in the EXTERNAL list — the shape `installPlugin` writes. */
function asInstalled(m: PluginManifest) {
loaded.list = [m];
marketplaceState.installedMeta = [
{ id: m.id, version: m.version, sourceId: "voltius", hash: "h", repo: "https://example.com/x" },
];
}

/** Puts a plugin in the BUNDLED list — loaded, with no installedMeta entry. */
function asBundled(m: PluginManifest) {
loaded.list = [m];
marketplaceState.installedMeta = [];
}

beforeEach(() => {
localStorage.clear();
runtime.setPluginActive.mockClear();
usePluginRegistryStore.setState({ overrides: {} });
usePluginStore.setState({ settingsPages: new Map() });
marketplaceState.catalog = [];
marketplaceState.appVersion = null;
});
afterEach(cleanup);

test("an installed non-theme plugin can be disabled, exactly like the bundled copy", () => {
asInstalled(DOCKER);
render(<InstalledTab />);
expect(screen.queryByTestId("enable-toggle")).not.toBeNull();
});

test("the same plugin bundled with the app also shows the toggle", () => {
asBundled(DOCKER);
render(<InstalledTab />);
expect(screen.queryByTestId("enable-toggle")).not.toBeNull();
});

test("a theme plugin is installed-or-not, so it shows no enable toggle when installed", () => {
asInstalled(THEME);
render(<InstalledTab />);
expect(screen.queryByTestId("enable-toggle")).toBeNull();
});

test("a theme plugin shows no enable toggle when bundled either", () => {
asBundled(THEME);
render(<InstalledTab />);
expect(screen.queryByTestId("enable-toggle")).toBeNull();
});

test("toggling an installed plugin off deactivates it and persists the override", () => {
asInstalled(DOCKER);
render(<InstalledTab />);
fireEvent.click(screen.getByTestId("enable-toggle"));
expect(runtime.setPluginActive).toHaveBeenCalledWith("plugin-docker", false);
expect(usePluginRegistryStore.getState().overrides["plugin-docker"]).toBe(false);
});
31 changes: 28 additions & 3 deletions src/components/settings/sections/PluginsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,25 @@ function usePluginInstaller() {

// ─── Installed tab ─────────────────────────────────────────────────────────

/**
* The enable/disable control, and the one rule for whether a plugin gets one.
*
* A theme plugin contributes nothing but themes, so "disabled" and "not installed"
* would mean the same thing to the user — it is installed or it isn't. Everything
* else can be turned off without losing it. This is a property of the PLUGIN, not
* of how it reached the disk: the same plugin must offer the same control whether
* it shipped with the app or was installed from a catalogue.
*/
function EnableToggle({ manifest, enabled, onToggle }: {
manifest: PluginManifest;
enabled: boolean;
onToggle: (id: string, enabled: boolean) => void;
}) {
const themeOnly = manifest.permissions.length === 1 && manifest.permissions[0] === "themes";
if (themeOnly) return null;
return <Toggle checked={enabled} onChange={() => onToggle(manifest.id, enabled)} />;
}

/**
* Seeded first-party plugins have no static entry anywhere — the runtime registry
* is the only place that knows they exist. `excludeIds` strips out externally-installed
Expand Down Expand Up @@ -484,7 +503,7 @@ export function InstalledTab() {
>
<Icon icon={isUninstalling ? "lucide:loader" : "lucide:trash-2"} width={14} className={isUninstalling ? "animate-spin" : ""} />
</button>
<Toggle checked={enabled} onChange={() => handleToggle(manifest.id, enabled)} />
<EnableToggle manifest={manifest} enabled={enabled} onToggle={handleToggle} />
</div>
{manifest.permissions.length > 0 && (
<div className="flex flex-wrap gap-1 px-4 py-2 border-t border-t-(--t-border)">
Expand All @@ -501,6 +520,11 @@ export function InstalledTab() {
{filteredExternal.map((meta) => {
const manifest = externalManifests.find((m) => m.id === meta.id);
const isLoaded = loadedIds.has(meta.id);
// `true` mirrors marketplaceStore's externalPluginActive: installing IS the
// opt-in, so an installed plugin is on unless the user turned it off. Kept
// as a literal rather than imported because that helper reads the store via
// getState(), which would not re-render this row when the override changes.
const enabled = isLoaded && isEnabled(meta.id, true);
const isReloading = reloading.has(meta.id);
const isUninstalling = uninstalling.has(meta.id);
const update = availableUpdate(meta, catalog);
Expand All @@ -511,11 +535,11 @@ export function InstalledTab() {
<div
key={meta.id}
className="rounded-xl overflow-hidden bg-(--t-bg-card)"
style={{ border: `1px solid ${isLoaded ? "var(--t-border-hover)" : "var(--t-border)"}`, opacity: isLoaded ? 1 : 0.7 }}
style={{ border: `1px solid ${enabled ? "var(--t-border-hover)" : "var(--t-border)"}`, opacity: enabled ? 1 : 0.7 }}
>
<div className="flex items-center gap-3 px-4 py-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-(--t-bg-elevated) border border-(--t-border)">
<Icon icon="lucide:puzzle" width={15} style={{ color: isLoaded ? "var(--t-accent)" : "var(--t-text-dim)" }} />
<Icon icon="lucide:puzzle" width={15} style={{ color: enabled ? "var(--t-accent)" : "var(--t-text-dim)" }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -580,6 +604,7 @@ export function InstalledTab() {
>
<Icon icon={isUninstalling ? "lucide:loader" : "lucide:trash-2"} width={14} className={isUninstalling ? "animate-spin" : ""} />
</button>
{manifest && <EnableToggle manifest={manifest} enabled={enabled} onToggle={handleToggle} />}
</div>
{manifest && manifest.permissions.length > 0 && (
<div className="flex flex-wrap gap-1 px-4 py-2 border-t border-t-(--t-border)">
Expand Down