diff --git a/.changeset/studio-pending-badge.md b/.changeset/studio-pending-badge.md
new file mode 100644
index 00000000..0493bd7f
--- /dev/null
+++ b/.changeset/studio-pending-badge.md
@@ -0,0 +1,6 @@
+---
+"@cloudhal/api": patch
+"@cloudhal/cli": patch
+---
+
+Studio shows changes that are not live yet: a badge beside Publish with the count ("2 changes", or "Not published" before the first publish) and a tooltip by kind ("3 changes aren't live yet · 2 articles and 1 page"). It comes from the new `GET /api/sites/:siteId/pending` (`site:read`): counts of what a publish would add, change and remove, by kind; adding or removing a page counts once. Studio refreshes it after every write to the site, on window focus and every 30 s.
diff --git a/apps/studio/src/components/PendingChanges.tsx b/apps/studio/src/components/PendingChanges.tsx
new file mode 100644
index 00000000..d3c9d19b
--- /dev/null
+++ b/apps/studio/src/components/PendingChanges.tsx
@@ -0,0 +1,71 @@
+import { Badge, Tooltip } from "@cloudhal/ui";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { formatNumber, listOf, type TFunction, useI18n } from "../i18n/index.tsx";
+import { api, onApiWrite, type PendingChanges } from "../lib/api.ts";
+
+type NodeKind = keyof PendingChanges["byKind"];
+
+export const pendingKey = (siteId: string) => ["pending", siteId] as const;
+
+/** Writes that change nothing a publish would carry (draft renders, preview links). */
+const NOT_CONTENT = /\/(preview|preview-fragment|preview-links)$/;
+
+/**
+ * What a publish would carry (`GET …/pending`), for the badge beside Publish. Every successful
+ * write to this site through the API client invalidates it (`onApiWrite`), so saves, publishes,
+ * rollbacks, approvals, media, menus, redirects, settings and taxonomy all refresh it without each
+ * screen knowing; agents' changes show on the next window focus or after 30 s.
+ */
+export function usePendingChanges(siteId: string) {
+ const qc = useQueryClient();
+ useEffect(() => {
+ const prefix = `/api/sites/${siteId}`;
+ return onApiWrite((path) => {
+ if ((path === prefix || path.startsWith(`${prefix}/`)) && !NOT_CONTENT.test(path))
+ void qc.invalidateQueries({ queryKey: pendingKey(siteId) });
+ });
+ }, [qc, siteId]);
+ return useQuery({
+ queryKey: pendingKey(siteId),
+ queryFn: () => api.sites.pending(siteId),
+ staleTime: 30_000,
+ refetchOnWindowFocus: true,
+ retry: false,
+ });
+}
+
+/** "3 changes aren't live yet · 2 articles and 1 page" (or the never-published sentence). */
+export function pendingText(t: TFunction, pending: PendingChanges): string {
+ const kinds = listOf(
+ (Object.entries(pending.byKind) as [NodeKind, number][]).map(([kind, count]) =>
+ t("publishing.pendingKind", { kind, count }),
+ ),
+ );
+ return pending.live
+ ? t("publishing.pendingSummary", { count: pending.counts.total, kinds })
+ : t("publishing.firstPublish", { kinds });
+}
+
+/**
+ * The badge beside Publish (DESIGN.md §2d): the number of changes a publish would carry, or "Not
+ * published" for a site that never went live, with the breakdown in its tooltip; phones show the
+ * number only. Nothing when everything is live.
+ */
+export function PendingBadge({ pending }: { pending: PendingChanges | undefined }) {
+ const { t, locale } = useI18n();
+ if (!pending || (pending.live && pending.counts.total === 0)) return null;
+ return (
+
+
+ {/* The top bar is full on phones: the number alone, the words from `sm` up. */}
+ {formatNumber(locale, pending.counts.total)}
+
+ {pending.live
+ ? t("publishing.pending", { count: pending.counts.total })
+ : t("publishing.notPublished")}
+
+
+
+ );
+}
diff --git a/apps/studio/src/components/SitePublish.tsx b/apps/studio/src/components/SitePublish.tsx
index 8c7065a8..8d32d96f 100644
--- a/apps/studio/src/components/SitePublish.tsx
+++ b/apps/studio/src/components/SitePublish.tsx
@@ -13,6 +13,7 @@ import { useT } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { apiErrorText } from "../lib/api-errors.ts";
import { UnpublishedPagesNote } from "./PageStatus.tsx";
+import { PendingBadge, pendingText, usePendingChanges } from "./PendingChanges.tsx";
import { usePublishMode, usePublishSite } from "./Publish.tsx";
import { useToast } from "./Toast.ts";
@@ -22,7 +23,8 @@ import { useToast } from "./Toast.ts";
* the person's role (§2s): an Author whose changes include others' work sends "Request publish"
* (the server files a request an Editor approves, 202); a Contributor sees Publish disabled with
* the reason, since an Editor publishes their work. The palette's Publish command opens the same
- * confirmation (RFC-0029 §7), never publishing on its own.
+ * confirmation (RFC-0029 §7), never publishing on its own. Beside it, `PendingBadge` says how many
+ * changes are not live yet; the Publish button's accessible name carries the same sentence.
*/
export function SitePublish({
siteId,
@@ -52,8 +54,12 @@ export function SitePublish({
onError: (e) => toast.error(t("snapshots.failed"), apiErrorText(t, e)),
});
const request = mode === "request";
+ const action = request ? t("publishing.request") : t("site.publish");
+ const pending = usePendingChanges(siteId).data;
+ const waiting = pending && (!pending.live || pending.counts.total > 0);
return (
<>
+
>;
+}
+
/** A signed, expiring link (`POST /api/sites/:id/preview-links`). */
export interface PreviewLink {
token: string;
@@ -89,6 +101,7 @@ export const contentApi = {
edition?: { pages: number; menus: number; skipped: { what: string; reason: string }[] };
}>("/api/sites", { method: "POST", ...json(input) }),
get: (id: string) => request(`/api/sites/${id}`),
+ pending: (id: string) => request(`/api/sites/${id}/pending`),
update: (site: Site) =>
request(`/api/sites/${site.id}`, { method: "PUT", ...json(site) }),
},
diff --git a/apps/studio/src/lib/api/http.ts b/apps/studio/src/lib/api/http.ts
index 411dc7e8..39e0b974 100644
--- a/apps/studio/src/lib/api/http.ts
+++ b/apps/studio/src/lib/api/http.ts
@@ -18,19 +18,40 @@ export class ApiRequestError extends Error {
}
}
+const writeListeners = new Set<(path: string) => void>();
+
+/**
+ * Calls `listener` with the path of every write (`POST`/`PUT`/`PATCH`/`DELETE`) the API answered
+ * with success, so a view of derived state (the pending changes beside Publish) refreshes after
+ * any save without each screen invalidating it. Returns the unsubscribe.
+ */
+export function onApiWrite(listener: (path: string) => void): () => void {
+ writeListeners.add(listener);
+ return () => writeListeners.delete(listener);
+}
+
+/** Tells `onApiWrite` listeners about a write made outside `request` (a streamed Build turn). */
+export function wrote(path: string, method: string | undefined): void {
+ if (method && method !== "GET" && method !== "HEAD") for (const fn of writeListeners) fn(path);
+}
+
export async function request(path: string, init: RequestInit = {}): Promise {
const res = await fetch(path, {
credentials: "include",
...init,
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
});
- if (res.status === 204) return undefined as T;
+ if (res.status === 204) {
+ wrote(path, init.method);
+ return undefined as T;
+ }
const body = (await res
.json()
.catch(() => ({ error: "invalid_json", message: res.statusText }))) as T | ApiError;
// The session ended on the server: the next navigation asks again (and lands on the login).
if (res.status === 401) forgetSession();
if (!res.ok) throw new ApiRequestError(res.status, body as ApiError);
+ wrote(path, init.method);
return body as T;
}
diff --git a/apps/studio/test/pending-changes.test.tsx b/apps/studio/test/pending-changes.test.tsx
new file mode 100644
index 00000000..63fc31f9
--- /dev/null
+++ b/apps/studio/test/pending-changes.test.tsx
@@ -0,0 +1,167 @@
+/**
+ * The badge beside Publish: the count of changes a publish would carry, its pluralised summary
+ * (tooltip and the Publish button's accessible name), "Not published" for a site that never went
+ * live, nothing when everything is live, and a refetch after any write to the site.
+ */
+import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { pendingText } from "../src/components/PendingChanges.tsx";
+import { SitePublish } from "../src/components/SitePublish.tsx";
+import { I18nProvider, useT } from "../src/i18n/index.tsx";
+import { type PendingChanges, request } from "../src/lib/api.ts";
+import { asRole, on, serve, wrap } from "./roles-fixtures.tsx";
+
+vi.mock("@tanstack/react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => vi.fn(),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+const pending = (patch: Partial = {}): PendingChanges => ({
+ live: true,
+ liveSnapshotId: "snap_live",
+ counts: { added: 1, changed: 2, removed: 0, total: 3 },
+ byKind: { contentEntry: 2, page: 1 },
+ ...patch,
+});
+
+const site = on("GET", "/api/sites/site_1", {
+ status: 200,
+ body: { id: "site_1", name: "Demo", publishedSnapshotId: "snap_live" },
+});
+
+describe("pendingText", () => {
+ const tFor = (locale: "en" | "tr" | "ru") =>
+ renderHook(() => useT(), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ }).result.current;
+
+ it("pluralises the total and each kind", () => {
+ const t = tFor("en");
+ expect(pendingText(t, pending())).toBe("3 changes aren't live yet · 2 articles and 1 page");
+ expect(
+ pendingText(
+ t,
+ pending({
+ counts: { added: 0, changed: 1, removed: 0, total: 1 },
+ byKind: { contentEntry: 1 },
+ }),
+ ),
+ ).toBe("1 change isn't live yet · 1 article");
+ expect(
+ pendingText(
+ t,
+ pending({
+ counts: { added: 0, changed: 2, removed: 1, total: 3 },
+ byKind: { site: 1, menu: 2 },
+ }),
+ ),
+ ).toBe("3 changes aren't live yet · site settings and 2 menus");
+ });
+
+ it("says what the first publish takes when the site was never published", () => {
+ const t = tFor("en");
+ const first = pending({
+ live: false,
+ liveSnapshotId: undefined,
+ counts: { added: 5, changed: 0, removed: 0, total: 5 },
+ byKind: { site: 1, page: 2, contentEntry: 2 },
+ });
+ expect(pendingText(t, first)).toBe(
+ "Not published yet. The first publish puts site settings, 2 pages, and 2 articles live.",
+ );
+ });
+});
+
+describe("PendingBadge beside Publish", { timeout: 30_000 }, () => {
+ it("shows the count and puts the summary in the Publish button's name", async () => {
+ serve(
+ ...asRole("owner"),
+ site,
+ on("GET", "/api/sites/site_1/pending", { status: 200, body: pending() }),
+ );
+ render(wrap());
+ expect(await screen.findByText("3 changes")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", {
+ name: "Publish. 3 changes aren't live yet · 2 articles and 1 page",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("says Not published for a site that never went live", async () => {
+ serve(
+ ...asRole("owner"),
+ site,
+ on("GET", "/api/sites/site_1/pending", {
+ status: 200,
+ body: pending({
+ live: false,
+ liveSnapshotId: undefined,
+ counts: { added: 4, changed: 0, removed: 0, total: 4 },
+ byKind: { site: 1, page: 3 },
+ }),
+ }),
+ );
+ render(wrap());
+ expect(await screen.findByText("Not published")).toBeInTheDocument();
+ expect(screen.queryByText(/changes$/)).not.toBeInTheDocument();
+ });
+
+ it("shows nothing when everything is live", async () => {
+ const fetch = serve(
+ ...asRole("owner"),
+ site,
+ on("GET", "/api/sites/site_1/pending", {
+ status: 200,
+ body: pending({ counts: { added: 0, changed: 0, removed: 0, total: 0 }, byKind: {} }),
+ }),
+ );
+ render(wrap());
+ await waitFor(() =>
+ expect(fetch.mock.calls.some(([u]) => String(u).endsWith("/pending"))).toBe(true),
+ );
+ expect(await screen.findByRole("button", { name: "Publish" })).toBeInTheDocument();
+ expect(screen.queryByText("Not published")).not.toBeInTheDocument();
+ expect(screen.queryByText(/changes?$/)).not.toBeInTheDocument();
+ });
+
+ it("refetches after a save to this site, not after previews or other sites", async () => {
+ let total = 0;
+ const calls = { pending: 0 };
+ serve(
+ ...asRole("owner"),
+ site,
+ on("GET", "/api/sites/site_1/pending", () => {
+ calls.pending++;
+ return {
+ status: 200,
+ body: pending({
+ counts: { added: 0, changed: total, removed: 0, total },
+ byKind: total ? { contentEntry: total } : {},
+ }),
+ };
+ }),
+ on("PUT", "/api/sites/site_1/entries/entry_1", { status: 200, body: {} }),
+ on("POST", "/api/sites/site_1/entries/entry_1/preview", { status: 200, body: {} }),
+ on("PUT", "/api/sites/site_2/entries/entry_9", { status: 200, body: {} }),
+ );
+ render(wrap());
+ await waitFor(() => expect(calls.pending).toBe(1));
+ expect(screen.queryByText(/changes?$/)).not.toBeInTheDocument();
+
+ await request("/api/sites/site_1/entries/entry_1/preview", { method: "POST" });
+ await request("/api/sites/site_2/entries/entry_9", { method: "PUT" });
+ total = 1;
+ await request("/api/sites/site_1/entries/entry_1", { method: "PUT" });
+ expect(await screen.findByText("1 change")).toBeInTheDocument();
+ expect(calls.pending).toBe(2);
+ });
+});
diff --git a/packages/api/src/pending.ts b/packages/api/src/pending.ts
new file mode 100644
index 00000000..8b6ac86c
--- /dev/null
+++ b/packages/api/src/pending.ts
@@ -0,0 +1,74 @@
+import type { NodeRef, Site } from "@cloudhal/core-contracts";
+import { canonicalJson, type SiteGraphStore, type SnapshotDiff } from "@cloudhal/core-ts";
+
+/**
+ * What a publish would carry (Studio's top bar badge next to Publish): the node counts of
+ * `SiteGraphStore.pendingChanges` — the current state against the live snapshot, held nodes at
+ * their live version, the site node's publish-only rewrite left out. Numbers only; the list is the
+ * approval dialog's diff.
+ *
+ * Adding or deleting a page also rewrites the site node's `pageIds`; that follows from the page
+ * change and is not counted again (a reorder or a settings change still counts as the site).
+ *
+ * A site that was never published has no live snapshot: `live` is false and every node the first
+ * publish would take, the site node included, counts as added.
+ */
+export interface PendingChanges {
+ live: boolean;
+ liveSnapshotId?: string;
+ counts: { added: number; changed: number; removed: number; total: number };
+ /** The total by node kind; kinds without changes are left out. */
+ byKind: Partial>;
+}
+
+/** The pending counts of `site` (one diff of node refs; one blob read when the site node moved). */
+export async function pendingFor(store: SiteGraphStore, site: Site): Promise {
+ const diff = await store.pendingChanges(site.id);
+ const siteChange = diff.changed.find((c) => c.after.kind === "site");
+ if (siteChange && (await onlyPageList(store, siteChange.before, site, diff)))
+ return pendingSummary(
+ { ...diff, changed: diff.changed.filter((c) => c !== siteChange) },
+ site.publishedSnapshotId,
+ );
+ return pendingSummary(diff, site.publishedSnapshotId);
+}
+
+/** Does the site node differ from its live version only by the pages added and removed? */
+async function onlyPageList(
+ store: SiteGraphStore,
+ liveRef: NodeRef,
+ site: Site,
+ diff: SnapshotDiff,
+): Promise {
+ const live = await store.readSnapshotNode(liveRef).catch(() => undefined);
+ if (!live) return false;
+ const added = new Set(diff.added.filter((n) => n.kind === "page").map((n) => n.id));
+ const removed = new Set(diff.removed.filter((n) => n.kind === "page").map((n) => n.id));
+ const strip = ({ pageIds: _i, publishedSnapshotId: _p, rev: _r, updatedAt: _u, ...rest }: Site) =>
+ canonicalJson(rest);
+ const before = live.pageIds.filter((id) => !removed.has(id));
+ const after = site.pageIds.filter((id) => !added.has(id));
+ return strip(live) === strip(site) && canonicalJson(before) === canonicalJson(after);
+}
+
+export function pendingSummary(diff: SnapshotDiff, liveSnapshotId?: string): PendingChanges {
+ const byKind: PendingChanges["byKind"] = {};
+ const count = (kind: NodeRef["kind"]) => {
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
+ };
+ for (const n of diff.added) count(n.kind);
+ for (const n of diff.removed) count(n.kind);
+ for (const c of diff.changed) count(c.after.kind);
+ const { added, removed, changed } = diff;
+ return {
+ live: liveSnapshotId !== undefined,
+ ...(liveSnapshotId ? { liveSnapshotId } : {}),
+ counts: {
+ added: added.length,
+ changed: changed.length,
+ removed: removed.length,
+ total: added.length + changed.length + removed.length,
+ },
+ byKind,
+ };
+}
diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts
index 3c5a570f..2a9f84f8 100644
--- a/packages/api/src/playground.ts
+++ b/packages/api/src/playground.ts
@@ -108,6 +108,7 @@ export const PLAYGROUND_ALLOW: ReadonlySet = new Set([
"GET /api/sites",
"GET /api/projects",
`GET ${S}`,
+ `GET ${S}/pending`,
`PUT ${S}`,
`GET ${S}/pages`,
`POST ${S}/pages`,
diff --git a/packages/api/src/routes/sites.ts b/packages/api/src/routes/sites.ts
index 96e8b0a0..03c606d1 100644
--- a/packages/api/src/routes/sites.ts
+++ b/packages/api/src/routes/sites.ts
@@ -4,6 +4,7 @@ import { Hono } from "hono";
import type { Env } from "../context.ts";
import { badRequest, notFound } from "../errors.ts";
import { param } from "../params.ts";
+import { pendingFor } from "../pending.ts";
import { createSiteRoute } from "./projects.ts";
export const sites: Hono = new Hono()
@@ -23,6 +24,13 @@ export const sites: Hono = new Hono()
if (!site) throw notFound("site not found");
return c.json(site);
})
+ // What a publish would carry, as counts (Studio's badge beside Publish).
+ .get("/:siteId/pending", async (c) => {
+ const store = c.get("store");
+ const site = await store.getSite(param(c, "siteId"));
+ if (!site) throw notFound("site not found");
+ return c.json(await pendingFor(store, site));
+ })
.put("/:siteId", async (c) => {
const body = (await c.req.json()) as Site;
if (body.id !== param(c, "siteId")) throw badRequest("body.id must match the URL");
diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts
index f406d57a..cd5f0baa 100644
--- a/packages/api/src/scopes.ts
+++ b/packages/api/src/scopes.ts
@@ -117,6 +117,8 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement]
["POST", "/api/sites", "site:create"],
["GET", "/api/projects", "site:read"],
["GET", S, "site:read"],
+ // what a publish would carry, as counts (Studio's badge beside Publish)
+ ["GET", `${S}/pending`, "site:read"],
["PUT", S, "site:settings"],
["GET", `${S}/pages`, "content:read"],
["POST", `${S}/pages`, "content:write"],
diff --git a/packages/api/src/testing.ts b/packages/api/src/testing.ts
index 64ec156a..acae47a7 100644
--- a/packages/api/src/testing.ts
+++ b/packages/api/src/testing.ts
@@ -227,6 +227,76 @@ export function describeApi(label: string, factory: ApiStoreFactory): void {
]);
});
+ it("pending: what a publish would carry, as counts", async () => {
+ type Pending = {
+ live: boolean;
+ liveSnapshotId?: string;
+ counts: { added: number; changed: number; removed: number; total: number };
+ byKind: Record;
+ };
+ const pending = async () => {
+ const res = await app.request(`/api/sites/${siteId}/pending`, auth);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("cache-control")).toBe("no-store");
+ return (await res.json()) as Pending;
+ };
+ expect((await call(`/api/sites/${siteId}/pending`)).status).toBe(401);
+ expect((await call(`/api/sites/site_doesnotexist1/pending`, auth)).status).toBe(404);
+
+ // Never published: no live snapshot, every node the first publish takes counts as added.
+ const first = await pending();
+ const nodes = (await store.createSnapshot(siteId, { reason: "manual", actor: human })).nodes;
+ expect(first).toMatchObject({ live: false, counts: { changed: 0, removed: 0 } });
+ expect(first.liveSnapshotId).toBeUndefined();
+ expect(first.counts.added).toBe(nodes.length);
+ expect(first.counts.total).toBe(nodes.length);
+ expect(first.byKind.site).toBe(1);
+ expect(first.byKind.page).toBe(nodes.filter((n) => n.kind === "page").length);
+
+ // Right after a publish nothing is pending.
+ const published = await call(`/api/sites/${siteId}/snapshots`, {
+ ...auth,
+ method: "POST",
+ body: JSON.stringify({ publish: true }),
+ });
+ const live = (published.body as { site: Site }).site.publishedSnapshotId;
+ expect(await pending()).toEqual({
+ live: true,
+ liveSnapshotId: live,
+ counts: { added: 0, changed: 0, removed: 0, total: 0 },
+ byKind: {},
+ });
+
+ // An edited entry is one change; an added page is one more.
+ const entry = (await store.listContentEntries(siteId, "posts"))[0] as ContentEntry;
+ await call(`/api/sites/${siteId}/entries/${entry.id}`, {
+ ...auth,
+ method: "PUT",
+ body: JSON.stringify({ ...entry, title: `${entry.title} (edited)` }),
+ });
+ expect(await pending()).toMatchObject({
+ counts: { added: 0, changed: 1, removed: 0, total: 1 },
+ byKind: { contentEntry: 1 },
+ });
+ await call(`/api/sites/${siteId}/pages`, {
+ ...auth,
+ method: "POST",
+ body: JSON.stringify({
+ path: "/pending",
+ title: "Pending",
+ locale: "en",
+ status: "draft",
+ sections: [],
+ }),
+ });
+ expect(await pending()).toEqual({
+ live: true,
+ liveSnapshotId: live,
+ counts: { added: 1, changed: 1, removed: 0, total: 2 },
+ byKind: { contentEntry: 1, page: 1 },
+ });
+ });
+
it("unknown routes and ids are 404", async () => {
expect((await call("/api/nope", auth)).status).toBe(404);
expect((await call(`/api/sites/site_doesnotexist1`, auth)).status).toBe(404);