Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/datatype-conforms-to.md
Original file line number Diff line number Diff line change
@@ -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.

`<patchwork-view>` 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.
41 changes: 35 additions & 6 deletions core/elements/src/legacy-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import {
import {
getFallbackTool,
getRegistry,
getSupertypes,
isKeyhiveDoc,
isLoadablePlugin,
registerPlugins,
type DatatypeDescription,
type LoadedTool,
type ToolDescription,
type ToolElement,
Expand All @@ -29,16 +31,19 @@ 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,
type: string | undefined
): 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 = {
Expand Down Expand Up @@ -415,9 +420,32 @@ 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<DatatypeDescription>(
"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 || fallback.id == this.#fallbackId) return;
this.#fallbackId = fallback.id;
if (!fallback.module) {
// The "loaded" listener mounts it once it's in.
if (isLoadablePlugin(fallback)) toolRegistry.load(fallback.id);
Comment on lines +434 to +437
} 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;
Expand Down Expand Up @@ -451,15 +479,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
Expand Down
65 changes: 63 additions & 2 deletions core/elements/test/patchwork-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {

const registry = getRegistry<ComponentDescription>("patchwork:component");
const toolRegistry = getRegistry("patchwork:tool");
const datatypeRegistry = getRegistry("patchwork:datatype");

type Counters = { mounts: number; cleanups: number };

Expand Down Expand Up @@ -102,9 +103,13 @@ 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++;
Expand All @@ -117,7 +122,7 @@ function registerWildcardTool(): Counters {
id,
type: "patchwork:tool",
name: id,
supportedDatatypes: "*",
supportedDatatypes,
load: async () => render,
module: render,
} as never,
Expand All @@ -127,6 +132,20 @@ function registerWildcardTool(): Counters {
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({});

Expand All @@ -141,6 +160,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) {
Expand Down Expand Up @@ -178,4 +199,44 @@ 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("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);
});
});
29 changes: 29 additions & 0 deletions core/plugins/src/datatypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<DatatypeDescription>("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
Expand Down
102 changes: 40 additions & 62 deletions core/plugins/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type HasPatchworkMetadata,
} from "@inkandswitch/patchwork-filesystem";
import { getRegistry } from "./registry/index.js";
import { getSupertypes } from "./datatypes.js";

import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";

Expand Down Expand Up @@ -53,14 +54,34 @@ export type Tool<T = unknown> = LoadablePlugin<

export type LegacyEditorProps = { docUrl: AutomergeUrl };

function datatypesOf(tool: ToolDescription): string[] {
const datatypes = tool.supportedDatatypes;
if (!datatypes) return [];
return datatypes === "*" ? ["*"] : datatypes;
}

/**
* How well `tool` fits a doc whose type and supertypes are `types` (as
* `[type, ...getSupertypes(type)]`): the index of the nearest one it names,
* `types.length` if it only matches through a `*` wildcard, or `undefined` if
* it doesn't support the doc at all. Lower is a closer fit.
*/
function matchRank(tool: ToolDescription, types: string[]): number | undefined {
const datatypes = datatypesOf(tool);
const index = types.findIndex((type) => datatypes.includes(type));
if (index >= 0) return index;
Comment on lines +71 to +72
if (datatypes.includes("*")) return types.length;
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 types = [type, ...getSupertypes(type)];
const plugins = getRegistry<ToolDescription>("patchwork:tool").filter(
(desc) => {
return (
desc.supportedDatatypes?.includes(type) ||
desc.supportedDatatypes?.includes("*")
);
}
(desc) => matchRank(desc, types) !== undefined
);

return plugins as LoadedTool[];
Expand All @@ -72,61 +93,18 @@ 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<LoadedTool, ToolDescription, ToolImplementation>(
plugins,
"supportedDatatypes",
type,
"id"
)?.filter((tool) => !tool.unlisted)?.[0];
const type = getType(doc);
if (!type) return undefined;
const types = [type, ...getSupertypes(type)];
return getSupportedToolsForType(type)
.filter((tool) => !tool.unlisted)
.sort(
(a, b) =>
matchRank(a, types)! - matchRank(b, types)! || a.id.localeCompare(b.id)
)[0];
}

const sortPlugins = <
T extends LoadedPlugin<D, I>,
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;
});
};
Loading