feat(web-console): "Update all" on the plugins screen #198
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } };
|
||||
@@ -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`,
|
||||
),
|
||||
),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user