Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions modules/store/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,12 @@
}
}

// Keep the name clear of the corner update badge on installed cards;
// catalog cards have no badge, so they keep the full width.
&:not(.spicetify-store-card--catalog) .spicetify-store-card-name {
padding-right: 30px;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 30px clearance applies to every installed card, so names on cards with no badge truncate slightly earlier than needed. Consider adding a modifier class only when props.update renders and scoping the padding to it.

}

.spicetify-store-card-desc {
margin: 0;
color: var(--spice-subtext, #a7a7a7);
Expand Down Expand Up @@ -576,6 +582,52 @@
}
}

// The per-card update affordance, pinned to the card's top-right corner: a
// bare icon (the starburst carries its own "NEW" wording and colors), so a
// click installs and the tooltip states the direction. A button rather than a
// badge, because clicking installs.
.spicetify-store-update-badge {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: none;
cursor: pointer;
z-index: 1;

svg {
display: block;
width: 24px;
height: 24px;
}

&:hover:not(:disabled) {
opacity: 0.85;
}

&:disabled {
cursor: default;
opacity: 0.5;
}

// Mid-update: pulse, since a static glyph cannot show progress.
&--busy {
animation: spicetify-store-update-pulse 1s ease-in-out infinite;
}
}

@keyframes spicetify-store-update-pulse {
50% {
opacity: 0.3;
}
}

// Authors appear only in the details dialog, which renders outside the card,
// so this rule cannot be nested under it. Size is inherited so the line sits
// with the dialog's body copy rather than reading as a card caption.
Expand Down
2 changes: 1 addition & 1 deletion modules/store/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "store",
"kind": "app",
"version": "1.8.1",
"version": "1.9.0",
"authors": ["spicetify"],
"description": "Browse, install, and manage v3 modules from vaults",
"entries": {
Expand Down
150 changes: 127 additions & 23 deletions modules/store/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import {
toast,
} from "./runtime.ts";
import { loadPreviewBlob, previewRevision, prunePreviewCache } from "./previewCache.ts";
import { pendingUpdates, runUpdates, stdlibRestartPending } from "./updates.ts";
import { pendingUpdates, runUpdates, stdlibRestartPending, updateActionLabel } from "./updates.ts";

// ---------- lazily acquired stdlib bindings ----------

Expand Down Expand Up @@ -382,6 +382,8 @@ function ModuleDetails(props: {
installLabel: string;
installDisabled: boolean;
canActivate: boolean;
// True while any install or update is in flight elsewhere on the page.
locked: boolean;
onActivate: () => Promise<void>;
onInstall: () => Promise<void>;
onClose: () => void;
Expand Down Expand Up @@ -465,7 +467,7 @@ function ModuleDetails(props: {
</Button>
)}
<Button
disabled={busy || props.installDisabled}
disabled={busy || props.installDisabled || props.locked}
onClick={() => {
setBusy(true);
void props.onInstall().finally(() => setBusy(false));
Expand Down Expand Up @@ -615,6 +617,9 @@ function CatalogCard(props: {
enabled: boolean;
// Only true when the grid is showing more than one kind at once.
showKind: boolean;
// True while any install or update (this card's or another's) is in
// flight, so the page runs one at a time.
locked: boolean;
onOpenDetails: () => void;
onInstall: () => Promise<void>;
onRemove: () => Promise<void>;
Expand Down Expand Up @@ -676,7 +681,7 @@ function CatalogCard(props: {
title={cliCurrent ? "Installed via the CLI (spicetify pkg)" : cta}
aria-label={cliCurrent ? `${displayName(mod)} installed via the CLI` : `${cta} ${displayName(mod)}`}
aria-disabled={cliCurrent || undefined}
disabled={busy}
disabled={busy || props.locked}
onClick={(event) => {
// The inert installed tell has no action of its own; letting
// the click bubble keeps the corner opening details instead
Expand Down Expand Up @@ -767,12 +772,70 @@ function ActiveThemeBar(props: { record: InstalledRecord; refresh: () => void })
);
}

// The one wording for a staged stdlib, shared by the per-card update guard
// (runInstall) and the badge's disabled tooltip, so both say the same thing.
function stdlibHoldMessage(): string {
return stdlibDiskStaged()
? "a stdlib update is staged: use the apply or repair control above before updating modules"
: "a stdlib update is staged: restart Spotify before updating modules";
}

// The badge glyph: a red starburst carrying "NEW". Its own two fills, so it
// is not a currentColor GlyphIcon; still decorative, the button names it.
function UpdateBadgeIcon(): ReactElement {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#e96262"
d="M12 0c1.267 0 2.28 1.96 3.294 2.204s3.04-.735 4.054 0 .507 2.939 1.267 3.674 2.787 1.224 3.294 2.449S22.642 11.02 22.642 12s1.52 2.449 1.267 3.674c-.253 1.224-2.534 1.469-3.294 2.448-.76.98-.253 2.94-1.267 3.674-1.014.735-3.04-.245-4.054 0S13.267 24 12 24s-2.28-1.96-3.294-2.204c-1.013-.245-3.04.735-4.054 0s-.507-2.939-1.267-3.674S.598 16.898.091 15.674C-.416 14.449 1.358 12.98 1.358 12S-.162 9.551.091 8.327s2.534-1.47 3.294-2.45c.76-.979.253-2.938 1.267-3.673s3.04.245 4.054 0S10.733 0 12 0"
/>
<path
fill="#fff"
d="m6.225 19.242-3.491-5.4 1.074-.649 4.217 2.363-2.283-3.53 1.074-.65 3.491 5.4-1.074.65-4.216-2.363 2.282 3.53zm1.45-8.385L10.9 8.911l.672 1.038-2.15 1.298.806 1.246 1.934-1.168.672 1.039-1.934 1.167.671 1.039 2.149-1.298.671 1.039-3.223 1.946zm3.869-2.336 1.074-.648 3.062 3.349-1.558-4.258 1.075-.649 3.276 3.22-1.772-4.128 1.074-.65 2.417 6.05-1.075.649-3.142-3.012 1.423 4.05-1.074.649z"
/>
</svg>
);
}

// Icon-only affordance in an installed card's meta row: a click installs the
// one pending update, and it carries the direction (upgrade or pinned
// rollback) as its tooltip, since the glyph alone cannot say which way the
// move goes.
function UpdateBadge(props: {
installedVersion: string;
targetVersion: string;
busy: boolean;
disabled: boolean;
reason: string | undefined;
onUpdate: () => void;
}): ReactElement {
const label = updateActionLabel(props.installedVersion, props.targetVersion);
const title = props.busy ? "Updating…" : props.disabled && props.reason ? props.reason : label;
return (
<button
type="button"
className={`spicetify-store-update-badge${props.busy ? " spicetify-store-update-badge--busy" : ""}`}
title={title}
aria-label={label}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Busy and hold states only change title; a screen reader keeps hearing the stale label. Derive aria-label from the same title value so the Updating… and stdlib-hold states are announced.

disabled={props.disabled}
onClick={props.onUpdate}
>
<UpdateBadgeIcon />
</button>
);
}

function InstalledCard(props: {
record: InstalledRecord;
loaded: boolean;
revokedReason: string | undefined;
daemonReady: boolean;
update: VaultModule | undefined;
updateBusy: boolean;
updateDisabled: boolean;
updateReason: string | undefined;
refresh: () => void;
onUpdate: () => void;
onEdit: (existing: { id: string; name: string; css: string }) => void;
}): ReactElement {
const { record } = props;
Expand Down Expand Up @@ -830,6 +893,16 @@ function InstalledCard(props: {

return (
<article className={`spicetify-store-card${props.revokedReason ? " spicetify-store-card--revoked" : ""}`}>
{props.update && (
<UpdateBadge
installedVersion={version}
targetVersion={props.update.version}
busy={props.updateBusy}
disabled={props.updateDisabled}
reason={props.updateReason}
onUpdate={props.onUpdate}
/>
)}
<h3 className="spicetify-store-card-name">{record.metadata.name ?? id}</h3>
<div className="spicetify-store-card-meta">
{version && <Badge>{version}</Badge>}
Expand Down Expand Up @@ -930,7 +1003,11 @@ function StorePage(props: { api: PageApi }): ReactElement {
});
const [status, setStatus] = React.useState("");
const [overlay, setOverlay] = React.useState<Overlay | null>(null);
const [updatingAll, setUpdatingAll] = React.useState(false);
// One install/update at a time. The value names what is running ("all" or
// a module id) for the UI; the ref is the guard, so a second click that
// lands before the re-render cannot start a competing install.
const [updating, setUpdating] = React.useState<false | "all" | string>(false);
const updateLock = React.useRef(false);
const [resetArmed, setResetArmed] = React.useState(false);
// Module state changes outside this page (dev pushes, manager
// actions); bumping the epoch re-reads live registry state, the React
Expand Down Expand Up @@ -1060,6 +1137,10 @@ function StorePage(props: { api: PageApi }): ReactElement {
.map((s: any) => [s.identifier, s]),
);
const pending = pendingUpdates(catalog);
const pendingById = new Map(pending.map((mod) => [mod.id, mod]));
// A staged stdlib holds every other module's hot-swap back (see runInstall).
const stdlibHold = stdlibRestartPending();
const anyUpdating = updating !== false;
const activeTheme = installed.find(
(r) =>
kindOf(r.metadata) === "theme" &&
Expand Down Expand Up @@ -1100,23 +1181,26 @@ function StorePage(props: { api: PageApi }): ReactElement {
}, [catalog, registryEpoch]);

const runInstall = async (mod: VaultModule) => {
// One install at a time: the ref guard is what keeps the gallery FABs,
// the details dialog, the per-card badges and Update all from racing.
if (updateLock.current) return;
// Updating a running module hot-swaps it, and a staged stdlib update
// means the swap would run against the old stdlib; fresh installs are
// fine (a missing dependency restart-gates them instead).
if (mod.id !== "stdlib" && installedVersions.get(mod.id) !== undefined && stdlibRestartPending()) {
toast(
stdlibDiskStaged()
? "a stdlib update is staged: use the apply or repair control above before updating modules"
: "a stdlib update is staged: restart Spotify before updating modules",
);
toast(stdlibHoldMessage());
return;
}
updateLock.current = true;
setUpdating(mod.id);
try {
await installModule(mod, setStatus);
} catch {
// installModule already toasted the failure; just clear the progress line.
setStatus("");
} finally {
updateLock.current = false;
setUpdating(false);
refreshRegistry();
}
};
Expand Down Expand Up @@ -1149,11 +1233,14 @@ function StorePage(props: { api: PageApi }): ReactElement {
};

const updateAll = async () => {
setUpdatingAll(true);
if (updateLock.current) return;
updateLock.current = true;
setUpdating("all");
try {
await runUpdates(pending, setStatus);
} finally {
setUpdatingAll(false);
updateLock.current = false;
setUpdating(false);
refreshRegistry();
}
};
Expand Down Expand Up @@ -1371,7 +1458,7 @@ function StorePage(props: { api: PageApi }): ReactElement {
<button
type="button"
className="spicetify-store-cta"
disabled={updatingAll}
disabled={anyUpdating}
onClick={() => void updateAll()}
>
Update all
Expand All @@ -1390,6 +1477,7 @@ function StorePage(props: { api: PageApi }): ReactElement {
localInstall={localIds.has(mod.id)}
enabled={!!(states.get(mod.id) as { loaded?: boolean } | undefined)?.loaded}
showKind={activeTab === "all"}
locked={anyUpdating}
onOpenDetails={() => openDetails(mod)}
onInstall={() => runInstall(mod)}
onRemove={() => runRemove(mod)}
Expand All @@ -1406,17 +1494,32 @@ function StorePage(props: { api: PageApi }): ReactElement {
Installed
</h2>
<div className="spicetify-store-grid">
{installed.map((record) => (
<InstalledCard
key={record.metadata.identifier}
record={record}
loaded={!!(states.get(record.metadata.identifier) as { loaded?: boolean } | undefined)?.loaded}
revokedReason={catalog.revoked[record.metadata.identifier]}
daemonReady={daemonReady}
refresh={refreshRegistry}
onEdit={(existing) => setOverlay({ kind: "snippet", existing })}
/>
))}
{installed.map((record) => {
const id = record.metadata.identifier;
const update = pendingById.get(id);
const thisUpdating = updating === id;
// A staged stdlib blocks every module but stdlib itself; an
// in-flight install blocks every card but the one running.
const holdBlocked = stdlibHold && id !== "stdlib";
return (
<InstalledCard
key={id}
record={record}
loaded={!!(states.get(id) as { loaded?: boolean } | undefined)?.loaded}
revokedReason={catalog.revoked[id]}
daemonReady={daemonReady}
update={update}
updateBusy={thisUpdating}
updateDisabled={holdBlocked || anyUpdating}
updateReason={holdBlocked ? stdlibHoldMessage() : undefined}
refresh={refreshRegistry}
onUpdate={() => {
if (update) void runInstall(update);
}}
onEdit={(existing) => setOverlay({ kind: "snippet", existing })}
/>
);
})}
</div>
<div className="spicetify-store-data-row">
<span className="spicetify-store-data-label">Store data:</span>
Expand Down Expand Up @@ -1448,6 +1551,7 @@ function StorePage(props: { api: PageApi }): ReactElement {
installLabel={overlay.installLabel}
installDisabled={overlay.installDisabled}
canActivate={overlay.canActivate}
locked={anyUpdating}
onActivate={() => runActivate(overlay.mod)}
onInstall={() => runInstall(overlay.mod)}
onClose={() => setOverlay(null)}
Expand Down
16 changes: 16 additions & 0 deletions modules/store/updates.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
stdlibGate,
stdlibMarkerWithdrawn,
stdlibRestartPending,
updateActionLabel,
} from "./updates.ts";

type LocalRecord = {
Expand Down Expand Up @@ -196,6 +197,21 @@ describe("pendingUpdates", () => {
});
});

describe("updateActionLabel", () => {
it("calls a newer vault version an update", () => {
assert.equal(updateActionLabel("1.0.0", "1.1.0"), "Update available to 1.1.0");
});

it("calls an older vault version a rollback", () => {
// A pin withdraws a bad release, so the pending move is backwards.
assert.equal(updateActionLabel("1.1.0", "1.0.0"), "Roll back to 1.0.0");
});

it("strips build metadata from the shown version", () => {
assert.equal(updateActionLabel("1.0.0", "1.1.0+cm-1020094"), "Update available to 1.1.0");
});
});

describe("stdlibGate", () => {
it("passes a batch with no stdlib update through untouched", () => {
const pending = [entry("a", "1.1.0"), entry("b", "2.1.0")];
Expand Down
11 changes: 11 additions & 0 deletions modules/store/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ export function pendingUpdates(catalog: Catalog): VaultModule[] {
.sort((a, b) => Number(dependedUpon.has(b.id)) - Number(dependedUpon.has(a.id)));
}

// The action wording for a pending update. A pinned vault entry can be older
// than what is installed (a maintainer withdrawing a bad release), so the move
// is not always forward; the per-card button shows only a glyph, so this is the
// tooltip and accessible name that tell the user which way it goes.
export function updateActionLabel(installedVersion: string, targetVersion: string): string {
const target = displayVersion(targetVersion);
return compareVersions(targetVersion, installedVersion) < 0
? `Roll back to ${target}`
: `Update available to ${target}`;
}

// Once a boot runs a stdlib at least as new as the marker, the apply
// happened and the hold can lift.
export function clearSettledStdlibMarker(): void {
Expand Down
Loading