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:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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àĩĺàƀĺē",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "辅助智能体不可用",
|
||||
|
||||
@@ -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": "輔助代理程式不可用",
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user