From b7f349f99aa09b01eb0833d32b6818c848b0036d Mon Sep 17 00:00:00 2001 From: Jordan Santell Date: Wed, 26 Nov 2025 07:28:18 -0800 Subject: [PATCH 1/3] Shell view refactor (#2167) chore: Refactor shell's AppState to better handle top level 'Views', including built-ins, spaces, and charms. --- .../background-charm-service/cast-admin.ts | 7 +- .../integration/counter.test.ts | 6 +- .../background-charm-service/src/worker.ts | 7 +- packages/charm/src/manager.ts | 2 +- packages/identity/src/index.ts | 6 +- packages/identity/src/session.ts | 48 ++++++------- packages/integration/shell-utils.ts | 29 +++++--- packages/patterns/home.tsx | 13 ++++ packages/patterns/integration/chatbot.test.ts | 6 +- packages/patterns/integration/counter.test.ts | 12 ++-- .../patterns/integration/ct-checkbox.test.ts | 6 +- packages/patterns/integration/ct-list.test.ts | 6 +- .../patterns/integration/ct-render.test.ts | 12 ++-- packages/patterns/integration/ct-tags.test.ts | 6 +- .../patterns/integration/fetch-data.test.ts | 12 ++-- .../integration/instantiate-recipe.test.ts | 6 +- .../integration/list-operations.test.ts | 6 +- packages/patterns/integration/llm.test.ts | 6 +- .../integration/nested-counter.test.ts | 12 ++-- packages/shell/integration/charm.test.ts | 6 +- .../iframe-counter-charm.disabled_test.ts | 12 ++-- packages/shell/integration/login.test.ts | 6 +- packages/shell/src/components/CharmLink.ts | 2 - packages/shell/src/lib/app/commands.ts | 13 ++-- packages/shell/src/lib/app/controller.ts | 9 +-- packages/shell/src/lib/app/mod.ts | 1 + packages/shell/src/lib/app/state.ts | 25 +++---- packages/shell/src/lib/app/view.ts | 68 ++++++++++++++++++ packages/shell/src/lib/navigate.ts | 70 ++----------------- packages/shell/src/lib/runtime.ts | 49 ++++++++++--- packages/shell/src/views/AppView.ts | 53 ++++++++++---- packages/shell/src/views/QuickJumpView.ts | 2 +- packages/shell/src/views/RootView.ts | 27 +++++-- packages/shell/test/app-state.test.ts | 24 ++++--- 34 files changed, 354 insertions(+), 221 deletions(-) create mode 100644 packages/patterns/home.tsx create mode 100644 packages/shell/src/lib/app/view.ts diff --git a/packages/background-charm-service/cast-admin.ts b/packages/background-charm-service/cast-admin.ts index d83c9a45d..e386be90b 100644 --- a/packages/background-charm-service/cast-admin.ts +++ b/packages/background-charm-service/cast-admin.ts @@ -3,7 +3,7 @@ import { CharmManager, compileRecipe } from "@commontools/charm"; import { Runtime } from "@commontools/runner"; import { StorageManager } from "@commontools/runner/storage/cache.deno"; import { type DID } from "@commontools/identity"; -import { createSessionFromDid } from "@commontools/identity"; +import { createSession } from "@commontools/identity"; import { BG_CELL_CAUSE, BG_SYSTEM_SPACE_ID, @@ -87,10 +87,9 @@ async function castRecipe() { console.log("Casting recipe..."); // Create session and charm manager (matching main.ts pattern) - const session = await createSessionFromDid({ + const session = await createSession({ identity, - spaceName: "recipe-caster", - space: spaceId as DID, + spaceDid: spaceId as DID, }); // Create charm manager for the specified space diff --git a/packages/background-charm-service/integration/counter.test.ts b/packages/background-charm-service/integration/counter.test.ts index 5b53a034c..5c99c41d2 100644 --- a/packages/background-charm-service/integration/counter.test.ts +++ b/packages/background-charm-service/integration/counter.test.ts @@ -54,8 +54,10 @@ describe("background charm counter tests", () => { await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); diff --git a/packages/background-charm-service/src/worker.ts b/packages/background-charm-service/src/worker.ts index 1ca0b286f..fc6677da7 100644 --- a/packages/background-charm-service/src/worker.ts +++ b/packages/background-charm-service/src/worker.ts @@ -12,7 +12,7 @@ import { import { StorageManager } from "@commontools/runner/storage/cache.deno"; import { - createSessionFromDid, + createSession, type DID, Identity, Session, @@ -93,10 +93,9 @@ async function initialize( // Initialize session spaceId = did as DID; - currentSession = await createSessionFromDid({ + currentSession = await createSession({ identity, - spaceName: "~background-service-worker", - space: spaceId, + spaceDid: spaceId, }); // Initialize runtime and charm manager diff --git a/packages/charm/src/manager.ts b/packages/charm/src/manager.ts index 649dc458f..2979aafbe 100644 --- a/packages/charm/src/manager.ts +++ b/packages/charm/src/manager.ts @@ -217,7 +217,7 @@ export class CharmManager { return this.space; } - getSpaceName(): string { + getSpaceName(): string | undefined { return this.session.spaceName; } diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts index 54cf5f646..29f54402f 100644 --- a/packages/identity/src/index.ts +++ b/packages/identity/src/index.ts @@ -6,8 +6,4 @@ export { } from "./identity.ts"; export { KeyStore } from "./key-store.ts"; export * from "./interface.ts"; -export { - createSession, - createSessionFromDid, - type Session, -} from "./session.ts"; +export { createSession, type Session } from "./session.ts"; diff --git a/packages/identity/src/session.ts b/packages/identity/src/session.ts index 4cbbc68a3..290ddc638 100644 --- a/packages/identity/src/session.ts +++ b/packages/identity/src/session.ts @@ -2,39 +2,39 @@ import { Identity } from "./identity.ts"; import { type DID } from "./interface.ts"; export type Session = { - spaceName: string; + spaceName?: string; spaceIdentity?: Identity; space: DID; as: Identity; }; -// Create a session where `Identity` is used directly and not derived. -export const createSessionFromDid = ( - { identity, space, spaceName }: { - identity: Identity; - space: DID; - spaceName: string; - }, -): Promise => { - return Promise.resolve({ - spaceName, - space, - as: identity, - }); +export type SessionCreateOptions = { + identity: Identity; + spaceName: string; +} | { + identity: Identity; + spaceDid: DID; }; -// Create a session where `Identity` is used to derive a space key. +// Create a session with DID and identity provided, or where +// a key is reproducibly derived via the provided space name. export const createSession = async ( - { identity, spaceName }: { identity: Identity; spaceName: string }, + options: SessionCreateOptions, ): Promise => { - const spaceIdentity = await (await Identity.fromPassphrase("common user")) - .derive( - spaceName, - ); + if ("spaceName" in options) { + const spaceIdentity = await (await Identity.fromPassphrase("common user")) + .derive( + options.spaceName, + ); + return { + spaceName: options.spaceName, + spaceIdentity, + space: spaceIdentity.did(), + as: options.identity, + }; + } return { - spaceName, - spaceIdentity, - space: spaceIdentity.did(), - as: identity, + as: options.identity, + space: options.spaceDid, }; }; diff --git a/packages/integration/shell-utils.ts b/packages/integration/shell-utils.ts index 8e4edbeeb..f30d2012f 100644 --- a/packages/integration/shell-utils.ts +++ b/packages/integration/shell-utils.ts @@ -12,7 +12,13 @@ import { TransferrableInsecureCryptoKeyPair, } from "@commontools/identity"; import { afterAll, afterEach, beforeAll, beforeEach } from "@std/testing/bdd"; -import { AppState, deserialize } from "../shell/src/lib/app/mod.ts"; +import { + AppState, + AppView, + appViewToUrlPath, + deserialize, + isAppViewEqual, +} from "../shell/src/lib/app/mod.ts"; import { waitFor } from "./utils.ts"; import { ConsoleEvent, PageErrorEvent } from "@astral/astral"; @@ -88,8 +94,7 @@ export class ShellIntegration { // has a matching `spaceName`, ignoring all other properties. async waitForState( params: { - spaceName?: string; - charmId?: string; + view: AppView; identity?: Identity; }, ): Promise { @@ -99,8 +104,7 @@ export class ShellIntegration { ): boolean { return !!( state && - (params.spaceName ? state.spaceName === params.spaceName : true) && - (params.charmId ? state.activeCharmId === params.charmId : true) && + isAppViewEqual(state.view, params.view) && (params.identity ? state.identity?.did() === params.identity.did() : true) @@ -128,22 +132,25 @@ export class ShellIntegration { // If `identity` provided, logs in with the identity // after navigation. async goto( - { frontendUrl, spaceName, charmId, identity }: { + { frontendUrl, view, identity }: { frontendUrl: string; - spaceName: string; - charmId?: string; + view: AppView; identity?: Identity; }, ): Promise { this.checkIsOk(); - const url = `${frontendUrl}${spaceName}${charmId ? `/${charmId}` : ""}`; + + // Strip the proceeding "/" in the url path + const path = appViewToUrlPath(view).substring(1); + + const url = `${frontendUrl}${path}`; const page = this.page(); await page.goto(url); await page.applyConsoleFormatter(); - await this.waitForState({ spaceName, charmId }); + await this.waitForState({ view }); if (identity) { await this.login(identity); - await this.waitForState({ identity, spaceName, charmId }); + await this.waitForState({ identity, view }); } } diff --git a/packages/patterns/home.tsx b/packages/patterns/home.tsx new file mode 100644 index 000000000..91c1c2716 --- /dev/null +++ b/packages/patterns/home.tsx @@ -0,0 +1,13 @@ +/// +import { NAME, pattern, UI } from "commontools"; + +export default pattern((_) => { + return { + [NAME]: `Home`, + [UI]: ( +

+ homespace +

+ ), + }; +}); diff --git a/packages/patterns/integration/chatbot.test.ts b/packages/patterns/integration/chatbot.test.ts index 17a607310..8a7364d7a 100644 --- a/packages/patterns/integration/chatbot.test.ts +++ b/packages/patterns/integration/chatbot.test.ts @@ -56,8 +56,10 @@ describe("Chat pattern test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); diff --git a/packages/patterns/integration/counter.test.ts b/packages/patterns/integration/counter.test.ts index 222c8ed0a..1a0e55311 100644 --- a/packages/patterns/integration/counter.test.ts +++ b/packages/patterns/integration/counter.test.ts @@ -43,8 +43,10 @@ describe("counter direct operations test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); @@ -95,8 +97,10 @@ describe("counter direct operations test", () => { console.log("Refreshing the page..."); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); diff --git a/packages/patterns/integration/ct-checkbox.test.ts b/packages/patterns/integration/ct-checkbox.test.ts index cc1c92f40..24d0071e8 100644 --- a/packages/patterns/integration/ct-checkbox.test.ts +++ b/packages/patterns/integration/ct-checkbox.test.ts @@ -55,8 +55,10 @@ testComponents.forEach(({ name, file }) => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); await page.waitForSelector("ct-checkbox", { strategy: "pierce" }); diff --git a/packages/patterns/integration/ct-list.test.ts b/packages/patterns/integration/ct-list.test.ts index 85ad1c5ca..6a9f5d610 100644 --- a/packages/patterns/integration/ct-list.test.ts +++ b/packages/patterns/integration/ct-list.test.ts @@ -45,8 +45,10 @@ describe("ct-list integration test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); await page.waitForSelector("ct-list", { strategy: "pierce" }); diff --git a/packages/patterns/integration/ct-render.test.ts b/packages/patterns/integration/ct-render.test.ts index 824abfe3c..d5545d45c 100644 --- a/packages/patterns/integration/ct-render.test.ts +++ b/packages/patterns/integration/ct-render.test.ts @@ -44,8 +44,10 @@ describe("ct-render integration test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); @@ -98,8 +100,10 @@ describe("ct-render integration test", () => { // Navigate to the charm to see if UI reflects the change await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); diff --git a/packages/patterns/integration/ct-tags.test.ts b/packages/patterns/integration/ct-tags.test.ts index fae290176..82c7b90e3 100644 --- a/packages/patterns/integration/ct-tags.test.ts +++ b/packages/patterns/integration/ct-tags.test.ts @@ -45,8 +45,10 @@ describe("ct-tags integration test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); await page.waitForSelector("ct-tags", { strategy: "pierce" }); diff --git a/packages/patterns/integration/fetch-data.test.ts b/packages/patterns/integration/fetch-data.test.ts index b27f76f21..f5955a1e1 100644 --- a/packages/patterns/integration/fetch-data.test.ts +++ b/packages/patterns/integration/fetch-data.test.ts @@ -55,8 +55,10 @@ describe("fetch data integration test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); @@ -85,8 +87,10 @@ describe("fetch data integration test", () => { // Navigate to the charm to see updated data await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); diff --git a/packages/patterns/integration/instantiate-recipe.test.ts b/packages/patterns/integration/instantiate-recipe.test.ts index c43470c83..1f148596f 100644 --- a/packages/patterns/integration/instantiate-recipe.test.ts +++ b/packages/patterns/integration/instantiate-recipe.test.ts @@ -46,8 +46,10 @@ describe("instantiate-recipe integration test", () => { await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); diff --git a/packages/patterns/integration/list-operations.test.ts b/packages/patterns/integration/list-operations.test.ts index 76f40b073..9d7d0b5cf 100644 --- a/packages/patterns/integration/list-operations.test.ts +++ b/packages/patterns/integration/list-operations.test.ts @@ -43,8 +43,10 @@ describe("list-operations simple test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); diff --git a/packages/patterns/integration/llm.test.ts b/packages/patterns/integration/llm.test.ts index 06d345466..b0abd8088 100644 --- a/packages/patterns/integration/llm.test.ts +++ b/packages/patterns/integration/llm.test.ts @@ -56,8 +56,10 @@ describe("LLM pattern test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); diff --git a/packages/patterns/integration/nested-counter.test.ts b/packages/patterns/integration/nested-counter.test.ts index b2dc62f8f..b32a519c0 100644 --- a/packages/patterns/integration/nested-counter.test.ts +++ b/packages/patterns/integration/nested-counter.test.ts @@ -44,8 +44,10 @@ describe("nested counter integration test", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); @@ -99,8 +101,10 @@ describe("nested counter integration test", () => { // Navigate to the charm to see if UI reflects the change await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); diff --git a/packages/shell/integration/charm.test.ts b/packages/shell/integration/charm.test.ts index a02b8f847..a8e8b929a 100644 --- a/packages/shell/integration/charm.test.ts +++ b/packages/shell/integration/charm.test.ts @@ -52,8 +52,10 @@ describe("shell charm tests", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId, + view: { + spaceName: SPACE_NAME, + charmId, + }, identity, }); diff --git a/packages/shell/integration/iframe-counter-charm.disabled_test.ts b/packages/shell/integration/iframe-counter-charm.disabled_test.ts index cc68b582c..a2f9af83e 100644 --- a/packages/shell/integration/iframe-counter-charm.disabled_test.ts +++ b/packages/shell/integration/iframe-counter-charm.disabled_test.ts @@ -119,8 +119,10 @@ describe("shell iframe counter tests", () => { const page = shell.page(); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); @@ -170,8 +172,10 @@ describe("shell iframe counter tests", () => { console.log("\nReloading page to test persistence..."); await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName: SPACE_NAME, - charmId: charm.id, + view: { + spaceName: SPACE_NAME, + charmId: charm.id, + }, identity, }); diff --git a/packages/shell/integration/login.test.ts b/packages/shell/integration/login.test.ts index ffc2b3296..2c4c8e4fb 100644 --- a/packages/shell/integration/login.test.ts +++ b/packages/shell/integration/login.test.ts @@ -19,12 +19,14 @@ describe("shell login tests", () => { await shell.goto({ frontendUrl: FRONTEND_URL, - spaceName, + view: { spaceName }, }); const state = await shell.state(); assert(state); - assert(state.spaceName === "common-knowledge"); + assert( + (state.view as { spaceName: string }).spaceName === "common-knowledge", + ); let handle = await page.waitForSelector( '[test-id="register-new-key"]', diff --git a/packages/shell/src/components/CharmLink.ts b/packages/shell/src/components/CharmLink.ts index 742104e1d..7e3607eb2 100644 --- a/packages/shell/src/components/CharmLink.ts +++ b/packages/shell/src/components/CharmLink.ts @@ -23,13 +23,11 @@ export class CharmLinkElement extends LitElement { } if (this.charmId) { navigate({ - type: "charm", spaceName: this.spaceName, charmId: this.charmId, }); } else { navigate({ - type: "space", spaceName: this.spaceName, }); } diff --git a/packages/shell/src/lib/app/commands.ts b/packages/shell/src/lib/app/commands.ts index 5ec7ea488..b466d9d46 100644 --- a/packages/shell/src/lib/app/commands.ts +++ b/packages/shell/src/lib/app/commands.ts @@ -1,9 +1,9 @@ import { Identity } from "@commontools/identity"; +import { AppView, isAppView } from "./view.ts"; export type Command = - | { type: "set-active-charm-id"; charmId?: string } + | { type: "set-view"; view: AppView } | { type: "set-identity"; identity: Identity } - | { type: "set-space"; spaceName: string } | { type: "clear-authentication" } | { type: "set-show-charm-list-view"; show: boolean } | { type: "set-show-debugger-view"; show: boolean } @@ -22,13 +22,8 @@ export function isCommand(value: unknown): value is Command { case "set-identity": { return "identity" in value && value.identity instanceof Identity; } - case "set-space": { - return "spaceName" in value && !!value.spaceName && - typeof value.spaceName === "string"; - } - case "set-active-charm-id": { - return "charmId" in value && !!value.charmId && - (typeof value.charmId === "string" || value.charmId === undefined); + case "set-view": { + return "view" in value && isAppView(value.view); } case "clear-authentication": { return true; diff --git a/packages/shell/src/lib/app/controller.ts b/packages/shell/src/lib/app/controller.ts index dbd8b6d14..3e16588b1 100644 --- a/packages/shell/src/lib/app/controller.ts +++ b/packages/shell/src/lib/app/controller.ts @@ -8,6 +8,7 @@ import { XRootView } from "../../views/RootView.ts"; import { Command } from "./commands.ts"; import { AppState, AppUpdateEvent } from "./mod.ts"; import { AppStateSerialized, serialize } from "./state.ts"; +import { AppView } from "./view.ts"; // Key store key name for user's key export const ROOT_KEY = "$ROOT_KEY"; @@ -35,12 +36,8 @@ export class App extends EventTarget { return serialize(this.state()); } - async setSpace(spaceName: string) { - await this.apply({ type: "set-space", spaceName }); - } - - async setActiveCharmId(charmId?: string) { - await this.apply({ type: "set-active-charm-id", charmId }); + async setView(view: AppView) { + await this.apply({ type: "set-view", view }); } async setIdentity(id: Identity | TransferrableInsecureCryptoKeyPair) { diff --git a/packages/shell/src/lib/app/mod.ts b/packages/shell/src/lib/app/mod.ts index ca6af83a6..5e6053033 100644 --- a/packages/shell/src/lib/app/mod.ts +++ b/packages/shell/src/lib/app/mod.ts @@ -1,3 +1,4 @@ export * from "./controller.ts"; export * from "./state.ts"; export * from "./events.ts"; +export * from "./view.ts"; diff --git a/packages/shell/src/lib/app/state.ts b/packages/shell/src/lib/app/state.ts index 0a4664da0..058ce2de7 100644 --- a/packages/shell/src/lib/app/state.ts +++ b/packages/shell/src/lib/app/state.ts @@ -5,12 +5,12 @@ import { TransferrableInsecureCryptoKeyPair, } from "@commontools/identity"; import { Command } from "./commands.ts"; +import { AppView } from "./view.ts"; // Primary application state. export interface AppState { identity?: Identity; - spaceName?: string; - activeCharmId?: string; + view: AppView; apiUrl: URL; showShellCharmListView?: boolean; showDebuggerView?: boolean; @@ -24,7 +24,12 @@ export type AppStateSerialized = Omit & { }; export function clone(state: AppState): AppState { - return Object.assign({}, state); + const view = typeof state.view === "object" + ? Object.assign({}, state.view) + : state.view; + const cloned = Object.assign({}, state); + cloned.view = view; + return cloned; } export function applyCommand( @@ -33,19 +38,15 @@ export function applyCommand( ): AppState { const next = clone(state); switch (command.type) { - case "set-active-charm-id": { - next.activeCharmId = command.charmId; - if (command.charmId) { - next.showShellCharmListView = false; - } - break; - } case "set-identity": { next.identity = command.identity; break; } - case "set-space": { - next.spaceName = command.spaceName; + case "set-view": { + next.view = command.view; + if ("charmId" in command.view && command.view.charmId) { + next.showShellCharmListView = false; + } break; } case "clear-authentication": { diff --git a/packages/shell/src/lib/app/view.ts b/packages/shell/src/lib/app/view.ts new file mode 100644 index 000000000..a4f05df4b --- /dev/null +++ b/packages/shell/src/lib/app/view.ts @@ -0,0 +1,68 @@ +import { DID, isDID } from "@commontools/identity"; + +export type AppBuiltInView = "home"; + +export type AppView = { + builtin: AppBuiltInView; +} | { + spaceName: string; + charmId?: string; +} | { + spaceDid: DID; + charmId?: string; +}; + +export function isAppBuiltInView(view: unknown): view is AppBuiltInView { + switch (view as AppBuiltInView) { + case "home": + return true; + } + return false; +} + +export function isAppView(view: unknown): view is AppView { + if (!view || typeof view !== "object") return false; + if ("builtin" in view) return isAppBuiltInView(view.builtin); + if ("spaceName" in view) { + return typeof view.spaceName === "string" && !!view.spaceName; + } + if ("spaceDid" in view) return isDID(view.spaceDid); + return false; +} + +export function isAppViewEqual(a: AppView, b: AppView): boolean { + if (a === b) return true; + return JSON.stringify(a) === JSON.stringify(b); +} + +export function appViewToUrlPath(view: AppView): `/${string}` { + if ("builtin" in view) { + switch (view.builtin) { + case "home": + return `/`; + } + } else if ("spaceName" in view) { + return "charmId" in view + ? `/${view.spaceName}/${view.charmId}` + : `/${view.spaceName}`; + } else if ("spaceDid" in view) { + // did routes not yet supported + return "charmId" in view + ? `/${view.spaceDid}/${view.charmId}` + : `/${view.spaceDid}`; + } + return `/`; +} + +export function urlToAppView(url: URL): AppView { + const segments = url.pathname.split("/"); + segments.shift(); // shift off the pathnames' prefix "/"; + const [first, charmId] = [segments[0], segments[1]]; + + if (charmId) { + return { spaceName: first, charmId }; + } else if (first) { + return { spaceName: first }; + } + return { builtin: "home" }; +} diff --git a/packages/shell/src/lib/navigate.ts b/packages/shell/src/lib/navigate.ts index 3de9f77dd..b52a344b8 100644 --- a/packages/shell/src/lib/navigate.ts +++ b/packages/shell/src/lib/navigate.ts @@ -1,4 +1,4 @@ -import { App } from "./app/controller.ts"; +import { App, AppView, appViewToUrlPath, urlToAppView } from "./app/mod.ts"; import { getLogger } from "@commontools/utils/logger"; const logger = getLogger("shell.navigation", { @@ -6,18 +6,7 @@ const logger = getLogger("shell.navigation", { level: "debug", }); -// Could contain other nav types, like external pages, -// or viewing a User's "Settings" etc. -export type NavigationCommandType = "charm" | "space"; - -export type NavigationCommand = { - type: "charm"; - charmId: string; - spaceName: string; -} | { - type: "space"; - spaceName: string; -}; +export type NavigationCommand = AppView; const NavigationEventName = "ct-navigate"; @@ -66,7 +55,8 @@ export class Navigation { ); globalThis.addEventListener("popstate", this.onPopState); - const init = generateCommandFromPageLoad(); + const thisUrl = new URL(globalThis.location.href); + const init = urlToAppView(thisUrl); // Initial state is `null` -- reflect the state given // from the current URL. this.replace(init); @@ -101,7 +91,7 @@ export class Navigation { // Push a new command state to the browser's history. private push(command: NavigationCommand) { logger.log("Push", command); - globalThis.history.pushState(command, "", getNavigationHref(command)); + globalThis.history.pushState(command, "", appViewToUrlPath(command)); } // Updates the current browser history state and page with a new title. @@ -110,59 +100,13 @@ export class Navigation { globalThis.history.replaceState( command, title || "", - getNavigationHref(command), + appViewToUrlPath(command), ); } // Propagates the command state into the App. private apply(command: NavigationCommand) { logger.log("Apply", command); - switch (command.type) { - case "charm": { - this.#app.setSpace(command.spaceName); - this.#app.setActiveCharmId(command.charmId); - break; - } - case "space": { - this.#app.setSpace(command.spaceName); - this.#app.setActiveCharmId(undefined); - break; - } - default: { - throw new Error("Unsupported navigation type."); - } - } - } -} - -function getNavigationHref(command: NavigationCommand): string { - let content = ""; - switch (command.type) { - case "charm": { - content = `${command.spaceName}/${command.charmId}`; - break; - } - case "space": { - content = `${command.spaceName}`; - break; - } - default: { - throw new Error("Unsupported navigation type."); - } - } - return `/${content}`; -} - -function generateCommandFromPageLoad(): NavigationCommand { - const location = new URL(globalThis.location.href); - const segments = location.pathname.split("/"); - segments.shift(); // shift off the pathnames' prefix "/"; - const [first, charmId] = [segments[0], segments[1]]; - - const spaceName = first || "common-knowledge"; - if (charmId) { - return { type: "charm", spaceName, charmId }; - } else { - return { type: "space", spaceName }; + this.#app.setView(command); } } diff --git a/packages/shell/src/lib/runtime.ts b/packages/shell/src/lib/runtime.ts index 286dfb23f..5f54460c9 100644 --- a/packages/shell/src/lib/runtime.ts +++ b/packages/shell/src/lib/runtime.ts @@ -12,6 +12,7 @@ import { navigate } from "./navigate.ts"; import * as Inspector from "@commontools/runner/storage/inspector"; import { setupIframe } from "./iframe-ctx.ts"; import { getLogger } from "@commontools/utils/logger"; +import { AppView } from "./app/view.ts"; const logger = getLogger("shell.telemetry", { enabled: false, @@ -97,19 +98,36 @@ export class RuntimeInternals extends EventTarget { } static async create( - { identity, spaceName, apiUrl }: { + { identity, view, apiUrl }: { identity: Identity; - spaceName: string; + view: AppView; apiUrl: URL; }, ): Promise { - const session = await createSession({ identity, spaceName }); + let session; + let spaceName; + if (typeof view === "string") { + switch (view) { + case "home": + session = await createSession({ identity, spaceDid: identity.did() }); + spaceName = ""; + break; + } + } else if ("spaceName" in view) { + session = await createSession({ identity, spaceName: view.spaceName }); + spaceName = view.spaceName; + } else if ("spaceDid" in view) { + session = await createSession({ identity, spaceDid: view.spaceDid }); + } + if (!session) { + throw new Error("Unexpected view provided."); + } // Log user identity for debugging and sharing identityLogger.log("telemetry", `[Identity] User DID: ${session.as.did()}`); identityLogger.log( "telemetry", - `[Identity] Space: ${spaceName} (${session.space})`, + `[Identity] Space: ${spaceName ?? ""} (${session.space})`, ); // We're hoisting CharmManager so that @@ -151,6 +169,10 @@ export class RuntimeInternals extends EventTarget { // some sort of address book / dns-style server, OR just navigate to the // DID. + // Get the space name for navigation until we support + // DID spaces from the shell. + const spaceName = charmManager.getSpaceName(); + // Await storage being synced, at least for now, as the page fully // reloads. Once we have in-page navigation with reloading, we don't // need this anymore @@ -173,20 +195,25 @@ export class RuntimeInternals extends EventTarget { await charmManager.add([target]); } + if (!spaceName) { + throw new Error( + "Does not yet support navigating to a charm within a space loaded by DID.", + ); + } // Use the human-readable space name from CharmManager instead of DID navigate({ - type: "charm", - spaceName: charmManager.getSpaceName(), + spaceName, charmId: id, }); }).catch((err) => { console.error("[navigateCallback] Error during storage sync:", err); - navigate({ - type: "charm", - spaceName: charmManager.getSpaceName(), - charmId: id, - }); + if (spaceName) { + navigate({ + spaceName, + charmId: id, + }); + } }); }, }); diff --git a/packages/shell/src/views/AppView.ts b/packages/shell/src/views/AppView.ts index 17da6a021..7ef29b4ea 100644 --- a/packages/shell/src/views/AppView.ts +++ b/packages/shell/src/views/AppView.ts @@ -93,8 +93,10 @@ export class XAppView extends BaseView { this.keyboard.register( { code: "KeyW", alt: true, preventDefault: true }, () => { - const spaceName = this.app?.spaceName ?? "common-knowledge"; - navigate({ type: "space", spaceName }); + const spaceName = this.app && "spaceName" in this.app.view + ? this.app.view.spaceName + : "common-knowledge"; + navigate({ spaceName }); }, ), ); @@ -121,31 +123,53 @@ export class XAppView extends BaseView { this.hasSidebarContent = event.detail.hasSidebarContent; }; + // Maps the app level view to a specific charm to load + // as the primary, active charm. + _activeCharmId = new Task(this, { + task: ([app, rt]): string | undefined => { + if (!app || !rt) { + return; + } + if ("builtin" in app.view) { + console.warn("Unsupported view type"); + } else if ("spaceDid" in app.view) { + console.warn("Unsupported view type"); + } else if ("spaceName" in app.view) { + // eventually, this should load the default pattern + // for a space if needed, but for now is handled + // in BodyView, and only set the active charm ID + // for explicit charms set in the URL. + return app.view.charmId; + } + }, + args: () => [this.app, this.rt], + }); + // Do not make private, integration tests access this directly. _activeCharm = new Task(this, { - task: async ([app, rt]): Promise => { - if (!app || !app.activeCharmId || !rt) { + task: async ([activeCharmId]): Promise => { + if (!this.rt || !this.app || !activeCharmId) { this.#setTitleSubscription(); return; } const current: CharmController | undefined = this._activeCharm.value; if ( - current && current.id === app.activeCharmId + current && current.id === activeCharmId ) { return current; } - const activeCharm = await rt.cc().get( - app.activeCharmId, + const activeCharm = await this.rt.cc().get( + activeCharmId, true, nameSchema, ); // Record the charm as recently accessed so recents stay fresh. - await rt.cc().manager().trackRecentCharm(activeCharm.getCell()); + await this.rt.cc().manager().trackRecentCharm(activeCharm.getCell()); this.#setTitleSubscription(activeCharm); return activeCharm; }, - args: () => [this.app, this.rt], + args: () => [this._activeCharmId.value], }); #setTitleSubscription(activeCharm?: CharmController) { @@ -157,7 +181,9 @@ export class XAppView extends BaseView { ); } this.titleSubscription = undefined; - this.charmTitle = this.app?.spaceName ?? "Common Tools"; + this.charmTitle = this.app && "spaceName" in this.app.view + ? this.app.view.spaceName + : "Common Tools"; } else { const cell = activeCharm.getCell(); this.titleSubscription = new CellEventTarget(cell.key(NAME)); @@ -217,16 +243,19 @@ export class XAppView extends BaseView { > `; + const spaceName = this.app && "spaceName" in this.app.view + ? this.app.view.spaceName + : undefined; const content = this.app?.identity ? authenticated : unauthenticated; return html`
{ it("serialize", async () => { const state: AppState = { apiUrl: new URL(API_URL), - spaceName: SPACE_NAME, + view: { + spaceName: SPACE_NAME, + }, }; let serialized = serialize(state); assert(serialized.apiUrl === API_URL); - assert(serialized.spaceName === SPACE_NAME); + assert((serialized.view as { spaceName: string }).spaceName === SPACE_NAME); assert( serialized.identity === undefined, "Identity not provided (undefined).", @@ -30,7 +32,7 @@ describe("AppState", () => { state.identity = await Identity.generate({ implementation: "webcrypto" }), serialized = serialize(state); assert(serialized.apiUrl === API_URL); - assert(serialized.spaceName === SPACE_NAME); + assert((serialized.view as { spaceName: string }).spaceName === SPACE_NAME); assert( serialized.identity === null, "WebCrypto keys cannot be serialized (null).", @@ -39,7 +41,7 @@ describe("AppState", () => { state.identity = await Identity.generate({ implementation: "noble" }); serialized = serialize(state); assert(serialized.apiUrl === API_URL); - assert(serialized.spaceName === SPACE_NAME); + assert((serialized.view as { spaceName: string }).spaceName === SPACE_NAME); assert(serialized.identity); assert( (await Identity.fromRaw(Uint8Array.from(serialized.identity.privateKey))) @@ -56,30 +58,34 @@ describe("AppState", () => { const serialized: AppStateSerialized = { apiUrl: API_URL, - spaceName: SPACE_NAME, + view: { spaceName: SPACE_NAME }, }; let state = await deserialize(serialized); assert(state.apiUrl.toString() === API_URL.toString()); - assert(state.spaceName === SPACE_NAME); + assert((state.view as { spaceName: string }).spaceName === SPACE_NAME); assert(state.identity === undefined); serialized.identity = identityRaw; state = await deserialize(serialized); assert(state.apiUrl.toString() === API_URL.toString()); - assert(state.spaceName === SPACE_NAME); + assert((state.view as { spaceName: string }).spaceName === SPACE_NAME); assert(state.identity?.did() === identity.did(), "deserializes identity."); }); it("clears charm list view when activating a charm", () => { const initial: AppState = { apiUrl: new URL(API_URL), + view: { builtin: "home" }, showShellCharmListView: true, }; const next = applyCommand(initial, { - type: "set-active-charm-id", - charmId: "example", + type: "set-view", + view: { + spaceName: SPACE_NAME, + charmId: "example", + }, }); assert(next.showShellCharmListView === false); From 62e03294a37a86c042d718256e7fccf8d54decbf Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Wed, 26 Nov 2025 11:52:29 -0800 Subject: [PATCH 2/3] chore(runner): rename `tag` to `query` and extract tags by default (#2168) chore(runner): wish built-in: rename `tag` to `query` and extract tags by default --- packages/api/index.ts | 2 +- packages/patterns/favorites-manager.tsx | 2 +- packages/patterns/wish.tsx | 2 +- packages/runner/src/builtins/wish.ts | 63 +++++++++++++++---------- packages/runner/test/wish.test.ts | 23 ++++----- 5 files changed, 54 insertions(+), 38 deletions(-) diff --git a/packages/api/index.ts b/packages/api/index.ts index 3147a5a60..a53022ec9 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -1257,7 +1257,7 @@ export type WishTag = `/${string}` | `#${string}`; export type DID = `did:${string}:${string}`; export type WishParams = { - tag?: WishTag; + query: WishTag | string; path?: string[]; context?: Record; schema?: JSONSchema; diff --git a/packages/patterns/favorites-manager.tsx b/packages/patterns/favorites-manager.tsx index 90c800da2..eee57ac1e 100644 --- a/packages/patterns/favorites-manager.tsx +++ b/packages/patterns/favorites-manager.tsx @@ -13,7 +13,7 @@ const onRemoveFavorite = handler< }); export default pattern>((_) => { - const wishResult = wish>({ tag: "#favorites" }); + const wishResult = wish>({ query: "#favorites" }); return { [NAME]: "Favorites Manager", diff --git a/packages/patterns/wish.tsx b/packages/patterns/wish.tsx index f168381f7..d92cd20e3 100644 --- a/packages/patterns/wish.tsx +++ b/packages/patterns/wish.tsx @@ -2,7 +2,7 @@ import { NAME, pattern, UI, wish } from "commontools"; export default pattern>((_) => { - const wishResult = wish<{ content: string }>({ tag: "#note" }); + const wishResult = wish<{ content: string }>({ query: "#note" }); return { [NAME]: "Wish tester", diff --git a/packages/runner/src/builtins/wish.ts b/packages/runner/src/builtins/wish.ts index 91d077a90..1c0716fd8 100644 --- a/packages/runner/src/builtins/wish.ts +++ b/packages/runner/src/builtins/wish.ts @@ -90,7 +90,7 @@ function parseWishTarget(target: string): ParsedWishTarget { segment.length > 0 ); if (segments.length === 0) { - throw new WishError(`Wish target "${target}" is not recognized.`); + throw new WishError(`Wish tag target "${target}" is not recognized.`); } const key = `#${segments[0]}` as WishTag; return { key, path: segments.slice(1) }; @@ -101,7 +101,7 @@ function parseWishTarget(target: string): ParsedWishTarget { return { key: "/", path: segments }; } - throw new WishError(`Wish target "${target}" is not recognized.`); + throw new WishError(`Wish path target "${target}" is not recognized.`); } type WishContext = { @@ -202,7 +202,7 @@ function resolveBase( case "#now": { if (parsed.path.length > 0) { throw new WishError( - `Wish target "${formatTarget(parsed)}" is not recognized.`, + `Wish now target "${formatTarget(parsed)}" is not recognized.`, ); } const nowCell = ctx.runtime.getImmutableCell( @@ -265,7 +265,7 @@ const TARGET_SCHEMA = { }, { type: "object", properties: { - tag: { type: "string" }, + query: { type: "string" }, path: { type: "array", items: { type: "string" } }, context: { type: "object", additionalProperties: { asCell: true } }, scope: { type: "array", items: { type: "string" } }, @@ -307,13 +307,13 @@ export function wish( } return; } else if (typeof targetValue === "object") { - const { tag, path, schema, context: _context, scope: _scope } = + const { query, path, schema, context: _context, scope: _scope } = targetValue as WishParams; - if (!tag) { + if (query === undefined || query === null || query === "") { const errorMsg = `Wish target "${ JSON.stringify(targetValue) - }" is not recognized.`; + }" has no query.`; console.error(errorMsg); sendResult( tx, @@ -322,23 +322,38 @@ export function wish( return; } - try { - const parsed: ParsedWishTarget = { key: tag, path: path ?? [] }; - const ctx: WishContext = { runtime, tx, parentCell }; - const baseResolution = resolveBase(parsed, ctx); - const combinedPath = baseResolution.pathPrefix - ? [...baseResolution.pathPrefix, ...(path ?? [])] - : path ?? []; - const resolvedCell = resolvePath(baseResolution.cell, combinedPath); - const resultCell = schema !== undefined - ? resolvedCell.asSchema(schema) - : resolvedCell; - sendResult(tx, { - result: resultCell, - [UI]: cellLinkUI(resultCell), - }); - } catch (e) { - const errorMsg = e instanceof WishError ? e.message : String(e); + // If the query is a path or a hash tag, resolve it directly + if (query.startsWith("/") || /^#[a-zA-Z0-9-]+$/.test(query)) { + try { + const parsed: ParsedWishTarget = { + key: query as WishTag, + path: path ?? [], + }; + const ctx: WishContext = { runtime, tx, parentCell }; + const baseResolution = resolveBase(parsed, ctx); + const combinedPath = baseResolution.pathPrefix + ? [...baseResolution.pathPrefix, ...(path ?? [])] + : path ?? []; + const resolvedCell = resolvePath(baseResolution.cell, combinedPath); + const resultCell = schema + ? resolvedCell.asSchema(schema) + : resolvedCell; + sendResult(tx, { + result: resultCell, + [UI]: cellLinkUI(resultCell), + }); + } catch (e) { + const errorMsg = e instanceof WishError ? e.message : String(e); + console.error(errorMsg); + sendResult( + tx, + { error: errorMsg, [UI]: errorUI(errorMsg) } satisfies WishState< + any + >, + ); + } + } else { + const errorMsg = "Non hash tag or path query not yet supported"; console.error(errorMsg); sendResult( tx, diff --git a/packages/runner/test/wish.test.ts b/packages/runner/test/wish.test.ts index 9f5b9213b..83978f463 100644 --- a/packages/runner/test/wish.test.ts +++ b/packages/runner/test/wish.test.ts @@ -425,7 +425,7 @@ describe("wish built-in", () => { tx = runtime.edit(); const wishRecipe = recipe("wish object syntax allCharms", () => { - const allCharms = wish({ tag: "#allCharms" }); + const allCharms = wish({ query: "#allCharms" }); return { allCharms }; }); @@ -471,7 +471,7 @@ describe("wish built-in", () => { const wishRecipe = recipe("wish object syntax with path", () => { const firstTitle = wish({ - tag: "#allCharms", + query: "#allCharms", path: ["0", "title"], }); return { firstTitle }; @@ -505,7 +505,7 @@ describe("wish built-in", () => { tx = runtime.edit(); const wishRecipe = recipe("wish object syntax space", () => { - const spaceResult = wish({ tag: "/" }); + const spaceResult = wish({ query: "/" }); return { spaceResult }; }); @@ -540,8 +540,8 @@ describe("wish built-in", () => { const wishRecipe = recipe("wish object syntax space subpaths", () => { return { - configLink: wish({ tag: "/", path: ["config"] }), - dataLink: wish({ tag: "/", path: ["nested", "deep", "data"] }), + configLink: wish({ query: "/", path: ["config"] }), + dataLink: wish({ query: "/", path: ["nested", "deep", "data"] }), }; }); @@ -569,7 +569,7 @@ describe("wish built-in", () => { it("returns current timestamp via #now tag", async () => { const wishRecipe = recipe("wish object syntax now", () => { - return { nowValue: wish({ tag: "#now" }) }; + return { nowValue: wish({ query: "#now" }) }; }); const resultCell = runtime.getCell<{ @@ -604,7 +604,7 @@ describe("wish built-in", () => { try { const wishRecipe = recipe("wish object syntax unknown", () => { - const missing = wish({ tag: "#unknownTag" }); + const missing = wish({ query: "#unknownTag" }); return { missing }; }); @@ -641,7 +641,7 @@ describe("wish built-in", () => { try { const wishRecipe = recipe("wish object syntax no tag", () => { - const missing = wish({ path: ["some", "path"] }); + const missing = wish({ query: "", path: ["some", "path"] }); return { missing }; }); @@ -661,7 +661,7 @@ describe("wish built-in", () => { await runtime.idle(); const missingResult = result.key("missing").get(); - expect(missingResult?.error).toMatch(/not recognized/); + expect(missingResult?.error).toMatch(/no query/); expect(errors.length).toBeGreaterThan(0); } finally { console.error = originalError; @@ -678,7 +678,7 @@ describe("wish built-in", () => { tx = runtime.edit(); const wishRecipe = recipe("wish object syntax UI success", () => { - const spaceResult = wish({ tag: "/" }); + const spaceResult = wish({ query: "/" }); return { spaceResult }; }); @@ -701,6 +701,7 @@ describe("wish built-in", () => { string | symbol, unknown >; + expect(wishResult?.error).toBeUndefined(); expect(wishResult?.result).toEqual(spaceData); const ui = wishResult?.[UI] as { type: string; name: string; props: any }; @@ -718,7 +719,7 @@ describe("wish built-in", () => { try { const wishRecipe = recipe("wish object syntax UI error", () => { - const missing = wish({ tag: "#unknownTag" }); + const missing = wish({ query: "#unknownTag" }); return { missing }; }); From a831098509d5133069dbb06a483e2d08b3cb92ec Mon Sep 17 00:00:00 2001 From: Jordan Santell Date: Wed, 26 Nov 2025 12:13:17 -0800 Subject: [PATCH 3/3] feat: Add home space to shell when on the root path (#2170) --- packages/shell/src/lib/default-pattern.ts | 35 ------------- packages/shell/src/lib/pattern-factory.ts | 61 +++++++++++++++++++++++ packages/shell/src/lib/runtime.ts | 4 +- packages/shell/src/views/AppView.ts | 23 ++++++++- packages/shell/src/views/BodyView.ts | 6 +-- packages/shell/src/views/RootView.ts | 2 +- 6 files changed, 88 insertions(+), 43 deletions(-) delete mode 100644 packages/shell/src/lib/default-pattern.ts create mode 100644 packages/shell/src/lib/pattern-factory.ts diff --git a/packages/shell/src/lib/default-pattern.ts b/packages/shell/src/lib/default-pattern.ts deleted file mode 100644 index f99f24fae..000000000 --- a/packages/shell/src/lib/default-pattern.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { CharmController, CharmsController } from "@commontools/charm/ops"; -import { HttpProgramResolver } from "@commontools/js-compiler"; -import { API_URL } from "./env.ts"; - -const DEFAULT_CHARM_NAME = "DefaultCharmList"; -const DEFAULT_APP_URL = `${API_URL}api/patterns/default-app.tsx`; - -export async function create(cc: CharmsController): Promise { - const manager = cc.manager(); - const runtime = manager.runtime; - - const program = await cc.manager().runtime.harness.resolve( - new HttpProgramResolver(DEFAULT_APP_URL), - ); - - const charm = await cc.create(program, undefined, "default-charm"); - - // Wait for the link to be processed - await runtime.idle(); - await manager.synced(); - - // Link the default pattern to the space cell - await manager.linkDefaultPattern(charm.getCell()); - - return charm; -} - -export function getDefaultPattern( - charms: CharmController[], -): CharmController | undefined { - return charms.find((c) => { - const name = c.name(); - return name && name.startsWith(DEFAULT_CHARM_NAME); - }); -} diff --git a/packages/shell/src/lib/pattern-factory.ts b/packages/shell/src/lib/pattern-factory.ts new file mode 100644 index 000000000..fa312d2dd --- /dev/null +++ b/packages/shell/src/lib/pattern-factory.ts @@ -0,0 +1,61 @@ +import { CharmController, CharmsController } from "@commontools/charm/ops"; +import { HttpProgramResolver } from "@commontools/js-compiler"; +import { API_URL } from "./env.ts"; + +export type BuiltinPatternType = "home" | "space-default"; + +type BuiltinPatternConfig = { + url: URL; + cause: string; + name: string; +}; + +const Configs: Record = { + "home": { + name: "Home", + url: new URL(`/api/patterns/home.tsx`, API_URL), + cause: "home-pattern", + }, + "space-default": { + name: "DefaultCharmList", + url: new URL(`/api/patterns/default-app.tsx`, API_URL), + cause: "default-charm", + }, +}; + +export async function create( + cc: CharmsController, + type: BuiltinPatternType, +): Promise { + const config = Configs[type]; + const manager = cc.manager(); + const runtime = manager.runtime; + + const program = await cc.manager().runtime.harness.resolve( + new HttpProgramResolver(config.url.href), + ); + + const charm = await cc.create(program, undefined, config.cause); + + // Wait for the link to be processed + await runtime.idle(); + await manager.synced(); + + if (type === "space-default") { + // Link the default pattern to the space cell + await manager.linkDefaultPattern(charm.getCell()); + } + + return charm; +} + +export function getPattern( + charms: CharmController[], + type: BuiltinPatternType, +): CharmController | undefined { + const config = Configs[type]; + return charms.find((c) => { + const name = c.name(); + return name && name.startsWith(config.name); + }); +} diff --git a/packages/shell/src/lib/runtime.ts b/packages/shell/src/lib/runtime.ts index 5f54460c9..e0bfefdf2 100644 --- a/packages/shell/src/lib/runtime.ts +++ b/packages/shell/src/lib/runtime.ts @@ -106,8 +106,8 @@ export class RuntimeInternals extends EventTarget { ): Promise { let session; let spaceName; - if (typeof view === "string") { - switch (view) { + if ("builtin" in view) { + switch (view.builtin) { case "home": session = await createSession({ identity, spaceDid: identity.did() }); spaceName = ""; diff --git a/packages/shell/src/views/AppView.ts b/packages/shell/src/views/AppView.ts index 7ef29b4ea..292470ed9 100644 --- a/packages/shell/src/views/AppView.ts +++ b/packages/shell/src/views/AppView.ts @@ -16,6 +16,7 @@ import { navigate, updatePageTitle } from "../lib/navigate.ts"; import { provide } from "@lit/context"; import { KeyboardRouter } from "../lib/keyboard-router.ts"; import { keyboardRouterContext } from "@commontools/ui"; +import * as PatternFactory from "../lib/pattern-factory.ts"; export class XAppView extends BaseView { static override styles = css` @@ -126,12 +127,30 @@ export class XAppView extends BaseView { // Maps the app level view to a specific charm to load // as the primary, active charm. _activeCharmId = new Task(this, { - task: ([app, rt]): string | undefined => { + task: async ([app, rt]): Promise => { if (!app || !rt) { return; } if ("builtin" in app.view) { - console.warn("Unsupported view type"); + if (app.view.builtin !== "home") { + console.warn("Unsupported view type"); + return; + } + { + await rt.cc().manager().synced(); + const pattern = await PatternFactory.getPattern( + rt.cc().getAllCharms(), + "home", + ); + if (pattern) { + return pattern.id; + } + } + const pattern = await PatternFactory.create( + rt.cc(), + "home", + ); + return pattern.id; } else if ("spaceDid" in app.view) { console.warn("Unsupported view type"); } else if ("spaceName" in app.view) { diff --git a/packages/shell/src/views/BodyView.ts b/packages/shell/src/views/BodyView.ts index e14fb553e..87b81aafc 100644 --- a/packages/shell/src/views/BodyView.ts +++ b/packages/shell/src/views/BodyView.ts @@ -4,7 +4,7 @@ import { Task } from "@lit/task"; import { BaseView } from "./BaseView.ts"; import { RuntimeInternals } from "../lib/runtime.ts"; import { CharmController } from "@commontools/charm/ops"; -import * as DefaultPattern from "../lib/default-pattern.ts"; +import * as PatternFactory from "../lib/pattern-factory.ts"; import "../components/OmniLayout.ts"; export class XBodyView extends BaseView { @@ -79,7 +79,7 @@ export class XBodyView extends BaseView { this.creatingDefaultPattern = true; try { - await DefaultPattern.create(this.rt.cc()); + await PatternFactory.create(this.rt.cc(), "space-default"); } catch (error) { console.error("Could not create default pattern:", error); // Re-throw to expose errors instead of swallowing them @@ -117,7 +117,7 @@ export class XBodyView extends BaseView { } const defaultPattern = charms - ? DefaultPattern.getDefaultPattern(charms) + ? PatternFactory.getPattern(charms, "space-default") : undefined; const activeCharm = this.activeCharm; diff --git a/packages/shell/src/views/RootView.ts b/packages/shell/src/views/RootView.ts index 191d04822..65f5fbb2a 100644 --- a/packages/shell/src/views/RootView.ts +++ b/packages/shell/src/views/RootView.ts @@ -42,7 +42,7 @@ export class XRootView extends BaseView { // Non-private for typing in `updated()` callback _app = { apiUrl: API_URL, - view: { spaceName: "common-knowledge" }, + view: { builtin: "home" }, } as AppState; @property()