feat(web-console): "Update all" on the plugins screen #198

Merged
enricobuehler merged 1 commits from worktree-console-plugin-update-all into main 2026-08-13 14:55:50 +00:00
10 changed files with 648 additions and 38 deletions
+12
View File
@@ -485,6 +485,18 @@
"store_uninstall_body": "Du kannst es jederzeit wieder aus dem Katalog installieren.",
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
"store_update_all_count": "Alle aktualisieren ({count})",
"store_updates_pending": "Für {count} Plugins gibt es Updates",
"store_update_all_title": "Diese Plugins aktualisieren?",
"store_update_all_body": "Jedes wird nacheinander auf seine Katalogversion gebracht — der Host führt immer nur einen Paketvorgang auf einmal aus. Nach jedem startet der Plugin-Runner neu.",
"store_update_all_external_note": "Einige stammen aus Katalogen, die du selbst hinzugefügt hast ({sources}). unom hat diesen Code nicht geprüft; er ist festgepinnt und auf Integrität geprüft, läuft aber mit den Rechten des Plugin-Runners auf diesem Host.",
"store_update_all_confirm": "Alle aktualisieren",
"store_update_all_external_confirm": "Trotzdem alle aktualisieren",
"store_update_all_skipped": "Nicht dabei: {names}. Entweder führt sie kein Katalog, oder dieser Host kann die angebotene Version nicht ausführen.",
"store_update_all_step": "Update {index} von {total}",
"store_update_all_running": "Nächstes Update startet",
"store_update_all_finished": "{count} Plugins aktualisiert.",
"store_update_all_stopped": "Nach einem Fehler gestoppt — {done} aktualisiert, {left} nicht versucht. Du kannst sie unten einzeln erneut anstoßen.",
"store_sources_title": "Katalogquellen",
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
"store_refresh_all": "Alle aktualisieren",
+12
View File
@@ -485,6 +485,18 @@
"store_uninstall_body": "You can install it again from the catalog.",
"store_uninstall_failed": "Could not start the removal.",
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
"store_update_all_count": "Update all ({count})",
"store_updates_pending": "{count} plugins can be updated",
"store_update_all_title": "Update these plugins?",
"store_update_all_body": "Each one is installed at its catalog version, one after another — the host takes a single package operation at a time. The plugin runner restarts after each.",
"store_update_all_external_note": "Some of these come from catalogs you added yourself ({sources}). unom has not reviewed that code; it is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
"store_update_all_confirm": "Update all",
"store_update_all_external_confirm": "Update all anyway",
"store_update_all_skipped": "Not included: {names}. Either no catalog carries them, or this host can't run the version on offer.",
"store_update_all_step": "Update {index} of {total}",
"store_update_all_running": "Starting the next update",
"store_update_all_finished": "Updated {count} plugins.",
"store_update_all_stopped": "Stopped after a failure — {done} updated, {left} not attempted. Retry them from the rows below.",
"store_sources_title": "Catalog sources",
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
"store_refresh_all": "Refresh all",
+68
View File
@@ -108,6 +108,74 @@ export interface InstalledPlugin {
blocked?: string;
}
/** An installed plugin paired with the catalog entry an update would install. */
export interface PendingUpdate {
plugin: InstalledPlugin;
entry: StoreEntry;
}
/** What "Update all" would do: the run, and what it deliberately left out of it. */
export interface UpdatePlan {
/** Ready to install, in the order the run will work through them. */
updates: PendingUpdate[];
/** Display names of plugins offering an update this host cannot take right now. */
skipped: string[];
}
/**
* The catalog entry an installed plugin updates FROM.
*
* Resolve by the entry the plugin was actually installed from (source + entry id) before falling
* back to the package name: two sources may carry the same `pkg`, and matching on the name alone
* could offer a row badged "verified" an entry from somebody else's source at a different version.
*/
export function catalogEntryFor(
plugin: InstalledPlugin,
entries: StoreEntry[] | undefined,
): StoreEntry | undefined {
const list = entries ?? [];
return (
(plugin.source && plugin.entry_id
? list.find((e) => e.source === plugin.source && e.id === plugin.entry_id)
: undefined) ??
(plugin.source
? list.find((e) => e.source === plugin.source && e.pkg === plugin.pkg)
: undefined) ??
list.find((e) => e.pkg === plugin.pkg)
);
}
/**
* Everything an "Update all" run would install, in the installed list's own order — so the run
* follows the rows on screen rather than some order of its own.
*
* Two things are dropped rather than attempted, and both are reported instead of hidden: an update
* with no catalog entry to install (the same dead end a single row's button reports on click), and
* an entry this host will refuse — incompatible ones are a `400` from `POST /store/install`, and a
* blocked one is what Browse already greys its Install button out for. Either would end the run on
* a failure card that says nothing about the updates still queued behind it, so they never enter
* the queue in the first place.
*
* `plugin.blocked` is NOT a reason to skip: that advisory is against the version installed right
* now, and updating away from it is the fix, not the risk.
*/
export function planUpdates(
installed: InstalledPlugin[] | undefined,
entries: StoreEntry[] | undefined,
): UpdatePlan {
const plan: UpdatePlan = { updates: [], skipped: [] };
for (const plugin of installed ?? []) {
if (plugin.update_available === undefined) continue;
const entry = catalogEntryFor(plugin, entries);
if (entry?.compatible && entry.blocked === undefined) {
plan.updates.push({ plugin, entry });
} else {
plan.skipped.push(plugin.title ?? plugin.pkg);
}
}
return plan;
}
export type JobKind = "install" | "uninstall";
export type JobState = "running" | "done" | "failed";
+22 -1
View File
@@ -1,5 +1,6 @@
import { AnimatedButton, buttonVariants } from "@unom/ui/button";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
// The console's Button IS @unom/ui's animated button — pill shape, specular
// material gloss + UI click/hover sounds (enabled via UnomProviders), driven by
@@ -7,6 +8,26 @@ import type { ComponentProps } from "react";
// (default/destructive/outline/secondary/ghost/link + default/sm/lg/icon).
export type ButtonProps = ComponentProps<typeof AnimatedButton>;
export const Button = AnimatedButton;
/**
* One correction, in the wrapper layer like the other `components/ui/*` ones: make `disabled`
* VISIBLE.
*
* `AnimatedButton` is a motion element, and its mount animation settles as an inline `opacity: 1`.
* An inline style outranks any class, so the `disabled:opacity-50` the library also ships never
* applied: measured `opacity: 1` on a `disabled` button, console-wide. Every disabled control in
* the app therefore looked live and simply ignored the click — `pointer-events: none` landed,
* because nothing sets that inline.
*
* `!important` is the one thing that beats an inline declaration, and it is preferable here to
* fighting motion for ownership of the animation: the library keeps animating opacity, this only
* pins the disabled end state.
*/
export const Button = ({ className, ...props }: ButtonProps) => (
<AnimatedButton
className={cn("disabled:opacity-50!", className)}
{...props}
/>
);
Button.displayName = "Button";
export { buttonVariants };
+90 -1
View File
@@ -1,6 +1,6 @@
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useEffect, useState } from "react";
import type { StoreEntry } from "@/api/store";
import type { PendingUpdate, StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -87,6 +87,95 @@ export const InstallDialog: FC<{
);
};
/**
* "Update all": one confirmation for a whole run of catalog installs.
*
* It is the same trust decision as `InstallDialog`, taken once for several packages, so it keeps
* the same escalation rule — if ANY entry in the run comes from an operator-added source, the whole
* dialog wears the external treatment and names those sources. A bulk action must not be a way to
* wave through, in one click, a warning each package would have shown on its own.
*/
export const UpdateAllDialog: FC<{
/** The updates to run, in order — null when the dialog is closed. */
updates: PendingUpdate[] | null;
/** Plugins with an update the run will not attempt; named so the count adds up on screen. */
skipped: string[];
onCancel: () => void;
onConfirm: (updates: PendingUpdate[]) => void;
isPending: boolean;
}> = ({ updates, skipped, onCancel, onConfirm, isPending }) => {
const external = (updates ?? []).filter((u) => u.entry.tier === "external");
// Each source named once, in the order the run meets it.
const sources = [...new Set(external.map((u) => u.entry.source))];
return (
<Dialog
open={updates !== null}
onOpenChange={(open) => !open && onCancel()}
>
{updates && (
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{sources.length > 0 ? (
<ShieldQuestion className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
) : (
<BadgeCheck className="size-5 shrink-0 text-[var(--success)]" />
)}
{m.store_update_all_title()}
</DialogTitle>
<DialogDescription>{m.store_update_all_body()}</DialogDescription>
</DialogHeader>
{/* Every version change, spelled out: a bulk confirm that only says "3 plugins" is
asking the operator to trust a number. Scrolls rather than growing past the
dialog's own max-height when a host has a lot installed. */}
<ul className="max-h-64 space-y-1 overflow-y-auto rounded-md bg-muted p-3 text-xs">
{updates.map((u) => (
<li
key={u.plugin.pkg}
className="flex items-baseline justify-between gap-3"
>
<span className="truncate font-medium">
{u.plugin.title ?? u.plugin.pkg}
</span>
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
{u.plugin.version ? `v${u.plugin.version}` : "—"} v
{u.entry.version}
</span>
</li>
))}
</ul>
{sources.length > 0 && (
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
{m.store_update_all_external_note({
sources: sources.join(", "),
})}
</p>
)}
{skipped.length > 0 && (
<p className="text-xs text-muted-foreground">
{m.store_update_all_skipped({ names: skipped.join(", ") })}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button disabled={isPending} onClick={() => onConfirm(updates)}>
{sources.length > 0
? m.store_update_all_external_confirm()
: m.store_update_all_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
};
/**
* Tier 3: install a raw package spec. No catalog, no review, no pinning — so the dialog spells out
* exactly what that means and asks for two independent confirmations (retype the spec, tick the
+38 -5
View File
@@ -17,10 +17,22 @@ import { SourceChip, TierBadge } from "./TierBadge";
*/
export const InstalledTab: FC<{
onUpdate: (plugin: InstalledPlugin) => void;
onUpdateAll: () => void;
onUninstall: (plugin: InstalledPlugin) => void;
/** How many plugins "Update all" would install; the button hides at zero. */
updateCount: number;
/** Package whose install/uninstall is in flight, or null — only that row's actions disable. */
busyPkg: string | null;
}> = ({ onUpdate, onUninstall, busyPkg }) => {
/** An Update-all run is working through the queue — every action here waits for it. */
batchRunning: boolean;
}> = ({
onUpdate,
onUpdateAll,
onUninstall,
updateCount,
busyPkg,
batchRunning,
}) => {
const installed = useInstalledPlugins();
return (
<div className="flex flex-col gap-card">
@@ -28,8 +40,11 @@ export const InstalledTab: FC<{
<InstalledList
installed={installed}
onUpdate={onUpdate}
onUpdateAll={onUpdateAll}
onUninstall={onUninstall}
updateCount={updateCount}
busyPkg={busyPkg}
batchRunning={batchRunning}
/>
</div>
);
@@ -43,15 +58,33 @@ export const InstalledTab: FC<{
export const InstalledList: FC<{
installed: Loadable<InstalledPlugin[]>;
onUpdate: (plugin: InstalledPlugin) => void;
onUpdateAll: () => void;
onUninstall: (plugin: InstalledPlugin) => void;
updateCount: number;
busyPkg: string | null;
}> = ({ installed, onUpdate, onUninstall, busyPkg }) => {
batchRunning: boolean;
}> = ({
installed,
onUpdate,
onUpdateAll,
onUninstall,
updateCount,
busyPkg,
batchRunning,
}) => {
const rows = installed.data ?? [];
return (
<Card>
<CardContent flush>
<CardHeader>
{/* The bulk action sits with the list it acts on, the way Sources' "Refresh all" does. */}
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
<CardTitle>{m.store_installed_title()}</CardTitle>
{updateCount > 0 && (
<Button size="sm" disabled={batchRunning} onClick={onUpdateAll}>
<ArrowUpCircle className="size-4" />
{m.store_update_all_count({ count: updateCount })}
</Button>
)}
</CardHeader>
<QueryState
@@ -108,7 +141,7 @@ export const InstalledList: FC<{
{p.update_available !== undefined && (
<Button
size="sm"
disabled={busyPkg === p.pkg}
disabled={batchRunning || busyPkg === p.pkg}
onClick={() => onUpdate(p)}
>
<ArrowUpCircle className="size-4" />
@@ -121,7 +154,7 @@ export const InstalledList: FC<{
variant="ghost"
size="icon"
aria-label={m.store_uninstall()}
disabled={busyPkg === p.pkg}
disabled={batchRunning || busyPkg === p.pkg}
onClick={() => onUninstall(p)}
>
<Trash2 className="size-4 text-destructive" />
+62 -8
View File
@@ -27,6 +27,13 @@ const phaseLabel = (phase: string): string => PHASES[phase]?.() ?? phase;
/** Keep the tail — an install log can run long and only the end is ever interesting. */
const LOG_TAIL = 200;
/** Where a job sits in an "Update all" run. Absent for a job the operator started on its own. */
export interface BatchStep {
/** 1-based, so it reads the way it is written: "Update 2 of 5". */
index: number;
total: number;
}
/**
* Container: the in-flight install/uninstall. Polls the job once a second while it runs (the query
* stops polling itself once the job settles), and refreshes everything the job touched — catalog,
@@ -35,24 +42,39 @@ const LOG_TAIL = 200;
export const JobProgressSection: FC<{
jobId: string;
onDismiss: () => void;
}> = ({ jobId, onDismiss }) => {
/**
* Called once, with the final job, when it reaches `done` or `failed` — how an Update-all run
* learns it may start the next install. The host takes one package operation at a time, so the
* run has to be driven by this rather than by a timer.
*/
onSettled?: (job: StoreJob) => void;
step?: BatchStep;
}> = ({ jobId, onDismiss, onSettled, step }) => {
const qc = useQueryClient();
const job = useStoreJob(jobId);
const settled = job.data?.state === "done" || job.data?.state === "failed";
// Refresh once per job, not on every re-render while the finished card sits there.
const final =
job.data?.state === "done" || job.data?.state === "failed"
? job.data
: undefined;
// Refresh once per job, not on every re-render while the finished card sits there. The settle
// handler starts the next install of a run, so riding the same guard is what keeps a run from
// double-stepping when a later poll re-renders this with the same finished job.
const refreshed = useRef<string | null>(null);
useEffect(() => {
if (!settled || refreshed.current === jobId) return;
if (!final || refreshed.current === jobId) return;
refreshed.current = jobId;
invalidateStore(qc);
}, [settled, jobId, qc]);
onSettled?.(final);
}, [final, jobId, qc, onSettled]);
// A job the host can no longer tell us about — it restarted, and jobs live in memory. This used
// to render `null`, so the card simply vanished while the Install buttons stayed armed and the
// query kept polling a dead id once a second forever. Say what happened and offer the way out.
if (!job.data) {
if (!job.isError) return null;
// Mid-run, "no data yet" is just the first poll of the install we only now started, and
// rendering nothing would blink the run's progress off the page between every package.
if (!job.isError) return step ? <BatchPendingCard step={step} /> : null;
return (
<Card className="ring-2 ring-destructive/60">
<CardContent className="flex items-start gap-3">
@@ -75,14 +97,36 @@ export const JobProgressSection: FC<{
</Card>
);
}
return <JobProgressCard job={job.data} onDismiss={onDismiss} />;
return <JobProgressCard job={job.data} onDismiss={onDismiss} step={step} />;
};
/**
* The gap between two installs of a run: the last one finished, the next has not been accepted yet.
*
* It exists so the run never appears to stop. Without it the card unmounts the moment the finished
* job is let go and comes back a request later, which reads as "it gave up" precisely when the
* operator is watching to see that it hasn't.
*/
export const BatchPendingCard: FC<{ step: BatchStep }> = ({ step }) => (
<Card aria-live="polite">
<CardContent className="flex items-start gap-3">
<Spinner className="mt-0.5 size-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{m.store_update_all_running()}</p>
<p className="text-sm text-muted-foreground">
{m.store_update_all_step({ index: step.index, total: step.total })}
</p>
</div>
</CardContent>
</Card>
);
/** The progress card: phase (or outcome), a collapsible log tail, and the failure reason if any. */
export const JobProgressCard: FC<{
job: StoreJob;
onDismiss: () => void;
}> = ({ job, onDismiss }) => {
step?: BatchStep;
}> = ({ job, onDismiss, step }) => {
const running = job.state === "running";
const failed = job.state === "failed";
const log = job.log.slice(-LOG_TAIL);
@@ -107,6 +151,16 @@ export const JobProgressCard: FC<{
? m.store_job_uninstall({ target: job.target })
: m.store_job_install({ target: job.target })}
</p>
{/* Which package is being installed answers "what is happening"; the step answers
"how much longer", which is the only question a multi-package run adds. */}
{step && (
<p className="text-xs text-muted-foreground">
{m.store_update_all_step({
index: step.index,
total: step.total,
})}
</p>
)}
<p className="text-sm text-muted-foreground">
{running
? phaseLabel(job.phase)
+156 -23
View File
@@ -1,12 +1,18 @@
import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useState } from "react";
import { type FC, useEffect, useMemo, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
catalogEntryFor,
type InstallBody,
type InstalledPlugin,
type PendingUpdate,
planUpdates,
runningJob,
type StoreEntry,
type StoreJob,
type UpdatePlan,
useInstalledPlugins,
useInstallPlugin,
useStoreCatalog,
useStoreJobs,
@@ -17,13 +23,34 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { BrowseTab } from "./Browse";
import { InstallDialog, SpecInstallDialog } from "./InstallDialogs";
import {
InstallDialog,
SpecInstallDialog,
UpdateAllDialog,
} from "./InstallDialogs";
import { InstalledTab } from "./Installed";
import { JobProgressSection } from "./JobProgress";
import { BatchPendingCard, JobProgressSection } from "./JobProgress";
import { SourcesTab } from "./Sources";
type StoreTab = "browse" | "installed" | "sources";
/**
* An "Update all" run in flight.
*
* The host takes one package operation at a time (`409` otherwise — `bun` operations share a
* lockfile and a `node_modules` tree), so this is a queue the console works through one job at a
* time, not a fan-out. It carries its own copy of what is left rather than re-deriving it from the
* catalog between packages: every finished install invalidates the installed list, and a queue that
* re-derived itself would change shape underneath a run the operator already confirmed.
*/
interface UpdateRun {
/** Not yet started. The one currently installing has already been taken off the front. */
queue: PendingUpdate[];
/** How many have finished successfully — `done + 1` is the step now running. */
done: number;
total: number;
}
/**
* The plugin store: browse a catalog, manage what's installed, and choose which catalogs this host
* trusts. Each tab owns its own queries; this container owns only what genuinely spans them — the
@@ -41,16 +68,35 @@ export const SectionStore: FC = () => {
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
// finished job's log is the only record of what happened.
const [jobId, setJobId] = useState<string | null>(null);
// The plan awaiting its one confirmation, and the run that confirmation started. A SNAPSHOT taken
// when the button was pressed — the installed list refetches on a timer, and the operator must
// confirm the list they were shown, not whatever it became while they read it.
const [updateAllTarget, setUpdateAllTarget] = useState<UpdatePlan | null>(
null,
);
const [run, setRun] = useState<UpdateRun | null>(null);
const catalog = useStoreCatalog();
// Also queried by the Installed tab; react-query serves both from one fetch. Here it is what
// "Update all" counts, so the button is right even while that tab has never been opened.
const installed = useInstalledPlugins();
const plan = useMemo(
() => planUpdates(installed.data, catalog.data?.plugins),
[installed.data, catalog.data],
);
// Re-attach to a job that was already running when this page loaded — an install survives a
// reload on the host side, and losing sight of it left the Install buttons armed against a host
// that answers 409.
const jobs = useStoreJobs();
const orphan = runningJob(jobs.data);
useEffect(() => {
// A run owns the job slot while it lasts, and it clears the id between packages. This list is
// only refetched on a focus or a stale read, so during that gap `orphan` can still be the job
// that just finished — re-attaching to it would remount the progress card, fire its settle
// handler a second time, and step the run forward over a package it never installed.
if (run) return;
if (orphan && !jobId) setJobId(orphan.id);
}, [orphan, jobId]);
}, [orphan, jobId, run]);
const install = useInstallPlugin();
const uninstall = useUninstallPlugin();
@@ -98,24 +144,8 @@ export const SectionStore: FC = () => {
// An update from the Installed tab installs the CATALOG version — so it goes through the very
// same tier-appropriate dialog a fresh install would, warning included.
//
// Resolve by the entry the plugin was actually installed FROM (source + entry id) before falling
// back to the package name: two sources may carry the same `pkg`, and matching on the name alone
// could offer a row badged "verified" an entry from somebody else's source at a different version.
const onUpdate = (plugin: InstalledPlugin) => {
const entries = catalog.data?.plugins ?? [];
const entry =
(plugin.source && plugin.entry_id
? entries.find(
(e) => e.source === plugin.source && e.id === plugin.entry_id,
)
: undefined) ??
(plugin.source
? entries.find(
(e) => e.source === plugin.source && e.pkg === plugin.pkg,
)
: undefined) ??
entries.find((e) => e.pkg === plugin.pkg);
const entry = catalogEntryFor(plugin, catalog.data?.plugins);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
@@ -123,6 +153,72 @@ export const SectionStore: FC = () => {
setTarget(entry);
};
/**
* Start the next install of a run, or finish it when the queue runs dry.
*
* What is left is threaded through the arguments rather than read from `run`: the caller is a
* settle handler that already knows the outcome, and reading state it is itself about to replace
* is how a queue skips or repeats an entry.
*/
const runNext = async (
queue: PendingUpdate[],
done: number,
total: number,
) => {
const [next, ...rest] = queue;
if (!next) {
setRun(null);
toast.success(m.store_update_all_finished({ count: done }));
return;
}
// Let the finished job's card go before asking for the next one: the run's own progress card
// takes over for the moment in between, so the page never shows "Installed." while the next
// package is already on its way.
setJobId(null);
setRun({ queue: rest, done, total });
try {
const { job } = await install.mutateAsync({
source: next.entry.source,
id: next.entry.id,
});
setJobId(job);
} catch (e) {
setRun(null);
failed(e, m.store_install_failed());
}
};
/**
* A job of the run finished.
*
* A failure ENDS the run. The failed job's card — its phase, its error, its log — is the only
* record of what went wrong, and starting the next install would replace it with a fresh
* spinner; the operator would be left knowing only that something, somewhere, went wrong. So the
* run stops on the evidence and says what it did not get to, which they can retry from the rows.
*/
const onJobSettled = (job: StoreJob) => {
if (!run) return;
if (job.state !== "done") {
setRun(null);
toast.error(
m.store_update_all_stopped({
done: run.done,
left: run.queue.length + 1,
}),
);
return;
}
void runNext(run.queue, run.done + 1, run.total);
};
const onConfirmUpdateAll = (updates: PendingUpdate[]) => {
setUpdateAllTarget(null);
void runNext(updates, 0, updates.length);
};
// 1-based, and only while a run is live — this is what turns the install card into "2 of 5".
const step = run ? { index: run.done + 1, total: run.total } : undefined;
const onUninstall = async (plugin: InstalledPlugin) => {
const ok = await confirm({
title: m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }),
@@ -147,15 +243,42 @@ export const SectionStore: FC = () => {
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
</div>
{jobId && (
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
{jobId ? (
<JobProgressSection
jobId={jobId}
onDismiss={() => setJobId(null)}
onSettled={onJobSettled}
step={step}
/>
) : (
// No job id yet, but a run is live — the install we just asked for has not come
// back with one. Only reachable mid-run; a lone install has nothing to show here.
step && <BatchPendingCard step={step} />
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
<TabsList>
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
{/* Browse is the tab this page opens on, so the count has to travel to where the
operator already is — otherwise "Update all" is only ever found by someone
who went looking for it. */}
<TabsTrigger value="installed">
{m.store_tab_installed()}
{plan.updates.length > 0 && (
<>
{/* The digit is shorthand for the sentence beside it; a screen reader
gets the sentence, not a tab label that ends in a bare number. */}
<span
aria-hidden="true"
className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-[0.6875rem] font-medium leading-none tabular-nums text-primary-foreground"
>
{plan.updates.length}
</span>
<span className="sr-only">
{m.store_updates_pending({ count: plan.updates.length })}
</span>
</>
)}
</TabsTrigger>
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
</TabsList>
@@ -169,10 +292,13 @@ export const SectionStore: FC = () => {
<TabsContent value="installed">
<InstalledTab
onUpdate={onUpdate}
onUpdateAll={() => setUpdateAllTarget(plan)}
onUninstall={onUninstall}
updateCount={plan.updates.length}
busyPkg={
uninstall.isPending ? (uninstall.variables ?? null) : null
}
batchRunning={run !== null}
/>
</TabsContent>
<TabsContent value="sources">
@@ -186,6 +312,13 @@ export const SectionStore: FC = () => {
onCancel={() => setTarget(null)}
onConfirm={onConfirmEntry}
/>
<UpdateAllDialog
updates={updateAllTarget?.updates ?? null}
skipped={updateAllTarget?.skipped ?? []}
isPending={install.isPending}
onCancel={() => setUpdateAllTarget(null)}
onConfirm={onConfirmUpdateAll}
/>
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { InstalledPlugin } from "@/api/store";
import { InstalledList } from "@/sections/Store/Installed";
// The installed-plugins list, driven straight from fixtures — it fetches nothing, so the header's
// "Update all" affordance can be checked in every state it has (absent, offered, and disabled
// because a run is already working through the queue) without a host or a catalog.
const meta = {
title: "Store/InstalledList",
component: InstalledList,
parameters: { layout: "padded" },
args: {
onUpdate: () => {},
onUpdateAll: () => {},
onUninstall: () => {},
busyPkg: null,
batchRunning: false,
},
} satisfies Meta<typeof InstalledList>;
export default meta;
type Story = StoryObj<typeof meta>;
const ROWS: InstalledPlugin[] = [
{
pkg: "@punktfunk/plugin-rom-manager",
title: "ROM Manager",
version: "0.3.1",
tier: "verified",
source: "unom official",
entry_id: "rom-manager",
running: true,
update_available: "0.3.2",
},
{
pkg: "@punktfunk/plugin-playnite",
title: "Playnite",
version: "0.2.0",
tier: "external",
source: "community catalog",
entry_id: "playnite",
running: true,
update_available: "0.2.1",
},
{
pkg: "@somebody/plugin-scratch",
version: "0.1.0",
tier: "unverified",
running: false,
},
];
const loaded = (data: InstalledPlugin[]) => ({
data,
isLoading: false,
error: null,
});
/** Nothing to update: the header carries its title alone. */
export const UpToDate: Story = {
args: {
installed: loaded(ROWS.map(({ update_available, ...p }) => p)),
updateCount: 0,
},
};
/** Two updates on offer — the bulk action appears beside the title. */
export const UpdatesAvailable: Story = {
args: { installed: loaded(ROWS), updateCount: 2 },
};
/** A run is working through the queue: every action here waits for it, bulk included. */
export const RunInFlight: Story = {
args: { installed: loaded(ROWS), updateCount: 2, batchRunning: true },
};
/** One plugin's own uninstall is in flight — only that row's actions go quiet. */
export const RowBusy: Story = {
args: {
installed: loaded(ROWS),
updateCount: 2,
busyPkg: "@punktfunk/plugin-playnite",
},
};
export const Empty: Story = { args: { installed: loaded([]), updateCount: 0 } };
+101
View File
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { PendingUpdate } from "@/api/store";
import { UpdateAllDialog } from "@/sections/Store/InstallDialogs";
// The one confirmation an "Update all" run takes. Rendered open, from fixtures, so the escalation
// rule can be read off the screen: all-verified is an ordinary confirm, and a single entry from an
// operator-added source turns the whole dialog amber and names the catalogs it came from.
const meta = {
title: "Store/UpdateAllDialog",
component: UpdateAllDialog,
parameters: { layout: "fullscreen" },
args: {
skipped: [],
isPending: false,
onCancel: () => {},
onConfirm: () => {},
},
} satisfies Meta<typeof UpdateAllDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
const update = (
title: string,
pkg: string,
from: string,
to: string,
source = "unom official",
tier: "verified" | "external" = "verified",
): PendingUpdate => ({
plugin: {
pkg,
title,
version: from,
tier,
source,
running: true,
update_available: to,
},
entry: {
id: pkg.split("/").pop() ?? pkg,
pkg,
title,
description: "",
author: "unom",
version: to,
source,
tier,
platforms: ["linux", "windows"],
compatible: true,
update_available: true,
},
});
const VERIFIED: PendingUpdate[] = [
update("ROM Manager", "@punktfunk/plugin-rom-manager", "0.3.1", "0.3.2"),
update("Steam Library", "@punktfunk/plugin-steam", "1.0.0", "1.1.0"),
];
/** Everything from the built-in catalog: a plain confirm, no warning to earn. */
export const AllVerified: Story = { args: { updates: VERIFIED } };
/** One entry from a catalog the operator added — the whole dialog escalates and names it. */
export const WithExternal: Story = {
args: {
updates: [
...VERIFIED,
update(
"Playnite",
"@punktfunk/plugin-playnite",
"0.2.0",
"0.2.1",
"community catalog",
"external",
),
],
},
};
/** Updates the run will not attempt are named, so the button's count adds up on screen. */
export const WithSkipped: Story = {
args: {
updates: VERIFIED,
skipped: ["Emulator Bridge", "@somebody/plugin-scratch"],
},
};
/** A host with a lot installed: the list scrolls rather than pushing the footer off screen. */
export const LongList: Story = {
args: {
updates: Array.from({ length: 12 }, (_, i) =>
update(
`Plugin ${i + 1}`,
`@punktfunk/plugin-number-${i + 1}`,
`0.${i}.0`,
`0.${i}.1`,
),
),
},
};