diff --git a/.changeset/datatype-conforms-to.md b/.changeset/datatype-conforms-to.md new file mode 100644 index 000000000..e84d9bbbb --- /dev/null +++ b/.changeset/datatype-conforms-to.md @@ -0,0 +1,10 @@ +--- +"@inkandswitch/patchwork-plugins": patch +"@inkandswitch/patchwork-elements": patch +--- + +Datatypes can declare `conformsTo: string[]`, listing the datatype ids they are also a kind of, like a UTI's `UTTypeConformsTo`. A `sketchbox` datatype with `conformsTo: ["folder", "tldraw"]` opens in folder and tldraw tools. + +`getSupportedToolsForType` and `getSupportedTools` now include tools that support any supertype. `getFallbackTool` ranks by how close the match is: a tool naming the doc's own type, then its nearest supertype, then a `*` wildcard, with ties going by id. The new `getSupertypes(type)` returns every registered datatype `type` conforms to, transitively and nearest first, not including `type` itself. + +`` treats a supertype match as a real tool rather than a wildcard stopgap. When a datatype registers with a `conformsTo` that makes an existing tool a closer fit, it swaps that tool in. diff --git a/.changeset/same-package-fallback-swap.md b/.changeset/same-package-fallback-swap.md new file mode 100644 index 000000000..2c9a14624 --- /dev/null +++ b/.changeset/same-package-fallback-swap.md @@ -0,0 +1,5 @@ +--- +"@inkandswitch/patchwork-elements": patch +--- + +Remount a legacy view when its fallback tool changes within the same package. diff --git a/core/elements/src/legacy-impl.ts b/core/elements/src/legacy-impl.ts index a8f7030cd..3d114d487 100644 --- a/core/elements/src/legacy-impl.ts +++ b/core/elements/src/legacy-impl.ts @@ -14,9 +14,11 @@ import { import { getFallbackTool, getRegistry, + getSupertypes, isKeyhiveDoc, isLoadablePlugin, registerPlugins, + type DatatypeDescription, type LoadedTool, type ToolDescription, type ToolElement, @@ -29,8 +31,9 @@ const log = debug("patchwork:elements:legacy"); /** * Whether `tool` supports `type` only through a `*` wildcard rather than by - * naming the datatype explicitly. A wildcard-only match means the tool is a - * generic fallback (e.g. a raw viewer), not a tool built for this datatype. + * naming the datatype or one of its supertypes. A wildcard-only match means the + * tool is a generic fallback (e.g. a raw viewer), not a tool built for this + * datatype. */ function isWildcardOnlyMatch( tool: LoadedTool, @@ -38,7 +41,9 @@ function isWildcardOnlyMatch( ): boolean { const datatypes = tool.supportedDatatypes; const list = datatypes === "*" ? ["*"] : datatypes; - return list.includes("*") && (type === undefined || !list.includes(type)); + if (!list.includes("*")) return false; + if (type === undefined) return true; + return ![type, ...getSupertypes(type)].some((t) => list.includes(t)); } const State = { @@ -406,7 +411,10 @@ export class LegacyImpl { isChosenTool) || (this.#state == "fallback" && isFallbackTool) ) { - if (loadedTool.importUrl !== this.#tool?.importUrl) { + if ( + loadedTool.id !== this.#tool?.id || + loadedTool.importUrl !== this.#tool?.importUrl + ) { await this.#teardown(); void this.#init(); } @@ -415,9 +423,35 @@ export class LegacyImpl { } ); + // Registering the doc's datatype, or one of its supertypes, can bring in a + // `conformsTo` that makes an already-registered tool a closer fit. + const removeDatatypeListener = getRegistry( + "patchwork:datatype" + ).on("registered", (added) => { + if (!this.#handle || this.#toolId) return; + const type = getType(this.#handle.doc()); + if (!type) return; + if (added.id != type && !getSupertypes(type).includes(added.id)) return; + const fallback = getFallbackTool(this.#handle.doc()); + if (!fallback) return; + if (fallback.id == this.#fallbackId && this.#state != State.unable) { + return; + } + this.#fallbackId = fallback.id; + if (!fallback.module) { + // The "loaded" listener mounts it once it's in. + if (isLoadablePlugin(fallback)) toolRegistry.load(fallback.id); + } else if (this.#state == State.unable) { + this.#queueRender(); + } else if (this.#state == State.fallback) { + void this.#teardown().then(() => this.#init()); + } + }); + this.#teardowns.add(() => { removeAddedListener(); removeLoadedListener(); + removeDatatypeListener(); }); const repo = this.#repo; @@ -451,15 +485,16 @@ export class LegacyImpl { }; // A registration can shift the fallback only when we don't have one yet or - // the new tool supports the doc's type (a specific match sorts ahead of a - // wildcard, so a supporting tool can displace the current pick). + // the new tool supports the doc's type or a supertype (a closer match sorts + // ahead, so a supporting tool can displace the current pick). #couldChangeFallback(added: ToolDescription): boolean { if (!this.#fallbackId) return true; const supported = added.supportedDatatypes; if (!supported) return false; if (supported === "*" || supported.includes("*")) return true; const type = this.#handle ? getType(this.#handle.doc()) : undefined; - return type != null && supported.includes(type); + if (type == null) return false; + return [type, ...getSupertypes(type)].some((t) => supported.includes(t)); } // Serialized: overlapping calls (doc-url and tool-id both changing in one diff --git a/core/elements/test/patchwork-view.test.ts b/core/elements/test/patchwork-view.test.ts index 6c246608c..3d036f2cc 100644 --- a/core/elements/test/patchwork-view.test.ts +++ b/core/elements/test/patchwork-view.test.ts @@ -10,6 +10,7 @@ import { const registry = getRegistry("patchwork:component"); const toolRegistry = getRegistry("patchwork:tool"); +const datatypeRegistry = getRegistry("patchwork:datatype"); type Counters = { mounts: number; cleanups: number }; @@ -102,9 +103,16 @@ describe("patchwork-view (component mode)", () => { // tests take it in turns: `afterEach` unregisters, or the previous test's // wildcard would be the one a later fallback picks. const registeredTools: string[] = []; +const registeredDatatypes: string[] = []; function registerWildcardTool(): Counters { - const id = `test-wildcard-${++seq}`; + return registerTool(`test-wildcard-${++seq}`, "*"); +} + +function registerTool( + id: string, + supportedDatatypes: "*" | string[] +): Counters { const counters: Counters = { mounts: 0, cleanups: 0 }; const render = () => { counters.mounts++; @@ -117,7 +125,7 @@ function registerWildcardTool(): Counters { id, type: "patchwork:tool", name: id, - supportedDatatypes: "*", + supportedDatatypes, load: async () => render, module: render, } as never, @@ -127,6 +135,46 @@ function registerWildcardTool(): Counters { return counters; } +function registerLazyTool( + id: string, + supportedDatatypes: "*" | string[], + importUrl: string +): Counters { + const counters: Counters = { mounts: 0, cleanups: 0 }; + const render = () => { + counters.mounts++; + return () => { + counters.cleanups++; + }; + }; + toolRegistry.register( + { + id, + type: "patchwork:tool", + name: id, + supportedDatatypes, + load: async () => render, + } as never, + importUrl + ); + registeredTools.push(id); + return counters; +} + +function registerDatatype(id: string, conformsTo?: string[]) { + datatypeRegistry.register( + { + id, + type: "patchwork:datatype", + name: id, + icon: "", + conformsTo, + } as never, + `test://${id}` + ); + registeredDatatypes.push(id); +} + describe("patchwork-view (legacy mode)", () => { const repo = new Repo({}); @@ -141,6 +189,8 @@ describe("patchwork-view (legacy mode)", () => { afterEach(() => { for (const id of registeredTools) toolRegistry.remove(id); registeredTools.length = 0; + for (const id of registeredDatatypes) datatypeRegistry.remove(id); + registeredDatatypes.length = 0; }); function mount(doc: unknown) { @@ -178,4 +228,78 @@ describe("patchwork-view (legacy mode)", () => { expect(raw.mounts).toBe(1); }); + + it("opens a doc in a tool for its supertype ahead of the wildcard", async () => { + const raw = registerWildcardTool(); + const folder = registerTool(`test-folder-tool-${++seq}`, ["test-folder"]); + registerDatatype("test-sketchbox", ["test-folder", "test-tldraw"]); + + mount({ "@patchwork": { type: "test-sketchbox" } }); + await settle(); + + expect(folder.mounts).toBe(1); + expect(raw.mounts).toBe(0); + }); + + it("breaks ties between equally near supertypes by tool id", async () => { + const tldraw = registerTool(`test-a-tldraw-${++seq}`, ["test-tldraw"]); + const folder = registerTool(`test-z-folder-${++seq}`, ["test-folder"]); + registerDatatype("test-equal-supertype-sketchbox", [ + "test-folder", + "test-tldraw", + ]); + + mount({ "@patchwork": { type: "test-equal-supertype-sketchbox" } }); + await settle(); + + expect(tldraw.mounts).toBe(1); + expect(folder.mounts).toBe(0); + }); + + it("prefers the nearest supertype's tool", async () => { + const far = registerTool(`test-a-far-${++seq}`, ["test-file"]); + const near = registerTool(`test-z-near-${++seq}`, ["test-text"]); + registerDatatype("test-javascript", ["test-text"]); + registerDatatype("test-text", ["test-file"]); + + mount({ "@patchwork": { type: "test-javascript" } }); + await settle(); + + expect(near.mounts).toBe(1); + expect(far.mounts).toBe(0); + }); + + it("swaps the wildcard for a supertype's tool when the datatype registers", async () => { + const raw = registerWildcardTool(); + const folder = registerTool(`test-folder-tool-${++seq}`, ["test-folder"]); + + mount({ "@patchwork": { type: "test-late-sketchbox" } }); + await settle(); + expect(raw.mounts).toBe(1); + + registerDatatype("test-late-sketchbox", ["test-folder"]); + await settle(); + + expect(raw.cleanups).toBe(1); + expect(folder.mounts).toBe(1); + }); + + it("swaps to a lazy supertype tool from the same package", async () => { + const raw = registerWildcardTool(); + const folder = registerLazyTool( + `test-lazy-folder-tool-${++seq}`, + ["test-lazy-folder"], + "test://same-package" + ); + + mount({ "@patchwork": { type: "test-late-lazy-sketchbox" } }); + await settle(); + expect(raw.mounts).toBe(1); + + registerDatatype("test-late-lazy-sketchbox", ["test-lazy-folder"]); + await settle(); + + expect(raw.cleanups).toBe(1); + expect(folder.mounts).toBe(1); + }); }); diff --git a/core/plugins/src/datatypes.ts b/core/plugins/src/datatypes.ts index 7f906c77b..98c0ecc97 100644 --- a/core/plugins/src/datatypes.ts +++ b/core/plugins/src/datatypes.ts @@ -10,6 +10,7 @@ import type { LoadedPlugin, PluginDescription, } from "./registry/types.js"; +import { getRegistry } from "./registry/index.js"; import type { HasPatchworkMetadata } from "@inkandswitch/patchwork-filesystem"; import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive"; import { isKeyhiveDoc } from "./keyhive.js"; @@ -26,6 +27,34 @@ export interface DatatypeDescription extends PluginDescription { type: "patchwork:datatype"; icon: string; unlisted?: boolean; + /** + * The datatype ids this datatype is also a kind of, like a UTI's + * `UTTypeConformsTo`. A doc of this type opens in tools that support any of + * them (transitively). List only the direct parents. Conforming is a promise + * that the doc also has each parent's shape, so the parents' tools can work + * on it. + */ + conformsTo?: string[]; +} + +/** + * Every datatype `type` conforms to through `conformsTo`, transitively and + * nearest first, not including `type` itself. Only registered datatypes + * contribute, so the result can grow as packages register. + */ +export function getSupertypes(type: string): string[] { + const registry = getRegistry("patchwork:datatype"); + // Breadth-first, so nearer supertypes come first; `seen` also stops cycles. + const queue = [type]; + const seen = new Set(queue); + for (let i = 0; i < queue.length; i++) { + for (const parent of registry.get(queue[i])?.conformsTo ?? []) { + if (seen.has(parent)) continue; + seen.add(parent); + queue.push(parent); + } + } + return queue.slice(1); } // Loadable Datatype description using the generic LoadablePlugin diff --git a/core/plugins/src/tools.ts b/core/plugins/src/tools.ts index 12e4dd672..2fe5a01c1 100644 --- a/core/plugins/src/tools.ts +++ b/core/plugins/src/tools.ts @@ -13,6 +13,7 @@ import { type HasPatchworkMetadata, } from "@inkandswitch/patchwork-filesystem"; import { getRegistry } from "./registry/index.js"; +import { getSupertypes, type DatatypeDescription } from "./datatypes.js"; import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive"; @@ -53,14 +54,53 @@ export type Tool = LoadablePlugin< export type LegacyEditorProps = { docUrl: AutomergeUrl }; +function datatypesOf(tool: ToolDescription): string[] { + const datatypes = tool.supportedDatatypes; + if (!datatypes) return []; + return datatypes === "*" ? ["*"] : datatypes; +} + +function datatypeDepths(type: string): Map { + const registry = getRegistry("patchwork:datatype"); + const depths = new Map([[type, 0]]); + const queue = [type]; + for (let i = 0; i < queue.length; i++) { + const current = queue[i]; + for (const parent of registry.get(current)?.conformsTo ?? []) { + if (depths.has(parent)) continue; + depths.set(parent, depths.get(current)! + 1); + queue.push(parent); + } + } + return depths; +} + +/** + * How well `tool` fits a doc: the minimum inheritance depth of a datatype it + * names, the depth after the farthest supertype for a `*` wildcard, or + * `undefined` if it doesn't support the doc. Lower is a closer fit. + */ +function matchRank( + tool: ToolDescription, + depths: Map +): number | undefined { + const datatypes = datatypesOf(tool); + const namedDepths = datatypes + .map((type) => depths.get(type)) + .filter((depth): depth is number => depth !== undefined); + if (namedDepths.length > 0) return Math.min(...namedDepths); + if (datatypes.includes("*")) return depths.size; + return undefined; +} + +/** + * Tools that support `type`: those naming it or one of its supertypes (see + * {@link getSupertypes}), and wildcard tools. + */ export function getSupportedToolsForType(type: string): LoadedTool[] { + const depths = datatypeDepths(type); const plugins = getRegistry("patchwork:tool").filter( - (desc) => { - return ( - desc.supportedDatatypes?.includes(type) || - desc.supportedDatatypes?.includes("*") - ); - } + (desc) => matchRank(desc, depths) !== undefined ); return plugins as LoadedTool[]; @@ -72,61 +112,19 @@ export function getSupportedTools(doc: HasPatchworkMetadata): LoadedTool[] { return getSupportedToolsForType(type); } +/** + * The listed tool that fits `doc` most closely: one naming its type, then one + * naming its nearest supertype, then a wildcard. Ties go by id. + */ export function getFallbackTool(doc: HasPatchworkMetadata) { - const type = getType(doc)!; - const plugins = getSupportedTools(doc); - return sortPlugins( - plugins, - "supportedDatatypes", - type, - "id" - )?.filter((tool) => !tool.unlisted)?.[0]; + const type = getType(doc); + if (!type) return undefined; + const depths = datatypeDepths(type); + return getSupportedToolsForType(type) + .filter((tool) => !tool.unlisted) + .sort( + (a, b) => + matchRank(a, depths)! - matchRank(b, depths)! || + a.id.localeCompare(b.id) + )[0]; } - -const sortPlugins = < - T extends LoadedPlugin, - D extends PluginDescription, - I, ->( - plugins: T[], - matchField: keyof D, - matchValue: string, - sortField?: keyof D -): T[] => { - return [...plugins].sort((a, b) => { - const aValue = a[matchField]; - const bValue = b[matchField]; - - // Convert string values to arrays for consistent comparison - const aArray = Array.isArray(aValue) - ? (aValue as string[]) - : [aValue as string]; - const bArray = Array.isArray(bValue) - ? (bValue as string[]) - : [bValue as string]; - - const aHasWildcard = aArray.includes("*"); - const bHasWildcard = bArray.includes("*"); - const aHasMatch = aArray.includes(matchValue); - const bHasMatch = bArray.includes(matchValue); - - // Specific matches come first - if (aHasMatch && !bHasMatch) return -1; - if (!aHasMatch && bHasMatch) return 1; - - // Then wildcard matches come last - if (aHasWildcard && !bHasWildcard) return 1; - if (!aHasWildcard && bHasWildcard) return -1; - - // If both are wildcards or both are specific matches, sort by the optional sort field - if (sortField) { - const aSort = a[sortField]; - const bSort = b[sortField]; - if (typeof aSort === "string" && typeof bSort === "string") { - return aSort.localeCompare(bSort); - } - } - - return 0; - }); -};