diff --git a/packages/charm/src/ops/charms-controller.ts b/packages/charm/src/ops/charms-controller.ts index 7e877ec86..b4947e9fc 100644 --- a/packages/charm/src/ops/charms-controller.ts +++ b/packages/charm/src/ops/charms-controller.ts @@ -15,7 +15,7 @@ export class CharmsController { } async create( - program: RuntimeProgram, + program: RuntimeProgram | string, input?: object, ): Promise { const recipe = await compileProgram(this.#manager, program); diff --git a/packages/shell/src/components/Body.ts b/packages/shell/src/components/Body.ts index 06b67a6fd..4b4860263 100644 --- a/packages/shell/src/components/Body.ts +++ b/packages/shell/src/components/Body.ts @@ -21,19 +21,15 @@ export class XBodyElement extends BaseView { activeCharmId?: string; override render() { - const connected = this.cc ? "Connected" : "Not Connected"; const charmView = html` `; - const charmList = html` - + const spaceView = html` + `; - const view = this.activeCharmId ? charmView : charmList; + const view = this.activeCharmId ? charmView : spaceView; return html` -
-

App!! (${connected})

-
${view}
-
+
${view}
`; } } diff --git a/packages/shell/src/components/CharmList.ts b/packages/shell/src/components/CharmList.ts index fd1f082f8..e20ed99b5 100644 --- a/packages/shell/src/components/CharmList.ts +++ b/packages/shell/src/components/CharmList.ts @@ -1,8 +1,7 @@ import { css, html } from "lit"; import { property } from "lit/decorators.js"; import { BaseView } from "../views/BaseView.ts"; -import { CharmsController } from "@commontools/charm/ops"; -import { Task } from "@lit/task"; +import { CharmController } from "@commontools/charm/ops"; import { getNavigationHref } from "../lib/navigate.ts"; export class XCharmListElement extends BaseView { @@ -17,26 +16,20 @@ export class XCharmListElement extends BaseView { `; @property({ attribute: false }) - cc?: CharmsController; + charms?: CharmController[]; - private _charmList = new Task(this, { - task: ([cc]) => { - return cc ? cc.getAllCharms() : undefined; - }, - args: () => [this.cc], - }); + @property({ attribute: false }) + spaceName?: string; override render() { - const spaceName = this.cc ? this.cc.manager().getSpaceName() : undefined; - const charmList = this._charmList.value; - - if (!spaceName || !charmList) { + const { charms, spaceName } = this; + if (!spaceName || !charms) { return html` `; } - const list = (charmList ?? []).map((charm) => { + const list = charms.map((charm) => { const name = charm.name(); const id = charm.id; const href = getNavigationHref(spaceName, id); diff --git a/packages/shell/src/components/Space.ts b/packages/shell/src/components/Space.ts new file mode 100644 index 000000000..58c845086 --- /dev/null +++ b/packages/shell/src/components/Space.ts @@ -0,0 +1,117 @@ +import { css, html } from "lit"; +import { property, state } from "lit/decorators.js"; +import { BaseView } from "../views/BaseView.ts"; +import { CharmsController } from "@commontools/charm/ops"; +import { Task } from "@lit/task"; +import * as DefaultRecipe from "../lib/default-recipe.ts"; + +type CharmData = { + name: string; + id: string; + href: string; +}; + +export class XSpaceElement extends BaseView { + static override styles = css` + :host { + display: block; + width: 100%; + height: 100%; + background-color: white; + padding: 1rem; + } + `; + + @property({ attribute: false }) + cc?: CharmsController; + + @state() + showCharmList = false; + + @state() + creatingDefaultRecipe = false; + + private _charms = new Task(this, { + task: async ([cc]) => { + if (!cc) return undefined; + + // Ensure charms are synced before checking + const manager = cc.manager(); + await manager.synced(); + return await cc.getAllCharms(); + }, + args: () => [this.cc], + }); + + async onRequestDefaultRecipe(e: Event) { + e.preventDefault(); + if (this.creatingDefaultRecipe) { + return; + } + if (!this.cc) { + throw new Error( + "Cannot create default recipe without a charms controller.", + ); + } + this.creatingDefaultRecipe = true; + try { + await DefaultRecipe.create(this.cc); + } catch (e) { + console.error(`Could not create default recipe: ${e}`); + } finally { + this.creatingDefaultRecipe = false; + this._charms.run(); + } + } + + onViewToggle(e: Event) { + e.preventDefault(); + this.showCharmList = !this.showCharmList; + } + + override render() { + const spaceName = this.cc ? this.cc.manager().getSpaceName() : undefined; + const charms = this._charms.value; + const defaultRecipe = charms + ? DefaultRecipe.getDefaultRecipe(charms) + : undefined; + + const inner = !charms + ? html` + + ` + : this.showCharmList + ? html` + + ` + : !defaultRecipe + ? (this.creatingDefaultRecipe + ? html` +
+ Creating default recipe... + +
+ ` + : html` +
+ Create default recipe? + +
+ `) + // TBD if we want to use x-charm or ct-render directly here + : html` + + `; + + return html` +
+ + ${inner} +
+ `; + } +} + +globalThis.customElements.define("x-space", XSpaceElement); diff --git a/packages/shell/src/components/index.ts b/packages/shell/src/components/index.ts index 27f60ee8a..6867c6193 100644 --- a/packages/shell/src/components/index.ts +++ b/packages/shell/src/components/index.ts @@ -2,5 +2,6 @@ export * from "./Header.ts"; export * from "./Body.ts"; export * from "./Charm.ts"; export * from "./CharmList.ts"; +export * from "./Space.ts"; export * from "./CTLogo.ts"; export * from "./Spinner.ts"; diff --git a/packages/shell/src/index.ts b/packages/shell/src/index.ts index 27ece6e7a..e2b2e8762 100644 --- a/packages/shell/src/index.ts +++ b/packages/shell/src/index.ts @@ -11,6 +11,7 @@ import { XRootView } from "./views/RootView.ts"; import "./components/index.ts"; import "./views/index.ts"; import { AppController } from "./lib/app/controller.ts"; +import { getNavigationHref } from "./lib/navigate.ts"; console.log(`ENVIRONMENT=${ENVIRONMENT}`); console.log(`API_URL=${API_URL}`); @@ -60,4 +61,9 @@ globalThis.addEventListener("navigate-to-charm", (e) => { } app.setSpace(spaceName); app.setActiveCharmId(charmId); + + // Update the browser URL to reflect the new location + // (DefaultCharmList should not use this event, it sets activeCharmId directly) + const href = getNavigationHref(spaceName, charmId); + globalThis.history.pushState({}, "", href); }); diff --git a/packages/shell/src/lib/default-recipe.ts b/packages/shell/src/lib/default-recipe.ts new file mode 100644 index 000000000..5f86def62 --- /dev/null +++ b/packages/shell/src/lib/default-recipe.ts @@ -0,0 +1,47 @@ +import { CharmController, CharmsController } from "@commontools/charm/ops"; +import { processSchema } from "@commontools/charm"; + +const ALL_CHARMS_ID = + "baedreiahv63wxwgaem4hzjkizl4qncfgvca7pj5cvdon7cukumfon3ioye"; +const DEFAULT_CHARM_NAME = "DefaultCharmList"; + +export async function create(cc: CharmsController): Promise { + const manager = cc.manager(); + const runtime = manager.runtime; + + const recipeContent = await runtime.staticCache.getText( + "recipes/charm-list.tsx", + ); + const charm = await cc.create(recipeContent); + + const tx = runtime.edit(); + const charmCell = charm.getCell(); + const sourceCell = charmCell.getSourceCell(processSchema); + + if (!sourceCell) { + // Not sure how/when this happens + throw new Error("Could not create and link default recipe."); + } + + // Get the well-known allCharms cell using its EntityId format + const allCharmsCell = await manager.getCellById({ "/": ALL_CHARMS_ID }); + sourceCell.withTx(tx).key("argument").key("allCharms").set( + allCharmsCell.withTx(tx), + ); + await tx.commit(); + + // Wait for the link to be processed + await runtime.idle(); + await manager.synced(); + + return charm; +} + +export function getDefaultRecipe( + 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/runtime.ts b/packages/shell/src/lib/runtime.ts index 03d824113..cd0796695 100644 --- a/packages/shell/src/lib/runtime.ts +++ b/packages/shell/src/lib/runtime.ts @@ -1,6 +1,6 @@ import { ANYONE, Identity, Session } from "@commontools/identity"; import { Runtime } from "@commontools/runner"; -import { charmId, CharmManager } from "@commontools/charm"; +import { charmId, CharmManager, processSchema } from "@commontools/charm"; import { CharmsController } from "@commontools/charm/ops"; import { StorageManager } from "@commontools/runner/storage/cache"; import { API_URL } from "./env.ts"; @@ -72,6 +72,13 @@ export async function createCharmsController( const staticAssetUrl = new URL(API_URL); staticAssetUrl.pathname = "/static"; + + // We're hoisting CharmManager so that + // we can create it after the runtime, but still reference + // its `getSpaceName` method in a runtime callback. + // deno-lint-ignore prefer-const + let charmManager: CharmManager; + const runtime = new Runtime({ storageManager: StorageManager.open({ as: session.as, @@ -97,12 +104,19 @@ export async function createCharmsController( if (!id) { throw new Error(`Could not navigate to cell that is not a charm.`); } - navigateToCharm(target.space, id); + + // NOTE(jake): Eventually, once we're doing multi-space navigation, we will + // need to replace this charmManager.getSpaceName() with a call to some + // sort of address book / dns-style server, OR just navigate to the DID. + + // Use the human-readable space name from CharmManager instead of the DID + navigateToCharm(charmManager.getSpaceName(), id); }, }); console.log("[createCharmsController] Creating CharmManager with session"); - const charmManager = new CharmManager(session, runtime); + charmManager = new CharmManager(session, runtime); + await charmManager.synced(); console.log("[createCharmsController] Creating CharmsController"); return new CharmsController(charmManager); diff --git a/packages/static/assets.ts b/packages/static/assets.ts index 4c399dec4..d7c248964 100644 --- a/packages/static/assets.ts +++ b/packages/static/assets.ts @@ -6,4 +6,5 @@ export const assets: Readonly = [ "types/es2023.d.ts", "types/jsx.d.ts", "types/turndown.d.ts", + "recipes/charm-list.tsx", ]; diff --git a/packages/static/assets/recipes/charm-list.tsx b/packages/static/assets/recipes/charm-list.tsx new file mode 100644 index 000000000..7c7d81513 --- /dev/null +++ b/packages/static/assets/recipes/charm-list.tsx @@ -0,0 +1,82 @@ +import { + h, + derive, + JSONSchema, + NAME, + recipe, + str, + UI, + handler, + navigateTo, +} from "commontools"; + +const CharmsListInputSchema = { + type: "object", + properties: { + allCharms: { + type: "array", + items: {}, + default: [], + }, + }, + required: ["allCharms"], +} as const satisfies JSONSchema; + +const CharmsListOutputSchema = { + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + charm: {}, + }, + required: ["name", "charm"], + }, + }, + }, + required: ["items"], +} as const satisfies JSONSchema; + +const visit = handler<{}, { charm: any }>((_, state) => { + return navigateTo(state.charm); +}); + +export default recipe( + CharmsListInputSchema, + CharmsListOutputSchema, + ({ allCharms }) => { + const charmCount = derive(allCharms, (allCharms) => allCharms.length); + + return { + [NAME]: str`DefaultCharmList (${charmCount})`, + [UI]: ( +
+

+ Charms ({charmCount}) +

+ +
+ {derive(allCharms, (allCharms) => + allCharms.map((charm) => ( +
+ + {charm[NAME] || "Untitled Charm"} + + + Visit + +
+ )), + )} +
+
+ ), + }; + }, +); \ No newline at end of file