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