- {destinations.map(({ title, icon, selected, disabled, open }) => (
+ {items.map(({ key, title, icon, selected, disabled, open }) => (
);
}
+// Image URLs are masks so plugin icons follow the row's text color.
+function PageIcon({ icon: Icon }: { icon: string | ComponentType }) {
+ if (typeof Icon === "function") return
;
+ return (
+
+ );
+}
+export type SidebarDestination = Readonly<{
+ key: string;
+ pluginId: string;
+ pageId: string;
+ entry: NavigationEntry;
+}>;
class SidebarBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ failed: boolean }
@@ -253,7 +246,7 @@ function ReadySidebar({
providers,
target,
sessionsEnabled,
- agentsEnabled,
+ destinations,
queries,
cached,
connectionError,
@@ -972,7 +965,7 @@ function ReadySidebar({
)}
{
+ const root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const pages = new PagesService(root);
+ const scope = root.extend({
+ pluginOwner: { id: "example.nav", revision: "one" },
+ });
+ const fiber = scope.plugin((ctx) => {
+ const base = {
+ id: "nav",
+ title: "Nav",
+ component: () => null,
+ route: { version: 1, validate: (params: unknown) => params === "inbox" },
+ };
+ for (const navigation of [
+ {},
+ [null],
+ [{ icon: "/icon.svg" }],
+ [{ title: " ", icon: "/icon.svg" }],
+ [{ title: "Nav" }],
+ [{ title: "Nav", icon: " " }],
+ [{ title: "Nav", icon: "/icon.svg", requiresCommunity: "yes" }],
+ // Params must select a view the page's own route accepts.
+ [{ title: "Nav", icon: "/icon.svg", params: "outbox" }],
+ ])
+ expect(() =>
+ ctx.pages.register({ ...base, navigation } as never),
+ ).toThrow("navigation");
+ expect(() =>
+ ctx.pages.register({
+ ...base,
+ route: undefined,
+ navigation: [{ title: "Nav", icon: "/icon.svg", params: "inbox" }],
+ } as never),
+ ).toThrow("navigation");
+ const entry = {
+ title: "Nav",
+ icon: "/icon.svg",
+ params: "inbox",
+ requiresCommunity: true,
+ };
+ ctx.pages.register({ ...base, navigation: [entry] });
+ entry.title = "Mutated";
+ });
+ await fiber.await();
+ const [registered] = pages.snapshot();
+ expect(registered?.navigation).toEqual([
+ {
+ title: "Nav",
+ icon: "/icon.svg",
+ params: "inbox",
+ requiresCommunity: true,
+ },
+ ]);
+ expect(Object.isFrozen(registered?.navigation)).toBe(true);
+ expect(Object.isFrozen(registered?.navigation?.[0])).toBe(true);
+ await fiber.dispose();
+ await root.fiber.dispose();
+});
diff --git a/src/features/pages/service.ts b/src/features/pages/service.ts
index 2707d4539..ac2ecdf27 100644
--- a/src/features/pages/service.ts
+++ b/src/features/pages/service.ts
@@ -25,6 +25,18 @@ export type Page = Readonly<{
}>;
/** This page acknowledges its own domain reveal rather than just successful mounting. */
handlesNavigation?: boolean;
+ /** Top-level sidebar destinations that open this page, in order. */
+ navigation?: readonly NavigationEntry[];
+}>;
+/** One sidebar destination. Params select a view inside the page and must pass
+ * its route validation; entries without params open the page's default view. */
+export type NavigationEntry = Readonly<{
+ title: string;
+ /** A component, or an image URL drawn as a mask in the current text color. */
+ icon: string | ComponentType;
+ params?: import("../navigation/targets").JsonValue;
+ /** Disabled until a community is selected. */
+ requiresCommunity?: boolean;
}>;
export type RegisteredPage = Contribution;
export type PagesReader = {
@@ -70,6 +82,41 @@ export class PagesService extends Service implements Pages {
typeof page.route.validate !== "function")
)
throw new Error("Invalid page route contract");
- this.contributions.register(this.ctx, page);
+ if (
+ page.navigation !== undefined &&
+ (!Array.isArray(page.navigation) ||
+ !page.navigation.every((entry) => validEntry(page, entry)))
+ )
+ throw new Error("Invalid page navigation entry");
+ this.contributions.register(this.ctx, {
+ ...page,
+ ...(page.navigation && {
+ navigation: Object.freeze(
+ page.navigation.map((entry) =>
+ Object.freeze({
+ title: entry.title,
+ icon: entry.icon,
+ ...(entry.params !== undefined && {
+ params: structuredClone(entry.params),
+ }),
+ ...(entry.requiresCommunity && { requiresCommunity: true }),
+ }),
+ ),
+ ),
+ }),
+ });
}
}
+
+function validEntry(page: Page, entry: NavigationEntry) {
+ return (
+ !!entry &&
+ typeof entry.title === "string" &&
+ !!entry.title.trim() &&
+ (typeof entry.icon === "function" ||
+ (typeof entry.icon === "string" && !!entry.icon.trim())) &&
+ (entry.requiresCommunity === undefined ||
+ typeof entry.requiresCommunity === "boolean") &&
+ (entry.params === undefined || page.route?.validate(entry.params) === true)
+ );
+}
diff --git a/src/plugins/author.ts b/src/plugins/author.ts
index fae027ef2..75ea747e3 100644
--- a/src/plugins/author.ts
+++ b/src/plugins/author.ts
@@ -17,7 +17,11 @@ export type {
} from "../features/identity-names/service";
export type { NamingIdentity } from "../features/identity-names/policy";
export type { Browser, BrowserViewProps } from "../features/browser/api";
-export type { Page, Pages } from "../features/pages/service";
+export type {
+ NavigationEntry,
+ Page,
+ Pages,
+} from "../features/pages/service";
export type {
Panel,
Panels,