diff --git a/apps/web/app/settings/plugins/page.test.tsx b/apps/web/app/settings/plugins/page.test.tsx index 58145ab053..395d8e148b 100644 --- a/apps/web/app/settings/plugins/page.test.tsx +++ b/apps/web/app/settings/plugins/page.test.tsx @@ -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 { 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 { return { added: [], installed: [], missing: [], errors: [], ...overrides }; } +function catalogEntry(overrides: Record = {}) { + 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(); - 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; 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/apps/web/components/settings/plugins/marketplace-browser.tsx b/apps/web/components/settings/plugins/marketplace-browser.tsx index 4059390ccb..09765caa74 100644 --- a/apps/web/components/settings/plugins/marketplace-browser.tsx +++ b/apps/web/components/settings/plugins/marketplace-browser.tsx @@ -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; + /** 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. */ diff --git a/apps/web/components/settings/plugins/plugin-row.test.tsx b/apps/web/components/settings/plugins/plugin-row.test.tsx index 13d161d91c..dd1f479167 100644 --- a/apps/web/components/settings/plugins/plugin-row.test.tsx +++ b/apps/web/components/settings/plugins/plugin-row.test.tsx @@ -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 { }; } -function updateEntry(): MarketplaceEntry { +function marketplaceEntry(overrides: Partial = {}): 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 { + 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( - , + , ); - 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(); - 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( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + 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(); + + 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(); diff --git a/apps/web/components/settings/plugins/plugin-row.tsx b/apps/web/components/settings/plugins/plugin-row.tsx index f1b69aa5d8..e52eb2c652 100644 --- a/apps/web/components/settings/plugins/plugin-row.tsx +++ b/apps/web/components/settings/plugins/plugin-row.tsx @@ -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" > + +
+ +
+ + ); +} + +type PluginRowContentProps = { + plugin: PluginRecord; + update?: PluginRowUpdateState; + autoUpdateDefault: boolean; + needsSetup: boolean; + isFinePointer: boolean; + mutationBusy: boolean; + canEnable: boolean; + canDisable: boolean; + confirmingUninstall: boolean; + uninstallAnchorRef: RefObject; + 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 ( + <>
- +
{plugin.description}
)} + {update?.error && ( +
+ {update.error} +
+ )} {plugin.categories.length > 0 && (
{plugin.categories.map((category) => ( @@ -141,18 +252,7 @@ export function PluginRow({ onSetAutoUpdate={onSetAutoUpdate} />
-
- -
-
+ ); } @@ -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 (
@@ -227,11 +335,49 @@ function PluginRowIdentity({ plugin, needsSetup }: { plugin: PluginRecord; needs {plugin.id} · v{plugin.version} +
); } +/** + * The latest-known marketplace version for this plugin, once a catalog check + * has completed at least once: "Latest v" 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 ( + + {t("plugins:notInMarketplace")} + + ); + } + + return ( + + {t("plugins:latestVersion", { version: update.latest.version })} + + ); +} + /** * 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 (
- {update && onUpdate && ( + {updateEntry && onUpdate && ( )} {canEnable && ( @@ -375,6 +529,15 @@ function PluginRowActions({ {t("plugins:uninstall")} )} + + + {t("plugins:settings")} +
); } diff --git a/apps/web/components/settings/plugins/plugin-update-status.tsx b/apps/web/components/settings/plugins/plugin-update-status.tsx new file mode 100644 index 0000000000..13389d7a43 --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-update-status.tsx @@ -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 ( +
+

{t("plugins:updateCheckDescription")}

+ {checking &&

{t("plugins:checkingForUpdates")}

} + {!checking && lastCheckedAt && ( +

+ {t("plugins:updatesLastChecked", { time: formatDateTime(lastCheckedAt) })} +

+ )} + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/apps/web/components/settings/plugins/plugins-settings.tsx b/apps/web/components/settings/plugins/plugins-settings.tsx index 60ba7f9c15..8fd6235172 100644 --- a/apps/web/components/settings/plugins/plugins-settings.tsx +++ b/apps/web/components/settings/plugins/plugins-settings.tsx @@ -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(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} /> - + @@ -102,23 +101,22 @@ type InstalledTabProps = { list: ReturnType; actions: ReturnType; autoUpdate: ReturnType; - updates: Map; - updatingId: string | null; + updates: ReturnType; + updateAction: ReturnType; 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 ( <> @@ -136,6 +134,16 @@ function InstalledTab({ {t("plugins:sync")} +