feat(store): console plugin store, index repo, and the fixes on-glass found
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s

Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.

Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.

Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
  scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
  own framework: it listed as installed and would have been imported as a
  unit. Both now key off the plugins dir's top-level dependencies, with an
  emptied dependency list meaning 'nothing installed' rather than falling
  back to the naming convention
- the store must not pass new flags to the runner: the scripting package
  ships separately and an older one reads an unknown flag's value as a
  package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
  as Ok with an empty body — handled as an error it made every refresh
  after the first verify a signature over zero bytes and sit stale

Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:44:05 +02:00
parent 45c3b96907
commit 833f3348a0
30 changed files with 4028 additions and 21 deletions
+108 -1
View File
@@ -267,5 +267,112 @@
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
"stats_detail_title": "Aufnahme-Details",
"stats_close": "Schließen",
"stats_no_samples": "Diese Aufnahme enthält keine Proben."
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
"store_title": "Plugins",
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
"store_tab_browse": "Entdecken",
"store_tab_installed": "Installiert",
"store_tab_sources": "Quellen",
"store_tier_verified": "Geprüft",
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
"store_tier_external": "Externe Quelle",
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
"store_tier_unverified": "Ungeprüft",
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
"store_tier_cli": "Über die CLI installiert",
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
"store_from_source": "von",
"store_search_placeholder": "Plugins durchsuchen…",
"store_filter_all": "Alle Quellen",
"store_empty": "Noch keine Plugins im Katalog.",
"store_no_match": "Kein Plugin passt zur Suche.",
"store_by_author": "von {author}",
"store_homepage": "Website",
"store_install": "Installieren",
"store_installed_label": "Installiert",
"store_update_to": "Auf {version} aktualisieren",
"store_incompatible": "Nicht kompatibel mit diesem Host.",
"store_blocked": "Vom Host blockiert: {reason}",
"store_spec_open": "Aus Paketangabe installieren…",
"store_runner_title": "Plugin-Runner",
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
"store_runner_enable": "Runner aktivieren",
"store_runner_disable": "Runner deaktivieren",
"store_runner_state_running": "Läuft",
"store_runner_state_stopped": "Gestoppt",
"store_runner_state_disabled": "Deaktiviert",
"store_runner_state_missing": "Nicht installiert",
"store_runner_unit": "Dienst",
"store_runner_principal": "Läuft als",
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
"store_installed_title": "Installierte Plugins",
"store_installed_empty": "Noch keine Plugins installiert.",
"store_running": "Läuft",
"store_stopped": "Läuft nicht",
"store_uninstall": "Deinstallieren",
"store_uninstall_confirm": "{title} deinstallieren? 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_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",
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
"store_source_builtin": "Eingebaut",
"store_source_signed": "Signiert",
"store_source_unsigned": "Unsigniert",
"store_source_stale": "Veraltet",
"store_source_entries": "{count} Plugins",
"store_source_fetched": "zuletzt geladen {when}",
"store_source_never": "nie",
"store_source_remove": "Quelle entfernen",
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
"store_add_source_title": "Katalogquelle hinzufügen",
"store_field_source_name": "Name",
"store_field_source_url": "Index-URL",
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
"store_add_source": "Quelle hinzufügen",
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
"store_source_trust_title": "Dieser Quelle vertrauen?",
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
"store_install_title": "{title} installieren?",
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
"store_install_confirm": "Installieren",
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
"store_install_external_confirm": "Trotzdem installieren",
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
"store_spec_title": "Aus einer Paketangabe installieren",
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
"store_spec_field": "Paketangabe",
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
"store_spec_confirm": "Ungeprüft installieren",
"store_job_install": "{target} wird installiert",
"store_job_uninstall": "{target} wird entfernt",
"store_job_done_install": "Installiert.",
"store_job_done_uninstall": "Entfernt.",
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
"store_job_log": "Log anzeigen",
"store_job_dismiss": "Ausblenden",
"store_phase_queued": "In der Warteschlange",
"store_phase_verifying": "Paket wird verifiziert",
"store_phase_installing": "Wird installiert",
"store_phase_removing": "Wird entfernt",
"store_phase_checking": "Installation wird geprüft",
"store_phase_rolling_back": "Wird zurückgerollt",
"store_phase_recording": "Herkunft wird vermerkt",
"store_phase_restarting": "Plugin-Runner startet neu",
"store_phase_done": "Fertig"
}
+108 -1
View File
@@ -267,5 +267,112 @@
"stats_delete_confirm": "Delete this recording? This can't be undone.",
"stats_detail_title": "Recording detail",
"stats_close": "Close",
"stats_no_samples": "This recording has no samples."
"stats_no_samples": "This recording has no samples.",
"store_title": "Plugins",
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
"store_tab_browse": "Browse",
"store_tab_installed": "Installed",
"store_tab_sources": "Sources",
"store_tier_verified": "Verified",
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
"store_tier_external": "External source",
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
"store_tier_unverified": "Unverified",
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
"store_tier_cli": "Installed via CLI",
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
"store_from_source": "from",
"store_search_placeholder": "Search plugins…",
"store_filter_all": "All sources",
"store_empty": "No plugins in the catalog yet.",
"store_no_match": "No plugin matches your search.",
"store_by_author": "by {author}",
"store_homepage": "Homepage",
"store_install": "Install",
"store_installed_label": "Installed",
"store_update_to": "Update to {version}",
"store_incompatible": "Not compatible with this host.",
"store_blocked": "Blocked by the host: {reason}",
"store_spec_open": "Install from package spec…",
"store_runner_title": "Plugin runner",
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
"store_runner_enable": "Enable runner",
"store_runner_disable": "Disable runner",
"store_runner_state_running": "Running",
"store_runner_state_stopped": "Stopped",
"store_runner_state_disabled": "Disabled",
"store_runner_state_missing": "Not installed",
"store_runner_unit": "Service",
"store_runner_principal": "Runs as",
"store_runner_failed": "Could not change the plugin runner.",
"store_installed_title": "Installed plugins",
"store_installed_empty": "No plugins installed yet.",
"store_running": "Running",
"store_stopped": "Not running",
"store_uninstall": "Uninstall",
"store_uninstall_confirm": "Uninstall {title}? 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_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",
"store_refresh_failed": "Could not refresh the catalogs.",
"store_source_builtin": "Built in",
"store_source_signed": "Signed",
"store_source_unsigned": "Unsigned",
"store_source_stale": "Out of date",
"store_source_entries": "{count} plugins",
"store_source_fetched": "last fetched {when}",
"store_source_never": "never",
"store_source_remove": "Remove source",
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
"store_source_remove_failed": "Could not remove the source.",
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
"store_add_source_title": "Add a catalog source",
"store_field_source_name": "Name",
"store_field_source_url": "Index URL",
"store_field_source_key": "Public key (optional)",
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
"store_add_source": "Add source",
"store_add_source_failed": "Could not save the source — check the name and the URL.",
"store_source_trust_title": "Trust this source?",
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
"store_source_trust_confirm": "I understand — add the source",
"store_install_title": "Install {title}?",
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
"store_install_confirm": "Install",
"store_install_external_title": "Install {title} from an external source?",
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
"store_install_external_confirm": "Install anyway",
"store_install_failed": "Could not start the install.",
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
"store_spec_title": "Install from a package spec",
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
"store_spec_field": "Package spec",
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
"store_spec_confirm_field": "Type the package spec again to confirm",
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
"store_spec_confirm": "Install unverified",
"store_job_install": "Installing {target}",
"store_job_uninstall": "Removing {target}",
"store_job_done_install": "Installed.",
"store_job_done_uninstall": "Removed.",
"store_job_failed": "The job failed.",
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
"store_job_log": "Show log",
"store_job_dismiss": "Dismiss",
"store_phase_queued": "Queued",
"store_phase_verifying": "Verifying the package",
"store_phase_installing": "Installing",
"store_phase_removing": "Removing",
"store_phase_checking": "Checking the install",
"store_phase_rolling_back": "Rolling back",
"store_phase_recording": "Recording provenance",
"store_phase_restarting": "Restarting the plugin runner",
"store_phase_done": "Done"
}
+282
View File
@@ -0,0 +1,282 @@
// The plugin store: catalog, install/uninstall jobs, catalog sources, and the scripting runner.
// Like `api/plugins.ts` this is a hand-written client rather than an orval-generated one — the
// OpenAPI document is regenerated on a Linux box (punktfunk-host doesn't build on macOS), so the
// console must not wait on a regen to talk to these endpoints. It rides the same `/api` BFF path as
// every other call, so the bearer token is injected server-side and the browser only ever sends its
// session cookie.
import {
type QueryClient,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/api/fetcher";
/**
* How much a plugin's provenance is worth, from most to least trustworthy:
*
* - `verified` — from the built-in `unom` source; unom reviewed that exact tarball.
* - `external` — from an operator-added source; pinned and integrity-checked, but curated by
* somebody else. It is NOT reviewed by unom and never wears the verified badge.
* - `unverified` — installed from a raw package spec through the high-friction dialog. No catalog,
* no review, no pinning. Stays badged unverified forever.
* - `cli` — installed with the CLI, so the host holds no provenance record at all.
*/
export type StoreTier = "verified" | "external" | "unverified" | "cli";
/** The tier a *catalog* entry can carry — the raw-spec/CLI tiers never appear in a catalog. */
export type CatalogTier = Extract<StoreTier, "verified" | "external">;
export interface StoreHostInfo {
version: string;
platform: string;
}
export interface StoreSource {
name: string;
url: string;
/** The `unom` source ships with the host: it can't be edited or removed. */
builtin: boolean;
signed: boolean;
/** The last fetch failed or is too old — entries may be out of date. */
stale: boolean;
/** Unix seconds of the last successful fetch. */
fetched_at: number;
error?: string;
entry_count: number;
public_key?: string;
}
export interface StoreEntry {
id: string;
pkg: string;
title: string;
description: string;
icon?: string;
author: string;
homepage?: string;
license?: string;
version: string;
/** Name of the source this entry came from — the attribution an external entry shows. */
source: string;
tier: CatalogTier;
reviewed_at?: string;
platforms: string[];
min_host?: string;
compatible: boolean;
incompatible_reason?: string;
installed_version?: string;
update_available: boolean;
blocked?: string;
}
export interface StoreCatalog {
host: StoreHostInfo;
sources: StoreSource[];
plugins: StoreEntry[];
/** An install/uninstall is already running — the host takes one at a time. */
busy: boolean;
}
export interface InstalledPlugin {
pkg: string;
version: string;
tier: StoreTier;
source?: string;
entry_id?: string;
/** The plugin's runtime id — how it's keyed in the plugin directory (`api/plugins.ts`). */
plugin_id?: string;
title?: string;
installed_at?: string;
running: boolean;
/** The version available to update to, if any. */
update_available?: string;
blocked?: string;
}
export type JobKind = "install" | "uninstall";
export type JobState = "running" | "done" | "failed";
export interface StoreJob {
id: string;
kind: JobKind;
target: string;
state: JobState;
phase: string;
log: string[];
error?: string;
started_at: number;
finished_at?: number;
}
export interface RuntimeStatus {
installed: boolean;
enabled: boolean;
running: boolean;
unit: string;
principal?: string;
detail?: string;
}
/** What `POST /store/install` and `POST /store/uninstall` answer with (202). */
export interface JobAccepted {
job: string;
}
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
export type InstallBody =
| { source: string; id: string }
| { spec: string; accept_unverified: true };
export interface SourceBody {
url: string;
public_key?: string;
}
const BASE = "/api/v1/store";
/** Query keys, in one place so any mutation can invalidate precisely. */
export const storeKeys = {
all: ["store"] as const,
catalog: ["store", "catalog"] as const,
installed: ["store", "installed"] as const,
sources: ["store", "sources"] as const,
runtime: ["store", "runtime"] as const,
job: (id: string) => ["store", "job", id] as const,
};
const json = (method: string, body: unknown): RequestInit => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
/**
* Refresh everything a completed install/uninstall touches — the catalog (installed markers), the
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
*/
export function invalidateStore(qc: QueryClient): Promise<void> {
return Promise.all([
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
qc.invalidateQueries({ queryKey: storeKeys.installed }),
qc.invalidateQueries({ queryKey: storeKeys.sources }),
qc.invalidateQueries({ queryKey: storeKeys.runtime }),
qc.invalidateQueries({ queryKey: ["plugins"] }),
]).then(() => undefined);
}
/** The merged catalog across every source, plus the sources' own health. */
export function useStoreCatalog() {
return useQuery({
queryKey: storeKeys.catalog,
queryFn: () => apiFetch<StoreCatalog>(`${BASE}/catalog`),
});
}
/** What's installed right now, with each plugin's permanent provenance tier. */
export function useInstalledPlugins() {
return useQuery({
queryKey: storeKeys.installed,
queryFn: () => apiFetch<InstalledPlugin[]>(`${BASE}/installed`),
refetchInterval: 30_000,
});
}
export function useStoreSources() {
return useQuery({
queryKey: storeKeys.sources,
queryFn: () => apiFetch<StoreSource[]>(`${BASE}/sources`),
});
}
export function useStoreRuntime() {
return useQuery({
queryKey: storeKeys.runtime,
queryFn: () => apiFetch<RuntimeStatus>(`${BASE}/runtime`),
});
}
/**
* A single install/uninstall job, polled once a second while it runs and left alone once it
* settles. Pass `null` to park the query (no job in flight).
*/
export function useStoreJob(id: string | null) {
return useQuery({
queryKey: storeKeys.job(id ?? ""),
queryFn: () =>
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
enabled: id !== null,
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
});
}
/** Re-fetch every source's index; answers with the freshly merged catalog. */
export function useRefreshCatalog() {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiFetch<StoreCatalog>(`${BASE}/refresh`, { method: "POST" }),
onSuccess: (catalog) => {
qc.setQueryData(storeKeys.catalog, catalog);
qc.setQueryData(storeKeys.sources, catalog.sources);
},
});
}
/** Start an install. Answers 202 with the job to poll; 409 means another op is already running. */
export function useInstallPlugin() {
return useMutation({
mutationFn: (body: InstallBody) =>
apiFetch<JobAccepted>(`${BASE}/install`, json("POST", body)),
});
}
export function useUninstallPlugin() {
return useMutation({
mutationFn: (pkg: string) =>
apiFetch<JobAccepted>(`${BASE}/uninstall`, json("POST", { pkg })),
});
}
/** Add or update an operator source (the built-in one is not editable). */
export function useSetSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, ...body }: SourceBody & { name: string }) =>
apiFetch<void>(
`${BASE}/sources/${encodeURIComponent(name)}`,
json("PUT", body),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: storeKeys.sources });
qc.invalidateQueries({ queryKey: storeKeys.catalog });
},
});
}
export function useDeleteSource() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
apiFetch<void>(`${BASE}/sources/${encodeURIComponent(name)}`, {
method: "DELETE",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: storeKeys.sources });
qc.invalidateQueries({ queryKey: storeKeys.catalog });
},
});
}
/** Enable or disable the plugin/script runner service. */
export function useSetRuntime() {
const qc = useQueryClient();
return useMutation({
mutationFn: (enabled: boolean) =>
apiFetch<RuntimeStatus>(`${BASE}/runtime`, json("POST", { enabled })),
onSuccess: (status) => {
qc.setQueryData(storeKeys.runtime, status);
qc.invalidateQueries({ queryKey: ["plugins"] });
},
});
}
+10 -2
View File
@@ -6,6 +6,7 @@ import {
LibraryBig,
MonitorPlay,
MoreHorizontal,
Puzzle,
ScrollText,
Server,
Settings,
@@ -29,9 +30,15 @@ const NAV = [
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const;
// "/plugins" is the store's index route and "/plugins/<id>" a plugin's own UI, so the store entry
// only counts as active on an exact match — otherwise it would light up alongside the plugin's own
// nav entry below. Same reason "/" is exact.
const EXACT: readonly string[] = ["/", "/plugins"];
// On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a
// "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More".
const MOBILE_PRIMARY = NAV.slice(0, 4);
@@ -74,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
to={to}
activeOptions={{ exact: to === "/" }}
activeOptions={{ exact: EXACT.includes(to) }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
@@ -200,6 +207,7 @@ function MobileNav() {
key={to}
to={to}
onClick={() => setMoreOpen(false)}
activeOptions={{ exact: EXACT.includes(to) }}
className={cn(tab, "rounded-md")}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
@@ -232,7 +240,7 @@ function MobileNav() {
key={to}
to={to}
onClick={() => setMoreOpen(false)}
activeOptions={{ exact: to === "/" }}
activeOptions={{ exact: EXACT.includes(to) }}
className={tab}
activeProps={{ className: "text-[var(--brand-light)]" }}
>
+49
View File
@@ -0,0 +1,49 @@
// The console's Dialog IS @unom/ui's radix dialog — brand surface (material gloss + card radius)
// with the shared close/overlay behaviour. @unom/ui ships the SURFACE only and leaves placement to
// the app, so `DialogContent` here is the surface already wrapped in its portal + overlay and
// centred in the viewport; everything else is re-exported unchanged.
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogContent as DialogSurface,
DialogTitle,
DialogTrigger,
} from "@unom/ui/dialog";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const DialogContent = ({
className,
...props
}: ComponentProps<typeof DialogSurface>) => (
<DialogPortal>
<DialogOverlay />
<DialogSurface
className={cn(
"fixed left-1/2 top-1/2 z-100 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto p-6",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</DialogPortal>
);
DialogContent.displayName = "DialogContent";
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+34
View File
@@ -0,0 +1,34 @@
// The console's Tabs ARE @unom/ui's radix tabs, with one correction: the inactive trigger colour.
//
// @unom/ui styles inactive triggers `text-secondary` and the active one `data-[state=active]:
// text-main`. Those tokens come from @unom/ui's own palette; in the console's theme `text-secondary`
// lands on (near) the tab strip's own background, so every inactive tab renders as an invisible
// gap — the tab bar looks like a single lonely label with dead space beside it. Caught in a browser
// pass, not by types: the markup and the a11y roles are entirely correct.
//
// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's
// tokens rather than restyling it at every call site.
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger as TabsTriggerBase,
} from "@unom/ui/tabs";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const TabsTrigger = ({
className,
...props
}: ComponentProps<typeof TabsTriggerBase>) => (
<TabsTriggerBase
className={cn(
"text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
className,
)}
{...props}
/>
);
TabsTrigger.displayName = "TabsTrigger";
export { Tabs, TabsContent, TabsList, TabsTrigger };
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionStore } from "@/sections/Store";
// The plugin store. Sits at /plugins as an index route, so it coexists with the per-plugin UI route
// (`plugins.$pluginId.$.tsx`) without either shadowing the other.
export const Route = createFileRoute("/plugins/")({ component: SectionStore });
+9
View File
@@ -8,9 +8,11 @@ import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { ExternalLink, RefreshCw } from "lucide-react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { pluginIcon, usePlugins } from "@/api/plugins";
import { useInstalledPlugins } from "@/api/store";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { TierBadge } from "@/sections/Store/TierBadge";
const route = getRouteApi("/plugins/$pluginId/$");
@@ -26,6 +28,12 @@ export const SectionPlugin: FC = () => {
const Icon = pluginIcon(meta?.ui?.icon);
const title = meta?.title ?? pluginId;
// Provenance follows the plugin into its own page: an unverified plugin must stay visibly
// unverified WHILE you use it, not only in the store listing. The store keys installations by
// package, so the runtime id is matched through `plugin_id`.
const { data: installed } = useInstalledPlugins();
const provenance = installed?.find((p) => p.plugin_id === pluginId);
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
// offline card (the manual Retry re-probes).
const health = useQuery({
@@ -77,6 +85,7 @@ export const SectionPlugin: FC = () => {
v{meta.version}
</span>
)}
{provenance && <TierBadge tier={provenance.tier} />}
<a
href={`/plugin-ui/${pluginId}/`}
target="_blank"
+227
View File
@@ -0,0 +1,227 @@
import { AlertTriangle, Ban, Check, Download, Search } from "lucide-react";
import { type FC, useMemo, useState } from "react";
import { pluginIcon } from "@/api/plugins";
import { type StoreEntry, useStoreCatalog } from "@/api/store";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
import { RunnerBanner } from "./Runner";
import { SourceChip, TierBadge } from "./TierBadge";
/** Case-insensitive substring match across the fields an operator would actually search by. */
function matches(entry: StoreEntry, needle: string): boolean {
if (!needle) return true;
const q = needle.toLowerCase();
return [entry.title, entry.description, entry.pkg, entry.author].some((f) =>
f.toLowerCase().includes(q),
);
}
/**
* Container: the catalog. Owns the catalog query plus the local search/source filter; installing is
* escalated to the parent, which owns the tier-appropriate dialog and the resulting job — so this
* subsection never installs anything itself.
*/
export const BrowseTab: FC<{
onInstall: (entry: StoreEntry) => void;
onInstallSpec: () => void;
}> = ({ onInstall, onInstallSpec }) => {
const catalog = useStoreCatalog();
const [query, setQuery] = useState("");
const [source, setSource] = useState<string | null>(null);
const entries = catalog.data?.plugins ?? [];
const sources = catalog.data?.sources ?? [];
const shown = useMemo(
() =>
entries.filter(
(e) => (source === null || e.source === source) && matches(e, query),
),
[entries, source, query],
);
return (
<div className="flex flex-col gap-card">
<RunnerBanner />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative sm:max-w-xs sm:flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
className="pl-9"
aria-label={m.store_search_placeholder()}
placeholder={m.store_search_placeholder()}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{/* One chip per source, so an operator can see a third-party catalog's entries alone. */}
{sources.length > 1 && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={source === null ? "default" : "outline"}
aria-pressed={source === null}
onClick={() => setSource(null)}
>
{m.store_filter_all()}
</Button>
{sources.map((s) => (
<Button
key={s.name}
size="sm"
variant={source === s.name ? "default" : "outline"}
aria-pressed={source === s.name}
onClick={() => setSource(s.name)}
>
{s.name}
</Button>
))}
</div>
)}
</div>
<QueryState
isLoading={catalog.isLoading}
error={catalog.error}
refetch={catalog.refetch}
>
{shown.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{entries.length === 0 ? m.store_empty() : m.store_no_match()}
</CardContent>
</Card>
) : (
<div className="@container">
<div className="grid grid-cols-1 gap-card @xl:grid-cols-2 @4xl:grid-cols-3">
{shown.map((entry) => (
<StoreCard
key={`${entry.source}/${entry.id}`}
entry={entry}
onInstall={() => onInstall(entry)}
/>
))}
</div>
</div>
)}
</QueryState>
{/* The ONLY way to the raw-spec install. Deliberately a quiet footer link, not a button on
a card: an unverified install should take a decision, never a stray click. */}
<div className="text-center">
<button
type="button"
onClick={onInstallSpec}
className="text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
{m.store_spec_open()}
</button>
</div>
</div>
);
};
/** One catalog entry. Blocked entries shout; incompatible ones grey out; neither can be installed. */
export const StoreCard: FC<{ entry: StoreEntry; onInstall: () => void }> = ({
entry,
onInstall,
}) => {
const Icon = pluginIcon(entry.icon);
const blocked = entry.blocked !== undefined;
const installed = entry.installed_version !== undefined;
const installable = !blocked && entry.compatible;
return (
<Card
className={cn(
"flex flex-col",
blocked && "ring-2 ring-destructive/60",
!entry.compatible && !blocked && "opacity-60",
)}
>
<CardContent className="flex flex-1 flex-col gap-3 p-card">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/15">
<Icon className="size-5 text-foreground" />
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium" title={entry.title}>
{entry.title}
</h3>
<p className="truncate text-xs text-muted-foreground">
{m.store_by_author({ author: entry.author })} · v{entry.version}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<TierBadge tier={entry.tier} />
{/* Attribution, never verification: an external entry names who curated it. */}
{entry.tier === "external" && <SourceChip source={entry.source} />}
</div>
<p className="line-clamp-3 text-sm text-muted-foreground">
{entry.description}
</p>
<div className="flex flex-wrap gap-1.5">
{entry.platforms.map((p) => (
<Badge key={p} variant="secondary" className="font-normal">
{p}
</Badge>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
<Ban className="mt-0.5 size-4 shrink-0" />
<span>{m.store_blocked({ reason: entry.blocked ?? "" })}</span>
</p>
)}
{!entry.compatible && !blocked && (
<p className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="mt-px size-3.5 shrink-0" />
<span>{entry.incompatible_reason ?? m.store_incompatible()}</span>
</p>
)}
{/* Footer pinned to the bottom so cards in a row line their actions up. */}
<div className="mt-auto flex items-center gap-3 pt-1">
{entry.update_available ? (
<Button size="sm" disabled={!installable} onClick={onInstall}>
<Download className="size-4" />
{m.store_update_to({ version: entry.version })}
</Button>
) : installed ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Check className="size-4" />
{m.store_installed_label()}
</span>
) : (
<Button size="sm" disabled={!installable} onClick={onInstall}>
<Download className="size-4" />
{m.store_install()}
</Button>
)}
{entry.homepage && (
<a
href={entry.homepage}
target="_blank"
rel="noreferrer"
className="ml-auto text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
{m.store_homepage()}
</a>
)}
</div>
</CardContent>
</Card>
);
};
+191
View File
@@ -0,0 +1,191 @@
import { Checkbox } from "@unom/ui/form/checkbox";
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
import { type FC, useState } from "react";
import type { StoreEntry } from "@/api/store";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
// Friction proportional to trust. The three install paths deliberately do NOT share a confirm
// button: a verified install is one click on a plain dialog, an external one makes you read that
// unom didn't review the code and names who did curate it, and a raw package spec makes you retype
// the spec and tick a box. The last one is never reachable by accident — no card, no button on a
// listing, only the small footer link on Browse.
/**
* Tiers 1 and 2: install a curated catalog entry. Verified entries get an ordinary confirm; an
* entry from an operator-added source gets the warning treatment and names its source, because
* "pinned and integrity-checked" is not the same claim as "reviewed".
*/
export const InstallDialog: FC<{
/** The entry being confirmed, or null when the dialog is closed. */
entry: StoreEntry | null;
onCancel: () => void;
onConfirm: (entry: StoreEntry) => void;
isPending: boolean;
}> = ({ entry, onCancel, onConfirm, isPending }) => {
const external = entry?.tier === "external";
return (
<Dialog open={entry !== null} onOpenChange={(open) => !open && onCancel()}>
{entry && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{external ? (
<ShieldQuestion className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
) : (
<BadgeCheck className="size-5 shrink-0 text-[var(--success)]" />
)}
{external
? m.store_install_external_title({ title: entry.title })
: m.store_install_title({ title: entry.title })}
</DialogTitle>
<DialogDescription>
{external
? m.store_install_external_body({
version: entry.version,
source: entry.source,
})
: m.store_install_verified_body({ version: entry.version })}
</DialogDescription>
</DialogHeader>
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
{entry.pkg}@{entry.version}
</p>
{external && (
<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_install_external_note()}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isPending}>
{m.common_cancel()}
</Button>
{/* Red is reserved for the raw-spec install; an external one escalates through
its copy and its amber panel, not by borrowing the danger colour. */}
<Button disabled={isPending} onClick={() => onConfirm(entry)}>
{external
? m.store_install_external_confirm()
: m.store_install_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
* box) before it will even enable its button.
*/
export const SpecInstallDialog: FC<{
open: boolean;
onCancel: () => void;
onConfirm: (spec: string) => void;
isPending: boolean;
}> = ({ open, onCancel, onConfirm, isPending }) => {
const [spec, setSpec] = useState("");
const [echo, setEcho] = useState("");
const [accepted, setAccepted] = useState(false);
// Both confirmations are cleared on every exit, cancel AND confirm alike: reopening this dialog
// must never find it pre-armed with the last spec and a ticked box.
const reset = () => {
setSpec("");
setEcho("");
setAccepted(false);
};
const close = () => {
reset();
onCancel();
};
const wanted = spec.trim();
// Both gates must pass: the retyped spec matches exactly, AND the box is ticked.
const ready = wanted.length > 0 && echo.trim() === wanted && accepted;
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert className="size-5 shrink-0 text-destructive" />
{m.store_spec_title()}
</DialogTitle>
<DialogDescription>{m.store_spec_lead()}</DialogDescription>
</DialogHeader>
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{m.store_spec_permanent()}
</p>
<div className="space-y-2">
<Label htmlFor="store-spec">{m.store_spec_field()}</Label>
<Input
id="store-spec"
autoComplete="off"
spellCheck={false}
placeholder="@scope/plugin-name@1.2.3"
value={spec}
onChange={(e) => setSpec(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{m.store_spec_field_help()}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="store-spec-echo">
{m.store_spec_confirm_field()}
</Label>
<Input
id="store-spec-echo"
autoComplete="off"
spellCheck={false}
value={echo}
onChange={(e) => setEcho(e.target.value)}
/>
</div>
<Label className="flex items-start gap-3 text-sm font-normal">
<Checkbox
checked={accepted}
onCheckedChange={(next) => setAccepted(next === true)}
className="mt-0.5"
/>
<span>{m.store_spec_checkbox()}</span>
</Label>
<DialogFooter>
<Button variant="outline" onClick={close} disabled={isPending}>
{m.common_cancel()}
</Button>
<Button
variant="destructive"
disabled={!ready || isPending}
onClick={() => {
reset();
onConfirm(wanted);
}}
>
{m.store_spec_confirm()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+140
View File
@@ -0,0 +1,140 @@
import { ArrowUpCircle, Ban, Circle, Trash2 } from "lucide-react";
import type { FC } from "react";
import { type InstalledPlugin, useInstalledPlugins } from "@/api/store";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { RunnerCardSection } from "./Runner";
import { SourceChip, TierBadge } from "./TierBadge";
/**
* Container: what's installed. Owns the installed query; the runner switch above it owns its own.
* Updating and uninstalling are escalated to the parent, which owns the confirm dialogs and the
* resulting job card.
*/
export const InstalledTab: FC<{
onUpdate: (plugin: InstalledPlugin) => void;
onUninstall: (plugin: InstalledPlugin) => void;
/** Package whose install/uninstall is in flight, or null — only that row's actions disable. */
busyPkg: string | null;
}> = ({ onUpdate, onUninstall, busyPkg }) => {
const installed = useInstalledPlugins();
return (
<div className="flex flex-col gap-card">
<RunnerCardSection />
<InstalledList
installed={installed}
onUpdate={onUpdate}
onUninstall={onUninstall}
busyPkg={busyPkg}
/>
</div>
);
};
/**
* One row per installed plugin. The tier badge is permanent and non-negotiable: a plugin installed
* from a raw spec stays marked unverified here for as long as it's on the host, no matter what it
* later reports about itself.
*/
export const InstalledList: FC<{
installed: Loadable<InstalledPlugin[]>;
onUpdate: (plugin: InstalledPlugin) => void;
onUninstall: (plugin: InstalledPlugin) => void;
busyPkg: string | null;
}> = ({ installed, onUpdate, onUninstall, busyPkg }) => {
const rows = installed.data ?? [];
return (
<Card>
<CardContent className="p-0">
<CardHeader>
<CardTitle>{m.store_installed_title()}</CardTitle>
</CardHeader>
<QueryState
isLoading={installed.isLoading}
error={installed.error}
refetch={installed.refetch}
>
{rows.length === 0 ? (
<p className="p-card pt-0 text-sm text-muted-foreground">
{m.store_installed_empty()}
</p>
) : (
<Table>
<TableBody>
{rows.map((p) => (
<TableRow key={p.pkg} className="align-top">
<TableCell className="py-4">
<div className="font-medium">{p.title ?? p.pkg}</div>
<div className="font-mono text-xs text-muted-foreground">
{p.pkg}
</div>
{p.blocked !== undefined && (
<p className="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
<Ban className="mt-px size-3.5 shrink-0" />
<span>{m.store_blocked({ reason: p.blocked })}</span>
</p>
)}
</TableCell>
<TableCell className="py-4">
<div className="flex flex-col items-start gap-1">
<TierBadge tier={p.tier} />
{p.tier === "external" && p.source && (
<SourceChip source={p.source} />
)}
</div>
</TableCell>
<TableCell className="py-4 text-sm tabular-nums text-muted-foreground">
v{p.version}
</TableCell>
<TableCell className="py-4">
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Circle
className={
p.running
? "size-2 fill-[var(--success)] text-[var(--success)]"
: "size-2 fill-muted-foreground text-muted-foreground"
}
/>
{p.running ? m.store_running() : m.store_stopped()}
</span>
</TableCell>
<TableCell className="py-4 text-right">
<div className="flex items-center justify-end gap-2">
{p.update_available !== undefined && (
<Button
size="sm"
disabled={busyPkg === p.pkg}
onClick={() => onUpdate(p)}
>
<ArrowUpCircle className="size-4" />
{m.store_update_to({
version: p.update_available,
})}
</Button>
)}
<Button
variant="ghost"
size="icon"
aria-label={m.store_uninstall()}
disabled={busyPkg === p.pkg}
onClick={() => onUninstall(p)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
);
};
+134
View File
@@ -0,0 +1,134 @@
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, RotateCw, X, XCircle } from "lucide-react";
import { type FC, useEffect, useRef } from "react";
import { invalidateStore, type StoreJob, useStoreJob } from "@/api/store";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { m } from "@/paraglide/messages";
// The host reports a job's progress as a free-form phase string; these are the ones it emits, in
// order. Anything unrecognised falls through to the raw phase rather than an empty label, so a new
// host phase degrades to English-but-visible instead of disappearing.
const PHASES: Record<string, () => string> = {
queued: () => m.store_phase_queued(),
verifying: () => m.store_phase_verifying(),
installing: () => m.store_phase_installing(),
removing: () => m.store_phase_removing(),
checking: () => m.store_phase_checking(),
"rolling back": () => m.store_phase_rolling_back(),
recording: () => m.store_phase_recording(),
"restarting runner": () => m.store_phase_restarting(),
done: () => m.store_phase_done(),
};
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;
/**
* 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,
* installed list, runner state, plugin directory — the moment it finishes.
*/
export const JobProgressSection: FC<{
jobId: string;
onDismiss: () => void;
}> = ({ jobId, onDismiss }) => {
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 refreshed = useRef<string | null>(null);
useEffect(() => {
if (!settled || refreshed.current === jobId) return;
refreshed.current = jobId;
invalidateStore(qc);
}, [settled, jobId, qc]);
if (!job.data) return null;
return <JobProgressCard job={job.data} onDismiss={onDismiss} />;
};
/** 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 }) => {
const running = job.state === "running";
const failed = job.state === "failed";
const log = job.log.slice(-LOG_TAIL);
return (
<Card
className={failed ? "ring-2 ring-destructive/60" : undefined}
aria-live="polite"
>
<CardContent className="space-y-3 p-card">
<div className="flex items-start gap-3">
{running ? (
<Spinner className="mt-0.5 size-5 shrink-0" />
) : failed ? (
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
) : (
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--success)]" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{job.kind === "uninstall"
? m.store_job_uninstall({ target: job.target })
: m.store_job_install({ target: job.target })}
</p>
<p className="text-sm text-muted-foreground">
{running
? phaseLabel(job.phase)
: failed
? m.store_job_failed()
: job.kind === "uninstall"
? m.store_job_done_uninstall()
: m.store_job_done_install()}
</p>
</div>
{!running && (
<Button
variant="ghost"
size="icon"
aria-label={m.store_job_dismiss()}
onClick={onDismiss}
>
<X className="size-4" />
</Button>
)}
</div>
{failed && job.error && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{job.error}
</p>
)}
{/* The runner is bounced at the end of every successful job, so the plugin's nav entry
(and its UI) takes a moment longer to appear than the job's "done". Say so. */}
{job.state === "done" && (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<RotateCw className="size-3.5" />
{m.store_job_restarting()}
</p>
)}
{log.length > 0 && (
<details className="group">
<summary className="cursor-pointer list-none text-xs text-muted-foreground transition-colors hover:text-foreground">
{m.store_job_log()}
</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
<code>{log.join("\n")}</code>
</pre>
</details>
)}
</CardContent>
</Card>
);
};
+125
View File
@@ -0,0 +1,125 @@
import { toast } from "@unom/ui/toast";
import { Play, Power, PowerOff } from "lucide-react";
import type { FC } from "react";
import {
type RuntimeStatus,
useSetRuntime,
useStoreRuntime,
} from "@/api/store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
// The plugin/script runner is the service every plugin actually executes inside. Installing a plugin
// while it's switched off silently gets you nothing running, so Browse carries a banner and the
// Installed tab carries the full switch.
/** Small helper both surfaces share: fire the toggle, surface a failure as a toast. */
function useRunnerToggle() {
const set = useSetRuntime();
const toggle = (enabled: boolean) => {
set.mutate(enabled, {
onError: () => toast.error(m.store_runner_failed()),
});
};
return { toggle, isPending: set.isPending };
}
/**
* Browse-tab banner: the runner is installed but off, so nothing an operator installs here would
* start. Renders nothing in every other state (including "not installed" — the Installed tab's card
* explains that case properly).
*/
export const RunnerBanner: FC = () => {
const runtime = useStoreRuntime();
const { toggle, isPending } = useRunnerToggle();
const s = runtime.data;
if (!s?.installed || s.enabled) return null;
return (
<div className="flex flex-col gap-3 rounded-lg border border-amber-600/40 bg-amber-500/10 p-4 text-sm text-amber-600 sm:flex-row sm:items-center dark:border-amber-500/40 dark:text-amber-500">
<PowerOff className="size-5 shrink-0" />
<p className="flex-1">{m.store_runner_banner()}</p>
<Button size="sm" disabled={isPending} onClick={() => toggle(true)}>
<Play className="size-4" />
{m.store_runner_enable()}
</Button>
</div>
);
};
/**
* Container: the runner switch at the top of the Installed tab. Like the library's source toggles
* this is a secondary control — it stays out of the way until the query resolves rather than
* stacking a second error banner on top of the installed list's own.
*/
export const RunnerCardSection: FC = () => {
const runtime = useStoreRuntime();
const { toggle, isPending } = useRunnerToggle();
if (!runtime.data) return null;
return (
<RunnerCard status={runtime.data} busy={isPending} onToggle={toggle} />
);
};
/** The runner card: what the service is, whether it's up, and the one switch that changes it. */
export const RunnerCard: FC<{
status: RuntimeStatus;
busy: boolean;
onToggle: (enabled: boolean) => void;
}> = ({ status, busy, onToggle }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between gap-3 text-base">
<span>{m.store_runner_title()}</span>
{!status.installed ? (
<Badge variant="outline">{m.store_runner_state_missing()}</Badge>
) : status.running ? (
<Badge variant="success">{m.store_runner_state_running()}</Badge>
) : status.enabled ? (
<Badge variant="outline">{m.store_runner_state_stopped()}</Badge>
) : (
<Badge variant="secondary">{m.store_runner_state_disabled()}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{status.installed
? m.store_runner_help()
: m.store_runner_not_installed()}
</p>
<dl className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
<div className="flex gap-2">
<dt>{m.store_runner_unit()}</dt>
<dd className="font-mono text-foreground">{status.unit}</dd>
</div>
{status.principal && (
<div className="flex gap-2">
<dt>{m.store_runner_principal()}</dt>
<dd className="font-mono text-foreground">{status.principal}</dd>
</div>
)}
</dl>
{status.detail && (
<p className="text-xs text-muted-foreground">{status.detail}</p>
)}
{status.installed && (
<Button
size="sm"
variant={status.enabled ? "outline" : "default"}
disabled={busy}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? (
<PowerOff className="size-4" />
) : (
<Power className="size-4" />
)}
{status.enabled ? m.store_runner_disable() : m.store_runner_enable()}
</Button>
)}
</CardContent>
</Card>
);
+347
View File
@@ -0,0 +1,347 @@
import { toast } from "@unom/ui/toast";
import {
AlertTriangle,
Lock,
RefreshCw,
ShieldCheck,
ShieldOff,
Trash2,
} from "lucide-react";
import { type FC, type FormEvent, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type SourceBody,
type StoreSource,
useDeleteSource,
useRefreshCatalog,
useSetSource,
useStoreSources,
} from "@/api/store";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/** A source the operator has filled in but not yet agreed to trust. */
type SourceDraft = SourceBody & { name: string };
/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */
const fmtFetched = (secs: number): string =>
secs > 0 ? new Date(secs * 1000).toLocaleString() : m.store_source_never();
/**
* Container: the catalog sources. Owns the source listing, the refresh-all action, and add/remove.
* Adding is a two-step: the form collects the source, and a one-time trust dialog states plainly
* what trusting a third-party catalog means before anything is written to the host.
*/
export const SourcesTab: FC = () => {
const sources = useStoreSources();
const refresh = useRefreshCatalog();
const save = useSetSource();
const remove = useDeleteSource();
// The draft waiting on the trust dialog, and a key that re-mounts (and so clears) the form.
const [draft, setDraft] = useState<SourceDraft | null>(null);
const [formKey, setFormKey] = useState(0);
const onRefresh = () =>
refresh.mutate(undefined, {
onError: () => toast.error(m.store_refresh_failed()),
});
const onConfirmAdd = async () => {
if (!draft) return;
try {
await save.mutateAsync(draft);
setDraft(null);
setFormKey((k) => k + 1);
} catch {
toast.error(m.store_add_source_failed());
}
};
const onRemove = async (source: StoreSource) => {
if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return;
try {
await remove.mutateAsync(source.name);
} catch (e) {
// 403 is the host refusing to drop its built-in catalog — say exactly that.
toast.error(
e instanceof ApiError && e.status === 403
? m.store_source_builtin_locked()
: m.store_source_remove_failed(),
);
}
};
return (
<div className="flex flex-col gap-card">
<SourceList
sources={sources}
busyName={remove.isPending ? (remove.variables ?? null) : null}
isRefreshing={refresh.isPending}
onRefresh={onRefresh}
onRemove={onRemove}
/>
<AddSourceForm
key={formKey}
onSubmit={setDraft}
isSaving={save.isPending}
/>
<TrustSourceDialog
draft={draft}
isSaving={save.isPending}
onCancel={() => setDraft(null)}
onConfirm={onConfirmAdd}
/>
</div>
);
};
/** The source table: health per source, with the built-in one locked. */
export const SourceList: FC<{
sources: {
data?: StoreSource[];
isLoading: boolean;
error: unknown;
refetch?: () => void;
};
/** Name of the source whose delete is in flight, or null. */
busyName: string | null;
isRefreshing: boolean;
onRefresh: () => void;
onRemove: (source: StoreSource) => void;
}> = ({ sources, busyName, isRefreshing, onRefresh, onRemove }) => {
const rows = sources.data ?? [];
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>{m.store_sources_title()}</CardTitle>
<Button
variant="outline"
size="sm"
disabled={isRefreshing}
onClick={onRefresh}
>
<RefreshCw
className={isRefreshing ? "size-4 animate-spin" : "size-4"}
/>
{m.store_refresh_all()}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.store_sources_help()}
</p>
<QueryState
isLoading={sources.isLoading}
error={sources.error}
refetch={sources.refetch}
>
<div className="flex flex-col gap-3">
{rows.map((s) => (
<div
key={s.name}
className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-start"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{s.name}</span>
{s.builtin && (
<Badge variant="secondary" className="gap-1">
<Lock className="size-3" />
{m.store_source_builtin()}
</Badge>
)}
{s.signed ? (
<Badge variant="outline" className="gap-1">
<ShieldCheck className="size-3" />
{m.store_source_signed()}
</Badge>
) : (
<Badge
variant="outline"
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
>
<ShieldOff className="size-3" />
{m.store_source_unsigned()}
</Badge>
)}
{s.stale && (
<Badge
variant="outline"
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
>
<AlertTriangle className="size-3" />
{m.store_source_stale()}
</Badge>
)}
</div>
<p className="truncate font-mono text-xs text-muted-foreground">
{s.url}
</p>
<p className="text-xs text-muted-foreground">
{m.store_source_entries({ count: s.entry_count })} ·{" "}
{m.store_source_fetched({ when: fmtFetched(s.fetched_at) })}
</p>
{s.error && (
<p className="text-xs text-destructive">{s.error}</p>
)}
</div>
{/* The built-in catalog gets no delete button at all — not a disabled one. */}
{!s.builtin && (
<Button
variant="ghost"
size="icon"
aria-label={m.store_source_remove()}
disabled={busyName === s.name}
onClick={() => onRemove(s)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
</QueryState>
</CardContent>
</Card>
);
};
/** The add-source form. Reports a draft; the parent takes it through the trust dialog. */
export const AddSourceForm: FC<{
onSubmit: (draft: SourceDraft) => void;
isSaving: boolean;
}> = ({ onSubmit, isSaving }) => {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [publicKey, setPublicKey] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const key = publicKey.trim();
if (!name.trim() || !url.trim()) return;
onSubmit({
name: name.trim(),
url: url.trim(),
public_key: key ? key : undefined,
});
};
return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>{m.store_add_source_title()}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="store-source-name">
{m.store_field_source_name()}
</Label>
<Input
id="store-source-name"
required
autoComplete="off"
spellCheck={false}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="store-source-url">
{m.store_field_source_url()}
</Label>
<Input
id="store-source-url"
required
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="store-source-key">
{m.store_field_source_key()}
</Label>
<Input
id="store-source-key"
autoComplete="off"
spellCheck={false}
placeholder="ed25519:…"
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{m.store_field_source_key_help()}
</p>
</div>
<Button
type="submit"
disabled={isSaving || !name.trim() || !url.trim()}
>
{m.store_add_source()}
</Button>
</form>
</CardContent>
</Card>
);
};
/** The one-time trust warning shown before a third-party catalog is written to the host. */
export const TrustSourceDialog: FC<{
draft: SourceDraft | null;
isSaving: boolean;
onCancel: () => void;
onConfirm: () => void;
}> = ({ draft, isSaving, onCancel, onConfirm }) => (
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
{draft && (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
{m.store_source_trust_title()}
</DialogTitle>
<DialogDescription>
{m.store_source_trust_body({ name: draft.name })}
</DialogDescription>
</DialogHeader>
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
{draft.url}
</p>
{!draft.public_key && (
<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_source_trust_unsigned()}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
{m.common_cancel()}
</Button>
<Button disabled={isSaving} onClick={onConfirm}>
{m.store_source_trust_confirm()}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
);
+89
View File
@@ -0,0 +1,89 @@
import {
BadgeCheck,
ShieldAlert,
ShieldQuestion,
Terminal,
} from "lucide-react";
import type { FC } from "react";
import type { StoreTier } from "@/api/store";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
// The trust model, rendered. These badges are the only place the console makes a claim about where
// a plugin's code came from, so they are deliberately literal: ONLY the built-in `unom` source earns
// the check mark, everything else says out loud that nobody at unom looked at the code. An entry
// from an operator-added source additionally carries a <SourceChip> naming that source — attribution
// instead of a verification it hasn't got.
/** The short, permanent provenance badge for a tier. */
export const TierBadge: FC<{ tier: StoreTier; className?: string }> = ({
tier,
className,
}) => {
switch (tier) {
case "verified":
return (
<Badge
variant="success"
className={cn("gap-1", className)}
title={m.store_tier_verified_hint()}
>
<BadgeCheck className="size-3.5" />
{m.store_tier_verified()}
</Badge>
);
case "external":
return (
<Badge
variant="outline"
className={cn(
"gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500",
className,
)}
title={m.store_tier_external_hint()}
>
<ShieldQuestion className="size-3.5" />
{m.store_tier_external()}
</Badge>
);
case "unverified":
return (
<Badge
variant="destructive"
className={cn("gap-1", className)}
title={m.store_tier_unverified_hint()}
>
<ShieldAlert className="size-3.5" />
{m.store_tier_unverified()}
</Badge>
);
default:
return (
<Badge
variant="secondary"
className={cn("gap-1", className)}
title={m.store_tier_cli_hint()}
>
<Terminal className="size-3.5" />
{m.store_tier_cli()}
</Badge>
);
}
};
/** "from <source>" — who curated this entry. Shown for anything not from the built-in source. */
export const SourceChip: FC<{ source: string; className?: string }> = ({
source,
className,
}) => (
<span
className={cn(
"inline-flex items-center gap-1 text-xs text-muted-foreground",
className,
)}
>
{m.store_from_source()}
<span className="font-medium text-foreground">{source}</span>
</span>
);
+149
View File
@@ -0,0 +1,149 @@
import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { type FC, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type InstallBody,
type InstalledPlugin,
type StoreEntry,
useInstallPlugin,
useStoreCatalog,
useUninstallPlugin,
} from "@/api/store";
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 { InstalledTab } from "./Installed";
import { JobProgressSection } from "./JobProgress";
import { SourcesTab } from "./Sources";
type StoreTab = "browse" | "installed" | "sources";
/**
* 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
* install/uninstall mutations, the confirm dialogs their trust tier dictates, and the job the host
* hands back (which must stay visible whichever tab you switch to while it runs).
*/
export const SectionStore: FC = () => {
useLocale();
const [tab, setTab] = useState<StoreTab>("browse");
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
const [specOpen, setSpecOpen] = useState(false);
// 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);
const catalog = useStoreCatalog();
const install = useInstallPlugin();
const uninstall = useUninstallPlugin();
/** Turn a failed 202-request into a message: 409 means the host is busy, not that we're broken. */
const failed = (e: unknown, fallback: string) =>
toast.error(
e instanceof ApiError && e.status === 409 ? m.store_busy() : fallback,
);
const start = async (body: InstallBody) => {
try {
const { job } = await install.mutateAsync(body);
setJobId(job);
} catch (e) {
failed(e, m.store_install_failed());
}
};
const onConfirmEntry = async (entry: StoreEntry) => {
setTarget(null);
await start({ source: entry.source, id: entry.id });
};
const onConfirmSpec = async (spec: string) => {
setSpecOpen(false);
await start({ spec, accept_unverified: true });
};
// 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.
const onUpdate = (plugin: InstalledPlugin) => {
const entry = catalog.data?.plugins.find((e) => e.pkg === plugin.pkg);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
}
setTarget(entry);
};
const onUninstall = async (plugin: InstalledPlugin) => {
if (
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
)
return;
try {
const { job } = await uninstall.mutateAsync(plugin.pkg);
setJobId(job);
} catch (e) {
failed(e, m.store_uninstall_failed());
}
};
return (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.store_title()}</h1>
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
</div>
{jobId && (
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
<TabsList>
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
<TabsTrigger value="installed">
{m.store_tab_installed()}
</TabsTrigger>
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
</TabsList>
<TabsContent value="browse">
<BrowseTab
onInstall={setTarget}
onInstallSpec={() => setSpecOpen(true)}
/>
</TabsContent>
<TabsContent value="installed">
<InstalledTab
onUpdate={onUpdate}
onUninstall={onUninstall}
busyPkg={
uninstall.isPending ? (uninstall.variables ?? null) : null
}
/>
</TabsContent>
<TabsContent value="sources">
<SourcesTab />
</TabsContent>
</Tabs>
<InstallDialog
entry={target}
isPending={install.isPending}
onCancel={() => setTarget(null)}
onConfirm={onConfirmEntry}
/>
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
onCancel={() => setSpecOpen(false)}
onConfirm={onConfirmSpec}
/>
</div>
</Section>
);
};