From 81365663fbeba82eebf6fd300da41d8790424e49 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 10:25:49 -0500 Subject: [PATCH 01/15] initial draft of menubar --- package.json | 1 + src/components/App.tsx | 2 +- src/components/Navbar.tsx | 400 +++++++++++++++++++------------ src/components/SyncIndicator.tsx | 215 ++++++++--------- src/components/ui/menubar.tsx | 234 ++++++++++++++++++ yarn.lock | 76 ++++++ 6 files changed, 658 insertions(+), 270 deletions(-) create mode 100644 src/components/ui/menubar.tsx diff --git a/package.json b/package.json index e062f6fa..14ce0d12 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", diff --git a/src/components/App.tsx b/src/components/App.tsx index 7a4ce083..6368ace7 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -57,7 +57,7 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { /> -
+
- - -
- {title} -
- -
- - - - - - - {/* */} - - {sessionUser ? initials(sessionUser.name) : } - - - - - - Edit profile - - Log in as existing user, or sign up with your name - - -
-
- - - - - - - - - No user found. - - {users.map((user) => ( - { - if (user.id !== sessionUser?.id) { - setTentativeUser({ - _type: "existing", - id: user.id, - }); - } - setNamePickerOpen(false); - }} - > - - {user.name} - - ))} - - - - -
-
or
-
- - +
+ + + + {" "} + + + alert("not implemented yet")}> + About this OS + + + alert("not implemented yet")}> + System Settings + + + alert("not implemented yet")}> + Log out + + + + + + Tiny Essay Editor + + + alert("not implemented yet")}> + About Tiny Essay Editor + + + alert("not implemented yet")}> + Settings + + + + + File + + {/* TODO: this approach to making a new doc doesn't work for TEE/TR */} + window.open("/", "_blank")}> + New + + { + const automergeURLToOpen = prompt("Automerge URL:"); + if ( + automergeURLToOpen === null || + automergeURLToOpen === "" + ) { + return; } - onFocus={() => { - setTentativeUser({ _type: "new", name: "" }); - setNamePickerOpen(false); - }} - onChange={(e) => { - setTentativeUser({ - _type: "new", - name: e.target.value, - }); - }} - > -
-
- - - + + + + + No user found. + + {users.map((user) => ( + { + if (user.id !== sessionUser?.id) { + setTentativeUser({ + _type: "existing", + id: user.id, + }); + } + setNamePickerOpen(false); + }} + > + + {user.name} + + ))} + + + + +
+
or
+
+ + - Save changes - - - - -
+ onFocus={() => { + setTentativeUser({ _type: "new", name: "" }); + setNamePickerOpen(false); + }} + onChange={(e) => { + setTentativeUser({ + _type: "new", + name: e.target.value, + }); + }} + > +
+
+ + + + + + + +
+ {/*
+ + +
+ {title} +
+
*/} ); }; diff --git a/src/components/SyncIndicator.tsx b/src/components/SyncIndicator.tsx index 2689a78c..8a07d793 100644 --- a/src/components/SyncIndicator.tsx +++ b/src/components/SyncIndicator.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/popover"; import { getRelativeTimeString } from "@/utils"; import { useRef } from "react"; +import { MenubarContent, MenubarMenu, MenubarTrigger } from "./ui/menubar"; // if we are connected to the sync server and have outstanding changes we should switch to the error mode if we haven't received a sync message in a while // this variable specifies this timeout duration @@ -35,135 +36,64 @@ export const SyncIndicator = ({ handle }: { handle: DocHandle }) => { timestamp: onlineState.timestamp, isActive: onlineState.isOnline, }); - const [isHovered, setIsHovered] = useState(false); - const handleMouseEnter = () => { - setIsHovered(true); - }; - - const handleMouseLeave = () => { - setIsHovered(false); - }; + let icon =
; + let details =
; if (onlineState.isOnline) { if ( (isConnectedToServer || !hasInitialConnectionWaitTimedOut) && (isSynced || !hasSyncTimedOut) ) { - return ( - - -
- -
-
- -
-
-
Connection:
-
Connected to server
-
-
-
Last synced:
-
- {getRelativeTimeString(lastSyncUpdate)} -
-
-
-
Sync status:
-
- {isSynced ? "Up to date" : "Syncing..."} -
-
-
-
-
+ icon = ( +
+ +
); - } else { - return ( - - -
- - {!isSynced &&
*
} -
Sync Error
+ details = ( +
+
+
+
Connection:
+
Connected to server
- - -
- There was an unexpected error connecting to the sync server. Don't - worry, your changes are saved locally. Please try reloading and - see if that fixes the issue. +
+
Last synced:
+
+ {getRelativeTimeString(lastSyncUpdate)} +
+
+
+
Sync status:
+
+ {isSynced ? "Up to date" : "Syncing..."} +
-
-
-
Connection:
-
Connection error
-
-
-
Last synced:
-
- {getRelativeTimeString(lastSyncUpdate)} -
-
-
-
Sync status:
-
- {isSynced ? ( - "No unsynced changes" - ) : ( - Unsynced changes (*) - )} -
-
-
- - +
+
); - } - } else { - return ( - - -
- - {!isSynced && ( -
*
- )} + } else { + icon = ( +
+ + {!isSynced &&
*
} +
Sync Error
+
+ ); + details = ( +
+
+ There was an unexpected error connecting to the sync server. Don't + worry, your changes are saved locally. Please try reloading and see + if that fixes the issue.
- -
Connection:
-
Offline
+
Connection error
Last synced:
@@ -177,18 +107,63 @@ export const SyncIndicator = ({ handle }: { handle: DocHandle }) => { {isSynced ? ( "No unsynced changes" ) : ( - - You have unsynced changes. They are saved locally and will - sync next time you have internet and you open the app. - + Unsynced changes (*) )}
-
- +
+ ); + } + } else { + icon = ( +
+ + {!isSynced && ( +
*
+ )} +
+ ); + details = ( +
+
+
+
Connection:
+
Offline
+
+
+
Last synced:
+
{getRelativeTimeString(lastSyncUpdate)}
+
+
+
Sync status:
+
+ {isSynced ? ( + "No unsynced changes" + ) : ( + + You have unsynced changes. They are saved locally and will + sync next time you have internet and you open the app. + + )} +
+
+
+
); } + + return ( + + {icon} + +
{details}
+
+
+ ); }; const SYNC_SERVER_PREFIX = "storage-server-"; diff --git a/src/components/ui/menubar.tsx b/src/components/ui/menubar.tsx new file mode 100644 index 00000000..d11c2993 --- /dev/null +++ b/src/components/ui/menubar.tsx @@ -0,0 +1,234 @@ +import * as React from "react" +import * as MenubarPrimitive from "@radix-ui/react-menubar" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const MenubarMenu = MenubarPrimitive.Menu + +const MenubarGroup = MenubarPrimitive.Group + +const MenubarPortal = MenubarPrimitive.Portal + +const MenubarSub = MenubarPrimitive.Sub + +const MenubarRadioGroup = MenubarPrimitive.RadioGroup + +const Menubar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Menubar.displayName = MenubarPrimitive.Root.displayName + +const MenubarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName + +const MenubarSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName + +const MenubarSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName + +const MenubarContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref + ) => ( + + + + ) +) +MenubarContent.displayName = MenubarPrimitive.Content.displayName + +const MenubarItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +MenubarItem.displayName = MenubarPrimitive.Item.displayName + +const MenubarCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName + +const MenubarRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName + +const MenubarLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +MenubarLabel.displayName = MenubarPrimitive.Label.displayName + +const MenubarSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName + +const MenubarShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +MenubarShortcut.displayname = "MenubarShortcut" + +export { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, +} diff --git a/yarn.lock b/yarn.lock index 5f28805f..2d38953f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1030,6 +1030,17 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-collection@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159" + integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-compose-refs@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz#37595b1f16ec7f228d698590e78eeed18ff218ae" @@ -1100,6 +1111,13 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.5" +"@radix-ui/react-direction@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" + integrity sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-dismissable-layer@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.0.tgz#35b7826fa262fd84370faef310e627161dffa76b" @@ -1182,6 +1200,48 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-menu@2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.0.6.tgz#2c9e093c1a5d5daa87304b2a2f884e32288ae79e" + integrity sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-callback-ref" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + +"@radix-ui/react-menubar@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menubar/-/react-menubar-1.0.4.tgz#7d46ababfec63db3868d9ed79366686634c1201a" + integrity sha512-bHgUo9gayKZfaQcWSSLr++LyS0rgh+MvD89DE4fJ6TkGHvjHgPaBZf44hdka7ogOxIOdj9163J+5xL2Dn4qzzg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-menu" "2.0.6" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-popover@^1.0.7": version "1.0.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c" @@ -1280,6 +1340,22 @@ "@radix-ui/react-context" "1.0.1" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-roving-focus@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974" + integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-slot@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.0.tgz#7fa805b99891dea1e862d8f8fbe07f4d6d0fd698" From 32a810a6e18b6aa5eae18c2d1087cf64ae8e2891 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 10:41:51 -0500 Subject: [PATCH 02/15] empty fork button --- src/components/Navbar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ba3ca595..46d645a4 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -204,6 +204,8 @@ export const Navbar = ({ > Open + Fork + { navigator.clipboard.writeText(handle.url); @@ -214,7 +216,6 @@ export const Navbar = ({ Download ⌘ S - alert("Not implemented yet.")} From 3293b10046947531cfcfcf2b6583815f6917fa7b Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 10:47:04 -0500 Subject: [PATCH 03/15] basic fork button --- src/components/Navbar.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 46d645a4..1608c224 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -43,6 +43,7 @@ import { getTitle, saveFile } from "../utils"; import { uuid } from "@automerge/automerge"; import { DocHandle } from "@automerge/automerge-repo"; import { SyncIndicator } from "./SyncIndicator"; +import { useRepo } from "@automerge/automerge-repo-react-hooks"; const initials = (name: string) => { return name @@ -77,6 +78,7 @@ export const Navbar = ({ session: LocalSession; setSession: (session: LocalSession) => void; }) => { + const repo = useRepo(); const [namePickerOpen, setNamePickerOpen] = useState(false); const [tentativeUser, setTentativeUser] = useState({ _type: "unknown", @@ -138,6 +140,18 @@ export const Navbar = ({ document.title = title; }, [title]); + // fork and open clone in new tab + const forkDoc = useCallback(() => { + const clone = repo.clone(handle); + const cloneUrl = `${window.location.origin}/#${clone.url}`; + + // GL 12/6/23: If we don't wait before opening the clone, it's not ready yet sometimes. + // Figure out why we need this timeout and how to get rid of it! + setTimeout(() => { + window.open(cloneUrl, "_blank"); + }, 500); + }, [repo, handle]); + if (!doc) { return <>; } @@ -204,7 +218,7 @@ export const Navbar = ({ > Open - Fork + forkDoc()}>Fork { From dc1b08db3c69dd974b7a2dfa50d58e129f67e02f Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 11:17:15 -0500 Subject: [PATCH 04/15] very basic schema validation system --- package.json | 2 ++ src/components/App.tsx | 5 ++-- src/schema.ts | 64 ++++++++++++++++++++++++++-------------- src/useTypedDocument.ts | 65 +++++++++++++++++++++++++++++++++++++++++ yarn.lock | 10 +++++++ 5 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 src/useTypedDocument.ts diff --git a/package.json b/package.json index 14ce0d12..c9305a48 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@codemirror/language": "^6.9.1", "@codemirror/language-data": "^6.3.1", "@codemirror/view": "^6.21.3", + "@effect/schema": "^0.52.0", "@lezer/highlight": "^1.1.6", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", @@ -40,6 +41,7 @@ "clsx": "^2.0.0", "cmdk": "^0.2.0", "codemirror": "^6.0.1", + "effect": "^2.0.0-next.59", "lodash": "^4.17.21", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.284.0", diff --git a/src/components/App.tsx b/src/components/App.tsx index 6368ace7..0ea53394 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,5 +1,5 @@ import { AutomergeUrl } from "@automerge/automerge-repo"; -import { useDocument, useHandle } from "@automerge/automerge-repo-react-hooks"; +import { useHandle } from "@automerge/automerge-repo-react-hooks"; import { MarkdownEditor, TextSelection } from "./MarkdownEditor"; import { LocalSession, MarkdownDoc } from "../schema"; @@ -10,9 +10,10 @@ import { useEffect, useState } from "react"; import { EditorView } from "@codemirror/view"; import { CommentsSidebar } from "./CommentsSidebar"; import { useThreadsWithPositions } from "../utils"; +import { useTypedDocument } from "@/useTypedDocument"; function App({ docUrl }: { docUrl: AutomergeUrl }) { - const [doc, changeDoc] = useDocument(docUrl); // used to trigger re-rendering when the doc loads + const [doc, changeDoc] = useTypedDocument(docUrl, MarkdownDoc); // used to trigger re-rendering when the doc loads const handle = useHandle(docUrl); const [session, setSessionInMemory] = useState(); const [selection, setSelection] = useState(); diff --git a/src/schema.ts b/src/schema.ts index 1d0266a5..ea1a6b47 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,17 +1,24 @@ -export type Comment = { - id: string; - content: string; - userId: string | null; - timestamp: number; -}; +import { Doc, Heads, Patch } from "@automerge/automerge"; +import { Schema as S } from "@effect/schema"; -export type CommentThread = { - id: string; - comments: Comment[]; - resolved: boolean; - fromCursor: string; // Automerge cursor - toCursor: string; // Automerge cursor -}; +const Comment = S.struct({ + id: S.string, + content: S.string, + userId: S.nullable(S.string), + timestamp: S.number, +}); + +export type Comment = S.Schema.To; + +const CommentThread = S.struct({ + id: S.string, + comments: S.array(Comment), + resolved: S.boolean, + fromCursor: S.string, // Automerge cursor + toCursor: S.string, // Automerge cursor +}); + +export type CommentThread = S.Schema.To; export type CommentThreadForUI = CommentThread & { from: number; @@ -21,17 +28,30 @@ export type CommentThreadForUI = CommentThread & { export type CommentThreadWithPosition = CommentThreadForUI & { yCoord: number }; -export type User = { - id: string; - name: string; -}; +const User = S.struct({ + id: S.string, + name: S.DateFromString, +}); -export type MarkdownDoc = { - content: string; - commentThreads: { [key: string]: CommentThread }; - users: User[]; -}; +export type User = S.Schema.To; + +export const parseUser = S.parseSync(User); export type LocalSession = { userId: string | null; }; + +export const MarkdownDoc = S.struct({ + content: S.string, + commentThreads: S.readonlyMap(S.string, CommentThread), + users: S.array(User), +}); + +export type MarkdownDoc = S.Schema.To; + +export type Snapshot = { + heads: Heads; + doc: Doc; + previous: Snapshot | null; + diffFromPrevious: Patch[]; +}; diff --git a/src/useTypedDocument.ts b/src/useTypedDocument.ts new file mode 100644 index 00000000..28266f8e --- /dev/null +++ b/src/useTypedDocument.ts @@ -0,0 +1,65 @@ +import { ChangeFn, ChangeOptions, Doc } from "@automerge/automerge/next"; +import { + AutomergeUrl, + DocHandleChangePayload, +} from "@automerge/automerge-repo"; +import { useEffect, useState } from "react"; +import { useRepo } from "@automerge/automerge-repo-react-hooks"; +import { Schema as S } from "@effect/schema"; +import { isLeft, isRight } from "effect/Either"; + +// An experimental version of the automerge-repo useDocument hook +// which has stronger schema validation powered by @effect/schema + +export function useTypedDocument>( + documentUrl: AutomergeUrl | null, + schema: T +): [ + Doc> | undefined, + (changeFn: ChangeFn>) => void +] { + type ResultType = S.Schema.To; + + const [doc, setDoc] = useState>(); + const repo = useRepo(); + + const handle = documentUrl ? repo.find(documentUrl) : null; + + const parseSchema = S.parseEither(schema); + + useEffect(() => { + if (!handle) return; + + handle.doc().then((v) => { + const parseResult = parseSchema(v); + // GL 12/6/23: in the future we should actually do something more with this error. + // For now I'm just logging it to the console for awareness of how parsing works. + // There seems to be something wrong with how I'm handling maps. + if (isLeft(parseResult)) { + console.error( + "WARNING: document loaded from repo does not match schema" + ); + console.error(String(parseResult.left)); + } + setDoc(v); + }); + + const onChange = (h: DocHandleChangePayload) => setDoc(h.doc); + handle.on("change", onChange); + const cleanup = () => { + handle.removeListener("change", onChange); + }; + + return cleanup; + }, [handle, parseSchema]); + + const changeDoc = ( + changeFn: ChangeFn, + options?: ChangeOptions | undefined + ) => { + if (!handle) return; + handle.change(changeFn, options); + }; + + return [doc, changeDoc]; +} diff --git a/yarn.lock b/yarn.lock index 2d38953f..0922dde5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -627,6 +627,11 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@effect/schema@^0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@effect/schema/-/schema-0.52.0.tgz#5dc612e895c9f691f379a15679b6dffc0c1d087c" + integrity sha512-x6SmSdoL6PeZVAaK895NoRkKF8D/w+XyO8i17cUsQYFJBHNyUTi9Y1H2wrO8TkxhTmC93ejburpM+35/OKCi2Q== + "@esbuild/android-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" @@ -2179,6 +2184,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +effect@^2.0.0-next.59: + version "2.0.0-next.59" + resolved "https://registry.yarnpkg.com/effect/-/effect-2.0.0-next.59.tgz#312bb6c70a8adf29b857414e42af118faf26fcab" + integrity sha512-EE87vFl0/zIN5lKDtFccU3YCnbPqjxg9rY72obNN65/GE4JOJsXciyX8XC4pIDr3lE6KeJ0le8IXf+A7d92ntQ== + electron-to-chromium@^1.4.535: version "1.4.557" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.557.tgz#f3941b569c82b7bb909411855c6ff9bfe1507829" From 3acf42861e62f0731b24f5f95aea6e250b3d402e Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 11:20:21 -0500 Subject: [PATCH 05/15] tweak schema error --- src/schema.ts | 1 + src/useTypedDocument.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/schema.ts b/src/schema.ts index ea1a6b47..2e7c9988 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -45,6 +45,7 @@ export const MarkdownDoc = S.struct({ content: S.string, commentThreads: S.readonlyMap(S.string, CommentThread), users: S.array(User), + forkedFrom: S.nullable(S.string), }); export type MarkdownDoc = S.Schema.To; diff --git a/src/useTypedDocument.ts b/src/useTypedDocument.ts index 28266f8e..c6613715 100644 --- a/src/useTypedDocument.ts +++ b/src/useTypedDocument.ts @@ -39,6 +39,7 @@ export function useTypedDocument>( console.error( "WARNING: document loaded from repo does not match schema" ); + console.error(v); console.error(String(parseResult.left)); } setDoc(v); From 086a51a92a5e6d35841b23a59d5647bd02c02529 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 11:29:34 -0500 Subject: [PATCH 06/15] tweak schema stuff --- src/init.ts | 6 +++++- src/schema.ts | 15 +++++++++++++-- src/useTypedDocument.ts | 6 +++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/init.ts b/src/init.ts index 007df9b5..06fd4fee 100644 --- a/src/init.ts +++ b/src/init.ts @@ -11,7 +11,7 @@ const LAB_USERS = [ "Alex Good", "Orion Henry", "Mary Rose Cook", -].sort() +].sort(); export function init(doc: any) { doc.content = "# Untitled\n\n"; @@ -22,4 +22,8 @@ export function init(doc: any) { const user = { id: idStr, name }; doc.users.push(user); } + doc.forkMetadata = { + forkedFrom: null, + knownForks: [], + }; } diff --git a/src/schema.ts b/src/schema.ts index 2e7c9988..1d591df5 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -43,9 +43,20 @@ export type LocalSession = { export const MarkdownDoc = S.struct({ content: S.string, - commentThreads: S.readonlyMap(S.string, CommentThread), + + // GL 12/6/23: For some reason the schema parser doesn't like it + // when I use this correctly narrower type, + // for now I'm working around by using a too-wide type. + // commentThreads: S.readonlyMap(S.string, CommentThread), + commentThreads: S.object, + users: S.array(User), - forkedFrom: S.nullable(S.string), + + // GL 12/6/23: We should move this out to a generic forkable trait... + forkMetadata: S.struct({ + forkedFrom: S.nullable(S.string), + knownForks: S.array(S.string), + }), }); export type MarkdownDoc = S.Schema.To; diff --git a/src/useTypedDocument.ts b/src/useTypedDocument.ts index c6613715..420c6867 100644 --- a/src/useTypedDocument.ts +++ b/src/useTypedDocument.ts @@ -32,9 +32,9 @@ export function useTypedDocument>( handle.doc().then((v) => { const parseResult = parseSchema(v); - // GL 12/6/23: in the future we should actually do something more with this error. - // For now I'm just logging it to the console for awareness of how parsing works. - // There seems to be something wrong with how I'm handling maps. + // GL 12/6/23: + // For now I'm just logging parse errors to the console for awareness. + // In the future: surface this error, and play with transforms. if (isLeft(parseResult)) { console.error( "WARNING: document loaded from repo does not match schema" From a68cd87ec59c1ee2e3234f274119da38e570033a Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 11:34:08 -0500 Subject: [PATCH 07/15] write forkedFrom --- src/components/Navbar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 1608c224..530934e0 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { LocalSession, MarkdownDoc, User } from "../schema"; +import { LocalSession, MarkdownDoc, MutableMarkdownDoc, User } from "../schema"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { ChangeFn, save } from "@automerge/automerge/next"; import { Check, ChevronsUpDown, User as UserIcon } from "lucide-react"; @@ -143,6 +143,10 @@ export const Navbar = ({ // fork and open clone in new tab const forkDoc = useCallback(() => { const clone = repo.clone(handle); + clone.change((doc) => { + // @ts-expect-error need to figure out how to make the type mutable in change blocks + doc.forkMetadata.forkedFrom = handle.url; + }); const cloneUrl = `${window.location.origin}/#${clone.url}`; // GL 12/6/23: If we don't wait before opening the clone, it's not ready yet sometimes. From 0284e4fbf9153cf7b7831703b1760caa97491b82 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 11:58:00 -0500 Subject: [PATCH 08/15] compute a basic diff --- src/components/App.tsx | 9 ++++++--- src/components/Navbar.tsx | 7 +++++-- src/init.ts | 2 +- src/schema.ts | 19 ++++++++++++++++++- src/useTypedDocument.ts | 5 +++-- src/utils.ts | 35 ++++++++++++++++++++++++++++++++--- 6 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 0ea53394..dffd599e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,5 +1,5 @@ -import { AutomergeUrl } from "@automerge/automerge-repo"; -import { useHandle } from "@automerge/automerge-repo-react-hooks"; +import { AutomergeUrl, isValidAutomergeUrl } from "@automerge/automerge-repo"; +import { useHandle, useRepo } from "@automerge/automerge-repo-react-hooks"; import { MarkdownEditor, TextSelection } from "./MarkdownEditor"; import { LocalSession, MarkdownDoc } from "../schema"; @@ -9,7 +9,7 @@ import { useEffect, useState } from "react"; import { EditorView } from "@codemirror/view"; import { CommentsSidebar } from "./CommentsSidebar"; -import { useThreadsWithPositions } from "../utils"; +import { computeDiffForFork, useThreadsWithPositions } from "../utils"; import { useTypedDocument } from "@/useTypedDocument"; function App({ docUrl }: { docUrl: AutomergeUrl }) { @@ -42,6 +42,9 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { activeThreadId, }); + const patches = computeDiffForFork(doc); + console.log(patches); + if (!doc || !session) { return ; } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 530934e0..83dc650a 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { LocalSession, MarkdownDoc, MutableMarkdownDoc, User } from "../schema"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { ChangeFn, save } from "@automerge/automerge/next"; +import { ChangeFn, getHeads, save } from "@automerge/automerge/next"; import { Check, ChevronsUpDown, User as UserIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -145,7 +145,10 @@ export const Navbar = ({ const clone = repo.clone(handle); clone.change((doc) => { // @ts-expect-error need to figure out how to make the type mutable in change blocks - doc.forkMetadata.forkedFrom = handle.url; + doc.forkMetadata.parent = { + url: handle.url, + forkedAtHeads: getHeads(doc), + }; }); const cloneUrl = `${window.location.origin}/#${clone.url}`; diff --git a/src/init.ts b/src/init.ts index 06fd4fee..de11af61 100644 --- a/src/init.ts +++ b/src/init.ts @@ -23,7 +23,7 @@ export function init(doc: any) { doc.users.push(user); } doc.forkMetadata = { - forkedFrom: null, + parent: null, knownForks: [], }; } diff --git a/src/schema.ts b/src/schema.ts index 1d591df5..018f9b46 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -54,13 +54,30 @@ export const MarkdownDoc = S.struct({ // GL 12/6/23: We should move this out to a generic forkable trait... forkMetadata: S.struct({ - forkedFrom: S.nullable(S.string), + parent: S.nullable( + S.struct({ + url: S.string, + forkedAtHeads: S.array(S.string), + }) + ), knownForks: S.array(S.string), }), }); export type MarkdownDoc = S.Schema.To; +export type DeepMutable = { + -readonly [K in keyof T]: T[K] extends (infer R)[] + ? DeepMutable[] + : T[K] extends ReadonlyArray + ? DeepMutable[] + : T[K] extends object + ? DeepMutable + : T[K]; +}; + +export type MutableMarkdownDoc = DeepMutable; + export type Snapshot = { heads: Heads; doc: Doc; diff --git a/src/useTypedDocument.ts b/src/useTypedDocument.ts index 420c6867..5ebb3b15 100644 --- a/src/useTypedDocument.ts +++ b/src/useTypedDocument.ts @@ -33,8 +33,9 @@ export function useTypedDocument>( handle.doc().then((v) => { const parseResult = parseSchema(v); // GL 12/6/23: - // For now I'm just logging parse errors to the console for awareness. - // In the future: surface this error, and play with transforms. + // TODO: Need to think a lot more about what to do with errors here. + // Should we crash the app and prevent it from loading? + // Could use effect schema transforms to do a basic cambria thing. if (isLeft(parseResult)) { console.error( "WARNING: document loaded from repo does not match schema" diff --git a/src/utils.ts b/src/utils.ts index 553142c0..3eea8d12 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,9 +4,15 @@ import { MarkdownDoc, } from "./schema"; import { EditorView } from "@codemirror/view"; -import { next as A } from "@automerge/automerge"; +import { + diff, + getHeads, + getCursorPosition, + Patch, +} from "@automerge/automerge/next"; import { ReactElement, useEffect, useMemo, useState } from "react"; import ReactDOMServer from "react-dom/server"; +import { isValidAutomergeUrl } from "@automerge/automerge-repo"; // taken from https://www.builder.io/blog/relative-time /** @@ -82,8 +88,8 @@ export const getThreadsForUI = ( let from = 0; let to = 0; try { - from = A.getCursorPosition(doc, ["content"], thread.fromCursor); - to = A.getCursorPosition(doc, ["content"], thread.toCursor); + from = getCursorPosition(doc, ["content"], thread.fromCursor); + to = getCursorPosition(doc, ["content"], thread.toCursor); } catch (e) { if (e instanceof RangeError) { // If the cursor isn't found in the content string, hide the comment. @@ -368,3 +374,26 @@ export const useThreadsWithPositions = ({ return threadsWithPositions; }; + +export const computeDiffForFork = (doc: MarkdownDoc): Patch[] => { + if (!doc || !doc.forkMetadata || !doc.forkMetadata.parent) { + return []; + } + if (!isValidAutomergeUrl(doc.forkMetadata.parent.url)) { + console.error("forkedFrom URL is invalid"); + return; + } + + // GL 12/6/23: if we wanted to show diff against *latest* parent, + // we'd need to do a speculative rebase here, + // but for now we're just diffing against the point at which we forked. + + // The diff from the ForkedFrom to the rebased doc is what we want to show. + const oldHeads = doc.forkMetadata.parent.forkedAtHeads; + const newHeads = getHeads(doc); + const patches = diff(doc, oldHeads, newHeads).filter( + (p) => p.path[0] === "content" + ); + + return patches; +}; From 61bdb861de6ecca3578f0ecc994d1c5119874a60 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 12:01:56 -0500 Subject: [PATCH 09/15] cleanup --- src/components/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index dffd599e..021f663d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,5 +1,5 @@ -import { AutomergeUrl, isValidAutomergeUrl } from "@automerge/automerge-repo"; -import { useHandle, useRepo } from "@automerge/automerge-repo-react-hooks"; +import { AutomergeUrl } from "@automerge/automerge-repo"; +import { useHandle } from "@automerge/automerge-repo-react-hooks"; import { MarkdownEditor, TextSelection } from "./MarkdownEditor"; import { LocalSession, MarkdownDoc } from "../schema"; From 3314894ec622e55fbfeabd305a015f5fcff1fb9c Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 12:23:48 -0500 Subject: [PATCH 10/15] show the diff in the editor --- src/components/App.tsx | 20 ++++-- src/components/MarkdownEditor.tsx | 104 +++++++++++++++++++++++++++++- src/components/Navbar.tsx | 16 ++++- src/utils.ts | 23 ------- 4 files changed, 131 insertions(+), 32 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 021f663d..4f94ae9d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -5,12 +5,13 @@ import { MarkdownEditor, TextSelection } from "./MarkdownEditor"; import { LocalSession, MarkdownDoc } from "../schema"; import { Navbar } from "./Navbar"; import { LoadingScreen } from "./LoadingScreen"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { EditorView } from "@codemirror/view"; import { CommentsSidebar } from "./CommentsSidebar"; -import { computeDiffForFork, useThreadsWithPositions } from "../utils"; +import { useThreadsWithPositions } from "../utils"; import { useTypedDocument } from "@/useTypedDocument"; +import { getHeads } from "@automerge/automerge/next"; function App({ docUrl }: { docUrl: AutomergeUrl }) { const [doc, changeDoc] = useTypedDocument(docUrl, MarkdownDoc); // used to trigger re-rendering when the doc loads @@ -19,6 +20,7 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { const [selection, setSelection] = useState(); const [activeThreadId, setActiveThreadId] = useState(); const [view, setView] = useState(); + const [showDiff, setShowDiff] = useState(false); const localStorageKey = `LocalSession-${docUrl}`; @@ -42,8 +44,14 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { activeThreadId, }); - const patches = computeDiffForFork(doc); - console.log(patches); + const diffHeads = useMemo(() => { + if (!doc) return []; + if (doc?.forkMetadata?.parent && showDiff) { + return [...doc.forkMetadata.parent.forkedAtHeads]; + } else { + return getHeads(doc); + } + }, [doc, showDiff]); if (!doc || !session) { return ; @@ -58,6 +66,8 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { changeDoc={changeDoc} session={session} setSession={setSession} + showDiff={showDiff} + setShowDiff={setShowDiff} />
@@ -65,6 +75,8 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) {
; + diffHeads: Heads; path: Prop[]; setSelection: (selection: TextSelection) => void; setView: (view: EditorView) => void; @@ -105,6 +112,81 @@ const threadDecorations = EditorView.decorations.compute( } ); +// Stuff for patches decoration + +const setPatchesEffect = StateEffect.define(); +const patchesField = StateField.define({ + create() { + return []; + }, + update(patches, tr) { + for (const e of tr.effects) { + if (e.is(setPatchesEffect)) { + return e.value; + } + } + return patches; + }, +}); + +class DeletionMarker extends WidgetType { + constructor() { + super(); + } + + toDOM(): HTMLElement { + const box = document.createElement("div"); + box.style.display = "inline-block"; + box.style.color = "#ff5353"; + box.style.fontSize = "0.8em"; + box.style.marginTop = "0.3em"; + box.style.verticalAlign = "top"; + box.innerText = "⌫"; + return box; + } + + eq() { + // todo: i think this is right for now until we show hover of del text etc + return true; + } + + ignoreEvent() { + return true; + } +} + +const spliceDecoration = Decoration.mark({ class: "cm-patch-splice" }); +const deleteDecoration = Decoration.widget({ + widget: new DeletionMarker(), + side: 1, +}); + +const patchDecorations = EditorView.decorations.compute( + [patchesField], + (state) => { + const patches = state + .field(patchesField) + .filter((patch) => patch.path[0] === "content"); + + const decorations = patches.flatMap((patch) => { + switch (patch.action) { + case "splice": { + const from = patch.path[1]; + const length = patch.value.length; + return [spliceDecoration.range(from, from + length)]; + } + case "del": { + const from = patch.path[1]; + return [deleteDecoration.range(from)]; + } + } + return []; + }); + + return Decoration.set(decorations); + } +); + const theme = EditorView.theme({ "&": {}, "&.cm-editor.cm-focused": { @@ -136,6 +218,9 @@ const theme = EditorView.theme({ ".cm-comment-thread": { backgroundColor: "rgb(255 249 194)", }, + ".cm-patch-splice": { + backgroundColor: "rgb(0 255 0 / 20%)", + }, ".cm-comment-thread.active": { backgroundColor: "rgb(255 227 135)", }, @@ -256,6 +341,7 @@ export function MarkdownEditor({ setView, setActiveThreadId, threadsWithPositions, + diffHeads, }: EditorProps) { const containerRef = useRef(null); const editorRoot = useRef(null); @@ -268,6 +354,18 @@ export function MarkdownEditor({ }); }, [threadsWithPositions]); + const doc = handle.docSync(); + const view = editorRoot.current; + + // // Propagate patches into the codemirror + useEffect(() => { + const doc = handle.docSync(); + const patches = diff(doc, diffHeads, getHeads(doc)); + editorRoot.current?.dispatch({ + effects: setPatchesEffect.of(patches), + }); + }, [handle, doc, diffHeads, view]); + useEffect(() => { const doc = handle.docSync(); const source = doc.content; // this should use path @@ -308,6 +406,8 @@ export function MarkdownEditor({ frontmatterPlugin, threadsField, threadDecorations, + patchesField, + patchDecorations, previewFiguresPlugin, highlightKeywordsPlugin, tableOfContentsPreviewPlugin, diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 83dc650a..54d5f44e 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { LocalSession, MarkdownDoc, MutableMarkdownDoc, User } from "../schema"; +import { LocalSession, MarkdownDoc, User } from "../schema"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { ChangeFn, getHeads, save } from "@automerge/automerge/next"; import { Check, ChevronsUpDown, User as UserIcon } from "lucide-react"; @@ -29,6 +29,7 @@ import { } from "@/components/ui/dialog"; import { Menubar, + MenubarCheckboxItem, MenubarContent, MenubarItem, MenubarMenu, @@ -38,7 +39,7 @@ import { } from "@/components/ui/menubar"; import { Label } from "@/components/ui/label"; -import { useCallback, useEffect, useState } from "react"; +import { SetStateAction, useCallback, useEffect, useState } from "react"; import { getTitle, saveFile } from "../utils"; import { uuid } from "@automerge/automerge"; import { DocHandle } from "@automerge/automerge-repo"; @@ -71,12 +72,16 @@ export const Navbar = ({ changeDoc, session, setSession, + showDiff, + setShowDiff, }: { handle: DocHandle; doc: MarkdownDoc; changeDoc: (changeFn: ChangeFn) => void; session: LocalSession; setSession: (session: LocalSession) => void; + showDiff: boolean; + setShowDiff: React.Dispatch>; }) => { const repo = useRepo(); const [namePickerOpen, setNamePickerOpen] = useState(false); @@ -248,7 +253,12 @@ export const Navbar = ({ View - Show Changes + setShowDiff((prev) => !prev)} + > + Show changes + diff --git a/src/utils.ts b/src/utils.ts index 3eea8d12..a8c92d81 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -374,26 +374,3 @@ export const useThreadsWithPositions = ({ return threadsWithPositions; }; - -export const computeDiffForFork = (doc: MarkdownDoc): Patch[] => { - if (!doc || !doc.forkMetadata || !doc.forkMetadata.parent) { - return []; - } - if (!isValidAutomergeUrl(doc.forkMetadata.parent.url)) { - console.error("forkedFrom URL is invalid"); - return; - } - - // GL 12/6/23: if we wanted to show diff against *latest* parent, - // we'd need to do a speculative rebase here, - // but for now we're just diffing against the point at which we forked. - - // The diff from the ForkedFrom to the rebased doc is what we want to show. - const oldHeads = doc.forkMetadata.parent.forkedAtHeads; - const newHeads = getHeads(doc); - const patches = diff(doc, oldHeads, newHeads).filter( - (p) => p.path[0] === "content" - ); - - return patches; -}; From 71fe3b8ad9795723a1de5831bdbd490fb1c87a34 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 12:27:21 -0500 Subject: [PATCH 11/15] a basic way to go to the parent --- src/components/Navbar.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 54d5f44e..edf6ee77 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -230,7 +230,19 @@ export const Navbar = ({ > Open + forkDoc()}>Fork + + doc?.forkMetadata?.parent?.url && + window.open( + `${document.location.origin}${document.location.pathname}#${doc.forkMetadata.parent.url}` + ) + } + > + Go to parent + { From 6a0aabcd7e0ae92e90e157cf619238b9246dd97c Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 12:47:13 -0500 Subject: [PATCH 12/15] add merge button --- src/components/Navbar.tsx | 84 ++++++++++++++++++++++++++++++++++----- src/index.ts | 5 +++ src/main.tsx | 8 ++++ 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index edf6ee77..8e021f4e 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -35,6 +35,9 @@ import { MenubarMenu, MenubarSeparator, MenubarShortcut, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, MenubarTrigger, } from "@/components/ui/menubar"; @@ -42,7 +45,7 @@ import { Label } from "@/components/ui/label"; import { SetStateAction, useCallback, useEffect, useState } from "react"; import { getTitle, saveFile } from "../utils"; import { uuid } from "@automerge/automerge"; -import { DocHandle } from "@automerge/automerge-repo"; +import { DocHandle, isValidAutomergeUrl } from "@automerge/automerge-repo"; import { SyncIndicator } from "./SyncIndicator"; import { useRepo } from "@automerge/automerge-repo-react-hooks"; @@ -155,15 +158,46 @@ export const Navbar = ({ forkedAtHeads: getHeads(doc), }; }); - const cloneUrl = `${window.location.origin}/#${clone.url}`; // GL 12/6/23: If we don't wait before opening the clone, it's not ready yet sometimes. // Figure out why we need this timeout and how to get rid of it! setTimeout(() => { - window.open(cloneUrl, "_blank"); + window.openDocumentInNewTab(clone.url); }, 500); }, [repo, handle]); + const goToParent = () => { + if (!doc?.forkMetadata?.parent) return; + + // @ts-expect-error using window global + window.openDocumentInNewTab(doc.forkMetadata.parent.url); + }; + + const shareToParent = () => { + const parentUrl = doc?.forkMetadata?.parent?.url; + if (!parentUrl || !isValidAutomergeUrl(parentUrl)) { + return; + } + const parent = repo.find(parentUrl); + parent.change((doc) => { + if (!doc.forkMetadata.knownForks.includes(handle.url)) { + // @ts-expect-error need to figure out how to make the type mutable in change blocks + doc.forkMetadata.knownForks.push(handle.url); + } + }); + }; + + const mergeToParent = () => { + const parentUrl = doc?.forkMetadata?.parent?.url; + if (!parentUrl || !isValidAutomergeUrl(parentUrl)) { + return; + } + const parent = repo.find(parentUrl); + parent.merge(handle); + }; + + const knownForks = doc?.forkMetadata?.knownForks ?? []; + if (!doc) { return <>; } @@ -225,24 +259,54 @@ export const Navbar = ({ return; } const newUrl = `${document.location.origin}${document.location.pathname}#${automergeURLToOpen}`; - window.open(newUrl, "_blank"); + // @ts-expect-error window global + window.openDocumentInNewTab(newUrl); }} > Open forkDoc()}>Fork + + Go to fork + + {knownForks.length === 0 && ( + No known forks + )} + {knownForks.map((forkUrl) => ( + window.openDocumentInNewTab(forkUrl)} + > + {forkUrl} + + ))} + + - doc?.forkMetadata?.parent?.url && - window.open( - `${document.location.origin}${document.location.pathname}#${doc.forkMetadata.parent.url}` - ) - } + onClick={() => goToParent()} > Go to parent + { + shareToParent(); + goToParent(); + }} + > + Share fork to parent + + { + mergeToParent(); + goToParent(); + }} + > + Merge to parent + { diff --git a/src/index.ts b/src/index.ts index 337e8911..13274663 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,8 @@ window.logoImageUrl = new URL( "./assets/logo-favicon-310x310-transparent.png", import.meta.url ).href; + +// @ts-expect-error - set a window global +window.openDocumentInNewTab = (docUrl) => { + alert("Not implemented yet"); +}; diff --git a/src/main.tsx b/src/main.tsx index a60482e6..eb8f712e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -40,4 +40,12 @@ window.handle = handle; // we'll use this later for experimentation // @ts-expect-error - adding property to window window.logoImageUrl = "/assets/logo-favicon-310x310-transparent.png"; +// @ts-expect-error - set a window global +window.openDocumentInNewTab = (docUrl) => { + window.open( + `${document.location.origin}${document.location.pathname}#${docUrl}`, + "_blank" + ); +}; + mount(document.getElementById("root"), { docUrl }); From 71b987303fba6bf9611955bc0217bbd3525b81ec Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 18:18:40 -0500 Subject: [PATCH 13/15] fix ts errors --- src/components/App.tsx | 3 ++- src/components/Navbar.tsx | 11 ++++------- src/main.tsx | 13 ++++++++++--- src/schema.ts | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 4f94ae9d..204ec3b0 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -24,6 +24,8 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { const localStorageKey = `LocalSession-${docUrl}`; + console.log(doc); + useEffect(() => { const session = localStorage.getItem(localStorageKey); if (session) { @@ -75,7 +77,6 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) {
>; }) => { + console.log("navbar"); const repo = useRepo(); const [namePickerOpen, setNamePickerOpen] = useState(false); const [tentativeUser, setTentativeUser] = useState({ @@ -159,11 +160,8 @@ export const Navbar = ({ }; }); - // GL 12/6/23: If we don't wait before opening the clone, it's not ready yet sometimes. - // Figure out why we need this timeout and how to get rid of it! - setTimeout(() => { - window.openDocumentInNewTab(clone.url); - }, 500); + // @ts-expect-error window global + window.openDocumentInNewTab(clone.url); }, [repo, handle]); const goToParent = () => { @@ -293,7 +291,6 @@ export const Navbar = ({ disabled={!doc?.forkMetadata?.parent} onClick={() => { shareToParent(); - goToParent(); }} > Share fork to parent @@ -302,7 +299,6 @@ export const Navbar = ({ disabled={!doc?.forkMetadata?.parent} onClick={() => { mergeToParent(); - goToParent(); }} > Merge to parent @@ -469,6 +465,7 @@ export const Navbar = ({ name: tentativeUser.name, }; changeDoc((doc) => { + // @ts-expect-error need to figure out how to make the type mutable in change blocks doc.users.push(user); }); setSession({ userId: user.id }); diff --git a/src/main.tsx b/src/main.tsx index eb8f712e..2801571b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -40,11 +40,18 @@ window.handle = handle; // we'll use this later for experimentation // @ts-expect-error - adding property to window window.logoImageUrl = "/assets/logo-favicon-310x310-transparent.png"; +// GL 12/6/23: Sometimes we need to wait a bit before navigating to a new doc, +// e.g. when making a fork the repo seems to not be ready if we go immediately. +// investigate this and remove the timeout. // @ts-expect-error - set a window global window.openDocumentInNewTab = (docUrl) => { - window.open( - `${document.location.origin}${document.location.pathname}#${docUrl}`, - "_blank" + setTimeout( + () => + window.open( + `${document.location.origin}${document.location.pathname}#${docUrl}`, + "_blank" + ), + 500 ); }; diff --git a/src/schema.ts b/src/schema.ts index 018f9b46..f7704f63 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -30,7 +30,7 @@ export type CommentThreadWithPosition = CommentThreadForUI & { yCoord: number }; const User = S.struct({ id: S.string, - name: S.DateFromString, + name: S.string, }); export type User = S.Schema.To; From 925df21c193347aeb187adb3c348cde57189c240 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 18:21:11 -0500 Subject: [PATCH 14/15] remove console logs --- src/components/App.tsx | 2 -- src/components/Navbar.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 204ec3b0..147c6c83 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -24,8 +24,6 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { const localStorageKey = `LocalSession-${docUrl}`; - console.log(doc); - useEffect(() => { const session = localStorage.getItem(localStorageKey); if (session) { diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 5739114e..e91bc497 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -86,7 +86,6 @@ export const Navbar = ({ showDiff: boolean; setShowDiff: React.Dispatch>; }) => { - console.log("navbar"); const repo = useRepo(); const [namePickerOpen, setNamePickerOpen] = useState(false); const [tentativeUser, setTentativeUser] = useState({ From 1352f05b942bbd3692852a75b895616f4cc7940c Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Wed, 6 Dec 2023 18:44:50 -0500 Subject: [PATCH 15/15] fix a bug --- src/components/App.tsx | 6 +++--- src/components/MarkdownEditor.tsx | 27 +++++++++++++++++---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 147c6c83..e0ef512e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -11,7 +11,7 @@ import { EditorView } from "@codemirror/view"; import { CommentsSidebar } from "./CommentsSidebar"; import { useThreadsWithPositions } from "../utils"; import { useTypedDocument } from "@/useTypedDocument"; -import { getHeads } from "@automerge/automerge/next"; +import { Heads, getHeads } from "@automerge/automerge/next"; function App({ docUrl }: { docUrl: AutomergeUrl }) { const [doc, changeDoc] = useTypedDocument(docUrl, MarkdownDoc); // used to trigger re-rendering when the doc loads @@ -44,12 +44,12 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { activeThreadId, }); - const diffHeads = useMemo(() => { + const diffHeads: Heads | null = useMemo(() => { if (!doc) return []; if (doc?.forkMetadata?.parent && showDiff) { return [...doc.forkMetadata.parent.forkedAtHeads]; } else { - return getHeads(doc); + return null; } }, [doc, showDiff]); diff --git a/src/components/MarkdownEditor.tsx b/src/components/MarkdownEditor.tsx index 630edcf7..f084c003 100644 --- a/src/components/MarkdownEditor.tsx +++ b/src/components/MarkdownEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from "react"; +import React, { useEffect, useRef } from "react"; import { EditorView, @@ -18,14 +18,7 @@ import { markdown } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; import { tags } from "@lezer/highlight"; -import { - diff, - Doc, - getHeads, - Heads, - Patch, - Prop, -} from "@automerge/automerge/next"; +import { diff, getHeads, Heads, Patch, Prop } from "@automerge/automerge/next"; import { plugin as amgPlugin, PatchSemaphore, @@ -59,7 +52,7 @@ export type TextSelection = { export type EditorProps = { handle: DocHandle; - diffHeads: Heads; + diffHeads: Heads | null; path: Prop[]; setSelection: (selection: TextSelection) => void; setView: (view: EditorView) => void; @@ -108,6 +101,10 @@ const threadDecorations = EditorView.decorations.compute( } ) ?? []; + if (decorations.length === 0) { + return Decoration.none; + } + return Decoration.set(decorations); } ); @@ -173,6 +170,9 @@ const patchDecorations = EditorView.decorations.compute( case "splice": { const from = patch.path[1]; const length = patch.value.length; + if (length === 0) { + return []; + } return [spliceDecoration.range(from, from + length)]; } case "del": { @@ -183,6 +183,10 @@ const patchDecorations = EditorView.decorations.compute( return []; }); + if (decorations.length === 0) { + return Decoration.none; + } + return Decoration.set(decorations); } ); @@ -360,6 +364,9 @@ export function MarkdownEditor({ // // Propagate patches into the codemirror useEffect(() => { const doc = handle.docSync(); + if (!diffHeads) { + return; + } const patches = diff(doc, diffHeads, getHeads(doc)); editorRoot.current?.dispatch({ effects: setPatchesEffect.of(patches),