feat(plugins): surface plugin update checks and manual updates (#2513)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-23 17:35:46 +00:00
committed by GitHub
parent 8792a8d0ca
commit 96c48d0517
26 changed files with 2086 additions and 162 deletions
+190 -2
View File
@@ -21,6 +21,8 @@ const {
installPluginUploadSpy,
listPluginsSpy,
syncPluginsSpy,
getMarketplaceCatalogSpy,
refreshMarketplaceSpy,
} = vi.hoisted(() => ({
enablePluginSpy: vi.fn(),
disablePluginSpy: vi.fn(),
@@ -29,6 +31,8 @@ const {
installPluginUploadSpy: vi.fn(),
listPluginsSpy: vi.fn(),
syncPluginsSpy: vi.fn(),
getMarketplaceCatalogSpy: vi.fn(),
refreshMarketplaceSpy: vi.fn(),
}));
vi.mock("@/lib/api/domains/plugins-api", () => ({
@@ -58,6 +62,11 @@ vi.mock("@/lib/api/domains/plugins-api", () => ({
setPluginAutoUpdate: () => Promise.resolve(undefined),
}));
vi.mock("@/lib/api/domains/marketplace-api", () => ({
getMarketplaceCatalog: (...args: unknown[]) => getMarketplaceCatalogSpy(...args),
refreshMarketplace: (...args: unknown[]) => refreshMarketplaceSpy(...args),
}));
const { loadPluginsSpy, unloadPluginSpy } = vi.hoisted(() => ({
loadPluginsSpy: vi.fn(),
unloadPluginSpy: vi.fn(),
@@ -92,8 +101,10 @@ vi.mock("@/components/state-provider", () => ({
import PluginsSettingsPage from "./page";
const PLUGIN_ID = "acme-tools";
const PLUGIN_DISPLAY_NAME = "Acme Tools";
const NEW_PLUGIN_ID = "new-plugin";
const SYNC_BUTTON_TESTID = "plugins-sync-button";
const CHECK_UPDATES_BUTTON_TESTID = "plugins-check-updates-button";
const INSTALL_TRIGGER_TESTID = "install-plugin-trigger";
const INSTALL_URL_INPUT_TESTID = "install-plugin-url-input";
const INSTALL_URL_SUBMIT_TESTID = "install-plugin-url-submit";
@@ -109,7 +120,7 @@ function activePlugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
id: PLUGIN_ID,
api_version: 1,
version: "1.0.0",
display_name: "Acme Tools",
display_name: PLUGIN_DISPLAY_NAME,
description: "desc",
author: "acme",
categories: ["productivity"],
@@ -148,11 +159,38 @@ function emptySyncResult(overrides: Partial<SyncResult> = {}): SyncResult {
return { added: [], installed: [], missing: [], errors: [], ...overrides };
}
function catalogEntry(overrides: Record<string, unknown> = {}) {
return {
id: PLUGIN_ID,
name: PLUGIN_DISPLAY_NAME,
description: "",
author: "acme",
categories: [],
icon_url: "",
repo_url: "",
version: "2.0.0",
min_kandev_version: "",
package_url: "https://example.test/acme-tools-2.0.0.tar.gz",
package_sha256: "",
stars: 0,
updated_at: "",
install_state: "update_available",
installed_version: "1.0.0",
source_id: "official",
source_name: "Kandev Official",
...overrides,
};
}
beforeEach(() => {
uninstallPluginSpy.mockResolvedValue({ deleted: true });
installPluginFromUrlSpy.mockResolvedValue({ plugin: installedPlugin() });
installPluginUploadSpy.mockResolvedValue({ plugin: installedPlugin() });
syncPluginsSpy.mockResolvedValue(emptySyncResult());
getMarketplaceCatalogSpy.mockReset();
getMarketplaceCatalogSpy.mockResolvedValue({ plugins: [], sources: [] });
refreshMarketplaceSpy.mockReset();
refreshMarketplaceSpy.mockResolvedValue({ refreshed: true });
});
afterEach(() => {
@@ -186,7 +224,7 @@ describe("PluginsSettingsPage", () => {
render(<PluginsSettingsPage />);
expect(screen.getByText("Acme Tools")).toBeTruthy();
expect(screen.getByText(PLUGIN_DISPLAY_NAME)).toBeTruthy();
expect(screen.getByText(/acme-tools.*v1\.0\.0/)).toBeTruthy();
expect(screen.getAllByText(/active/i).length).toBeGreaterThan(0);
expect(screen.getAllByTestId("plugin-unsigned-badge")).toHaveLength(1);
@@ -446,6 +484,7 @@ describe("PluginsSettingsPage sync button", () => {
const setPlugins = storeState.setPlugins as ReturnType<typeof vi.fn>;
await vi.waitFor(() => expect(setPlugins).toHaveBeenCalledWith([]));
await vi.waitFor(() => expect(toast.success).toHaveBeenCalledWith("Sync: 1 sideloaded"));
expect(refreshMarketplaceSpy).not.toHaveBeenCalled();
});
it("toasts 'Everything up to date' when the sync finds nothing to do", async () => {
@@ -491,3 +530,152 @@ describe("PluginsSettingsPage sync button", () => {
expect(listPluginsSpy.mock.calls.length).toBe(callsBeforeClick);
});
});
describe("PluginsSettingsPage marketplace updates", () => {
it("checks for updates without a filesystem sync and refreshes the row in place", async () => {
setStoreState([activePlugin()]);
getMarketplaceCatalogSpy.mockResolvedValueOnce({ plugins: [], sources: [] });
getMarketplaceCatalogSpy.mockResolvedValueOnce({ plugins: [catalogEntry()], sources: [] });
render(<PluginsSettingsPage />);
await vi.waitFor(() => expect(getMarketplaceCatalogSpy).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByTestId(CHECK_UPDATES_BUTTON_TESTID));
await vi.waitFor(() =>
expect(screen.getByTestId(`plugin-latest-version-${PLUGIN_ID}`).textContent).toContain(
"2.0.0",
),
);
expect(screen.queryByTestId(`plugin-update-available-${PLUGIN_ID}`)).toBeNull();
expect(screen.getByTestId(`plugin-update-${PLUGIN_ID}`).getAttribute("data-variant")).toBe(
"default",
);
const refreshOrder = refreshMarketplaceSpy.mock.invocationCallOrder[0];
const catalogOrder = getMarketplaceCatalogSpy.mock.invocationCallOrder[1];
expect(refreshOrder).toBeLessThan(catalogOrder);
expect(syncPluginsSpy).not.toHaveBeenCalled();
});
it("shows a checking indicator while a triggered check is in flight, then a last-checked timestamp", async () => {
setStoreState([]);
render(<PluginsSettingsPage />);
await vi.waitFor(() => expect(screen.getByTestId("plugins-updates-last-checked")).toBeTruthy());
let resolveCatalog!: (v: { plugins: unknown[]; sources: unknown[] }) => void;
getMarketplaceCatalogSpy.mockReturnValueOnce(
new Promise((resolve) => {
resolveCatalog = resolve;
}),
);
fireEvent.click(screen.getByTestId(CHECK_UPDATES_BUTTON_TESTID));
await vi.waitFor(() => expect(screen.getByTestId("plugins-updates-checking")).toBeTruthy());
resolveCatalog({ plugins: [], sources: [] });
await vi.waitFor(() => expect(screen.queryByTestId("plugins-updates-checking")).toBeNull());
expect(screen.getByTestId("plugins-updates-last-checked")).toBeTruthy();
});
it("contains a marketplace check failure: the inline error renders while the plugin list and sync summary stay intact", async () => {
setStoreState([activePlugin()]);
getMarketplaceCatalogSpy.mockRejectedValue(new Error("marketplace is unavailable"));
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByTestId(CHECK_UPDATES_BUTTON_TESTID));
await vi.waitFor(() =>
expect(screen.getByTestId("plugins-update-check-error").textContent).toContain(
"marketplace is unavailable",
),
);
expect(screen.getByText(PLUGIN_DISPLAY_NAME)).toBeTruthy();
expect(syncPluginsSpy).not.toHaveBeenCalled();
});
});
describe("PluginsSettingsPage marketplace update lifecycle", () => {
it("reloads the Installed catalog after a successful Browse install", async () => {
setStoreState([]);
getMarketplaceCatalogSpy.mockResolvedValue({
plugins: [
catalogEntry({
id: NEW_PLUGIN_ID,
name: "New Plugin",
version: "1.0.0",
install_state: "available",
installed_version: undefined,
package_url: NEW_PLUGIN_URL,
}),
],
sources: [
{
id: "official",
name: "Official",
url: "https://example.test",
enabled: true,
builtin: true,
healthy: true,
},
],
});
render(<PluginsSettingsPage />);
fireEvent.mouseDown(screen.getByTestId("plugins-tab-browse"));
await vi.waitFor(() =>
expect(screen.getByTestId(`marketplace-install-${NEW_PLUGIN_ID}`)).toBeTruthy(),
);
const callsBeforeInstall = getMarketplaceCatalogSpy.mock.calls.length;
fireEvent.click(screen.getByTestId(`marketplace-install-${NEW_PLUGIN_ID}`));
await vi.waitFor(() => expect(installPluginFromUrlSpy).toHaveBeenCalledWith(NEW_PLUGIN_URL));
await vi.waitFor(() =>
expect(getMarketplaceCatalogSpy.mock.calls.length).toBeGreaterThanOrEqual(
callsBeforeInstall + 2,
),
);
});
it("after a successful manual update, the version updates and the update button disappears", async () => {
setStoreState([activePlugin()]);
getMarketplaceCatalogSpy.mockResolvedValueOnce({ plugins: [catalogEntry()], sources: [] });
installPluginFromUrlSpy.mockResolvedValueOnce({ plugin: activePlugin({ version: "2.0.0" }) });
getMarketplaceCatalogSpy.mockResolvedValueOnce({
plugins: [catalogEntry({ install_state: "installed", installed_version: "2.0.0" })],
sources: [],
});
render(<PluginsSettingsPage />);
await vi.waitFor(() => expect(screen.getByTestId(`plugin-update-${PLUGIN_ID}`)).toBeTruthy());
fireEvent.click(screen.getByTestId(`plugin-update-${PLUGIN_ID}`));
await vi.waitFor(() =>
expect(installPluginFromUrlSpy).toHaveBeenCalledWith(
"https://example.test/acme-tools-2.0.0.tar.gz",
),
);
await vi.waitFor(() => expect(screen.queryByTestId(`plugin-update-${PLUGIN_ID}`)).toBeNull());
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining(PLUGIN_DISPLAY_NAME));
});
it("shows an inline error on a failed manual update and keeps the Update button clickable", async () => {
setStoreState([activePlugin()]);
getMarketplaceCatalogSpy.mockResolvedValue({ plugins: [catalogEntry()], sources: [] });
installPluginFromUrlSpy.mockRejectedValueOnce(new Error("bad checksum"));
render(<PluginsSettingsPage />);
await vi.waitFor(() => expect(screen.getByTestId(`plugin-update-${PLUGIN_ID}`)).toBeTruthy());
fireEvent.click(screen.getByTestId(`plugin-update-${PLUGIN_ID}`));
await vi.waitFor(() =>
expect(screen.getByTestId(`plugin-update-error-${PLUGIN_ID}`).textContent).toContain(
"bad checksum",
),
);
const button = screen.getByTestId(`plugin-update-${PLUGIN_ID}`) as HTMLButtonElement;
expect(button.disabled).toBe(false);
});
});
@@ -15,8 +15,8 @@ import { MarketplaceSourcesDialog } from "./marketplace-sources-dialog";
const ALL_CATEGORIES = "__all__";
type MarketplaceBrowserProps = {
/** Installs a plugin from its package URL; resolves when the install settles. */
onInstallUrl: (url: string) => Promise<void>;
/** Installs a plugin from its package URL; resolves when the install settles. The result is unused here — failures already toast. */
onInstallUrl: (url: string) => Promise<{ ok: boolean; error?: string }>;
};
/** The "Browse" tab: search/filter/sort the catalog and install from it. */
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { PluginRow } from "./plugin-row";
import { PluginRow, type PluginRowUpdateState } from "./plugin-row";
import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins";
afterEach(() => cleanup());
@@ -24,7 +24,7 @@ function plugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
};
}
function updateEntry(): MarketplaceEntry {
function marketplaceEntry(overrides: Partial<MarketplaceEntry> = {}): MarketplaceEntry {
return {
id: "acme",
name: "Acme",
@@ -42,10 +42,25 @@ function updateEntry(): MarketplaceEntry {
install_state: "update_available",
source_id: "official",
source_name: "Kandev Official",
...overrides,
};
}
function updateState(overrides: Partial<PluginRowUpdateState> = {}): PluginRowUpdateState {
return {
latest: marketplaceEntry(),
hasUpdate: true,
checked: true,
busy: false,
...overrides,
};
}
const noop = () => undefined;
const UPDATE_BUTTON_TESTID = "plugin-update-acme";
const LATEST_VERSION_TESTID = "plugin-latest-version-acme";
const UPDATE_BADGE_TESTID = "plugin-update-available-acme";
const NOT_IN_MARKETPLACE_TESTID = "plugin-not-in-marketplace-acme";
// baseProps carries the always-required callbacks/flags so each test only
// spells out the props it is actually asserting on.
@@ -118,17 +133,143 @@ describe("PluginRow update button", () => {
it("shows an Update button with the new version and fires onUpdate", () => {
const onUpdate = vi.fn();
render(
<PluginRow {...baseProps} plugin={plugin()} update={updateEntry()} onUpdate={onUpdate} />,
<PluginRow {...baseProps} plugin={plugin()} update={updateState()} onUpdate={onUpdate} />,
);
const button = screen.getByTestId("plugin-update-acme");
const button = screen.getByTestId(UPDATE_BUTTON_TESTID);
expect(button.textContent).toContain("Update to v2.0.0");
fireEvent.click(button);
expect(onUpdate).toHaveBeenCalledWith(updateEntry());
expect(onUpdate).toHaveBeenCalledWith(marketplaceEntry());
});
it("renders no Update button when there is no pending update", () => {
render(<PluginRow {...baseProps} plugin={plugin()} />);
expect(screen.queryByTestId("plugin-update-acme")).toBeNull();
expect(screen.queryByTestId(UPDATE_BUTTON_TESTID)).toBeNull();
});
it("shows a spinner and 'Updating…' while a manual update is in flight, and disables Enable/Disable/Uninstall", () => {
render(
<PluginRow
{...baseProps}
busy
plugin={plugin()}
update={updateState({ busy: true })}
onUpdate={noop}
/>,
);
const button = screen.getByTestId(UPDATE_BUTTON_TESTID);
expect(button.textContent).toContain("Updating");
expect(button.querySelector(".animate-spin")).not.toBeNull();
expect(button.getAttribute("aria-busy")).toBe("true");
expect((button as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole("button", { name: "Uninstall" }) as HTMLButtonElement).disabled).toBe(
true,
);
});
it("shows an inline error after a failed manual update, and keeps the button clickable", () => {
const onUpdate = vi.fn();
render(
<PluginRow
{...baseProps}
plugin={plugin()}
update={updateState({ error: "bad checksum" })}
onUpdate={onUpdate}
/>,
);
expect(screen.getByTestId("plugin-update-error-acme").textContent).toContain("bad checksum");
const button = screen.getByTestId(UPDATE_BUTTON_TESTID);
expect((button as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(button);
expect(onUpdate).toHaveBeenCalled();
});
});
describe("PluginRow latest version info", () => {
it("shows the latest version without duplicating the update button with a badge", () => {
render(<PluginRow {...baseProps} plugin={plugin()} update={updateState()} onUpdate={noop} />);
expect(screen.getByTestId(LATEST_VERSION_TESTID).textContent).toContain("Latest v2.0.0");
expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull();
expect(screen.getByTestId(UPDATE_BUTTON_TESTID).getAttribute("data-variant")).toBe("default");
});
it("shows the latest version with no badge and no button when already up to date", () => {
render(
<PluginRow
{...baseProps}
plugin={plugin()}
update={updateState({
latest: marketplaceEntry({ version: "1.0.0", install_state: "installed" }),
hasUpdate: false,
})}
/>,
);
expect(screen.getByTestId(LATEST_VERSION_TESTID).textContent).toContain("Latest v1.0.0");
expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull();
expect(screen.queryByTestId(UPDATE_BUTTON_TESTID)).toBeNull();
});
it("shows a not-in-marketplace hint once checked and absent from every catalog", () => {
render(
<PluginRow
{...baseProps}
plugin={plugin()}
update={updateState({ latest: undefined, hasUpdate: false, checked: true })}
/>,
);
expect(screen.getByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeTruthy();
expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull();
});
// Regression: a plugin carried only by a source that failed this check is
// unknown, not delisted — claiming "Not in the marketplace" reads as removal.
it("withholds the not-in-marketplace hint when the check only reached some sources", () => {
render(
<PluginRow
{...baseProps}
plugin={plugin()}
update={updateState({
latest: undefined,
hasUpdate: false,
checked: true,
sourcesDegraded: true,
})}
/>,
);
expect(screen.queryByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeNull();
expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull();
});
it("still shows a known latest version when some other source is degraded", () => {
render(
<PluginRow
{...baseProps}
plugin={plugin()}
update={updateState({ sourcesDegraded: true })}
onUpdate={noop}
/>,
);
expect(screen.getByTestId(LATEST_VERSION_TESTID).textContent).toContain("Latest v2.0.0");
expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull();
expect(screen.getByTestId(UPDATE_BUTTON_TESTID).getAttribute("data-variant")).toBe("default");
});
it("shows neither the latest version nor the not-in-marketplace hint before the first successful check", () => {
render(
<PluginRow
{...baseProps}
plugin={plugin()}
update={updateState({ latest: undefined, hasUpdate: false, checked: false })}
/>,
);
expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull();
expect(screen.queryByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeNull();
});
it("renders nothing update-related when no update prop is passed at all", () => {
render(<PluginRow {...baseProps} plugin={plugin()} />);
expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull();
expect(screen.queryByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeNull();
expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull();
});
});
@@ -160,6 +301,17 @@ describe("PluginRow repo link", () => {
});
});
describe("PluginRow settings link", () => {
it("shows a labeled settings link beside the row actions", () => {
render(<PluginRow {...baseProps} plugin={plugin()} />);
const settingsLink = screen.getByTestId("plugin-settings-link-acme");
expect(settingsLink.textContent).toContain("Settings");
expect(settingsLink.getAttribute("href")).toBe("/settings/plugins/acme");
expect(settingsLink.className).toContain("min-h-11");
});
});
describe("PluginRow error recovery", () => {
it("shows the failure diagnostic and an Enable action for errored plugins", () => {
const onEnable = vi.fn();
@@ -1,7 +1,12 @@
"use client";
import { useRef, useState, type RefObject } from "react";
import { IconArrowUpCircle, IconChevronRight } from "@tabler/icons-react";
import {
IconArrowUpCircle,
IconChevronRight,
IconLoader2,
IconSettings,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
@@ -14,11 +19,38 @@ import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog";
import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins";
import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
/**
* The row's view of its marketplace-update status, computed by
* plugins-settings.tsx from usePluginUpdates + usePluginUpdateAction.
* `checked` mirrors the hook's global flag (true once the first successful
* catalog check has completed) so the row can distinguish "haven't checked
* yet" from "checked and this plugin isn't in any catalog" — never flashing
* a misleading "not in marketplace" before the first response arrives.
*/
export type PluginRowUpdateState = {
/** This plugin's catalog entry, present once checked and found in any enabled source. */
latest?: MarketplaceEntry;
/** True when `latest` is strictly newer than the installed version. */
hasUpdate: boolean;
/** True once a successful catalog check has completed at least once. */
checked: boolean;
/**
* True when the last check reached some sources but not all of them. A
* plugin absent from a partial catalog is unknown, not delisted, so the
* not-in-marketplace hint is withheld.
*/
sourcesDegraded?: boolean;
/** True while a manual update for this plugin is in flight. */
busy: boolean;
/** Set when the last manual update attempt for this plugin failed. */
error?: string;
};
type PluginRowProps = {
plugin: PluginRecord;
busy: boolean;
/** Set when the marketplace has a newer version than the installed one. */
update?: MarketplaceEntry;
/** Marketplace version/update-check state for this plugin; absent when the parent has no update data at all. */
update?: PluginRowUpdateState;
/** The instance-wide auto-update default, used when the plugin has no override. */
autoUpdateDefault: boolean;
/** True while this row's auto-update override request is in flight. */
@@ -63,7 +95,6 @@ export function PluginRow({
onUpdate,
onSetAutoUpdate,
}: PluginRowProps) {
const { t } = useTranslation();
const canEnable =
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
const canDisable = plugin.status === "active" || plugin.status === "error";
@@ -82,6 +113,77 @@ export function PluginRow({
data-testid={`plugin-row-${plugin.id}`}
className="group relative rounded-lg border border-border/70 bg-background p-4 transition-colors hover:border-border hover:bg-muted"
>
<PluginRowContent
plugin={plugin}
update={update}
autoUpdateDefault={autoUpdateDefault}
needsSetup={needsSetup}
isFinePointer={isFinePointer}
mutationBusy={mutationBusy}
canEnable={canEnable}
canDisable={canDisable}
confirmingUninstall={confirmingUninstall}
uninstallAnchorRef={uninstallAnchorRef}
onEnable={onEnable}
onDisable={onDisable}
onUninstall={requestUninstall}
onUpdate={onUpdate}
onSetAutoUpdate={onSetAutoUpdate}
/>
<div className="relative z-10">
<PluginUninstallConfirmation
target={plugin}
open={confirmingUninstall}
isFinePointer={isFinePointer}
anchorRef={uninstallAnchorRef}
onOpenChange={setConfirmingUninstall}
onCancel={cancelUninstall}
onConfirm={confirmUninstall}
/>
</div>
</div>
);
}
type PluginRowContentProps = {
plugin: PluginRecord;
update?: PluginRowUpdateState;
autoUpdateDefault: boolean;
needsSetup: boolean;
isFinePointer: boolean;
mutationBusy: boolean;
canEnable: boolean;
canDisable: boolean;
confirmingUninstall: boolean;
uninstallAnchorRef: RefObject<HTMLButtonElement | null>;
onEnable: (plugin: PluginRecord) => void;
onDisable: (plugin: PluginRecord) => void;
onUninstall: (plugin: PluginRecord) => void;
onUpdate?: (entry: MarketplaceEntry) => void;
onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void;
};
function PluginRowContent({
plugin,
update,
autoUpdateDefault,
needsSetup,
isFinePointer,
mutationBusy,
canEnable,
canDisable,
confirmingUninstall,
uninstallAnchorRef,
onEnable,
onDisable,
onUninstall,
onUpdate,
onSetAutoUpdate,
}: PluginRowContentProps) {
const { t } = useTranslation();
return (
<>
<Link
href={`/settings/plugins/${encodeURIComponent(plugin.id)}`}
aria-label={t("plugins:openSettingsFor", { name: plugin.display_name })}
@@ -96,7 +198,7 @@ export function PluginRow({
*/}
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<PluginRowIdentity plugin={plugin} needsSetup={needsSetup} />
<PluginRowIdentity plugin={plugin} needsSetup={needsSetup} update={update} />
<div className="flex items-center gap-2 shrink-0">
<PluginRowActions
@@ -110,7 +212,7 @@ export function PluginRow({
uninstallAnchorRef={uninstallAnchorRef}
onEnable={onEnable}
onDisable={onDisable}
onUninstall={requestUninstall}
onUninstall={onUninstall}
onUpdate={onUpdate}
/>
<IconChevronRight
@@ -124,6 +226,15 @@ export function PluginRow({
<div className="text-xs text-muted-foreground">{plugin.description}</div>
)}
<PluginErrorDiagnostic plugin={plugin} />
{update?.error && (
<div
role="alert"
data-testid={`plugin-update-error-${plugin.id}`}
className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive [overflow-wrap:anywhere]"
>
{update.error}
</div>
)}
{plugin.categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{plugin.categories.map((category) => (
@@ -141,18 +252,7 @@ export function PluginRow({
onSetAutoUpdate={onSetAutoUpdate}
/>
</div>
<div className="relative z-10">
<PluginUninstallConfirmation
target={plugin}
open={confirmingUninstall}
isFinePointer={isFinePointer}
anchorRef={uninstallAnchorRef}
onOpenChange={setConfirmingUninstall}
onCancel={cancelUninstall}
onConfirm={confirmUninstall}
/>
</div>
</div>
</>
);
}
@@ -191,7 +291,15 @@ function usePluginUninstallConfirmation(
* sits under the card's overlay link, except the repo link, which raises
* itself to z-10 to keep its own click.
*/
function PluginRowIdentity({ plugin, needsSetup }: { plugin: PluginRecord; needsSetup: boolean }) {
function PluginRowIdentity({
plugin,
needsSetup,
update,
}: {
plugin: PluginRecord;
needsSetup: boolean;
update?: PluginRowUpdateState;
}) {
const { t } = useTranslation();
return (
<div className="min-w-0 space-y-1">
@@ -227,11 +335,49 @@ function PluginRowIdentity({ plugin, needsSetup }: { plugin: PluginRecord; needs
{plugin.id} · v{plugin.version}
</span>
<PluginRepoLink url={plugin.repo_url} className="relative z-10" />
<PluginUpdateInfo pluginId={plugin.id} update={update} />
</div>
</div>
);
}
/**
* The latest-known marketplace version for this plugin, once a catalog check
* has completed at least once: "Latest v<x>" or a not-in-marketplace hint
* when no source carries this plugin id at all. The update action itself is
* the update-available affordance, so the row does not repeat it as a badge.
* Renders nothing before the first successful check — a stale "not in
* marketplace" flash would be actively misleading while the marketplace
* hasn't been queried yet — and likewise nothing when the check only reached
* some sources, since a plugin carried solely by the source that failed is
* unknown, not delisted.
*/
function PluginUpdateInfo({
pluginId,
update,
}: {
pluginId: string;
update?: PluginRowUpdateState;
}) {
const { t } = useTranslation();
if (!update?.checked) return null;
if (!update.latest) {
if (update.sourcesDegraded) return null;
return (
<span data-testid={`plugin-not-in-marketplace-${pluginId}`}>
{t("plugins:notInMarketplace")}
</span>
);
}
return (
<span data-testid={`plugin-latest-version-${pluginId}`}>
{t("plugins:latestVersion", { version: update.latest.version })}
</span>
);
}
/**
* The per-plugin auto-update control. The switch reflects the effective state
* (the plugin's own override, or the instance-wide default when it has none);
@@ -326,19 +472,27 @@ function PluginRowActions({
onUpdate,
}: PluginRowActionsProps) {
const { t } = useTranslation();
const updateEntry = update?.hasUpdate ? update.latest : undefined;
return (
<div className="relative z-10 flex flex-wrap items-center gap-2 shrink-0">
{update && onUpdate && (
{updateEntry && onUpdate && (
<Button
variant="outline"
variant="default"
size="sm"
data-testid={`plugin-update-${plugin.id}`}
className="cursor-pointer gap-1 min-h-11 sm:min-h-0"
aria-busy={update?.busy ? "true" : undefined}
disabled={busy}
onClick={() => onUpdate(update)}
onClick={() => onUpdate(updateEntry)}
>
<IconArrowUpCircle className="h-4 w-4" />
{busy ? t("plugins:updating") : t("plugins:updateToVersion", { version: update.version })}
{update?.busy ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconArrowUpCircle className="h-4 w-4" />
)}
{update?.busy
? t("plugins:updating")
: t("plugins:updateToVersion", { version: updateEntry.version })}
</Button>
)}
{canEnable && (
@@ -375,6 +529,15 @@ function PluginRowActions({
{t("plugins:uninstall")}
</Button>
)}
<Link
href={`/settings/plugins/${encodeURIComponent(plugin.id)}`}
data-testid={`plugin-settings-link-${plugin.id}`}
aria-label={t("plugins:openSettingsFor", { name: plugin.display_name })}
className="inline-flex min-h-11 shrink-0 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer sm:min-h-0"
>
<IconSettings className="h-4 w-4" aria-hidden />
{t("plugins:settings")}
</Link>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { useTranslation } from "react-i18next";
import { formatDateTime } from "@/lib/i18n/formats";
type PluginUpdateStatusProps = {
checking: boolean;
lastCheckedAt: string | null;
error: string | null;
};
/**
* The status strip below the plugin toolbar explains the independent Sync
* and marketplace-check actions, then reports the outcome of the last check.
* A check failure never blocks the plugin list; it only shows here.
*/
export function PluginUpdateStatus({ checking, lastCheckedAt, error }: PluginUpdateStatusProps) {
const { t } = useTranslation();
return (
<div className="space-y-1 text-xs text-muted-foreground">
<p>{t("plugins:updateCheckDescription")}</p>
{checking && <p data-testid="plugins-updates-checking">{t("plugins:checkingForUpdates")}</p>}
{!checking && lastCheckedAt && (
<p data-testid="plugins-updates-last-checked">
{t("plugins:updatesLastChecked", { time: formatDateTime(lastCheckedAt) })}
</p>
)}
{error && (
<div
role="alert"
data-testid="plugins-update-check-error"
className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive [overflow-wrap:anywhere]"
>
{error}
</div>
)}
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -12,11 +12,12 @@ import { useAutoUpdateSettings } from "@/hooks/domains/plugins/use-auto-update-s
import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
import { usePluginSetupStatus } from "@/hooks/domains/plugins/use-plugin-setup-status";
import { usePluginUpdates } from "@/hooks/domains/plugins/use-plugin-updates";
import type { MarketplaceEntry } from "@/lib/types/plugins";
import { InstallPluginDialog } from "./install-plugin-dialog";
import { MarketplaceBrowser } from "./marketplace-browser";
import { PluginRow } from "./plugin-row";
import { PluginRow, type PluginRowUpdateState } from "./plugin-row";
import { PluginUpdateStatus } from "./plugin-update-status";
import { usePluginActions } from "./use-plugin-actions";
import { usePluginUpdateAction } from "./use-plugin-update-action";
import { settingsActionClassName } from "@/components/settings/settings-control";
/**
@@ -30,20 +31,19 @@ export function PluginsSettings() {
const list = usePlugins();
const actions = usePluginActions();
const autoUpdate = useAutoUpdateSettings();
const { updates, reload: reloadUpdates } = usePluginUpdates();
const [updatingId, setUpdatingId] = useState<string | null>(null);
const updates = usePluginUpdates();
const installedIds = useMemo(() => new Set(list.items.map((p) => p.id)), [list.items]);
const updateAction = usePluginUpdateAction(
actions.marketplaceInstall,
updates.reload,
installedIds,
updates.markUpdated,
);
// Update = install the newer package over the current one (marketplaceInstall
// upserts the new record into the store, so the row's version refreshes),
// then re-check the catalog so the resolved update drops off the row.
const handleUpdate = async (entry: MarketplaceEntry) => {
setUpdatingId(entry.id);
try {
await actions.marketplaceInstall(entry.package_url);
reloadUpdates();
} finally {
setUpdatingId(null);
}
const handleMarketplaceInstall = async (url: string) => {
const result = await actions.marketplaceInstall(url);
if (result.ok) await updates.reload(result.pluginId);
return result;
};
return (
@@ -75,14 +75,13 @@ export function PluginsSettings() {
actions={actions}
autoUpdate={autoUpdate}
updates={updates}
updatingId={updatingId}
updateAction={updateAction}
isFinePointer={isFinePointer}
onUpdate={handleUpdate}
/>
</TabsContent>
<TabsContent value="browse">
<MarketplaceBrowser onInstallUrl={actions.marketplaceInstall} />
<MarketplaceBrowser onInstallUrl={handleMarketplaceInstall} />
</TabsContent>
</Tabs>
@@ -102,23 +101,22 @@ type InstalledTabProps = {
list: ReturnType<typeof usePlugins>;
actions: ReturnType<typeof usePluginActions>;
autoUpdate: ReturnType<typeof useAutoUpdateSettings>;
updates: Map<string, MarketplaceEntry>;
updatingId: string | null;
updates: ReturnType<typeof usePluginUpdates>;
updateAction: ReturnType<typeof usePluginUpdateAction>;
isFinePointer: boolean;
onUpdate: (entry: MarketplaceEntry) => void;
};
/** The Installed tab: auto-update toggle, sync/install toolbar, sync errors, and the plugin list. */
/** The Installed tab: auto-update toggle, sync/install toolbar, update status, sync errors, and the plugin list. */
function InstalledTab({
list,
actions,
autoUpdate,
updates,
updatingId,
updateAction,
isFinePointer,
onUpdate,
}: InstalledTabProps) {
const { t } = useTranslation();
return (
<>
<GlobalAutoUpdateToggle settings={autoUpdate} />
@@ -136,6 +134,16 @@ function InstalledTab({
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
{t("plugins:sync")}
</Button>
<Button
data-testid="plugins-check-updates-button"
variant="secondary"
disabled={updates.checking}
onClick={updates.checkForUpdates}
className={settingsActionClassName("cursor-pointer")}
>
<IconRefresh className={`h-4 w-4 ${updates.checking ? "animate-spin" : ""}`} />
{t("plugins:checkForUpdates")}
</Button>
<Button
data-testid="install-plugin-trigger"
onClick={actions.openInstall}
@@ -146,6 +154,12 @@ function InstalledTab({
</div>
</div>
<PluginUpdateStatus
checking={updates.checking}
lastCheckedAt={updates.lastCheckedAt}
error={updates.error}
/>
{actions.syncErrors.length > 0 && (
<div
data-testid="plugins-sync-errors"
@@ -164,9 +178,8 @@ function InstalledTab({
actions={actions}
autoUpdateDefault={autoUpdate.autoUpdateDefault}
updates={updates}
updatingId={updatingId}
updateAction={updateAction}
isFinePointer={isFinePointer}
onUpdate={onUpdate}
/>
</>
);
@@ -210,10 +223,9 @@ type PluginListProps = {
list: ReturnType<typeof usePlugins>;
actions: ReturnType<typeof usePluginActions>;
autoUpdateDefault: boolean;
updates: Map<string, MarketplaceEntry>;
updatingId: string | null;
updates: ReturnType<typeof usePluginUpdates>;
updateAction: ReturnType<typeof usePluginUpdateAction>;
isFinePointer: boolean;
onUpdate: (entry: MarketplaceEntry) => void;
};
function PluginList({
@@ -221,9 +233,8 @@ function PluginList({
actions,
autoUpdateDefault,
updates,
updatingId,
updateAction,
isFinePointer,
onUpdate,
}: PluginListProps) {
const { t } = useTranslation();
const { items, loaded, loading, error } = list;
@@ -255,26 +266,36 @@ function PluginList({
return (
<div className="space-y-3">
{items.map((plugin) => (
<PluginRow
key={plugin.id}
plugin={plugin}
busy={actions.busyId === plugin.id || updatingId === plugin.id}
update={updates.get(plugin.id)}
autoUpdateDefault={autoUpdateDefault}
autoUpdateBusy={actions.autoUpdateBusyId === plugin.id}
needsSetup={needsSetup.has(plugin.id)}
isFinePointer={isFinePointer}
uninstallBusy={actions.uninstallBusy}
onEnable={actions.handleEnable}
onDisable={actions.handleDisable}
onConfirmUninstall={async (target) => {
await actions.confirmUninstall(target);
}}
onUpdate={onUpdate}
onSetAutoUpdate={actions.handleSetAutoUpdate}
/>
))}
{items.map((plugin) => {
const rowUpdate: PluginRowUpdateState = {
latest: updates.latestById.get(plugin.id),
hasUpdate: updates.updates.has(plugin.id),
checked: updates.checked,
sourcesDegraded: updates.sourcesDegraded,
busy: updateAction.updatingIds.has(plugin.id),
error: updateAction.errorsById.get(plugin.id),
};
return (
<PluginRow
key={plugin.id}
plugin={plugin}
busy={actions.busyId === plugin.id || rowUpdate.busy}
update={rowUpdate}
autoUpdateDefault={autoUpdateDefault}
autoUpdateBusy={actions.autoUpdateBusyId === plugin.id}
needsSetup={needsSetup.has(plugin.id)}
isFinePointer={isFinePointer}
uninstallBusy={actions.uninstallBusy}
onEnable={actions.handleEnable}
onDisable={actions.handleDisable}
onConfirmUninstall={async (target) => {
await actions.confirmUninstall(target);
}}
onUpdate={updateAction.runUpdate}
onSetAutoUpdate={actions.handleSetAutoUpdate}
/>
);
})}
</div>
);
}
@@ -11,11 +11,21 @@ const installPluginFromUrl = vi.fn<() => Promise<InstallResult>>();
const installPluginUpload = vi.fn<() => Promise<InstallResult>>();
const enablePlugin = vi.fn(async () => ({ enabled: true }));
const getPlugin = vi.fn<() => Promise<PluginRecord>>();
const syncPlugins = vi.fn();
const listPlugins = vi.fn(async () => []);
const uninstallPlugin = vi.fn<() => Promise<{ deleted: boolean }>>();
const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() }));
const { toastError, toastSuccess, toastWarning } = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}));
vi.mock("@/lib/toast/sonner", () => ({
toast: { error: (...args: unknown[]) => toastError(...args) },
toast: {
error: (...args: unknown[]) => toastError(...args),
success: (...args: unknown[]) => toastSuccess(...args),
warning: (...args: unknown[]) => toastWarning(...args),
},
}));
vi.mock("@/lib/plugins/host", () => ({
@@ -33,6 +43,8 @@ vi.mock("@/lib/api/domains/plugins-api", async () => {
installPluginUpload: (...args: unknown[]) => installPluginUpload(...(args as [])),
enablePlugin: (...args: unknown[]) => enablePlugin(...(args as [])),
getPlugin: (...args: unknown[]) => getPlugin(...(args as [])),
syncPlugins: (...args: unknown[]) => syncPlugins(...(args as [])),
listPlugins: (...args: unknown[]) => listPlugins(...(args as [])),
uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...(args as [])),
};
});
@@ -70,8 +82,13 @@ beforeEach(() => {
installPluginUpload.mockReset();
enablePlugin.mockClear();
getPlugin.mockReset();
syncPlugins.mockReset();
listPlugins.mockClear();
listPlugins.mockResolvedValue([]);
uninstallPlugin.mockReset();
toastError.mockReset();
toastSuccess.mockReset();
toastWarning.mockReset();
});
describe("usePluginActions — install/update", () => {
@@ -171,6 +188,63 @@ describe("usePluginActions — enable", () => {
});
});
describe("usePluginActions — marketplaceInstall result", () => {
it("resolves ok: true on a successful install", async () => {
const plugin = activeRecord();
installPluginFromUrl.mockResolvedValue({ plugin });
const { result } = renderHook(() => usePluginActions(), { wrapper });
let outcome: { ok: boolean; error?: string; pluginId?: string } | undefined;
await act(async () => {
outcome = await result.current.marketplaceInstall("https://example.com/acme-tools.tar.gz");
});
expect(outcome).toEqual({ ok: true, pluginId: plugin.id });
});
it("resolves ok: false with the backend's error message on a failed install, without throwing", async () => {
installPluginFromUrl.mockRejectedValue(new Error("bad checksum"));
const { result } = renderHook(() => usePluginActions(), { wrapper });
let outcome: { ok: boolean; error?: string } | undefined;
await act(async () => {
outcome = await result.current.marketplaceInstall("https://example.com/acme-tools.tar.gz");
});
expect(outcome).toEqual({ ok: false, error: "bad checksum" });
});
});
describe("usePluginActions — handleSync result", () => {
it("resolves ok: true after a successful sync", async () => {
syncPlugins.mockResolvedValue({ added: [], installed: [], missing: [], errors: [] });
const { result } = renderHook(() => usePluginActions(), { wrapper });
let outcome: { ok: boolean } | undefined;
await act(async () => {
outcome = await result.current.handleSync();
});
expect(outcome).toEqual({ ok: true });
});
it("resolves ok: false when the sync call fails, without throwing", async () => {
syncPlugins.mockRejectedValue(new Error("backend unreachable"));
const { result } = renderHook(() => usePluginActions(), { wrapper });
let outcome: { ok: boolean } | undefined;
await act(async () => {
outcome = await result.current.handleSync();
});
expect(outcome).toEqual({ ok: false });
});
});
describe("usePluginActions — uninstall", () => {
it("confirms an explicitly supplied target and keeps busy state until API cleanup settles", async () => {
const plugin = activeRecord();
@@ -230,13 +230,21 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
// toast rather than the dialog-scoped installError region — the Browse tab
// has no such region. It resolves even on failure (after toasting) so its
// fire-and-forget onClick callers never leak an unhandled rejection; their
// try/finally still clears per-entry busy state.
const marketplaceInstall = async (url: string) => {
// try/finally still clears per-entry busy state. The resolved
// `{ ok, error, pluginId }` lets callers that need the outcome (the manual
// update action and the Browse tab) reconcile local update state without
// also duplicating the toast.
const marketplaceInstall = async (
url: string,
): Promise<{ ok: boolean; error?: string; pluginId?: string }> => {
try {
const result = await installPluginFromUrl(url);
await afterInstall(result);
return { ok: true, pluginId: result.plugin.id };
} catch (err) {
toast.error(err instanceof Error ? err.message : t("plugins:failedToInstallPlugin"));
const message = err instanceof Error ? err.message : t("plugins:failedToInstallPlugin");
toast.error(message);
return { ok: false, error: message };
}
};
@@ -264,12 +272,17 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
* UI bundle, but (unlike install/enable) this does not hot-load it — an
* operator can re-enable it (or reload) to pick up the bundle; wiring a
* silent hot-load here is out of scope for the sync button itself.
*
* `handleSync` resolves `{ ok }` (never throws) so a caller that chains a
* marketplace update check after the sync (plugins-settings.tsx) can skip
* that second request when the sync itself already failed — avoiding two
* stacked error toasts for what's likely one unreachable-backend root cause.
*/
function useSyncAction(setPlugins: (plugins: PluginRecord[]) => void) {
const [syncBusy, setSyncBusy] = useState(false);
const [syncErrors, setSyncErrors] = useState<SyncError[]>([]);
const handleSync = async () => {
const handleSync = async (): Promise<{ ok: boolean }> => {
setSyncBusy(true);
try {
const result = await syncPlugins();
@@ -277,8 +290,10 @@ function useSyncAction(setPlugins: (plugins: PluginRecord[]) => void) {
setPlugins(refreshed);
setSyncErrors(result.errors ?? []);
toast.success(summarizeSyncResult(result));
return { ok: true };
} catch (err) {
toast.error(err instanceof Error ? err.message : t("plugins:failedToSyncPlugins"));
return { ok: false };
} finally {
setSyncBusy(false);
}
@@ -0,0 +1,291 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { MarketplaceEntry } from "@/lib/types/plugins";
import { usePluginUpdateAction } from "./use-plugin-update-action";
const ENTRY_ID = "acme";
const CHECKSUM_ERROR = "bad checksum";
function entry(overrides: Partial<MarketplaceEntry> = {}): MarketplaceEntry {
return {
id: ENTRY_ID,
name: "Acme",
description: "",
author: "acme",
categories: [],
icon_url: "",
repo_url: "",
version: "2.0.0",
min_kandev_version: "",
package_url: "https://ex/acme-2.0.0.tar.gz",
package_sha256: "",
stars: 0,
updated_at: "",
install_state: "update_available",
source_id: "official",
source_name: "Kandev Official",
...overrides,
};
}
type MarketplaceInstall = (url: string) => Promise<{ ok: boolean; error?: string }>;
// Every id these tests act on is installed unless a case says otherwise.
const INSTALLED: ReadonlySet<string> = new Set([ENTRY_ID, "a", "b"]);
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
describe("usePluginUpdateAction", () => {
let marketplaceInstall: ReturnType<typeof vi.fn<MarketplaceInstall>>;
let reloadUpdates: ReturnType<typeof vi.fn<() => Promise<void>>>;
beforeEach(() => {
marketplaceInstall = vi.fn<MarketplaceInstall>();
reloadUpdates = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
});
it("tracks the updating id while the install is in flight, then clears it and re-checks the catalog", async () => {
let resolveInstall!: (v: { ok: boolean }) => void;
marketplaceInstall.mockReturnValue(
new Promise((resolve) => {
resolveInstall = resolve;
}),
);
const { result } = renderHook(() =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, INSTALLED),
);
let runPromise!: Promise<void>;
act(() => {
runPromise = result.current.runUpdate(entry());
});
expect(result.current.updatingIds.has(ENTRY_ID)).toBe(true);
await act(async () => {
resolveInstall({ ok: true });
await runPromise;
});
expect(result.current.updatingIds.has(ENTRY_ID)).toBe(false);
expect(marketplaceInstall).toHaveBeenCalledWith("https://ex/acme-2.0.0.tar.gz");
expect(reloadUpdates).toHaveBeenCalledTimes(1);
});
it("marks a successful install as current before rechecking the catalog", async () => {
marketplaceInstall.mockResolvedValue({ ok: true });
const markUpdated = vi.fn();
const { result } = renderHook(() =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, INSTALLED, markUpdated),
);
await act(async () => {
await result.current.runUpdate(entry());
});
expect(markUpdated).toHaveBeenCalledWith(ENTRY_ID);
});
it("records a per-row error on failure without throwing, and still re-checks the catalog", async () => {
marketplaceInstall.mockResolvedValue({ ok: false, error: CHECKSUM_ERROR });
const { result } = renderHook(() =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, INSTALLED),
);
await act(async () => {
await result.current.runUpdate(entry());
});
expect(result.current.errorsById.get(ENTRY_ID)).toBe(CHECKSUM_ERROR);
expect(result.current.updatingIds.has(ENTRY_ID)).toBe(false);
expect(reloadUpdates).toHaveBeenCalledTimes(1);
});
it("keeps the row busy until the post-install catalog reload settles", async () => {
marketplaceInstall.mockResolvedValue({ ok: true });
const catalogReload = deferred<void>();
reloadUpdates.mockReturnValueOnce(catalogReload.promise);
const { result } = renderHook(() =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, INSTALLED),
);
let runPromise!: Promise<void>;
act(() => {
runPromise = result.current.runUpdate(entry());
});
await act(async () => {
await marketplaceInstall.mock.results[0].value;
});
expect(result.current.updatingIds.has(ENTRY_ID)).toBe(true);
await act(async () => {
catalogReload.resolve();
await runPromise;
});
expect(result.current.updatingIds.has(ENTRY_ID)).toBe(false);
});
});
describe("usePluginUpdateAction — per-plugin state", () => {
let marketplaceInstall: ReturnType<typeof vi.fn<MarketplaceInstall>>;
let reloadUpdates: ReturnType<typeof vi.fn<() => Promise<void>>>;
beforeEach(() => {
marketplaceInstall = vi.fn<MarketplaceInstall>();
reloadUpdates = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
});
it("clears a previous error at the start of a retry", async () => {
marketplaceInstall.mockResolvedValueOnce({ ok: false, error: CHECKSUM_ERROR });
const { result } = renderHook(() =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, INSTALLED),
);
await act(async () => {
await result.current.runUpdate(entry());
});
expect(result.current.errorsById.get(ENTRY_ID)).toBe(CHECKSUM_ERROR);
let resolveRetry!: (v: { ok: boolean }) => void;
marketplaceInstall.mockReturnValueOnce(
new Promise((resolve) => {
resolveRetry = resolve;
}),
);
let retryPromise!: Promise<void>;
act(() => {
retryPromise = result.current.runUpdate(entry());
});
expect(result.current.errorsById.has(ENTRY_ID)).toBe(false);
await act(async () => {
resolveRetry({ ok: true });
await retryPromise;
});
expect(result.current.errorsById.has(ENTRY_ID)).toBe(false);
});
it("tracks each plugin's update independently", async () => {
marketplaceInstall.mockResolvedValue({ ok: false, error: "boom" });
const { result } = renderHook(() =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, INSTALLED),
);
await act(async () => {
await result.current.runUpdate(entry({ id: "a", package_url: "https://ex/a.tar.gz" }));
});
await act(async () => {
await result.current.runUpdate(entry({ id: "b", package_url: "https://ex/b.tar.gz" }));
});
expect(result.current.errorsById.get("a")).toBe("boom");
expect(result.current.errorsById.get("b")).toBe("boom");
});
});
describe("usePluginUpdateAction — overlapping updates", () => {
// Regression: `updatingId` was a single slot, so when two rows updated at
// once the first install to settle cleared the marker for the other — the
// still-installing row lost its spinner and its Update/Uninstall controls
// went live again mid-install, inviting a second concurrent install.
it("keeps every in-flight row marked busy when two updates overlap", async () => {
const resolvers: Record<string, (v: { ok: boolean }) => void> = {};
const marketplaceInstall = vi.fn<MarketplaceInstall>(
(url: string) =>
new Promise((resolve) => {
resolvers[url.includes("/a.") ? "a" : "b"] = resolve;
}),
);
const { result } = renderHook(() =>
usePluginUpdateAction(
marketplaceInstall,
vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
INSTALLED,
),
);
let runA!: Promise<void>;
let runB!: Promise<void>;
act(() => {
runA = result.current.runUpdate(entry({ id: "a", package_url: "https://ex/a.tar.gz" }));
});
act(() => {
runB = result.current.runUpdate(entry({ id: "b", package_url: "https://ex/b.tar.gz" }));
});
expect(result.current.updatingIds.has("a")).toBe(true);
expect(result.current.updatingIds.has("b")).toBe(true);
// A settles first; B is still installing and must stay busy.
await act(async () => {
resolvers.a({ ok: true });
await runA;
});
expect(result.current.updatingIds.has("a")).toBe(false);
expect(result.current.updatingIds.has("b")).toBe(true);
await act(async () => {
resolvers.b({ ok: true });
await runB;
});
expect(result.current.updatingIds.size).toBe(0);
});
});
describe("usePluginUpdateAction — error lifetime", () => {
let marketplaceInstall: ReturnType<typeof vi.fn<MarketplaceInstall>>;
let reloadUpdates: ReturnType<typeof vi.fn<() => Promise<void>>>;
beforeEach(() => {
marketplaceInstall = vi.fn<MarketplaceInstall>();
reloadUpdates = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
});
// Regression: uninstalling a plugin whose update failed and installing it
// again (same id, no page reload) used to resurface the previous copy's
// error on the new row, reporting a failure for an install that succeeded.
it("drops a plugin's error once it is no longer installed, and does not resurface it on reinstall", async () => {
marketplaceInstall.mockResolvedValue({ ok: false, error: CHECKSUM_ERROR });
const { result, rerender } = renderHook(
({ installed }: { installed: ReadonlySet<string> }) =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, installed),
{ initialProps: { installed: INSTALLED } },
);
await act(async () => {
await result.current.runUpdate(entry());
});
expect(result.current.errorsById.get(ENTRY_ID)).toBe(CHECKSUM_ERROR);
// Uninstalled: the id leaves the installed set.
rerender({ installed: new Set<string>() });
expect(result.current.errorsById.has(ENTRY_ID)).toBe(false);
// Reinstalled under the same id: still clean.
rerender({ installed: new Set([ENTRY_ID]) });
expect(result.current.errorsById.has(ENTRY_ID)).toBe(false);
});
it("keeps errors for plugins that are still installed", async () => {
marketplaceInstall.mockResolvedValue({ ok: false, error: CHECKSUM_ERROR });
const { result, rerender } = renderHook(
({ installed }: { installed: ReadonlySet<string> }) =>
usePluginUpdateAction(marketplaceInstall, reloadUpdates, installed),
{ initialProps: { installed: INSTALLED } },
);
await act(async () => {
await result.current.runUpdate(entry());
});
// A new Set with the same members must not drop a live error.
rerender({ installed: new Set([ENTRY_ID, "a", "b"]) });
expect(result.current.errorsById.get(ENTRY_ID)).toBe(CHECKSUM_ERROR);
});
});
@@ -0,0 +1,87 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { t } from "@/lib/i18n";
import type { MarketplaceEntry } from "@/lib/types/plugins";
/**
* Owns the manual "Update" button's busy/error state machine, independent of
* the auto-updater. `runUpdate` clears any previous failure for that plugin
* first (so a retry doesn't show a stale error while it's pending), installs
* the newer package via the shared `marketplaceInstall` (same path
* install/enable use — never disagrees with the auto-updater's own
* `install_state` comparison), and always re-checks the catalog afterward so
* a resolved update's row converges within one round-trip whether the
* install succeeded or failed. A successful install also marks the catalog
* entry current before that re-check, so a failed catalog request cannot leave
* an already-installed version looking updateable.
*
* `installedIds` is the id set of what is currently installed. A failure is a
* fact about one installed copy of a plugin, so it is dropped as soon as that
* plugin leaves the list — otherwise uninstalling a plugin whose update failed
* and installing it again (same id, no page reload) would surface the previous
* copy's error on the new, perfectly healthy row.
*/
export function usePluginUpdateAction(
marketplaceInstall: (url: string) => Promise<{ ok: boolean; error?: string }>,
reloadUpdates: () => Promise<void>,
installedIds: ReadonlySet<string>,
markUpdated?: (pluginId: string) => void,
) {
// A set, not a single id: rows update independently, so two installs can be
// in flight at once. With one slot the first to settle cleared the marker for
// the other, dropping a still-installing row's spinner and re-enabling its
// Update/Uninstall controls mid-install.
const [updatingIds, setUpdatingIds] = useState<ReadonlySet<string>>(new Set());
const [errorsById, setErrorsById] = useState<Map<string, string>>(new Map());
useEffect(() => {
setErrorsById((prev) => {
if (prev.size === 0) return prev;
const next = new Map(prev);
for (const id of prev.keys()) {
if (!installedIds.has(id)) next.delete(id);
}
return next.size === prev.size ? prev : next;
});
}, [installedIds]);
const clearError = useCallback((id: string) => {
setErrorsById((prev) => {
if (!prev.has(id)) return prev;
const next = new Map(prev);
next.delete(id);
return next;
});
}, []);
const runUpdate = useCallback(
async (entry: MarketplaceEntry) => {
clearError(entry.id);
setUpdatingIds((prev) => new Set(prev).add(entry.id));
try {
const result = await marketplaceInstall(entry.package_url);
if (!result.ok) {
const message = result.error ?? t("plugins:failedToUpdatePlugin", { name: entry.name });
setErrorsById((prev) => new Map(prev).set(entry.id, message));
} else {
markUpdated?.(entry.id);
}
} finally {
try {
await reloadUpdates();
} finally {
setUpdatingIds((prev) => {
if (!prev.has(entry.id)) return prev;
const next = new Set(prev);
next.delete(entry.id);
return next;
});
}
}
},
[marketplaceInstall, reloadUpdates, clearError, markUpdated],
);
return { updatingIds, errorsById, runUpdate };
}
@@ -0,0 +1,27 @@
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
export const PLUGIN_ID = "kandev-plugin-e2e";
export const PACKAGE_PATH = path.resolve(
__dirname,
"../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
);
export async function openInstallDialog(page: Page) {
await page.goto("/settings/plugins");
await page.getByTestId("install-plugin-trigger").click();
await expect(page.getByTestId("install-plugin-dialog")).toBeVisible();
}
export async function uploadPackage(page: Page, filePath: string) {
await page.getByTestId("install-plugin-tab-upload").click();
await page.getByTestId("install-plugin-file-input").setInputFiles(filePath);
await page.getByTestId("install-plugin-upload-submit").click();
}
export async function uninstallPluginFixture(apiClient: ApiClient) {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
}
+101 -19
View File
@@ -51,16 +51,17 @@ import type { ApiClient } from "../../helpers/api-client";
import { holdPluginInstallResponse } from "../../helpers/plugin-install";
import { dwell } from "../../helpers/causal-waits";
import { MAX_INLINE_PLUGIN_FOOTER_ITEMS } from "@/lib/navigation/plugin-footer-budget";
import {
openInstallDialog,
PACKAGE_PATH,
PLUGIN_ID,
uninstallPluginFixture,
uploadPackage,
} from "./plugin-test-helpers";
const PLUGIN_ID = "kandev-plugin-e2e";
const NAV_ITEM_ID = "e2e-hello";
const PLUGIN_ROUTE = "/plugins/e2e-hello";
const PACKAGE_PATH = path.resolve(
__dirname,
"../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
);
/** Every deliveries.jsonl `event_type` recorded so far, read straight off
* disk from the plugin's real KANDEV_PLUGIN_DATA_DIR (no in-process mock —
* this is the fixture Go binary's own gRPC OnEvent handler writing to its
@@ -75,18 +76,6 @@ function deliveredEventTypes(pluginsDir: string): string[] {
.map((line) => (JSON.parse(line) as { event_type: string }).event_type);
}
async function openInstallDialog(page: Page) {
await page.goto("/settings/plugins");
await page.getByTestId("install-plugin-trigger").click();
await expect(page.getByTestId("install-plugin-dialog")).toBeVisible();
}
async function uploadPackage(page: Page, filePath: string) {
await page.getByTestId("install-plugin-tab-upload").click();
await page.getByTestId("install-plugin-file-input").setInputFiles(filePath);
await page.getByTestId("install-plugin-upload-submit").click();
}
async function waitForPluginBundleReady(page: Page): Promise<void> {
const navItem = page.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`);
await expect(navItem).toBeVisible({ timeout: 15_000 });
@@ -98,7 +87,7 @@ async function waitForPluginBundleReady(page: Page): Promise<void> {
}
async function uninstallViaApi(apiClient: ApiClient) {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
await uninstallPluginFixture(apiClient);
}
async function installedPluginPath(apiClient: ApiClient): Promise<string> {
@@ -385,6 +374,99 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await expect(globalToggle).toHaveAttribute("aria-checked", "false");
});
/**
* Deliberate scope limit (see docs/plans/plugins/task-*): there is no
* fixture package for a *second* signed version, so this proves the
* operator-triggered check surfaces a highlighted Update button via a
* route-mocked catalog, and that a manual update failing against a
* (deliberately unreachable) mocked `package_url` renders inline without
* disturbing the rest of the row. The real, successful reinstall path is
* covered at the unit level (use-plugin-update-action.test.tsx).
*/
test("marketplace update check highlights the Update button, and a failing manual update shows an inline error", async ({
testPage,
}) => {
test.setTimeout(60_000);
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
await expect(pluginRow).toBeVisible({ timeout: 15_000 });
const newerVersion = "9.9.9";
await testPage.route("**/api/plugins/marketplace", async (route) => {
if (route.request().method() !== "GET") return route.fallback();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
plugins: [
{
id: PLUGIN_ID,
name: "E2E Hello",
description: "",
author: "kandev",
categories: [],
icon_url: "",
repo_url: "",
version: newerVersion,
min_kandev_version: "",
package_url: "https://example.invalid/kandev-plugin-e2e-9.9.9.tar.gz",
package_sha256: "",
stars: 0,
updated_at: new Date(0).toISOString(),
install_state: "update_available",
installed_version: "1.0.0",
source_id: "official",
source_name: "Kandev Official",
},
],
sources: [
{
id: "official",
name: "Kandev Official",
url: "https://example.invalid",
enabled: true,
builtin: true,
healthy: true,
},
],
}),
});
});
await testPage.route("**/api/plugins/marketplace/refresh", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ refreshed: true }),
}),
);
await testPage.getByTestId("plugins-check-updates-button").click();
const latestVersion = pluginRow.getByTestId(`plugin-latest-version-${PLUGIN_ID}`);
const updateButton = pluginRow.getByTestId(`plugin-update-${PLUGIN_ID}`);
await expect(latestVersion).toContainText(newerVersion, { timeout: 15_000 });
await expect(updateButton).toBeVisible();
await expect(updateButton).toHaveAttribute("data-variant", "default");
await expect(testPage.getByTestId("plugins-updates-last-checked")).toBeVisible();
// The update tries to install from the (deliberately unreachable) mocked
// package_url and fails — the row surfaces the error inline, keeps the
// old version, and keeps the button clickable, without disturbing
// enable/disable/uninstall or the rest of the row.
await updateButton.click();
await expect(pluginRow.getByTestId(`plugin-update-error-${PLUGIN_ID}`)).toBeVisible({
timeout: 15_000,
});
await expect(updateButton).toBeEnabled();
await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible();
await expect(pluginRow.getByRole("button", { name: "Disable" })).toBeEnabled();
await testPage.unroute("**/api/plugins/marketplace");
await testPage.unroute("**/api/plugins/marketplace/refresh");
});
test("row and detail uninstall confirmations stay local to their initiating controls", async ({
testPage,
}) => {
@@ -0,0 +1,110 @@
import { expect, test } from "../../fixtures/test-base";
import {
openInstallDialog,
PACKAGE_PATH,
PLUGIN_ID,
uninstallPluginFixture,
uploadPackage,
} from "../plugins/plugin-test-helpers";
test.describe("Mobile plugin updates", () => {
test.afterEach(async ({ apiClient }) => {
await uninstallPluginFixture(apiClient);
});
test("checks and updates marketplace state from the mobile settings flow", async ({
testPage,
}) => {
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible();
let catalogRequests = 0;
let refreshRequests = 0;
await testPage.route("**/api/plugins/marketplace", async (route) => {
catalogRequests += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
plugins: [
{
id: PLUGIN_ID,
name: "E2E Hello",
description: "",
author: "kandev",
categories: [],
icon_url: "",
repo_url: "",
version: "9.9.9",
min_kandev_version: "",
package_url: "https://example.invalid/kandev-plugin-e2e-9.9.9.tar.gz",
package_sha256: "",
stars: 0,
updated_at: new Date(0).toISOString(),
install_state: "update_available",
installed_version: "1.0.0",
source_id: "official",
source_name: "Kandev Official",
},
],
sources: [
{
id: "official",
name: "Kandev Official",
url: "https://example.invalid",
enabled: true,
builtin: true,
healthy: true,
},
],
}),
});
});
await testPage.route("**/api/plugins/marketplace/refresh", async (route) => {
refreshRequests += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ refreshed: true }),
});
});
await testPage.goto("/settings");
await testPage.getByRole("link", { name: "Plugins" }).tap();
await expect(testPage).toHaveURL(/\/settings\/plugins$/);
await expect.poll(() => catalogRequests).toBe(1);
const checkButton = testPage.getByTestId("plugins-check-updates-button");
await expect(checkButton).toBeVisible();
const buttonBox = await checkButton.boundingBox();
expect(buttonBox).not.toBeNull();
expect(buttonBox!.height).toBeGreaterThanOrEqual(44);
await checkButton.tap();
await expect.poll(() => refreshRequests).toBe(1);
await expect.poll(() => catalogRequests).toBe(2);
await expect(testPage.getByTestId("plugins-updates-last-checked")).toBeVisible();
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
const updateButton = pluginRow.getByTestId(`plugin-update-${PLUGIN_ID}`);
const settingsLink = pluginRow.getByTestId(`plugin-settings-link-${PLUGIN_ID}`);
await expect(updateButton).toBeVisible();
await expect(updateButton).toHaveAttribute("data-variant", "default");
await expect(settingsLink).toBeVisible();
await expect(settingsLink).toHaveText("Settings");
const rowBox = await pluginRow.boundingBox();
const updateBox = await updateButton.boundingBox();
expect(rowBox).not.toBeNull();
expect(updateBox).not.toBeNull();
expect(updateBox!.height).toBeGreaterThanOrEqual(44);
const settingsBox = await settingsLink.boundingBox();
expect(settingsBox).not.toBeNull();
expect(settingsBox!.height).toBeGreaterThanOrEqual(44);
expect(updateBox!.x + updateBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1);
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
});
});
@@ -2,9 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
const getMarketplaceCatalog = vi.fn();
const refreshMarketplace = vi.fn();
const REFRESH_UNAVAILABLE = "refresh unavailable";
vi.mock("@/lib/api/domains/marketplace-api", () => ({
getMarketplaceCatalog: (...args: unknown[]) => getMarketplaceCatalog(...args),
refreshMarketplace: (...args: unknown[]) => refreshMarketplace(...args),
}));
import { usePluginUpdates } from "./use-plugin-updates";
@@ -13,25 +16,63 @@ function entry(id: string, install_state: string, version = "2.0.0") {
return { id, install_state, version, package_url: `https://ex/${id}.tar.gz` };
}
function source(overrides: Record<string, unknown> = {}) {
return {
id: "official",
name: "Official",
url: "https://ex",
enabled: true,
builtin: true,
...overrides,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((next, fail) => {
resolve = next;
reject = fail;
});
return { promise, resolve, reject };
}
beforeEach(() => {
getMarketplaceCatalog.mockReset();
refreshMarketplace.mockReset();
refreshMarketplace.mockResolvedValue({ refreshed: true });
});
afterEach(() => cleanup());
describe("usePluginUpdates", () => {
it("keeps only update_available entries, keyed by id", async () => {
describe("usePluginUpdates — latestById", () => {
it("keeps both installed and update_available entries, keyed by id, and derives the update_available subset", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [entry("a", "update_available"), entry("b", "installed"), entry("c", "available")],
sources: [],
plugins: [
entry("a", "update_available"),
entry("b", "installed", "1.0.0"),
entry("c", "available"),
],
sources: [source()],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.updates.size).toBe(1));
await waitFor(() => expect(result.current.checked).toBe(true));
expect(result.current.latestById.has("a")).toBe(true);
expect(result.current.latestById.has("b")).toBe(true);
expect(result.current.latestById.has("c")).toBe(false);
expect(result.current.updates.has("a")).toBe(true);
expect(result.current.updates.get("a")?.version).toBe("2.0.0");
expect(result.current.updates.has("b")).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.lastCheckedAt).not.toBeNull();
});
it("is unchecked and empty before the first catalog response resolves", () => {
getMarketplaceCatalog.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => usePluginUpdates());
expect(result.current.checked).toBe(false);
expect(result.current.latestById.size).toBe(0);
});
it("reload re-fetches the catalog", async () => {
@@ -39,14 +80,329 @@ describe("usePluginUpdates", () => {
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1));
act(() => result.current.reload());
await act(async () => {
await result.current.reload();
});
await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(2));
});
it("swallows a catalog fetch error (no updates, no throw)", async () => {
getMarketplaceCatalog.mockRejectedValue(new Error("offline"));
it("marks an update entry installed locally while a catalog reload is unavailable", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [entry("acme", "update_available", "2.0.0")],
sources: [source()],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalled());
expect(result.current.updates.size).toBe(0);
await waitFor(() => expect(result.current.updates.has("acme")).toBe(true));
act(() => {
result.current.markUpdated("acme");
});
expect(result.current.latestById.get("acme")?.install_state).toBe("installed");
expect(result.current.updates.has("acme")).toBe(false);
});
});
describe("usePluginUpdates — checkForUpdates", () => {
it("busts the marketplace cache before re-fetching, and reports checking/lastCheckedAt", async () => {
getMarketplaceCatalog.mockResolvedValue({ plugins: [], sources: [] });
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.checked).toBe(true));
let checkPromise!: Promise<boolean>;
act(() => {
checkPromise = result.current.checkForUpdates();
});
expect(result.current.checking).toBe(true);
await act(async () => {
await checkPromise;
});
expect(result.current.checking).toBe(false);
expect(refreshMarketplace).toHaveBeenCalledTimes(1);
const refreshOrder = refreshMarketplace.mock.invocationCallOrder[0];
const catalogOrder = getMarketplaceCatalog.mock.invocationCallOrder.at(-1);
expect(refreshOrder).toBeLessThan(catalogOrder as number);
});
it("does not fetch or record a fresh catalog when cache refresh fails", async () => {
getMarketplaceCatalog.mockResolvedValue({ plugins: [], sources: [source()] });
refreshMarketplace.mockRejectedValueOnce(new Error(REFRESH_UNAVAILABLE));
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.checked).toBe(true));
const lastCheckedAt = result.current.lastCheckedAt;
await act(async () => {
await result.current.checkForUpdates();
});
expect(result.current.error).toBe(REFRESH_UNAVAILABLE);
expect(result.current.lastCheckedAt).toBe(lastCheckedAt);
expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1);
});
it("ignores an older page-load response after a manual check starts", async () => {
const pageLoad = deferred<{ plugins: unknown[]; sources: unknown[] }>();
getMarketplaceCatalog.mockReturnValueOnce(pageLoad.promise);
getMarketplaceCatalog.mockResolvedValueOnce({
plugins: [entry("acme", "update_available", "2.0.0")],
sources: [source()],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.checkForUpdates();
});
expect(result.current.latestById.get("acme")?.version).toBe("2.0.0");
await act(async () => {
pageLoad.resolve({
plugins: [entry("acme", "installed", "1.0.0")],
sources: [source()],
});
await pageLoad.promise;
});
expect(result.current.latestById.get("acme")?.version).toBe("2.0.0");
});
it("keeps the cache-busting check alive when a reload overlaps it", async () => {
getMarketplaceCatalog.mockResolvedValueOnce({
plugins: [entry("acme", "installed", "1.0.0")],
sources: [source()],
});
const refresh = deferred<{ refreshed: boolean }>();
refreshMarketplace.mockReturnValueOnce(refresh.promise);
getMarketplaceCatalog.mockResolvedValueOnce({
plugins: [entry("acme", "update_available", "2.0.0")],
sources: [source()],
});
getMarketplaceCatalog.mockResolvedValueOnce({
plugins: [entry("acme", "installed", "2.0.0")],
sources: [source()],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.checked).toBe(true));
let checkPromise!: Promise<boolean>;
act(() => {
checkPromise = result.current.checkForUpdates();
});
let reloadPromise!: Promise<void>;
act(() => {
reloadPromise = result.current.reload();
});
// The reload must wait for the refresh + catalog GET pair. If it starts
// its GET here, it can steal the check's request generation and make the
// cache-busting check finish without applying a fresh catalog.
expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1);
await act(async () => {
refresh.resolve({ refreshed: true });
await checkPromise;
await reloadPromise;
});
expect(result.current.latestById.get("acme")?.version).toBe("2.0.0");
expect(result.current.updates.has("acme")).toBe(false);
expect(getMarketplaceCatalog).toHaveBeenCalledTimes(3);
});
});
describe("usePluginUpdates — overlapping checks", () => {
it("does not reload stale catalog data after an overlapping refresh fails", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [entry("acme", "update_available", "2.0.0")],
sources: [source()],
});
const refresh = deferred<{ refreshed: boolean }>();
refreshMarketplace.mockReturnValueOnce(refresh.promise);
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.updates.has("acme")).toBe(true));
const lastCheckedAt = result.current.lastCheckedAt;
let checkPromise!: Promise<boolean>;
act(() => {
checkPromise = result.current.checkForUpdates();
});
let reloadPromise!: Promise<void>;
act(() => {
reloadPromise = result.current.reload();
});
await act(async () => {
refresh.reject(new Error(REFRESH_UNAVAILABLE));
await checkPromise;
await reloadPromise;
});
expect(result.current.error).toBe(REFRESH_UNAVAILABLE);
expect(result.current.lastCheckedAt).toBe(lastCheckedAt);
expect(result.current.updates.has("acme")).toBe(true);
expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1);
});
it("marks the installed plugin current when its overlapping refresh fails", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [entry("acme", "update_available", "2.0.0")],
sources: [source()],
});
const refresh = deferred<{ refreshed: boolean }>();
refreshMarketplace.mockReturnValueOnce(refresh.promise);
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.updates.has("acme")).toBe(true));
const lastCheckedAt = result.current.lastCheckedAt;
let checkPromise!: Promise<boolean>;
act(() => {
checkPromise = result.current.checkForUpdates();
});
let reloadPromise!: Promise<void>;
act(() => {
reloadPromise = result.current.reload("acme");
});
await act(async () => {
refresh.reject(new Error(REFRESH_UNAVAILABLE));
await checkPromise;
await reloadPromise;
});
expect(result.current.error).toBe(REFRESH_UNAVAILABLE);
expect(result.current.lastCheckedAt).toBe(lastCheckedAt);
expect(result.current.latestById.get("acme")?.install_state).toBe("installed");
expect(result.current.updates.has("acme")).toBe(false);
expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1);
});
it("does not replace a failed check with a stale catalog on a later install reload", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [entry("acme", "update_available", "2.0.0")],
sources: [source()],
});
refreshMarketplace.mockRejectedValueOnce(new Error(REFRESH_UNAVAILABLE));
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.updates.has("acme")).toBe(true));
const lastCheckedAt = result.current.lastCheckedAt;
await act(async () => {
await result.current.checkForUpdates();
});
await act(async () => {
await result.current.reload("acme");
});
expect(result.current.error).toBe(REFRESH_UNAVAILABLE);
expect(result.current.lastCheckedAt).toBe(lastCheckedAt);
expect(result.current.latestById.get("acme")?.install_state).toBe("installed");
expect(result.current.updates.has("acme")).toBe(false);
expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1);
});
});
describe("usePluginUpdates — failure isolation", () => {
it("sets error (not a throw) when the catalog fetch rejects, and never breaks updates", async () => {
getMarketplaceCatalog.mockRejectedValue(new Error("marketplace is unavailable"));
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.error).toBe("marketplace is unavailable"));
expect(result.current.updates.size).toBe(0);
});
it("sets error when every enabled source is unhealthy, even though the request itself succeeded", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [],
sources: [
source({ healthy: false, error: "timeout" }),
source({ id: "extra", enabled: false }),
],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.error).toBe("timeout"));
});
// Regression: the backend omits a failing source's entries entirely, which is
// indistinguishable from "this plugin was delisted" unless the partial result
// is flagged. Without `sourcesDegraded` the row claimed "not in the
// marketplace" for a plugin whose only source was merely unreachable.
it("flags a partially degraded catalog, keeps the healthy sources' entries, and reports the reason", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [entry("a", "installed", "1.0.0")],
sources: [source(), source({ id: "local", healthy: false, error: "connection refused" })],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.checked).toBe(true));
expect(result.current.sourcesDegraded).toBe(true);
expect(result.current.error).toBe("connection refused");
expect(result.current.latestById.has("a")).toBe(true);
expect(result.current.lastCheckedAt).not.toBeNull();
});
it("does not flag degradation when every enabled source is healthy", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [],
sources: [source(), source({ id: "local", enabled: false })],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.checked).toBe(true));
expect(result.current.sourcesDegraded).toBe(false);
expect(result.current.error).toBeNull();
});
// Regression: with no enabled source nothing was queried, so an empty
// catalog is evidence of nothing — not of every plugin being delisted. The
// guard on the all-unhealthy branch deliberately skips this case, which used
// to drop it into the fully-successful branch and flip every installed row
// to "not in the marketplace" under a clean "last checked" line.
it("flags a catalog with no enabled source as degraded and explains why", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [],
sources: [source({ enabled: false }), source({ id: "local", enabled: false })],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.checked).toBe(true));
expect(result.current.sourcesDegraded).toBe(true);
expect(result.current.error).toBe(
"No marketplace sources are enabled, so versions can't be checked. Enable one under Browse > Sources.",
);
});
// The early return above skipped `setSourcesDegraded`, so a total outage
// after a healthy check left the flag stale at `false` and rows kept
// claiming removal on data no source had confirmed this round.
it("flags degradation when every enabled source is unhealthy", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [],
sources: [source({ healthy: false, error: "timeout" })],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.error).toBe("timeout"));
expect(result.current.sourcesDegraded).toBe(true);
});
it("clears a previous error once a later check succeeds", async () => {
getMarketplaceCatalog.mockRejectedValueOnce(new Error("offline"));
getMarketplaceCatalog.mockResolvedValueOnce({ plugins: [], sources: [source()] });
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.error).toBe("offline"));
await act(async () => {
await result.current.checkForUpdates();
});
expect(result.current.error).toBeNull();
expect(result.current.checked).toBe(true);
});
});
@@ -1,44 +1,217 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getMarketplaceCatalog } from "@/lib/api/domains/marketplace-api";
import type { MarketplaceEntry } from "@/lib/types/plugins";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getMarketplaceCatalog, refreshMarketplace } from "@/lib/api/domains/marketplace-api";
import { t } from "@/lib/i18n";
import type { MarketplaceCatalog, MarketplaceEntry } from "@/lib/types/plugins";
/**
* Cross-references installed plugins against the marketplace catalog to find
* which have a newer version available, keyed by plugin id. Used by the
* Installed tab to show an Update button on rows with a pending update.
* The message shown when a check couldn't complete: the thrown error's own text
* when there is one, otherwise the generic fallback. A function, not a module
* constant — `t()` at module scope would freeze at the boot locale.
*/
function checkFailedMessage(err?: unknown): string {
return err instanceof Error ? err.message : t("plugins:updateCheckFailed");
}
async function runMarketplaceCheck(
check: number,
checkGeneration: { current: number },
applyCatalog: (catalog: MarketplaceCatalog) => void,
setChecking: (checking: boolean) => void,
setError: (error: string | null) => void,
): Promise<boolean> {
try {
await refreshMarketplace();
if (check !== checkGeneration.current) return false;
const catalog = await getMarketplaceCatalog();
if (check !== checkGeneration.current) return false;
applyCatalog(catalog);
return true;
} catch (err) {
if (check === checkGeneration.current) setError(checkFailedMessage(err));
return false;
} finally {
if (check === checkGeneration.current) setChecking(false);
}
}
async function waitForMarketplaceCheck(checkInFlight: {
current: Promise<boolean> | null;
}): Promise<boolean> {
while (checkInFlight.current) {
const inFlight = checkInFlight.current;
const succeeded = await inFlight;
if (!succeeded && (checkInFlight.current === inFlight || checkInFlight.current === null))
return false;
}
return true;
}
function markEntryInstalled(
entries: Map<string, MarketplaceEntry>,
pluginId: string,
): Map<string, MarketplaceEntry> {
const current = entries.get(pluginId);
if (!current || current.install_state !== "update_available") return entries;
return new Map(entries).set(pluginId, { ...current, install_state: "installed" });
}
function trackMarketplaceCheck(
run: Promise<boolean>,
checkInFlight: { current: Promise<boolean> | null },
lastCheckFailed: { current: boolean },
) {
void run.then(
(succeeded) => {
if (checkInFlight.current !== run) return;
checkInFlight.current = null;
lastCheckFailed.current = !succeeded;
},
() => {
if (checkInFlight.current !== run) return;
checkInFlight.current = null;
lastCheckFailed.current = true;
},
);
}
/**
* Cross-references installed plugins against the marketplace catalog: which
* ones have a catalog entry at all (`latestById`), and which of those are
* strictly newer than the installed version (`updates`, the `latestById`
* subset with `install_state === "update_available"`). Used by the Installed
* tab to show each row's latest-known version and, where relevant, an Update
* button.
*
* The catalog already computes install_state per entry (the backend joins the
* catalog against installed records by id+version), so this hook just keeps the
* `update_available` entries. Failures are swallowed — a missing/offline
* marketplace must never break installed-plugin management; it just means no
* update badges are shown.
* The catalog already computes `install_state` per entry server-side (the
* backend joins the catalog against installed records by id+version), so this
* hook only needs to bucket entries, never compare versions itself.
*
* A missing/offline marketplace must never break installed-plugin management:
* a failed check sets `error` (surfaced by the caller) but never throws and
* never clears previously-known `latestById` data. `checked` only flips to
* `true` after the *first* successful check, so callers can tell "haven't
* checked yet" apart from "checked and this plugin isn't in any catalog".
*/
export function usePluginUpdates() {
const [updates, setUpdates] = useState<Map<string, MarketplaceEntry>>(new Map());
const [reloadKey, setReloadKey] = useState(0);
const [latestById, setLatestById] = useState<Map<string, MarketplaceEntry>>(new Map());
const [checking, setChecking] = useState(false);
const [checked, setChecked] = useState(false);
const [lastCheckedAt, setLastCheckedAt] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [sourcesDegraded, setSourcesDegraded] = useState(false);
const requestGeneration = useRef(0);
const checkGeneration = useRef(0);
const checkInFlight = useRef<Promise<boolean> | null>(null);
const lastCheckFailed = useRef(false);
const applyCatalog = useCallback((catalog: MarketplaceCatalog) => {
const enabledSources = catalog.sources.filter((source) => source.enabled);
const unhealthy = enabledSources.filter((source) => source.healthy === false);
// A degraded source may not carry an error string; fall back to the generic
// message so the strip never renders an empty explanation.
const reason = () => unhealthy.find((source) => source.error)?.error ?? checkFailedMessage();
if (enabledSources.length > 0 && unhealthy.length === enabledSources.length) {
setSourcesDegraded(true);
setError(reason());
return;
}
const next = new Map<string, MarketplaceEntry>();
for (const plugin of catalog.plugins) {
if (plugin.install_state === "installed" || plugin.install_state === "update_available") {
next.set(plugin.id, plugin);
}
}
setLatestById(next);
setChecked(true);
setLastCheckedAt(new Date().toISOString());
// With no enabled source there was nothing to query, so this response is
// not evidence that anything was delisted — it is evidence of nothing at
// all. Flag it degraded (rows stay silent instead of claiming removal) and
// say so, since unlike an unreachable source this is something the
// operator turned off and can turn back on.
if (enabledSources.length === 0) {
setSourcesDegraded(true);
setError(t("plugins:noEnabledSources"));
return;
}
// A partially degraded catalog is a success for the sources that answered
// and a non-answer for the ones that didn't. Report the degraded source so
// the operator knows the picture is incomplete, and set `sourcesDegraded`
// so a row missing from this response is shown as unknown rather than as
// "not in the marketplace" — the backend omits a failing source's entries
// entirely, which is indistinguishable from delisting without this flag.
setSourcesDegraded(unhealthy.length > 0);
setError(unhealthy.length > 0 ? reason() : null);
}, []);
const markUpdated = useCallback(
(pluginId: string) => setLatestById((previous) => markEntryInstalled(previous, pluginId)),
[],
);
const reload = useCallback(
async (installedPluginId?: string) => {
const checkSucceeded = await waitForMarketplaceCheck(checkInFlight);
if (!checkSucceeded || lastCheckFailed.current) {
if (installedPluginId) markUpdated(installedPluginId);
return;
}
const generation = ++requestGeneration.current;
try {
const catalog = await getMarketplaceCatalog();
if (generation === requestGeneration.current) applyCatalog(catalog);
} catch (err) {
if (generation === requestGeneration.current) setError(checkFailedMessage(err));
}
},
[applyCatalog, markUpdated],
);
useEffect(() => {
let cancelled = false;
getMarketplaceCatalog()
.then((catalog) => {
if (cancelled) return;
const next = new Map<string, MarketplaceEntry>();
for (const entry of catalog.plugins) {
if (entry.install_state === "update_available") next.set(entry.id, entry);
}
setUpdates(next);
})
.catch(() => {
if (!cancelled) setUpdates(new Map());
});
void reload();
return () => {
cancelled = true;
requestGeneration.current += 1;
checkGeneration.current += 1;
};
}, [reloadKey]);
}, [reload]);
const reload = useCallback(() => setReloadKey((key) => key + 1), []);
const checkForUpdates = useCallback(() => {
// Invalidate any older reload. Reloads that begin while this pair is in
// flight wait for it, so they cannot steal the post-refresh GET.
++requestGeneration.current;
const check = ++checkGeneration.current;
lastCheckFailed.current = false;
setChecking(true);
const run = runMarketplaceCheck(check, checkGeneration, applyCatalog, setChecking, setError);
checkInFlight.current = run;
trackMarketplaceCheck(run, checkInFlight, lastCheckFailed);
return run;
}, [applyCatalog]);
return { updates, reload };
const updates = useMemo(() => {
const next = new Map<string, MarketplaceEntry>();
for (const [id, plugin] of latestById) {
if (plugin.install_state === "update_available") next.set(id, plugin);
}
return next;
}, [latestById]);
return {
latestById,
updates,
checking,
checked,
sourcesDegraded,
lastCheckedAt,
error,
reload,
checkForUpdates,
markUpdated,
};
}
+10
View File
@@ -8,6 +8,8 @@
"byAuthor": "by {{author}}",
"cancel": "Cancel",
"category": "Category",
"checkForUpdates": "Check for updates",
"checkingForUpdates": "Checking for updates…",
"confirmUninstall": "Confirm uninstall",
"disable": "Disable",
"dropzoneHint": "Drag and drop a {{extension}} package here, or click to browse",
@@ -25,6 +27,7 @@
"failedToSyncPlugins": "Failed to sync plugins",
"failedToUninstall": "Failed to uninstall {{name}}",
"failedToUpdateAutoUpdate": "Failed to update auto-update setting",
"failedToUpdatePlugin": "Failed to update {{name}}",
"failedToUpdateSource": "Failed to update source",
"fromUrl": "From URL",
"hideRaw": "Hide raw",
@@ -34,6 +37,7 @@
"installed": "Installed",
"installedPlugins": "Installed plugins",
"installing": "Installing…",
"latestVersion": "Latest v{{version}}",
"loadingMarketplace": "Loading marketplace…",
"loadingPlugins": "Loading plugins...",
"loadingSelectedUtilityAgent": "Loading selected utility agent...",
@@ -54,9 +58,11 @@
"no": "no",
"noDeclaredSettings": "This plugin does not declare any settings (no <1>config_schema</1> in its manifest).",
"noInstalledPluginWithId": "No installed plugin with id <1>{{id}}</1>.",
"noEnabledSources": "No marketplace sources are enabled, so versions can't be checked. Enable one under Browse > Sources.",
"noPluginsAvailable": "No plugins available yet. Check back soon, or add a marketplace source with your own.",
"noPluginsMatchSearch": "No plugins match your search. Try clearing the filters.",
"noPluginsYet": "No plugins yet. Install one from the Browse tab, or upload a package.",
"notInMarketplace": "Not in the marketplace",
"notSet": "Not set",
"openSettingsFor": "Open settings for {{name}}",
"override": "override",
@@ -96,6 +102,7 @@
"statusRegistered": "Registered",
"statusUninstalled": "Uninstalled",
"sync": "Sync",
"updateCheckDescription": "Check the marketplace for newer versions. Sync scans only your local plugins folder.",
"syncInstalled_one": "{{count}} installed",
"syncInstalled_other": "{{count}} installed",
"syncMissing_one": "{{count}} missing",
@@ -112,7 +119,10 @@
"uninstallPlugin": "Uninstall plugin",
"unsigned": "unsigned",
"update": "Update",
"updateAvailableVersion": "Update available: v{{version}}",
"updateCheckFailed": "Couldn't check for updates",
"updateToVersion": "Update to v{{version}}",
"updatesLastChecked": "Last checked {{time}}",
"updating": "Updating…",
"uploadFile": "Upload file",
"utilityAgentsUnavailable": "Utility agents unavailable",
+10
View File
@@ -8,6 +8,8 @@
"byAuthor": "ƀŷ {{author}}",
"cancel": "Ćàńćēĺ",
"category": "Ćàţēĝōŕŷ",
"checkForUpdates": "Ćĥēćķ ƒōŕ ũƥďàţēś",
"checkingForUpdates": "Ćĥēćķĩńĝ ƒōŕ ũƥďàţēś…",
"confirmUninstall": "Ćōńƒĩŕḿ ũńĩńśţàĺĺ",
"disable": "Ďĩśàƀĺē",
"dropzoneHint": "Ďŕàĝ àńď ďŕōƥ à {{extension}} ƥàćķàĝē ĥēŕē, ōŕ ćĺĩćķ ţō ƀŕōŵśē",
@@ -25,6 +27,7 @@
"failedToSyncPlugins": "Ƒàĩĺēď ţō śŷńć ƥĺũĝĩńś",
"failedToUninstall": "Ƒàĩĺēď ţō ũńĩńśţàĺĺ {{name}}",
"failedToUpdateAutoUpdate": "Ƒàĩĺēď ţō ũƥďàţē àũţō-ũƥďàţē śēţţĩńĝ",
"failedToUpdatePlugin": "Ƒàĩĺēď ţō ũƥďàţē {{name}}",
"failedToUpdateSource": "Ƒàĩĺēď ţō ũƥďàţē śōũŕćē",
"fromUrl": "Ƒŕōḿ ŨŔĹ",
"hideRaw": "Ĥĩďē ŕàŵ",
@@ -34,6 +37,7 @@
"installed": "Ĩńśţàĺĺēď",
"installedPlugins": "Ĩńśţàĺĺēď ƥĺũĝĩńś",
"installing": "Ĩńśţàĺĺĩńĝ…",
"latestVersion": "Ĺàţēśţ v{{version}}",
"loadingMarketplace": "Ĺōàďĩńĝ ḿàŕķēţƥĺàćē…",
"loadingPlugins": "Ĺōàďĩńĝ ƥĺũĝĩńś...",
"loadingSelectedUtilityAgent": "Ĺōàďĩńĝ śēĺēćţēď ũţĩĺĩţŷ àĝēńţ...",
@@ -54,9 +58,11 @@
"no": "ńō",
"noDeclaredSettings": "Ţĥĩś ƥĺũĝĩń ďōēś ńōţ ďēćĺàŕē àńŷ śēţţĩńĝś (ńō <1>ćōńƒĩĝ_śćĥēḿà</1> ĩń ĩţś ḿàńĩƒēśţ).",
"noInstalledPluginWithId": "Ńō ĩńśţàĺĺēď ƥĺũĝĩń ŵĩţĥ ĩď <1>{{id}}</1>.",
"noEnabledSources": "Ńō ḿàŕķēţƥĺàćē śōũŕćēś àŕē ēńàƀĺēď, śō vēŕśĩōńś ćàń'ţ ƀē ćĥēćķēď. Ēńàƀĺē ōńē ũńďēŕ Ɓŕōŵśē > Śōũŕćēś.",
"noPluginsAvailable": "Ńō ƥĺũĝĩńś àvàĩĺàƀĺē ŷēţ. Ćĥēćķ ƀàćķ śōōń, ōŕ àďď à ḿàŕķēţƥĺàćē śōũŕćē ŵĩţĥ ŷōũŕ ōŵń.",
"noPluginsMatchSearch": "Ńō ƥĺũĝĩńś ḿàţćĥ ŷōũŕ śēàŕćĥ. Ţŕŷ ćĺēàŕĩńĝ ţĥē ƒĩĺţēŕś.",
"noPluginsYet": "Ńō ƥĺũĝĩńś ŷēţ. Ĩńśţàĺĺ ōńē ƒŕōḿ ţĥē Ɓŕōŵśē ţàƀ, ōŕ ũƥĺōàď à ƥàćķàĝē.",
"notInMarketplace": "Ńōţ ĩń ţĥē ḿàŕķēţƥĺàćē",
"notSet": "Ńōţ śēţ",
"openSettingsFor": "Ōƥēń śēţţĩńĝś ƒōŕ {{name}}",
"override": "ōvēŕŕĩďē",
@@ -96,6 +102,7 @@
"statusRegistered": "Ŕēĝĩśţēŕēď",
"statusUninstalled": "Ũńĩńśţàĺĺēď",
"sync": "Śŷńć",
"updateCheckDescription": "Ćĥēćķ ţĥē ḿàŕķēţƥĺàćē ƒōŕ ńēŵēŕ vēŕśĩōńś. Śŷńć śćàńś ōńĺŷ ŷōũŕ ĺōćàĺ ƥĺũĝĩńś ƒōĺďēŕ.",
"syncInstalled_one": "{{count}} ĩńśţàĺĺēď",
"syncInstalled_other": "{{count}} ĩńśţàĺĺēď",
"syncMissing_one": "{{count}} ḿĩśśĩńĝ",
@@ -112,7 +119,10 @@
"uninstallPlugin": "Ũńĩńśţàĺĺ ƥĺũĝĩń",
"unsigned": "ũńśĩĝńēď",
"update": "Ũƥďàţē",
"updateAvailableVersion": "Ũƥďàţē àvàĩĺàƀĺē: v{{version}}",
"updateCheckFailed": "Ćōũĺďń'ţ ćĥēćķ ƒōŕ ũƥďàţēś",
"updateToVersion": "Ũƥďàţē ţō v{{version}}",
"updatesLastChecked": "Ĺàśţ ćĥēćķēď {{time}}",
"updating": "Ũƥďàţĩńĝ…",
"uploadFile": "Ũƥĺōàď ƒĩĺē",
"utilityAgentsUnavailable": "Ũţĩĺĩţŷ àĝēńţś ũńàvàĩĺàƀĺē",
+10
View File
@@ -8,6 +8,8 @@
"byAuthor": "de {{author}}",
"cancel": "Cancelar",
"category": "Categoria",
"checkForUpdates": "Verificar atualizações",
"checkingForUpdates": "A verificar atualizações…",
"confirmUninstall": "Confirmar a desinstalação",
"disable": "Desativar",
"dropzoneHint": "Arraste e largue aqui um pacote {{extension}} ou clique para procurar",
@@ -25,6 +27,7 @@
"failedToSyncPlugins": "Não foi possível sincronizar os plugins",
"failedToUninstall": "Não foi possível desinstalar {{name}}",
"failedToUpdateAutoUpdate": "Não foi possível alterar a definição de atualização automática",
"failedToUpdatePlugin": "Não foi possível atualizar {{name}}",
"failedToUpdateSource": "Não foi possível atualizar a origem",
"fromUrl": "A partir de um URL",
"hideRaw": "Ocultar o original",
@@ -34,6 +37,7 @@
"installed": "Instalado",
"installedPlugins": "Plugins instalados",
"installing": "A instalar…",
"latestVersion": "Mais recente: v{{version}}",
"loadingMarketplace": "A carregar o marketplace…",
"loadingPlugins": "A carregar os plugins...",
"loadingSelectedUtilityAgent": "A carregar o agente utilitário selecionado...",
@@ -54,9 +58,11 @@
"no": "não",
"noDeclaredSettings": "Este plugin não declara quaisquer definições (sem <1>config_schema</1> no manifesto).",
"noInstalledPluginWithId": "Não há nenhum plugin instalado com o id <1>{{id}}</1>.",
"noEnabledSources": "Não há origens de marketplace ativadas, por isso não é possível verificar versões. Ative uma em Explorar > Origens.",
"noPluginsAvailable": "Ainda não há plugins disponíveis. Volte em breve ou adicione uma origem de marketplace com os seus.",
"noPluginsMatchSearch": "Nenhum plugin corresponde à sua pesquisa. Experimente limpar os filtros.",
"noPluginsYet": "Ainda não há plugins. Instale um a partir do separador Explorar ou carregue um pacote.",
"notInMarketplace": "Não está no marketplace",
"notSet": "Não definido",
"openSettingsFor": "Abrir as definições de {{name}}",
"override": "substituição",
@@ -95,6 +101,7 @@
"statusRegistered": "Registado",
"statusUninstalled": "Desinstalado",
"sync": "Sincronizar",
"updateCheckDescription": "Verifique no marketplace se existem versões mais recentes. A sincronização analisa apenas a pasta local de plugins.",
"syncInstalled_one": "{{count}} instalado",
"syncInstalled_other": "{{count}} instalados",
"syncMissing_one": "{{count}} em falta",
@@ -111,7 +118,10 @@
"uninstallPlugin": "Desinstalar plugin",
"unsigned": "sem assinatura",
"update": "Atualizar",
"updateAvailableVersion": "Atualização disponível: v{{version}}",
"updateCheckFailed": "Não foi possível verificar atualizações",
"updateToVersion": "Atualizar para a v{{version}}",
"updatesLastChecked": "Última verificação: {{time}}",
"updating": "A atualizar…",
"uploadFile": "Carregar ficheiro",
"utilityAgentsUnavailable": "Agentes utilitários indisponíveis",
+10
View File
@@ -8,6 +8,8 @@
"byAuthor": "作者 {{author}}",
"cancel": "取消",
"category": "分类",
"checkForUpdates": "检查更新",
"checkingForUpdates": "正在检查更新…",
"confirmUninstall": "确认卸载",
"disable": "禁用",
"dropzoneHint": "将 {{extension}} 包拖放到此处,或点击浏览",
@@ -25,6 +27,7 @@
"failedToSyncPlugins": "同步插件失败",
"failedToUninstall": "卸载 {{name}} 失败",
"failedToUpdateAutoUpdate": "更新自动更新设置失败",
"failedToUpdatePlugin": "更新 {{name}} 失败",
"failedToUpdateSource": "更新来源失败",
"fromUrl": "来自 URL",
"hideRaw": "隐藏原始内容",
@@ -34,6 +37,7 @@
"installed": "已安装",
"installedPlugins": "已安装的插件",
"installing": "正在安装…",
"latestVersion": "最新版本 v{{version}}",
"loadingMarketplace": "正在加载市场…",
"loadingPlugins": "正在加载插件…",
"loadingSelectedUtilityAgent": "正在加载所选辅助智能体…",
@@ -54,9 +58,11 @@
"no": "否",
"noDeclaredSettings": "此插件未声明任何设置(清单中无 <1>config_schema</1>)。",
"noInstalledPluginWithId": "没有 id 为 <1>{{id}}</1> 的已安装插件。",
"noEnabledSources": "未启用任何市场来源,因此无法检查版本。请在“浏览 > 来源”中启用一个。",
"noPluginsAvailable": "暂无可用插件。请稍后再来,或添加包含自有插件的市场来源。",
"noPluginsMatchSearch": "没有与搜索匹配的插件。请尝试清除筛选条件。",
"noPluginsYet": "还没有插件。从“浏览”标签安装,或上传一个包。",
"notInMarketplace": "市场中未找到",
"notSet": "未设置",
"openSettingsFor": "打开 {{name}} 的设置",
"override": "覆盖",
@@ -95,6 +101,7 @@
"statusRegistered": "已注册",
"statusUninstalled": "已卸载",
"sync": "同步",
"updateCheckDescription": "检查市场中的较新版本。同步仅扫描本地插件文件夹。",
"syncInstalled_one": "已安装 {{count}} 个",
"syncInstalled_other": "已安装 {{count}} 个",
"syncMissing_one": "缺失 {{count}} 个",
@@ -111,7 +118,10 @@
"uninstallPlugin": "卸载插件",
"unsigned": "未签名",
"update": "更新",
"updateAvailableVersion": "有可用更新:v{{version}}",
"updateCheckFailed": "无法检查更新",
"updateToVersion": "更新到 v{{version}}",
"updatesLastChecked": "上次检查时间:{{time}}",
"updating": "正在更新…",
"uploadFile": "上传文件",
"utilityAgentsUnavailable": "辅助智能体不可用",
+10
View File
@@ -8,6 +8,8 @@
"byAuthor": "作者 {{author}}",
"cancel": "取消",
"category": "分類",
"checkForUpdates": "檢查更新",
"checkingForUpdates": "正在檢查更新…",
"confirmUninstall": "確認卸載",
"disable": "停用",
"dropzoneHint": "將 {{extension}} 包拖放到此處,或點擊瀏覽",
@@ -25,6 +27,7 @@
"failedToSyncPlugins": "同步外掛程式失敗",
"failedToUninstall": "卸載 {{name}} 失敗",
"failedToUpdateAutoUpdate": "更新自動更新設定失敗",
"failedToUpdatePlugin": "更新 {{name}} 失敗",
"failedToUpdateSource": "更新來源失敗",
"fromUrl": "來自 URL",
"hideRaw": "隱藏原始內容",
@@ -34,6 +37,7 @@
"installed": "已安裝",
"installedPlugins": "已安裝的外掛程式",
"installing": "正在安裝…",
"latestVersion": "最新版本 v{{version}}",
"loadingMarketplace": "正在載入市場…",
"loadingPlugins": "正在載入外掛程式…",
"loadingSelectedUtilityAgent": "正在載入所選輔助代理程式…",
@@ -54,9 +58,11 @@
"no": "否",
"noDeclaredSettings": "此外掛程式未聲明任何設定(清單中無 <1>config_schema</1>)。",
"noInstalledPluginWithId": "沒有 id 為 <1>{{id}}</1> 的已安裝外掛程式。",
"noEnabledSources": "未啓用任何市場來源,因此無法檢查版本。請在「瀏覽 > 來源」中啓用一個。",
"noPluginsAvailable": "暫無可用外掛程式。請稍後再來,或新增包含自有外掛程式的市場來源。",
"noPluginsMatchSearch": "沒有與搜尋匹配的外掛程式。請嘗試清除篩選條件。",
"noPluginsYet": "還沒有外掛程式。從「瀏覽」標籤安裝,或上傳一個包。",
"notInMarketplace": "市場中未找到",
"notSet": "未設定",
"openSettingsFor": "開啟 {{name}} 的設定",
"override": "覆蓋",
@@ -95,6 +101,7 @@
"statusRegistered": "已註冊",
"statusUninstalled": "已卸載",
"sync": "同步",
"updateCheckDescription": "檢查市場中的較新版本。同步僅掃描本地外掛程式資料夾。",
"syncInstalled_one": "已安裝 {{count}} 個",
"syncInstalled_other": "已安裝 {{count}} 個",
"syncMissing_one": "缺失 {{count}} 個",
@@ -111,7 +118,10 @@
"uninstallPlugin": "卸載外掛程式",
"unsigned": "未簽名",
"update": "更新",
"updateAvailableVersion": "有可用更新:v{{version}}",
"updateCheckFailed": "無法檢查更新",
"updateToVersion": "更新到 v{{version}}",
"updatesLastChecked": "上次檢查時間:{{time}}",
"updating": "正在更新…",
"uploadFile": "上傳檔案",
"utilityAgentsUnavailable": "輔助代理程式不可用",
+10
View File
@@ -8,6 +8,8 @@
"byAuthor": "作者 {{author}}",
"cancel": "取消",
"category": "分類",
"checkForUpdates": "檢查更新",
"checkingForUpdates": "正在檢查更新…",
"confirmUninstall": "確認解除安裝",
"disable": "停用",
"dropzoneHint": "將 {{extension}} 包拖放到此處,或點選瀏覽",
@@ -25,6 +27,7 @@
"failedToSyncPlugins": "同步外掛失敗",
"failedToUninstall": "解除安裝 {{name}} 失敗",
"failedToUpdateAutoUpdate": "更新自動更新設定失敗",
"failedToUpdatePlugin": "更新 {{name}} 失敗",
"failedToUpdateSource": "更新來源失敗",
"fromUrl": "來自 URL",
"hideRaw": "隱藏原始內容",
@@ -34,6 +37,7 @@
"installed": "已安裝",
"installedPlugins": "已安裝的外掛",
"installing": "正在安裝…",
"latestVersion": "最新版本 v{{version}}",
"loadingMarketplace": "正在載入市場…",
"loadingPlugins": "正在載入外掛…",
"loadingSelectedUtilityAgent": "正在載入所選輔助代理程式…",
@@ -54,9 +58,11 @@
"no": "否",
"noDeclaredSettings": "此外掛未宣告任何設定(清單中無 <1>config_schema</1>)。",
"noInstalledPluginWithId": "沒有 id 為 <1>{{id}}</1> 的已安裝外掛。",
"noEnabledSources": "未啟用任何市場來源,因此無法檢查版本。請在「瀏覽 > 來源」中啟用一個。",
"noPluginsAvailable": "暫無可用外掛。請稍後再來,或新增包含自有外掛的市場來源。",
"noPluginsMatchSearch": "沒有與搜尋匹配的外掛。請嘗試清除篩選條件。",
"noPluginsYet": "還沒有外掛。從「瀏覽」標籤安裝,或上傳一個包。",
"notInMarketplace": "市場中未找到",
"notSet": "未設定",
"openSettingsFor": "開啟 {{name}} 的設定",
"override": "覆蓋",
@@ -95,6 +101,7 @@
"statusRegistered": "已註冊",
"statusUninstalled": "已解除安裝",
"sync": "同步",
"updateCheckDescription": "檢查市場中的較新版本。同步僅掃描本地外掛資料夾。",
"syncInstalled_one": "已安裝 {{count}} 個",
"syncInstalled_other": "已安裝 {{count}} 個",
"syncMissing_one": "缺失 {{count}} 個",
@@ -111,7 +118,10 @@
"uninstallPlugin": "解除安裝外掛",
"unsigned": "未簽名",
"update": "更新",
"updateAvailableVersion": "有可用更新:v{{version}}",
"updateCheckFailed": "無法檢查更新",
"updateToVersion": "更新到 v{{version}}",
"updatesLastChecked": "上次檢查時間:{{time}}",
"updating": "正在更新…",
"uploadFile": "上傳檔案",
"utilityAgentsUnavailable": "輔助代理程式不可用",
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+53 -6
View File
@@ -70,13 +70,60 @@ letter tile.
### Keep plugins updated
When the marketplace advertises a newer version than the one you have
installed, the **Installed** tab shows an **Update to v`<version>`** button on
that plugin's row. Clicking it reinstalls the newer tarball through the normal
pipeline; Enable, Disable, and Uninstall are unchanged.
Every row on the **Installed** tab shows the plugin's latest known marketplace
version next to its installed version (e.g. "Latest v2.1.0"), so you can see
version drift at a glance, not only when an update is available. A plugin
that isn't in any configured source shows a "not in the marketplace" hint
instead. That hint only appears when the check actually reached every enabled
source: if one of your sources was unreachable, or you have no source enabled
at all, the plugins it carries are simply left blank rather than reported as
removed, and the header explains what went wrong.
Updates are never automatic; kandev surfaces the newer version and waits for
an explicit click. There are no update channels and no background auto-update.
This check runs once when the page loads. Select **Check for updates** to clear
the marketplace cache and retrieve current versions without reloading the
page. **Sync** remains a separate action that reconciles your local plugins
folder.
While a check is running, the header shows a "Checking for updates…"
indicator. After the check completes, the header shows the last check time.
If the marketplace cannot be reached, an inline error explains the problem.
Your installed plugins and their Enable, Disable, and Uninstall actions remain
available.
Each installed row also has a visible **Settings** link. Select it to open the
plugin's settings page, where you can change any configuration declared by the
plugin.
When the marketplace advertises a newer version than the one you have
installed, the row shows an **Update to v`<version>`** button with the primary
action color. Clicking it reinstalls the newer tarball through the normal
pipeline; while it's in progress the button shows a spinner and
Enable/Disable/Uninstall are disabled. On success the version and button
refresh; on failure an inline error shows the reason and the button stays
clickable so you can retry.
Updates are never automatic from this button; kandev surfaces the newer
version and waits for an explicit click. Separately, an **opt-in** background
auto-updater can install newer versions for you; see [Auto-update
(opt-in)](#auto-update-opt-in) below for the instance-wide and per-plugin
toggles. There are no update channels, version pinning, or scheduled
maintenance windows in either case.
### Auto-update (opt-in)
Beyond the manual **Update** button, kandev can update installed
plugins in the background, **off by default**. An instance-wide
**"Automatically update plugins"** switch (Settings > Plugins) sets the
default for every installed plugin; each row also has its own switch that can
override the default either way, with a **Reset** control to clear the
override and inherit the default again.
Only plugins that are currently **active** *and* opted in are eligible, on a
periodic background sweep. A disabled or errored plugin is never
auto-updated and stays on its installed version until you re-enable it. A
failed auto-update behaves exactly like a failed manual one: the installed
version is left untouched, or the plugin lands in the same **Error** state a
failed manual update would.
### Add a team or corporate source
+42 -4
View File
@@ -53,7 +53,17 @@ while keeping install-by-URL and sideloading as escape hatches.
(`POST /api/plugins/install`). No new install mechanism is introduced.
- A catalog entry for a plugin that is already installed SHALL show an **Installed**
state; when the catalog's latest version is newer than the installed version, it
SHALL show an **Update available** affordance (which reinstalls the newer tarball).
SHALL show a highlighted **Update** button (which reinstalls the newer tarball).
- The **Installed** tab SHALL show each installed plugin's latest known marketplace
version alongside its installed version — not only when an update is available — so
an operator can see version drift without switching to Browse. This check runs once
on page load and when the operator selects **Check for updates**. The explicit check
clears the server's five-minute catalog cache before it retrieves current versions.
**Sync** remains a separate filesystem action. A failed marketplace check degrades to
an inline "couldn't check" state and never blocks installed-plugin management.
- Each installed row SHALL show a visible **Settings** link that opens the plugin's
settings page. This link makes plugin configuration discoverable without requiring
the operator to know that the row itself is also navigable.
- The catalog SHALL be assembled from **one or more marketplace sources**. kandev
ships with the **official kandev source** enabled by default; operators MAY add
**additional sources** (a team or corporate registry) and the catalog merges them.
@@ -294,8 +304,36 @@ response but stays `enabled`.
- **GIVEN** a plugin is already installed at the catalog's latest version, **WHEN**
the catalog renders, **THEN** its entry shows **Installed** and no Install button.
- **GIVEN** a plugin is installed at a version older than the catalog's, **WHEN** the
catalog renders, **THEN** its entry shows **Update available**, and clicking it
installs the newer tarball.
catalog renders, **THEN** its Installed row shows a highlighted **Update** button,
and clicking it installs the newer tarball.
- **GIVEN** the Installed tab, **WHEN** it loads, **THEN** every installed plugin with a
catalog entry shows its latest known version (e.g. "Latest v2.1.0"), and a plugin with
no catalog entry anywhere shows a "not in the marketplace" hint instead — never before
the first successful check completes.
- **GIVEN** a check that reached only some of the enabled sources, **WHEN** it completes,
**THEN** plugins missing from that response show **no** "not in the marketplace" hint —
a source that failed contributes no entries, which is indistinguishable from delisting —
while the healthy sources' known versions and **Update** affordances render as usual, and
the degraded source's reason is reported inline.
- **GIVEN** no marketplace source is enabled, **WHEN** a check completes, **THEN** kandev
reports that versions can't be checked and where to enable a source, and no plugin claims
to be missing from the marketplace — nothing was queried, so nothing was learned.
- **GIVEN** the Installed tab, **WHEN** the operator selects **Check for updates**,
**THEN** kandev busts the marketplace cache and re-fetches the catalog, and any
plugin's latest-version text and highlighted **Update** button state update in place.
- **GIVEN** the Installed tab, **WHEN** the operator selects **Sync**, **THEN** kandev
reconciles the local plugins directory without starting a marketplace refresh.
- **GIVEN** an installed plugin row, **WHEN** the operator selects **Settings**, **THEN**
kandev opens that plugin's settings page.
- **GIVEN** the marketplace is unreachable or every enabled source reports unhealthy,
**WHEN** a check on load or through **Check for updates** fails, **THEN** an inline error explains the
check couldn't complete, while the installed-plugin list, enable/disable/uninstall,
and the filesystem-sync summary are all unaffected.
- **GIVEN** an installed plugin with an available update, **WHEN** the operator clicks
its **Update** button, **THEN** the button shows a busy/spinner state and the plugin's
Enable/Disable/Uninstall controls are disabled until the install settles; on success
the version and Update affordance refresh, and on failure an inline error shows the
backend's message while the button stays clickable for a retry.
- **GIVEN** an operator adds a source `{name: "Acme Internal", url: …}`, **WHEN** the
catalog is next fetched, **THEN** Acme's plugins appear alongside the official ones,
and an Acme entry with an id that also exists in the official source is hidden in
@@ -323,7 +361,7 @@ response but stays `enabled`.
## Auto-update (opt-in)
Beyond the manual **Update available** affordance, kandev can update installed
Beyond the manual **Update** affordance, kandev can update installed
plugins in the background — **off by default, opt-in**. This does not add a new
install mechanism: an auto-update is exactly the existing verified reinstall of a
newer catalog `package_url` (`Service.InstallFromURL`), driven automatically