import { actorProfile, blobUrl, completeSignIn, createProject, diffAgainstBase, forkProject, hashTree, listProjects, loadProject, ProjectConflictError, pushToken, resolveActor, resolveRepo, resume, saveProject, signIn, signOut, type Actor, type ActorProfile, type FileTree, type Project, type ProjectRecord, type ProjectRef, type Session, type TangledRepo, } from "@nibling/sdk"; import { editHref, projectName, repoHref } from "../pages/route.ts"; import type { Unsubscribe } from "../shell/workbench.ts"; import { isNotFound, knotOf, loadBase, PlatformError, rerootTree } from "./base.ts"; import { projectFiles, type ProjectFiles } from "./files.ts"; export type Did = Session["did"]; type GitBase = NonNullable; export interface Account { session: Session; handle?: string; } /** An account this browser has signed in to, as the account menu shows it. Its session may have lapsed. */ export interface KnownAccount { readonly did: Did; readonly handle?: string; readonly displayName?: string; readonly avatarUrl?: string; } /** What the editor has open: a saved project, or a tangled repo as it is, which saving makes a project. */ export type OpenProject = SavedProject | RepoDraft; interface OpenFiles { /** The git commit the files overlay, with that commit's tree. */ base?: { ref: GitBase; tree: FileTree }; files: ProjectFiles; } export interface SavedProject extends OpenFiles { readonly kind: "project"; project: Project; /** The author's handle when it resolves, else the DID. The project's addresses use it. */ actor: string; } /** A tangled repo opened by its address. Nothing is written until it is saved, as the saver's project. */ export interface RepoDraft extends OpenFiles { readonly kind: "repo"; /** * `owner` is the handle when it resolves, else the DID, as the repo's address names it. `ref` and * `path` are only the branch and subdirectory the address asked for explicitly, so a bare repo address * stays bare; the branch actually opened (the default when `ref` is unset) is `base.ref.ref`. */ repo: { owner: string; ownerDid: Did; name: string; ref?: string; path?: string }; } /** The open project's name, as the header shows it. */ export const openName = (open: OpenProject) => (open.kind === "project" ? open.project.record.name : open.repo.name); /** The editor's address for what is open: the project's edit address, or the repo's own. */ export const openHref = (open: OpenProject) => open.kind === "project" ? editHref(open.actor, projectName(open.project)) : repoHref(open.repo.owner, open.repo.name, open.repo.ref, open.repo.path); /** What the address names, so opening something else is a navigation and renaming the same one is not. */ export const openKey = (open: OpenProject) => (open.kind === "project" ? open.project.uri : `repo:${open.repo.ownerDid}/${open.repo.name}`); /** Who owns what is open: the project's author, or the repo's owner. */ export const openOwner = (open: OpenProject): Did => (open.kind === "project" ? open.project.did : open.repo.ownerDid); export interface Notice { tone: "info" | "error"; text: string; /** The failure was "no such project," not a network or server error. A surface address hoists on this. */ notFound?: boolean; } export interface PlatformState { /** The current account. */ readonly account?: Account; /** Every account signed in on this browser and not signed out, in the order they first signed in. */ readonly accounts: readonly KnownAccount[]; readonly project?: OpenProject; /** What is running now, such as "Saving". */ readonly busy?: string; readonly notice?: Notice; } /** OAuth, as the SDK does it in a browser. Tests swap in sessions over a fake PDS. */ export interface Auth { signIn(handle: string): Promise; complete(params: URLSearchParams): Promise; resume(did: Did): Promise; signOut(did: Did): Promise; } export type KeyValue = Pick; export interface PlatformOptions { auth?: Auth; /** Remembers who was signed in and what was open. Missing or throwing storage only costs that memory. */ storage?: KeyValue; /** The files a new project starts with. */ starter?: () => FileTree; resolveRepo?: (ref: string) => Promise; /** Mints the service-auth token a push to `knot` sends. */ pushToken?: (session: Session, knot: string) => Promise; } export const sdkAuth: Auth = { signIn, complete: completeSignIn, resume, signOut }; const DID_KEY = "nib.platform.did"; const ACCOUNTS_KEY = "nib.platform.accounts"; /** * The app's platform core (D12): who is signed in, which project is open, and the operations on them. * It has no DOM, so shell commands, the app header, and a future CLI all call these same methods. * Each operation reports failure through `notice` rather than throwing, since commands fire and forget. */ export class Platform { #state: PlatformState = { accounts: [] }; readonly #listeners = new Set<(state: PlatformState) => void>(); readonly #auth: Auth; readonly #storage: KeyValue | undefined; readonly #starter: () => FileTree; readonly #resolveRepo: (ref: string) => Promise; readonly #pushToken: (session: Session, knot: string) => Promise; #unwatch: Unsubscribe | undefined; constructor(options: PlatformOptions = {}) { this.#auth = options.auth ?? sdkAuth; this.#storage = options.storage; this.#starter = options.starter ?? (() => new Map()); this.#resolveRepo = options.resolveRepo ?? resolveRepo; this.#pushToken = options.pushToken ?? pushToken; } get state(): PlatformState { return this.#state; } subscribe(listener: (state: PlatformState) => void): Unsubscribe { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } /** * Resumes the remembered session, then opens the `project` or tangled `repo` the address names. * `ref` and `path` are the branch and subdirectory a tangled URL gave, and only apply with `repo`. */ boot(options: { project?: ProjectRef; repo?: string; ref?: string; path?: string } = {}): Promise { return this.#run("Signing in", async () => { const did = this.#recall(DID_KEY) as Did | undefined; const [session] = await Promise.all([did && this.#auth.resume(did), this.#loadAccounts(did)]); if (session) await this.#signedIn(session); if (options.project) await this.#load(options.project); if (options.repo) await this.#loadRepo(options.repo, { ref: options.ref, path: options.path }); }); } /** Sends the browser to the user's authorization server. */ signIn(handle: string): Promise { return this.#run("Signing in", () => this.#auth.signIn(handle)); } /** Finishes sign-in from the OAuth callback's parameters, adding the account to the ones already signed in. */ completeSignIn(params: URLSearchParams): Promise { return this.#run("Signing in", async () => { const [session] = await Promise.all([this.#auth.complete(params), this.#loadAccounts(this.#recall(DID_KEY) as Did | undefined)]); await this.#signedIn(session); }); } /** * Makes `did` the current account. A stored session that still resumes switches in place; one that * lapsed sends the browser through sign-in for that account. The open project stays open. */ switchAccount(did: Did): Promise { return this.#run("Switching account", async () => { if (this.#state.account?.session.did === did) return; const session = await this.#auth.resume(did); if (session) return this.#signedIn(session); const known = this.#state.accounts.find((account) => account.did === did); await this.#auth.signIn(known?.handle ?? did); }); } /** * Signs `did` out of this browser, the current account when it is undefined. Signing out the current * account switches to the next one whose session resumes; with none left, it closes the project too. */ signOut(did: Did | undefined = this.#state.account?.session.did): Promise { return this.#run("Signing out", async () => { if (!did) return; await this.#auth.signOut(did); const accounts = this.#state.accounts.filter((account) => account.did !== did); this.#rememberAccounts(accounts); this.#set({ accounts }); if (this.#state.account?.session.did !== did) return; this.#remember(DID_KEY, undefined); for (const next of accounts) { const session = await this.#auth.resume(next.did); if (session) return this.#signedIn(session); } this.#set({ account: undefined }); this.#setProject(undefined); }); } /** The signed-in account's projects, newest first, for an open picker. */ async projects(): Promise { const account = this.#state.account; return account ? listProjects(account.session.did) : []; } newProject(name: string): Promise { return this.#run("Creating project", async () => { const { session } = this.#account(); const files = this.#starter(); const project = await createProject(session, { name, files }); this.#setProject({ kind: "project", project, actor: this.#ownName(), files: projectFiles(files) }); }); } /** Opens an AT-URI, or a project by its author and its slug or record key. */ openProject(ref: ProjectRef): Promise { return this.#run("Opening project", () => this.#load(ref)); } saveProject(): Promise { return this.#run("Saving", async () => { const { session } = this.#account(); const open = this.#current(); if (open.kind === "repo") return this.#saveDraft(session, open); if (open.project.did !== session.did) throw new PlatformError("This project belongs to someone else. Fork it to save your changes."); const snapshot = open.files.snapshot(); try { const project = await saveProject(session, open.project, snapshot, open.base ? { base: open.base.tree } : {}); open.files.markSaved(snapshot); this.#set({ project: { ...open, project }, notice: { tone: "info", text: "Saved" } }); } catch (error) { if (!(error instanceof ProjectConflictError)) throw error; throw new PlatformError("Someone else saved this project after you opened it. Fork it to keep your edits; reopening discards them."); } }); } /** Copies the open project into the signed-in repo and keeps editing the copy, unsaved edits included. */ forkProject(): Promise { return this.#run("Forking", async () => { const { session } = this.#account(); const open = this.#current(); if (open.kind === "repo") return this.#saveDraft(session, open); const project = await forkProject(session, open.project.uri); this.#setProject({ ...open, project, actor: this.#ownName() }); }); } /** * Opens `handle/name` from tangled as it is. Saving it is what makes it a project. `ref` is the branch * to open (the repo's default when unset), and `path` scopes the project to that subdirectory of it. * `reuse` is a `RepoTree.reuse` token from a prior `listTree` of the same ref (the import picker's own * directory browse): with it, the fetch this needs is already done. */ openRepo(ref: string, options: { ref?: string; path?: string; reuse?: unknown } = {}): Promise { return this.#run("Opening repo", () => this.#loadRepo(ref, options)); } /** Pushes the open project's changes as one commit on its base branch, then saves the project on the new commit. */ pushToTangled(message: string): Promise { return this.#run("Pushing", async () => { const account = this.#account(); const open = this.#current(); if (openOwner(open) !== account.session.did) throw new PlatformError(open.kind === "repo" ? "This repo belongs to someone else. Save it as your project to keep your changes." : "This project belongs to someone else. Fork it to push your changes."); if (!open.base) throw new PlatformError("This project has no tangled repo to push to. Import one first."); const { ref: base, tree } = open.base; const knot = await knotOf(base.repo); const snapshot = open.files.snapshot(); const overlay = diffAgainstBase(await hashTree(tree), await hashTree(snapshot)); if (overlay.size === 0) throw new PlatformError("Nothing to push: the files match the base commit."); // `tree` and `snapshot` are re-rooted at `base.path` when the project is a subdirectory; map their // paths back under it, so the push only ever touches that subtree and leaves the rest of the repo alone. const prefix = base.path ? `${base.path}/` : ""; const changed = new Map(); const removed: string[] = []; for (const [path, cid] of overlay) { if (cid === null) removed.push(prefix + path); else changed.set(prefix + path, snapshot.get(path)!); } const { pushChanges } = await import("@nibling/sdk/git"); const { commit, ref } = await pushChanges({ repo: { cloneUrl: `${knot}/${base.repo}` }, base: { commit: base.commit, ref: base.ref ?? "main" }, files: changed, removed, message, author: { name: account.handle ?? account.session.did, email: `${account.handle ?? "unknown"}@users.invalid` }, token: () => this.#pushToken(account.session, knot), }); const next: GitBase = { ...base, commit, ref }; const pushed = `Pushed ${commit.slice(0, 7)} to ${ref}`; if (open.kind === "repo") { // A repo opened as it is has no record to move onto the new commit. open.files.markSaved(snapshot); this.#set({ project: { ...open, base: { ref: next, tree: snapshot } }, notice: { tone: "info", text: pushed } }); return; } const rebased = { ...open.project, record: { ...open.project.record, base: next } }; try { const project = await saveProject(account.session, rebased, snapshot, { base: snapshot }); open.files.markSaved(snapshot); this.#set({ project: { ...open, project, base: { ref: next, tree: snapshot } }, notice: { tone: "info", text: pushed }, }); } catch { // The commit landed on the knot even though saving the record failed. Keep the pushed commit as // the in-memory base (not just the one on the PDS) so the next push or load does not also fail; // a retry Save writes it for real once the transient failure clears. this.#set({ project: { ...open, project: rebased, base: { ref: next, tree: snapshot } }, notice: { tone: "error", text: `Pushed ${commit.slice(0, 7)} to ${ref}, but saving the project failed; Save again.` }, }); } }); } async #load(ref: ProjectRef) { const { project, files, base } = await loadProject(ref, { loadBase }); const actor = await resolveActor(project.did).then((a) => a.handle ?? a.did, () => project.did); const gitBase = project.record.base; this.#setProject({ kind: "project", project, actor, files: projectFiles(files), ...(gitBase && base ? { base: { ref: gitBase, tree: base } } : {}) }); } async #loadRepo(ref: string, options: { ref?: string; path?: string; reuse?: unknown } = {}) { const repo = await this.#resolveRepo(ref); const { importRepo } = await import("@nibling/sdk/git"); const [imported, owner] = await Promise.all([ importRepo(repo, { ...(options.ref && { ref: options.ref }), ...(options.reuse !== undefined && { reuse: options.reuse }) }), resolveActor(repo.owner).then((a) => a.handle ?? a.did, () => repo.owner), ]); const files = options.path ? rerootTree(imported.files, options.path) : imported.files; const base = repo.repoDid && { ref: { repo: repo.repoDid, ref: imported.ref, commit: imported.commit, ...(options.path ? { path: options.path } : {}) }, tree: files }; // A scoped path needs a ref segment to sit under in tangled's own shape, so a bare path still shows // the branch it resolved to; a bare repo address (no ref, no path) stays bare. const shownRef = options.ref ?? (options.path ? imported.ref : undefined); this.#setProject({ kind: "repo", repo: { owner, ownerDid: repo.owner, name: repo.name, ...(shownRef ? { ref: shownRef } : {}), ...(options.path ? { path: options.path } : {}) }, files: projectFiles(files), ...(base ? { base } : {}), }); } /** Saves an opened repo as the signed-in account's project, its files overlaying the repo's commit. */ async #saveDraft(session: Session, open: RepoDraft) { const snapshot = open.files.snapshot(); const project = await createProject(session, { name: open.repo.name, files: snapshot, ...(open.base ? { base: { ref: open.base.ref, files: open.base.tree } } : {}), }); open.files.markSaved(snapshot); this.#setProject({ kind: "project", project, actor: this.#ownName(), files: open.files, ...(open.base ? { base: open.base } : {}) }); this.#set({ notice: { tone: "info", text: "Saved" } }); } /** The signed-in account as a project address names it. */ #ownName(): string { const { session, handle } = this.#account(); return handle ?? session.did; } async #signedIn(session: Session) { this.#remember(DID_KEY, session.did); const known = await describe(session.did); const listed = this.#state.accounts.some((account) => account.did === session.did); const accounts = listed ? this.#state.accounts.map((account) => (account.did === session.did ? known : account)) : [...this.#state.accounts, known]; this.#rememberAccounts(accounts); this.#set({ account: { session, ...(known.handle && { handle: known.handle }) }, accounts }); } /** Fills `accounts` from storage on a fresh page, keeping `current` in the list. */ async #loadAccounts(current: Did | undefined) { if (this.#state.accounts.length > 0) return; const dids = this.#recallAccounts(); if (current && !dids.includes(current)) dids.unshift(current); this.#set({ accounts: await Promise.all(dids.map(describe)) }); } #recallAccounts(): Did[] { try { const parsed: unknown = JSON.parse(this.#recall(ACCOUNTS_KEY) ?? "[]"); return Array.isArray(parsed) ? parsed.filter((did): did is Did => typeof did === "string" && did.startsWith("did:")) : []; } catch { return []; } } #rememberAccounts(accounts: readonly KnownAccount[]) { this.#remember(ACCOUNTS_KEY, accounts.length ? JSON.stringify(accounts.map((account) => account.did)) : undefined); } #setProject(open: OpenProject | undefined) { this.#unwatch?.(); // A write changes what is dirty, so the header repaints on each one. this.#unwatch = open?.files.watch(() => this.#emit()); this.#set({ project: open }); } #account(): Account { if (!this.#state.account) throw new PlatformError("Sign in first."); return this.#state.account; } #current(): OpenProject { if (!this.#state.project) throw new PlatformError("Open a project first."); return this.#state.project; } /** * Runs one platform operation at a time. A call made while another is in flight is a no-op: the bar * already shows `busy`, and a queued retry would just fire the same request again later with no * guarantee the first one still makes sense (Mod+S twice should not mean "save, then save the saved * result"). Dropping it is simpler than queuing and is the behavior Mod+S twice should have anyway. */ async #run(task: string, work: () => Promise): Promise { if (this.#state.busy) return; this.#set({ busy: task, notice: undefined }); try { await work(); } catch (error) { this.#set({ notice: { tone: "error", text: error instanceof Error ? error.message : String(error), ...(isNotFound(error) ? { notFound: true } : {}) } }); } finally { this.#set({ busy: undefined }); } } #set(patch: Partial) { const next = { ...this.#state, ...patch }; for (const key of Object.keys(next) as (keyof PlatformState)[]) if (next[key] === undefined) delete next[key]; this.#state = next; this.#emit(); } #emit() { for (const listener of this.#listeners) listener(this.#state); } #recall(key: string): string | undefined { try { return this.#storage?.getItem(key) ?? undefined; } catch { return undefined; } } /** Writes `value`, or removes the key when it is undefined. */ #remember(key: string, value: string | undefined) { try { if (value === undefined) this.#storage?.removeItem(key); else this.#storage?.setItem(key, value); } catch { // Storage only saves signing in again after a reload. } } } /** Who `did` is: handle, display name, and avatar. Whatever fails to load is left out. */ async function describe(did: Did): Promise { try { const { actor, profile } = await actorProfile(did); const avatarUrl = avatarUrlOf(actor, profile); return { did, ...(actor.handle && { handle: actor.handle }), ...(profile.displayName && { displayName: profile.displayName }), ...(avatarUrl && { avatarUrl }), }; } catch { const handle = await resolveActor(did).then((actor) => actor.handle, () => undefined); return { did, ...(handle && { handle }) }; } } /** A URL for a profile's avatar blob, on the gateway or the account's PDS. */ export function avatarUrlOf(actor: Pick, profile: Pick): string | undefined { const avatar = profile.avatar && "ref" in profile.avatar ? profile.avatar.ref.$link : undefined; return avatar && blobUrl(actor, avatar); }