Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Runtime schema validation #17

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"@codemirror/language": "^6.9.1",
"@codemirror/language-data": "^6.3.1",
"@codemirror/view": "^6.21.3",
"@effect/schema": "^0.57.0",
"@lezer/highlight": "^1.1.6",
"@microlink/react-json-view": "^1.23.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
Expand All @@ -46,14 +48,18 @@
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"codemirror": "^6.0.1",
"effect": "^2.0.0",
"eventemitter3": "^5.0.1",
"fast-check": "^3.15.0",
"lodash": "^4.17.21",
"lorem-ipsum": "^2.0.8",
"lucide-react": "^0.284.0",
"mime-types": "^2.1.35",
"openai": "^4.11.0",
"react": "^18.2.0",
"react-arborist": "^3.3.1",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.12",
"react-usestateref": "^1.0.8",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
Expand Down
79 changes: 55 additions & 24 deletions src/DocExplorer/components/DocExplorer.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
import { AutomergeUrl, isValidAutomergeUrl } from "@automerge/automerge-repo";
import {
AutomergeUrl,
Repo,
isValidAutomergeUrl,
} from "@automerge/automerge-repo";
import React, { useCallback, useEffect, useState } from "react";
import { TinyEssayEditor } from "../../tee/components/TinyEssayEditor";
import { useDocument, useRepo } from "@automerge/automerge-repo-react-hooks";
import { init } from "../../tee/datatype";
import {
useDocument,
useHandle,
useRepo,
} from "@automerge/automerge-repo-react-hooks";
import { Essay, EssayDoc } from "@/tee/schemas/Essay";
import { Button } from "@/components/ui/button";
import { MarkdownDoc } from "@/tee/schema";
import { getTitle } from "@/tee/datatype";

import {
DocType,
FolderDoc,
useCurrentAccount,
useCurrentAccountDoc,
useCurrentRootFolderDoc,
} from "../account";

import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
import { LoadingScreen } from "./LoadingScreen";
import { LoadingScreen } from "../../automerge-repo-schema-utils/LoadingScreen";
import { ChangeFn } from "@automerge/automerge";
import { getTitle } from "@/tee/schemas/Essay";
import { withDocument } from "@/automerge-repo-schema-utils/LoadDocument";

export const DocExplorer: React.FC = () => {
const repo = useRepo();
Expand All @@ -25,8 +36,13 @@ export const DocExplorer: React.FC = () => {

const [showSidebar, setShowSidebar] = useState(true);

const { selectedDoc, selectDoc, selectedDocUrl, openDocFromUrl } =
useSelectedDoc({ rootFolderDoc, changeRootFolderDoc });
const {
selectedDoc,
selectedEssay,
selectedDocUrl,
selectDoc,
openDocFromUrl,
} = useSelectedDoc({ rootFolderDoc, changeRootFolderDoc, repo });

const selectedDocName = rootFolderDoc?.docs.find(
(doc) => doc.url === selectedDocUrl
Expand All @@ -38,8 +54,7 @@ export const DocExplorer: React.FC = () => {
throw new Error("Only essays are supported right now");
}

const newDocHandle = repo.create<MarkdownDoc>();
newDocHandle.change(init);
const newEssay = Essay.create(repo);

if (!rootFolderDoc) {
return;
Expand All @@ -49,11 +64,11 @@ export const DocExplorer: React.FC = () => {
doc.docs.unshift({
type: "essay",
name: "Untitled document",
url: newDocHandle.url,
url: newEssay.handle.url,
})
);

selectDoc(newDocHandle.url);
selectDoc(newEssay.handle.url);
},
[changeRootFolderDoc, repo, rootFolderDoc, selectDoc]
);
Expand All @@ -64,14 +79,16 @@ export const DocExplorer: React.FC = () => {
return;
}

const title = getTitle(selectedDoc.content);

changeRootFolderDoc((doc) => {
const existingDocLink = doc.docs.find(
(link) => link.url === selectedDocUrl
);
if (existingDocLink && existingDocLink.name !== title) {
existingDocLink.name = title;
if (
existingDocLink &&
selectedEssay &&
existingDocLink.name !== selectedEssay.title
) {
existingDocLink.name = selectedEssay.title;
}
});
}, [
Expand All @@ -80,6 +97,7 @@ export const DocExplorer: React.FC = () => {
changeAccountDoc,
rootFolderDoc,
changeRootFolderDoc,
selectedEssay,
]);

// update tab title to be the selected doc
Expand Down Expand Up @@ -180,11 +198,8 @@ export const DocExplorer: React.FC = () => {
</div>
)}

{/* NOTE: we set the URL as the component key, to force re-mount on URL change.
If we want more continuity we could not do this. */}
{selectedDocUrl && selectedDoc && (
<TinyEssayEditor docUrl={selectedDocUrl} key={selectedDocUrl} />
)}
{selectedDocUrl &&
withDocument(TinyEssayEditor, selectedDocUrl, Essay)}
</div>
</div>
</div>
Expand All @@ -195,9 +210,20 @@ export const DocExplorer: React.FC = () => {
// Drive the currently selected doc using the URL hash
// (We encapsulate the selection state in a hook so that the only
// API for changing the selection is properly thru the URL)
const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => {
const useSelectedDoc = ({
rootFolderDoc,
changeRootFolderDoc,
repo,
}: {
rootFolderDoc: FolderDoc;
changeRootFolderDoc: ChangeFn<(doc: FolderDoc) => void>;
repo: Repo;
}) => {
const [selectedDocUrl, setSelectedDocUrl] = useState<AutomergeUrl>(null);
const [selectedDoc] = useDocument<MarkdownDoc>(selectedDocUrl);
const selectedDocHandle = useHandle<EssayDoc>(selectedDocUrl);
const [selectedDoc] = useDocument<EssayDoc>(selectedDocUrl);

const selectedEssay = selectedDoc ? new Essay(selectedDocHandle, repo) : null;

const selectDoc = (docUrl: AutomergeUrl | null) => {
if (docUrl) {
Expand Down Expand Up @@ -226,7 +252,7 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => {

setSelectedDocUrl(docUrl);
},
[rootFolderDoc, changeRootFolderDoc, selectDoc]
[rootFolderDoc, changeRootFolderDoc]
);

// observe the URL hash to change the selected document
Expand All @@ -240,6 +266,9 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => {
return;
}
openDocFromUrl(docUrl);

// @ts-expect-error - adding property to window
window.handle = repo.find(docUrl);
}
};

Expand All @@ -257,6 +286,8 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => {
return {
selectedDocUrl,
selectedDoc,
selectedEssay,
selectedDocHandle,
selectDoc,
openDocFromUrl,
};
Expand Down
75 changes: 48 additions & 27 deletions src/DocExplorer/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@ import {
useHandle,
useRepo,
} from "@automerge/automerge-repo-react-hooks";
import { asMarkdownFile, markCopy } from "../../tee/datatype";
import { SyncIndicatorWrapper } from "./SyncIndicator";
import { AccountPicker } from "./AccountPicker";
import { MarkdownDoc } from "@/tee/schema";
import { getTitle } from "@/tee/datatype";
import { saveFile } from "../utils";
import { DocLink, useCurrentRootFolderDoc } from "../account";

Expand All @@ -31,6 +28,11 @@ import {
} from "@/components/ui/dropdown-menu";

import { save } from "@automerge/automerge";
import { parseSync } from "@effect/schema/Parser";
import { EssayV1ToHasTitleV1 } from "@/tee/schemas/transforms";

import { extension } from "mime-types";
import { Essay, EssayDoc } from "@/tee/schemas/Essay";

type TopbarProps = {
showSidebar: boolean;
Expand All @@ -52,21 +54,11 @@ export const Topbar: React.FC<TopbarProps> = ({
const selectedDocName = rootFolderDoc?.docs.find(
(doc) => doc.url === selectedDocUrl
)?.name;
const selectedDocHandle = useHandle<MarkdownDoc>(selectedDocUrl);
const selectedDocHandle = useHandle<any>(selectedDocUrl);
const [selectedDoc] = useDocument<any>(selectedDocUrl);

// GL 12/13: here we assume this is a TEE Markdown doc, but in future should be more generic.
const [selectedDoc] = useDocument<MarkdownDoc>(selectedDocUrl);

const exportAsMarkdown = useCallback(() => {
const file = asMarkdownFile(selectedDoc);
saveFile(file, "index.md", [
{
accept: {
"text/markdown": [".md"],
},
},
]);
}, [selectedDoc]);
// todo: do this creation in the hook itself, one time only
const essay = new Essay(selectedDocHandle, repo);

const downloadAsAutomerge = useCallback(() => {
const file = new Blob([save(selectedDoc)], {
Expand Down Expand Up @@ -124,13 +116,11 @@ export const Topbar: React.FC<TopbarProps> = ({
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const newHandle = repo.clone<MarkdownDoc>(selectedDocHandle);
newHandle.change((doc) => {
markCopy(doc);
});
const newEssay = essay.clone();
const newDocLink: DocLink = {
url: newHandle.url,
name: getTitle(newHandle.docSync().content),
url: newEssay.handle.url,
// TODO: generalize this to other doc types besides essays
name: newEssay.title,
type: "essay",
};

Expand All @@ -151,10 +141,41 @@ export const Topbar: React.FC<TopbarProps> = ({
Make a copy
</DropdownMenuItem>

<DropdownMenuItem onClick={() => exportAsMarkdown()}>
<Download size={14} className="inline-block text-gray-500 mr-2" />{" "}
Export as Markdown
</DropdownMenuItem>
{Object.entries(essay.fileExports).map(([fileType, getFile]) => (
<DropdownMenuItem
key={fileType}
onClick={() => {
const file = getFile();
const title = essay.title;
const safeTitle = title
.replace(/[^a-z0-9]/gi, "_")
.toLowerCase();
const fileExtension = extension(file.type);

if (!fileExtension) {
throw new Error(
`No file extension found for file type ${file.type}`
);
}

// TODO: generalize this logic more from markdown to others
saveFile(file, `${safeTitle}.${fileExtension}`, [
{
accept: {
"text/markdown": [`.${fileExtension}`],
},
},
]);
}}
>
<Download
size={14}
className="inline-block text-gray-500 mr-2"
/>{" "}
Export as {fileType}
</DropdownMenuItem>
))}

<DropdownMenuItem onClick={() => downloadAsAutomerge()}>
<SaveIcon size={14} className="inline-block text-gray-500 mr-2" />{" "}
Download Automerge file
Expand Down
83 changes: 83 additions & 0 deletions src/automerge-repo-schema-utils/LoadDocument.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// A wrapper component that loads data, used like this:

import { AutomergeUrl } from "@automerge/automerge-repo";
import { Schema as S } from "@effect/schema";
import { useTypedDocument } from "./useTypedDocument";
import { LoadingScreen } from "./LoadingScreen";
import { LoadDocumentChildProps } from "./utils";
import { formatErrors } from "@effect/schema/TreeFormatter";
import { RawView } from "./RawView";
import { Essay } from "@/tee/schemas/Essay";

// <LoadDocument docUrl={docUrl} schema={schema}>
// {({doc, changeDoc, handle}) =>
// <div doc={doc} changeDoc={changeDoc} handle={handle}>...</div>}
// </LoadDocument>

export const LoadDocument: React.FC<{
docUrl: AutomergeUrl;
schema: S.Schema<any, any>;
children: (props: {
doc: any;
changeDoc: any;
handle: any;
}) => React.ReactNode;
}> = ({ docUrl, schema, children }) => {
const result = useTypedDocument(docUrl, schema); // used to trigger re-rendering when the doc loads

if (result._tag === "loading") {
return <LoadingScreen docUrl={docUrl} handle={result.handle} />;
}

if (result._tag === "error") {
return (
<div className="p-4 h-full">
<div className="mb-4 bg-red-100 p-4 rounded-sm">
<div className="mb-4">
Error: The loaded document does not conform to the expected schema.
</div>
<pre className=" font-mono font-bold text-sm">
{formatErrors(result.error.errors)}
</pre>
</div>
<div className="mb-4 text-sm text-gray-700">
You can try to repair the error manually:
</div>
<div className="bg-white p-4">
<RawView documentUrl={docUrl} />
</div>
</div>
);
}

return (
<div>
{children({
doc: result.doc,
changeDoc: result.changeDoc,
handle: result.handle,
})}
</div>
);
};

// A higher-order component that makes it more concise to use the wrapper. Use like this:
// <div>
// {withDocument(MyChildComponent, docUrl, schema)}
// </div>

export const withDocument = <T extends typeof Essay>(
Component: React.FC<LoadDocumentChildProps<any>>,
docUrl: AutomergeUrl,
model: T
) => {
return (
// It's important to set the key to the doc URL here because that forces
// the component to remount when the URL changes, which is what we want.
<LoadDocument docUrl={docUrl} schema={model.schema} key={docUrl}>
{({ doc, changeDoc, handle }) => (
<Component {...{ doc, changeDoc, handle }} />
)}
</LoadDocument>
);
};
Loading