From b3ee31386da196d4ab586835b95d62cafe708bf1 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:15:54 +0800 Subject: [PATCH 01/13] Introduce shadcn's components --- app/global.css | 122 +++++++ components.json | 22 ++ components/ui/button.tsx | 60 ++++ components/ui/dialog.tsx | 123 +++++++ components/ui/input.tsx | 21 ++ components/ui/separator.tsx | 28 ++ components/ui/sheet.tsx | 103 ++++++ components/ui/sidebar.tsx | 673 ++++++++++++++++++++++++++++++++++++ components/ui/skeleton.tsx | 7 + components/ui/tooltip.tsx | 48 +++ hooks/use-mobile.ts | 19 + lib/utils.ts | 6 + package.json | 6 + pnpm-lock.yaml | 672 +++++++++++++++++++++++++++++++++++ tsconfig.json | 5 +- 15 files changed, 1914 insertions(+), 1 deletion(-) create mode 100644 components.json create mode 100644 components/ui/button.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 hooks/use-mobile.ts create mode 100644 lib/utils.ts diff --git a/app/global.css b/app/global.css index bb327b6..a696110 100644 --- a/app/global.css +++ b/app/global.css @@ -3,6 +3,9 @@ @import "@fontsource-variable/inter"; @import "@fontsource-variable/spline-sans-mono"; @import "@fontsource-variable/spline-sans-mono/wght-italic"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); @theme { --default-font-family: "Inter Variable"; @@ -26,3 +29,122 @@ button { .cm-editor.cm-focused { outline: none !important; } + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.147 0.004 49.25); + --card: oklch(1 0 0); + --card-foreground: oklch(0.147 0.004 49.25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.147 0.004 49.25); + --primary: oklch(0.216 0.006 56.043); + --primary-foreground: oklch(0.985 0.001 106.423); + --secondary: oklch(0.97 0.001 106.424); + --secondary-foreground: oklch(0.216 0.006 56.043); + --muted: oklch(0.97 0.001 106.424); + --muted-foreground: oklch(0.553 0.013 58.071); + --accent: oklch(0.97 0.001 106.424); + --accent-foreground: oklch(0.216 0.006 56.043); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.923 0.003 48.717); + --input: oklch(0.923 0.003 48.717); + --ring: oklch(0.709 0.01 56.259); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0.001 106.423); + --sidebar-foreground: oklch(0.147 0.004 49.25); + --sidebar-primary: oklch(0.216 0.006 56.043); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.97 0.001 106.424); + --sidebar-accent-foreground: oklch(0.216 0.006 56.043); + --sidebar-border: oklch(0.923 0.003 48.717); + --sidebar-ring: oklch(0.709 0.01 56.259); +} + +.dark { + --background: oklch(0.147 0.004 49.25); + --foreground: oklch(0.985 0.001 106.423); + --card: oklch(0.216 0.006 56.043); + --card-foreground: oklch(0.985 0.001 106.423); + --popover: oklch(0.216 0.006 56.043); + --popover-foreground: oklch(0.985 0.001 106.423); + --primary: oklch(0.923 0.003 48.717); + --primary-foreground: oklch(0.216 0.006 56.043); + --secondary: oklch(0.268 0.007 34.298); + --secondary-foreground: oklch(0.985 0.001 106.423); + --muted: oklch(0.268 0.007 34.298); + --muted-foreground: oklch(0.709 0.01 56.259); + --accent: oklch(0.268 0.007 34.298); + --accent-foreground: oklch(0.985 0.001 106.423); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.216 0.006 56.043); + --sidebar-foreground: oklch(0.985 0.001 106.423); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.268 0.007 34.298); + --sidebar-accent-foreground: oklch(0.985 0.001 106.423); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..006d007 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/global.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..47f3a55 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import {Slot} from "@radix-ui/react-slot"; +import {cva, type VariantProps} from "class-variance-authority"; + +import {cn} from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export {Button, buttonVariants}; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..df2268a --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,123 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import {XIcon} from "lucide-react"; + +import {cn} from "@/lib/utils"; + +function Dialog({...props}: React.ComponentProps) { + return ; +} + +function DialogTrigger({...props}: React.ComponentProps) { + return ; +} + +function DialogPortal({...props}: React.ComponentProps) { + return ; +} + +function DialogClose({...props}: React.ComponentProps) { + return ; +} + +function DialogOverlay({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({className, ...props}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({className, ...props}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..f66f04c --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import {cn} from "@/lib/utils"; + +function Input({className, type, ...props}: React.ComponentProps<"input">) { + return ( + + ); +} + +export {Input}; diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..f2e02d6 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import {cn} from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export {Separator}; diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..23a641d --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,103 @@ +"use client"; + +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import {XIcon} from "lucide-react"; + +import {cn} from "@/lib/utils"; + +function Sheet({...props}: React.ComponentProps) { + return ; +} + +function SheetTrigger({...props}: React.ComponentProps) { + return ; +} + +function SheetClose({...props}: React.ComponentProps) { + return ; +} + +function SheetPortal({...props}: React.ComponentProps) { + return ; +} + +function SheetOverlay({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({className, ...props}: React.ComponentProps<"div">) { + return
; +} + +function SheetFooter({className, ...props}: React.ComponentProps<"div">) { + return
; +} + +function SheetTitle({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +export {Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription}; diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..6ff9563 --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,673 @@ +"use client"; + +import * as React from "react"; +import {Slot} from "@radix-ui/react-slot"; +import {cva, type VariantProps} from "class-variance-authority"; +import {PanelLeftIcon} from "lucide-react"; + +import {useIsMobile} from "@/hooks/use-mobile"; +import {cn} from "@/lib/utils"; +import {Button} from "@/components/ui/button"; +import {Input} from "@/components/ui/input"; +import {Separator} from "@/components/ui/separator"; +import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"; +import {Skeleton} from "@/components/ui/skeleton"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const {isMobile, state, openMobile, setOpenMobile} = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +function SidebarTrigger({className, onClick, ...props}: React.ComponentProps) { + const {toggleSidebar} = useSidebar(); + + return ( + + ); +} + +function SidebarRail({className, ...props}: React.ComponentProps<"button">) { + const {toggleSidebar} = useSidebar(); + + return ( + - )} - {!showInput && isAdded && ( - - )} - {showInput || !isAdded ? ( - - ) : ( - {notebook.title} - )} -
+ + } + toolBarEnd={ + isDraft ? null : ( + + ) } /> +
); diff --git a/app/SafeLink.jsx b/app/SafeLink.jsx index 419087f..e978ddd 100644 --- a/app/SafeLink.jsx +++ b/app/SafeLink.jsx @@ -4,7 +4,7 @@ import {useRouter} from "next/navigation"; import {isDirtyStore, countStore} from "./store.js"; import Link from "next/link"; -export function SafeLink({href, children, className, onClick, ...props}) { +export function SafeLink({href, children, className, onClick = () => {}, ...props}) { const router = useRouter(); const isDirty = useSyncExternalStore( isDirtyStore.subscribe, @@ -19,7 +19,10 @@ export function SafeLink({href, children, className, onClick, ...props}) { if (!confirmLeave) return; } isDirtyStore.setDirty(false); - if (href === "/") countStore.increment(); + if (href === "/") { + console.log(`SafeLink (href = "${href}", count = ${countStore.getSnapshot()})`); + countStore.increment(); + } router.push(href); onClick?.(); }; diff --git a/app/works/page.jsx b/app/works/page.jsx index 70d5640..42bc01f 100644 --- a/app/works/page.jsx +++ b/app/works/page.jsx @@ -3,25 +3,21 @@ import {useState, useEffect} from "react"; import Link from "next/link"; import {Trash} from "lucide-react"; import {ThumbnailClient} from "../ThumbnailClient.js"; -import {getNotebooks, deleteNotebook} from "../api.js"; +// import {getNotebooks, deleteNotebook, getLatestSnapshotContent} from "../api.js"; import {findFirstOutputRange} from "../shared.js"; import {cn} from "../cn.js"; +import {useNotebooks} from "@/lib/notebooks/hooks.ts"; export default function Page() { - const [notebooks, setNotebooks] = useState([]); + const [notebooks, setNotebooks] = useNotebooks(); const isEmpty = notebooks.length === 0; - useEffect(() => { - const notebooks = getNotebooks(); - setNotebooks(notebooks); - }, []); - useEffect(() => { document.title = "Notebooks | Recho Notebook"; }, []); function onDelete(id) { - deleteNotebook(id); + // deleteNotebook(id); const newNotebooks = notebooks.filter((notebook) => notebook.id !== id); setNotebooks(newNotebooks); } @@ -43,37 +39,37 @@ export default function Page() { return (
- {notebooks.map((notebook) => ( -
-
-
- - {notebook.title} - -
- Created {new Date(notebook.created).toLocaleDateString()} + {notebooks.map((notebook) => { + const code = notebook.snapshots[0].content; + return ( +
+
+
+ + {notebook.title} + +
+ Created {new Date(notebook.created).toLocaleDateString()} +
+
- -
-
-
- +
+
+ +
-
- ))} + ); + })}
); diff --git a/components/notebooks/EditorPageHero.tsx b/components/notebooks/EditorPageHero.tsx new file mode 100644 index 0000000..9aa4e25 --- /dev/null +++ b/components/notebooks/EditorPageHero.tsx @@ -0,0 +1,63 @@ +import {useLatestNotebooks} from "@/lib/notebooks/hooks.ts"; +import {SafeLink} from "../../app/SafeLink.jsx"; +import {cn} from "../../app/cn.js"; + +export type EditorPageHeroProps = { + show: boolean; +}; + +export function EditorPageHero({show}: EditorPageHeroProps) { + const latestNotebooks = useLatestNotebooks(4); + if (!show) return null; + if (latestNotebooks.length === 0) { + return ( +
+

+ Explore code and art with instant feedback. +

+
+ ); + } + return ( +
+ +
+ + View your notebooks + +
+
+ ); +} diff --git a/components/notebooks/NotebookTitle.tsx b/components/notebooks/NotebookTitle.tsx new file mode 100644 index 0000000..0c60edd --- /dev/null +++ b/components/notebooks/NotebookTitle.tsx @@ -0,0 +1,95 @@ +import {Pencil, AsteriskIcon, CheckIcon} from "lucide-react"; +import {useEffect, useRef, useState} from "react"; + +export type NotebookTitleProps = { + title: string; + setTitle: (notebook: string) => void; + isDraft: boolean; + isDirty: boolean; + onCreate: () => void; +}; + +export function NotebookTitle({title, setTitle, isDraft, isDirty, onCreate}: NotebookTitleProps) { + const [showInput, setShowInput] = useState(false); + const [titleDraft, setTitleDraft] = useState(""); + const titleRef = useRef(null); + + useEffect(() => { + if (showInput) titleRef.current?.focus?.(); + }, [showInput]); + + useEffect(() => { + setTitleDraft(title); + }, [title]); + + // Only submit rename when blur with valid title. + function onTitleBlur() { + setShowInput(false); + // The title can't be empty. + if (!titleDraft) return setTitleDraft(title); + setTitle(titleDraft); + } + + function onTitleChange(e: React.ChangeEvent) { + setTitleDraft(e.target.value); + } + + function onTitleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + onTitleBlur(); + titleRef.current?.blur?.(); + } + } + + function onRename() { + setShowInput(true); + setTitleDraft(title); + } + + return ( +
+ {isDraft && ( + + )} + {!showInput && !isDraft && ( + + )} + {showInput || isDraft ? ( + + ) : ( + {title} + )} + {isDraft || isDirty ? ( + + ) : ( + + )} +
+ ); +} diff --git a/components/notebooks/SnapshotsDialog.tsx b/components/notebooks/SnapshotsDialog.tsx new file mode 100644 index 0000000..c240d8c --- /dev/null +++ b/components/notebooks/SnapshotsDialog.tsx @@ -0,0 +1,217 @@ +"use client"; +import {useState, useEffect} from "react"; +import {Camera, Trash2, RotateCcw} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + // DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog.tsx"; +import {Button} from "../ui/button.tsx"; +import {cn} from "../../app/cn.js"; +import {Editor} from "../../app/Editor.jsx"; +import {useSnapshots} from "@/lib/notebooks/hooks.ts"; +import type {Notebook, Snapshot} from "@/lib/notebooks/schema.ts"; + +export type SnapshotsDialogProps = { + notebook: Readonly; + open: boolean; + onOpenChange: (open: boolean) => void; + createSnapshot: (name: string | null) => Snapshot; + onRestore: (snapshot: Snapshot) => void; + deleteSnapshot: (snapshotId: string) => void; +}; + +export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, onRestore}: SnapshotsDialogProps) { + const {snapshots, deleteSnapshot} = useSnapshots(notebook.id); + const [selectedSnapshot, setSelectedSnapshot] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + useEffect(() => { + if (open && notebook?.id) { + if (snapshots.length > 0 && !selectedSnapshot) { + setSelectedSnapshot(snapshots[0]); + } + } else { + setSelectedSnapshot(null); + } + }, [open, notebook?.id, snapshots, selectedSnapshot]); + + function handleCreateSnapshot() { + if (!notebook) return; + setIsCreating(true); + setTimeout(() => { + const snapshot = createSnapshot(null); + setSelectedSnapshot(snapshot); + setIsCreating(false); + }, 100); + } + + function handleRestore(snapshotId: string) { + const snapshot = snapshots.find((s) => s.id === snapshotId); + if (snapshot) { + onRestore(snapshot); + onOpenChange(false); + } else { + console.error(`Snapshot with id ${snapshotId} not found`); + } + } + + function handleDelete(snapshotId: string, e: React.MouseEvent) { + e.stopPropagation(); + if (confirm("Are you sure you want to delete this snapshot?")) { + deleteSnapshot(snapshotId); + } + } + + return ( + + + + Snapshots + + Create snapshots to save the current state of your notebook. You can restore any snapshot later. + + +
+ {/* Sidebar */} +
+
+ +
+
+ {snapshots.length === 0 ? ( +
+ +

No snapshots yet. Create your first snapshot to get started.

+
+ ) : ( +
+ {snapshots.map((snapshot, index) => ( +
setSelectedSnapshot(snapshot)} + > +
+
+
+ {index === 0 ? "The Last Saved Version" : (snapshot.name ?? "Untitled snapshot")} +
+
{formatDate(snapshot.created)}
+
+
+ + +
+
+
+ ))} +
+ )} +
+
+ + {/* Code Viewer */} +
+ {selectedSnapshot ? ( + <> +
+
+
+ {selectedSnapshot.name ?? "Untitled snapshot"} +
+
{formatDate(selectedSnapshot.created)}
+
+ +
+
+ {}} + /> +
+ + ) : ( +
+
+ +

Select a snapshot to view its code

+
+
+ )} +
+
+
+
+ ); +} + +function formatDate(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`; + return date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"}); +} diff --git a/editor/blocks/update.ts b/editor/blocks/update.ts index 4c088a2..2a441ab 100644 --- a/editor/blocks/update.ts +++ b/editor/blocks/update.ts @@ -18,16 +18,16 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block const userEvent = tr.annotation(Transaction.userEvent); if (userEvent) { - console.group(`updateBlocks (${userEvent})`); + // console.group(`updateBlocks (${userEvent})`); } else { - console.groupCollapsed(`updateBlocks`); + // console.groupCollapsed(`updateBlocks`); } const isCopyingLines = userEvent === "input.copyline"; if (tr.changes.empty) { - console.log("No changes detected"); - console.groupEnd(); + // console.log("No changes detected"); + // console.groupEnd(); return oldBlocks; } @@ -46,12 +46,12 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block tr.changes.iterChanges((oldFrom, oldTo, newFrom, newTo, inserted) => { if (oldFrom === oldTo) { if (newFrom === oldFrom) { - console.groupCollapsed(`Insert ${newTo - newFrom} characters at ${oldFrom}`); + // console.groupCollapsed(`Insert ${newTo - newFrom} characters at ${oldFrom}`); } else { - console.groupCollapsed(`Insert ${newTo - newFrom} characters: ${oldFrom} -> ${newFrom}-${newTo}`); + // console.groupCollapsed(`Insert ${newTo - newFrom} characters: ${oldFrom} -> ${newFrom}-${newTo}`); } } else { - console.groupCollapsed(`Update: ${oldFrom}-${oldTo} -> ${newFrom}-${newTo}`); + // console.groupCollapsed(`Update: ${oldFrom}-${oldTo} -> ${newFrom}-${newTo}`); } let changeFrom = oldFrom; @@ -66,10 +66,10 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block // changeFrom = changeTo = index - inserted.length; changeFrom = changeTo = index; } else { - console.error("This is weird as the insertion point is not at the beginning or the end of the line"); + // console.error("This is weird as the insertion point is not at the beginning or the end of the line"); } } else { - console.error("This is weird as the cursor is not at the same position"); + // console.error("This is weird as the cursor is not at the same position"); } } @@ -77,7 +77,7 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block const affectedBlockRange = findAffectedBlockRange(oldBlocks, changeFrom, changeTo); - console.log(`Affected block range: ${affectedBlockRange[0]} to ${affectedBlockRange[1] ?? "the end"}`); + // console.log(`Affected block range: ${affectedBlockRange[0]} to ${affectedBlockRange[1] ?? "the end"}`); // Add the affected blocks to the set. for (let i = affectedBlockRange[0] ?? 0, n = affectedBlockRange[1] ?? oldBlocks.length; i < n; i++) { @@ -86,7 +86,7 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block // Check a corner case where the affected block range is empty but there are blocks. if (blockRangeLength(oldBlocks.length, affectedBlockRange) === 0 && oldBlocks.length > 0) { - console.error("This should never happen"); + // console.error("This should never happen"); } // Now, we are going to compute the range which should be re-parsed. @@ -94,7 +94,7 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block const reparseTo = affectedBlockRange[1] === null ? tr.state.doc.length : oldBlocks[affectedBlockRange[1] - 1]!.to; const newBlocks = detectBlocksWithinRange(syntaxTree(tr.state), tr.state.doc, reparseFrom, reparseTo); - console.log("New blocks from reparsed range:", newBlocks); + // console.log("New blocks from reparsed range:", newBlocks); // If only one block is affected and only one new block is created, we can // simply inherit the attributes from the old block. @@ -108,21 +108,21 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block newlyCreatedBlocks.insert(newBlocks[i]!); } - console.groupEnd(); + // console.groupEnd(); }); // Step 3: Combine the array of old blocks and the heap of new blocks. const newBlocks: BlockMetadata[] = []; - console.group("Combining old blocks and new blocks"); + // console.group("Combining old blocks and new blocks"); for (let i = 0, n = oldBlocks.length; i < n; i++) { const oldBlock = oldBlocks[i]!; // Skip affected blocks, as they have been updated. if (affectedBlocks.has(oldBlock)) { - console.log("Skipping affected old block:", oldBlock); + // console.log("Skipping affected old block:", oldBlock); continue; } @@ -133,7 +133,7 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block while (newlyCreatedBlocks.nonEmpty() && newlyCreatedBlocks.peek.from < newBlock.from) { newBlocks.push(newlyCreatedBlocks.peek); - console.log("Pushing new block from heap:", newlyCreatedBlocks.peek); + // console.log("Pushing new block from heap:", newlyCreatedBlocks.peek); newlyCreatedBlocks.extractMax(); } @@ -141,10 +141,10 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block // the current old block's `from` position. newBlocks.push(newBlock); - console.log("Pushing mapped old block:", newBlock); + // console.log("Pushing mapped old block:", newBlock); } - console.groupEnd(); + // console.groupEnd(); // In the end, push any remaining blocks from the heap. while (newlyCreatedBlocks.nonEmpty()) { @@ -152,12 +152,12 @@ export function updateBlocks(oldBlocks: BlockMetadata[], tr: Transaction): Block newlyCreatedBlocks.extractMax(); } - console.log("New blocks:", newBlocks); + // console.log("New blocks:", newBlocks); const deduplicatedBlocks = deduplicateNaive(newBlocks); - console.log("Deduplicated blocks:", deduplicatedBlocks); + // console.log("Deduplicated blocks:", deduplicatedBlocks); - console.groupEnd(); + // console.groupEnd(); return deduplicatedBlocks; } diff --git a/editor/index.js b/editor/index.js index 68ca8ba..6379d4b 100644 --- a/editor/index.js +++ b/editor/index.js @@ -31,53 +31,60 @@ const eslintConfig = { }; export function createEditor(container, options) { - const {code, onError, extensions = []} = options; + const {code, onError, extensions = [], readonly = false} = options; const dispatcher = d3Dispatch("userInput"); const runtimeRef = {current: null}; const myBasicSetup = Array.from(basicSetup); myBasicSetup.splice(2, 0, blockIndicator); + const editorExtensions = [ + myBasicSetup, + javascript(), + githubLightInit({ + styles: [ + {tag: [t.variableName], color: "#1f2328"}, + {tag: [t.function(t.variableName)], color: "#6f42c1"}, + ], + }), + EditorView.lineWrapping, + EditorView.theme({ + "&": {fontSize: "14px", fontFamily: "monospace"}, + ".cm-content": {whiteSpace: "pre"}, + ".cm-line": {wordWrap: "normal"}, + }), + ...(readonly ? [] : [EditorView.updateListener.of(onChange)]), + ...(readonly + ? [] + : [ + keymap.of([ + { + key: "Mod-s", + run: () => { + runtimeRef.current?.setIsRunning(true); + runtimeRef.current?.run(); + }, + preventDefault: true, + }, + indentWithTab, + ]), + ]), + javascriptLanguage.data.of({autocomplete: rechoCompletion}), + blockExtensions, + ...(readonly ? [] : [controls(runtimeRef)]), + // Disable this for now, because it prevents copying/pasting the code. + // outputProtection(), + docStringTag, + commentLink, + commentLinkClickHandler, + ...(readonly ? [] : [linter(esLint(new eslint.Linter(), eslintConfig))]), + ...(readonly ? [EditorState.readOnly.of(true)] : []), + ...extensions, + ]; + const state = EditorState.create({ doc: code, - extensions: [ - myBasicSetup, - javascript(), - githubLightInit({ - styles: [ - {tag: [t.variableName], color: "#1f2328"}, - {tag: [t.function(t.variableName)], color: "#6f42c1"}, - ], - }), - EditorView.lineWrapping, - EditorView.theme({ - "&": {fontSize: "14px", fontFamily: "monospace"}, - ".cm-content": {whiteSpace: "pre"}, - ".cm-line": {wordWrap: "normal"}, - }), - EditorView.updateListener.of(onChange), - keymap.of([ - { - key: "Mod-s", - run: () => { - runtimeRef.current?.setIsRunning(true); - runtimeRef.current?.run(); - }, - preventDefault: true, - }, - indentWithTab, - ]), - javascriptLanguage.data.of({autocomplete: rechoCompletion}), - blockExtensions, - controls(runtimeRef), - // Disable this for now, because it prevents copying/pasting the code. - // outputProtection(), - docStringTag, - commentLink, - commentLinkClickHandler, - linter(esLint(new eslint.Linter(), eslintConfig)), - ...extensions, - ], + extensions: editorExtensions, }); const view = new EditorView({state, parent: container}); diff --git a/lib/notebooks/atom.ts b/lib/notebooks/atom.ts new file mode 100644 index 0000000..908ebd2 --- /dev/null +++ b/lib/notebooks/atom.ts @@ -0,0 +1,16 @@ +import {atom} from "jotai"; +import {loadNotebooksFromStorage, saveNotebooksToStorage} from "./storage.ts"; +import type {Notebook} from "./schema.ts"; +import type {SetStateAction} from "react"; + +export const baseNotebooksAtom = atom(loadNotebooksFromStorage()); + +export const notebooksAtom = atom], void>( + (get) => get(baseNotebooksAtom), + (get, set, update) => { + const oldValue = get(baseNotebooksAtom); + const newValue = typeof update === "function" ? update(oldValue) : update; + set(baseNotebooksAtom, newValue); + saveNotebooksToStorage(newValue, oldValue); + }, +); diff --git a/lib/notebooks/hooks.ts b/lib/notebooks/hooks.ts new file mode 100644 index 0000000..ece28d8 --- /dev/null +++ b/lib/notebooks/hooks.ts @@ -0,0 +1,287 @@ +"use client"; + +import {useAtom, useAtomValue} from "jotai"; +import {useCallback, useMemo, useRef, useState, type Dispatch} from "react"; +import {notebooksAtom} from "./atom.ts"; +import {updateNotebook} from "./operations.ts"; +import type {Notebook, Snapshot} from "./schema.ts"; +import {createNotebook, createSnapshot, DEFAULT_CONTENT} from "./utils.ts"; +import {generate} from "short-uuid"; + +export type UseNotebookResult = { + /** + * The current notebook. It is `null` only if the notebook is not found. + */ + notebook: Readonly | null; + + /** + * Whether the notebook is a draft. + */ + isDraft: boolean; + + /** + * Whether the buffer has been modified since the last saved. + */ + isDirty: boolean; + + /** + * The initial content of the notebook. It is only changed when the notebook + * is created or loaded from the storage. + * + * Please initialize the editor using this content. + */ + initialContent: string; + + /** + * Save the notebook to the persistent storage. If the notebook is a draft, + * it will be added to the storage. + */ + saveNotebook: () => void; + + /** + * Update the content of the notebook. If the notebook is a draft, it only + * updates the draft but does not save it to the storage. + * + * @param content The new content of the notebook. + */ + updateContent: (content: string) => void; + + /** + * Update the title of the notebook. If the notebook is a draft, it only + * updates the draft but does not save it to the storage. + * + * @param title The new title of the notebook. + */ + updateTitle: (title: string) => void; + + /** + * Create a new snapshot for the notebook. + * + * The function does nothing when the notebook is a draft because drafts do + * not have snapshots. + * + * If the notebook is not saved, this function first saves the notebook before + * creating a new snapshot. + * + * @param name The name of the snapshot. + */ + createSnapshot: (name: string | null) => Snapshot; + + /** + * Forcibly set the content of the notebook to the given content. It also + * creates a new snapshot if the content is dirty. + * + * @param content The content to restore. + */ + restoreContent: (content: string) => void; +}; + +export function useLatestNotebooks(limit?: number): Notebook[] { + const notebooks = useAtomValue(notebooksAtom); + return useMemo( + () => (typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? notebooks.slice(0, limit) : notebooks), + [notebooks, limit], + ); +} + +export function useNotebooks(): [Notebook[], Dispatch] { + const [notebooks, setNotebooks] = useAtom(notebooksAtom); + return [notebooks, setNotebooks]; +} + +export function useNotebookTitle(id: string): [string, Dispatch] { + const [notebooks, setNotebooks] = useAtom(notebooksAtom); + const notebook = notebooks.find((n) => n.id === id); + return useMemo(() => { + return [ + notebook?.title ?? "", + (title: string) => { + setNotebooks((oldNotebooks) => updateNotebook(oldNotebooks, id, {title})); + }, + ]; + }, [id, notebook, setNotebooks]); +} + +const notFoundResult: UseNotebookResult = Object.freeze({ + notebook: null, + isDraft: false, + isDirty: false, + initialContent: "", + saveNotebook: () => {}, + updateContent: () => {}, + updateTitle: () => {}, + createSnapshot: () => { + throw new Error("`createSnapshot` is not available when the notebook is not found."); + }, + restoreContent: () => {}, +}); + +/** + * The hook that fetches a notebook by its ID. The returned notebook is a stable + * reference to the notebook and has only the latest snapshot. + * + * If the ID is not provided, the hook will return a draft notebook. + * + * @param id the notebook ID indicated in the URL + */ +export function useNotebook(id: string | undefined): UseNotebookResult { + const [notebooks, setNotebooks] = useAtom(notebooksAtom); + + const [actualId, setActualId] = useState(id); + + // The notebook found by the ID. + const foundNotebook = typeof actualId === "string" ? notebooks.find((n) => n.id === actualId) : undefined; + + // The draft notebook state. + const [draftNotebook, setDraftNotebook] = useState(createNotebook); + + const lastSavedTimestamp = (foundNotebook?.snapshots[0] ?? draftNotebook).created; + + // Keep the content of the latest snapshot in a reference to avoid re-renders. + const contentRef = useRef(foundNotebook?.snapshots[0].content ?? DEFAULT_CONTENT); + // Initialize the last edit timestamp to the last saved timestamp. + const lastEditTimestampRef = useRef(lastSavedTimestamp); + + const [isDirty, setIsDirty] = useState(false); + + const [initialContent, setInitialContent] = useState(foundNotebook?.snapshots[0].content ?? DEFAULT_CONTENT); + + const updateContent = useCallback((content: string) => { + contentRef.current = content; + lastEditTimestampRef.current = Date.now(); + setIsDirty(true); + }, []); + + // The notebook that is currently being edited. + return useMemo(() => { + if (foundNotebook === undefined) { + if (typeof actualId === "string") { + return notFoundResult; + } else { + return { + notebook: draftNotebook, + isDraft: true, + isDirty, + initialContent, + saveNotebook: () => { + const snapshots = [createSnapshot(contentRef.current)]; + setNotebooks((oldNotebooks) => [{...draftNotebook, snapshots}, ...oldNotebooks]); + setDraftNotebook(createNotebook()); + setActualId(draftNotebook.id); + setIsDirty(false); + }, + updateContent, + updateTitle: (title: string) => { + setDraftNotebook({...draftNotebook, title}); + }, + createSnapshot: () => { + throw new Error("`createSnapshot` is not supported for an unsaved notebook."); + }, + restoreContent: () => { + console.warn("`restoreContent` is not supported for an unsaved notebook."); + }, + }; + } + } else { + const [head, ...tail] = foundNotebook.snapshots; + return { + notebook: foundNotebook, + isDraft: false, + isDirty, + initialContent, + saveNotebook: () => { + setNotebooks((oldNotebooks) => { + return oldNotebooks.map((n) => { + if (n.id === foundNotebook.id) { + const snapshot: Snapshot = { + ...head, + content: contentRef.current, + created: lastEditTimestampRef.current, + }; + return {...n, snapshots: [snapshot, ...tail]}; + } else { + return n; + } + }); + }); + setIsDirty(false); + }, + updateContent, + updateTitle: (title: string) => { + setNotebooks((oldNotebooks) => { + return oldNotebooks.map((n) => { + if (n.id === foundNotebook.id) { + return {...n, title}; + } else { + return n; + } + }); + }); + }, + createSnapshot: (name: string | null): Snapshot => { + const snapshots = [...tail]; + const snapshot = createSnapshot(contentRef.current, name, lastEditTimestampRef.current); + // Prepend the new snapshot to the array. + snapshots.unshift(snapshot); + // Then, prepend a copy of the snapshot because the first snapshot is + // the latest version. + snapshots.unshift({...snapshot, id: generate()}); + // Update the notebook in the storage. + setNotebooks((oldNotebooks) => updateNotebook(oldNotebooks, foundNotebook.id, {snapshots})); + setIsDirty(false); + // Lastly, there is no need to call `setInitialContent` because the + // content is already the latest snapshot. + return snapshot; + }, + restoreContent: (content) => { + const snapshots = [...tail]; + if (isDirty) { + snapshots.unshift(createSnapshot(contentRef.current, "Unsaved changes", lastEditTimestampRef.current)); + } + snapshots.unshift(createSnapshot(content)); + setInitialContent(content); + setNotebooks((oldNotebooks) => updateNotebook(oldNotebooks, foundNotebook.id, {snapshots})); + }, + }; + } + }, [actualId, isDirty, draftNotebook, foundNotebook, setNotebooks, initialContent, updateContent]); +} + +export type UseSnapshotsResult = { + snapshots: Readonly[]; + addSnapshot: (snapshot: Snapshot) => void; + deleteSnapshot: (snapshotId: string) => void; +}; + +/** + * The hook fetches the history snapshots of a notebook. + * + * @param id The notebook ID + * + * @returns The history snapshots of the notebook. + */ +export function useSnapshots(id: string): UseSnapshotsResult { + const [notebooks, setNotebooks] = useAtom(notebooksAtom); + return useMemo(() => { + const notebook = notebooks.find((n) => n.id === id); + if (notebook === undefined) { + return { + snapshots: [], + addSnapshot: () => {}, + deleteSnapshot: () => {}, + }; + } else { + return { + snapshots: notebook.snapshots, + addSnapshot: (snapshot: Snapshot) => { + setNotebooks(notebooks.map((n) => (n.id === id ? {...n, snapshots: [snapshot, ...n.snapshots]} : n))); + }, + deleteSnapshot: (snapshotId: string) => { + setNotebooks( + notebooks.map((n) => (n.id === id ? {...n, snapshots: n.snapshots.filter((s) => s.id !== snapshotId)} : n)), + ); + }, + }; + } + }, [id, notebooks, setNotebooks]); +} diff --git a/lib/notebooks/operations.ts b/lib/notebooks/operations.ts new file mode 100644 index 0000000..7d45862 --- /dev/null +++ b/lib/notebooks/operations.ts @@ -0,0 +1,9 @@ +import type {Notebook} from "./schema.ts"; + +export function setNotebook(notebooks: Notebook[], id: string, notebook: Notebook): Notebook[] { + return notebooks.map((n) => (n.id === id ? notebook : n)); +} + +export function updateNotebook(notebooks: Notebook[], id: string, fields: Partial): Notebook[] { + return notebooks.map((n) => (n.id === id ? {...n, ...fields} : n)); +} diff --git a/lib/notebooks/schema.ts b/lib/notebooks/schema.ts new file mode 100644 index 0000000..de10624 --- /dev/null +++ b/lib/notebooks/schema.ts @@ -0,0 +1,54 @@ +import Type from "typebox"; + +/** + * A schema for loading data from localStorage. + */ +export const NotebookSchema = Type.Object({ + id: Type.String(), + title: Type.String(), + created: Type.Number(), + updated: Type.Number(), + + // For backward compatibility with old notebooks, we make `snapshots` and + // `content` optional. + // + // 1. If only `content` presents, we create a new snapshot with the content + // and add the snapshot id to `snapshots`. + // 2. If only `snapshots` presents, we do nothing. + // 3. If both `snapshots` and `content` presents, which should not happen, + // we add `content` to the first snapshot and name it "stale snapshot". + // 4. If none of them present, we initialize `snapshots` with an array + // containing a single snapshot with default content. + // + // The first item of `snapshots` is the latest version. + snapshots: Type.Optional(Type.Array(Type.String())), + content: Type.Optional(Type.String()), + + autoRun: Type.Boolean(), + runtime: Type.String(), +}); + +export const NotebooksSchema = Type.Array(NotebookSchema); + +export const SnapshotSchema = Type.Object({ + id: Type.String(), + name: Type.Union([Type.Null(), Type.String()]), + content: Type.String(), + created: Type.Number(), +}); + +export type Snapshot = Type.Static; + +/** + * This is the high-level representation of a notebook. Under the hood, we store + * snapshot content in a separate storage keyed by `content_${snapshotId}`. + */ +export type Notebook = { + id: string; + title: string; + created: number; + updated: number; + snapshots: Snapshot[]; + autoRun: boolean; + runtime: string; +}; diff --git a/lib/notebooks/storage.ts b/lib/notebooks/storage.ts new file mode 100644 index 0000000..2d0a43b --- /dev/null +++ b/lib/notebooks/storage.ts @@ -0,0 +1,119 @@ +import Value from "typebox/value"; +import {NotebooksSchema, type Notebook, type Snapshot} from "./schema.ts"; +import {createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, saveSnapshot, snapshotKey} from "./utils.ts"; + +export function loadSnapshotsFromStorage(ids: string[]): Snapshot[] { + const snapshots: Snapshot[] = []; + for (let i = 0, n = ids.length; i < n; i++) { + const raw = localStorage.getItem(snapshotKey(ids[i])); + if (!raw) continue; + try { + const snapshot = JSON.parse(raw); + snapshots.push(snapshot); + } catch { + continue; + } + } + // Keep the latest snapshot at the first position and sort the rest. + const latestSnapshot = snapshots.shift(); + snapshots.sort((a, b) => b.created - a.created); + if (latestSnapshot) snapshots.unshift(latestSnapshot); + return snapshots; +} + +export function loadNotebooksFromStorage(): Notebook[] { + const rawString = localStorage.getItem(RECHO_FILES_KEY); + if (!rawString) return []; + try { + const rawObject = JSON.parse(rawString); + // Parse the notebook first. + const rawNotebooks = Value.Parse(NotebooksSchema, rawObject); + const notebooks: Notebook[] = []; + for (let i = 0, n = rawNotebooks.length; i < n; i++) { + let snapshots: Snapshot[]; + // Examine the four cases documented above in the schema definition. + const current = rawNotebooks[i]; + if (typeof current.content === "string") { + const snapshot = createSnapshot(current.content); + saveSnapshot(snapshot); + if (current.snapshots === undefined) { + snapshots = [snapshot]; + } else { + snapshots = [snapshot, ...loadSnapshotsFromStorage(current.snapshots)]; + } + } else { + // This loads the snapshots that are present in the localStorage. + const restSnapshots = current.snapshots === undefined ? [] : loadSnapshotsFromStorage(current.snapshots); + if (restSnapshots.length > 0) { + snapshots = restSnapshots; + } else { + const snapshot = createSnapshot(DEFAULT_CONTENT); + saveSnapshot(snapshot); + snapshots = [snapshot]; + } + } + // Keep all other properties and use the new snapshots array. + notebooks.push({ + id: current.id, + title: current.title, + created: current.created, + updated: current.updated, + snapshots, + autoRun: current.autoRun, + runtime: current.runtime, + }); + } + return notebooks; + } catch { + // TODO: Recover as many as possible notebooks from the localStorage. + return []; + } +} + +export function saveNotebooksToStorage(notebooks: Notebook[], previousNotebooks: Notebook[]): void { + if (notebooks === previousNotebooks) return; + + const snapshotsSet = new Map(); + // Collect all snapshots from the previous notebooks. + for (let i = 0, n = previousNotebooks.length; i < n; i++) { + const notebook = previousNotebooks[i]; + for (let j = 0, m = notebook.snapshots.length; j < m; j++) { + // false means to call `removeItem`, true means to call `setItem`. + snapshotsSet.set(notebook.snapshots[j], false); + } + } + + // Fast serialization by manually constructing the JSON string. + let buffer = "["; + for (let i = 0, n = notebooks.length; i < n; i++) { + if (buffer.length > 1) buffer += ","; + const notebook = notebooks[i]; + // These fields are known to be safe without escaping. + buffer += `{"id":"${notebook.id}","title":${JSON.stringify(notebook.title)},"created":${notebook.created},"updated":${notebook.updated},"autoRun":${notebook.autoRun},"runtime":"${notebook.runtime}"`; + buffer += `,"snapshots":[`; + for (let j = 0, m = notebook.snapshots.length; j < m; j++) { + const snapshot = notebook.snapshots[j]; + if (j > 0) buffer += ","; + buffer += `"${snapshot.id}"`; + // Mark the sanpshot to be saved or removed. + if (snapshotsSet.has(snapshot)) { + snapshotsSet.delete(snapshot); + } else { + snapshotsSet.set(snapshot, true); + } + } + buffer += "]}"; + } + buffer += "]"; + // Save to localStorage. + localStorage.setItem(RECHO_FILES_KEY, buffer); + + // Update the snapshots in the localStorage. + for (const [snapshot, action] of snapshotsSet.entries()) { + if (action) { + localStorage.setItem(snapshotKey(snapshot.id), JSON.stringify(snapshot)); + } else { + localStorage.removeItem(snapshotKey(snapshot.id)); + } + } +} diff --git a/lib/notebooks/utils.ts b/lib/notebooks/utils.ts new file mode 100644 index 0000000..1c8afbe --- /dev/null +++ b/lib/notebooks/utils.ts @@ -0,0 +1,81 @@ +import {generate} from "short-uuid"; +import type {Notebook, Snapshot} from "./schema.ts"; +import {objects, predicates} from "friendly-words"; + +export const DEFAULT_CONTENT = ` +/* +** Welcome to +** ___ _ _ _ _ _ _ +** | _ \\___ __| |_ ___ | \\| |___| |_ ___| |__ ___ ___| |__ +** | / -_) _| ' \\/ _ \\ | .\` / _ \\ _/ -_) '_ \\/ _ \\/ _ \\ / / +** |_|_\\___\\__|_||_\\___/ |_|\\_\\___/\\__\\___|_.__/\\___/\\___/_\\_\\ +** +** A reactive editor for algorithms and ASCII art. +*/ + +// 1. You can call echo(value) to echo output inline as comments, which allows +// you to better understand the code by "seeing" every manipulation in-situ. + +const text = echo("dog"); + +const chars = echo(text.split("")); + +echo(chars.slice().reverse().join("")); + +// 2. You can also call recho.interval(ms) to create data-driven animations, +// which can help you find the minimalism of ASCII art is fascinating! + +const x = recho.interval(100); + +echo("🚗💨".padStart(40 - (x % 40))); + +// 3. Inputs are also supported, which can help you create interactive +// notebooks. Click the buttons to see what happens! + +const x1 = recho.number(10, {min: 0, max: 40, step: 1}); + +//➜ "(๑•̀ㅂ•́)و✧" +echo("~".repeat(x1) + "(๑•̀ㅂ•́)و✧"); + +// Refer to the links (cmd/ctrl + click) to learn more about Recho Notebook: +// - Docs: https://recho.dev/notebook/docs +// - Examples: https://recho.dev/notebook/examples +// - Github: https://github.com/recho-dev/notebook +`; + +export const LEGACY_RECHO_FILES_KEY = "obs-files"; + +export const RECHO_FILES_KEY = "recho-files"; + +const DEFAULT_RUNTIME = "javascript@0.1.0"; + +export function snapshotKey(id: string): string { + return `content_${id}`; +} + +export function createSnapshot(content: string, name: string | null = null, created: number = Date.now()): Snapshot { + return {id: generate(), name, content, created}; +} + +export function saveSnapshot(snapshot: Snapshot): void { + localStorage.setItem(snapshotKey(snapshot.id), JSON.stringify(snapshot)); +} + +function generateNotebookTitle() { + const adj = predicates[~~(Math.random() * predicates.length)]; + const obj = objects[~~(Math.random() * objects.length)]; + return `${adj}-${obj}.js`; +} + +export function createNotebook(): Notebook { + const snapshot = createSnapshot(DEFAULT_CONTENT); + return { + id: generate(), + title: generateNotebookTitle(), + created: snapshot.created, + updated: snapshot.created, + autoRun: true, + runtime: DEFAULT_RUNTIME, + snapshots: [snapshot], + }; +} diff --git a/package.json b/package.json index 854d909..bd9477d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", + "@types/friendly-words": "^1.2.2", "@types/node": "24.10.1", "@types/react": "19.2.6", "@types/react-dom": "^19.2.3", @@ -106,6 +107,7 @@ "shiki": "^3.20.0", "short-uuid": "^5.2.0", "source-map-support": "^0.5.21", - "table": "^6.9.0" + "table": "^6.9.0", + "typebox": "^1.0.73" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e02af47..ba069bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,10 +131,16 @@ importers: table: specifier: ^6.9.0 version: 6.9.0 + typebox: + specifier: ^1.0.73 + version: 1.0.73 devDependencies: '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 + '@types/friendly-words': + specifier: ^1.2.2 + version: 1.2.2 '@types/node': specifier: 24.10.1 version: 24.10.1 @@ -1516,6 +1522,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/friendly-words@1.2.2': + resolution: {integrity: sha512-8Gr82MHnVM0CAf8r0iXBI5r3OSZydew3tzNCB6TaeEXmWUED8gkInwHh4+IWdyG5JwQBPfEsQmNvTSxbjM3deQ==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -3739,6 +3748,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + typebox@1.0.73: + resolution: {integrity: sha512-hZSgYMjem+50YuTZzw3XWKyxkDjixapRIAoKPr2ugrpssEwwWzSeowZufiOQNYefnYFtf9KUtYdcnNSj6ZXaSQ==} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5354,6 +5366,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/friendly-words@1.2.2': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -7872,6 +7886,8 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + typebox@1.0.73: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 diff --git a/test/lib/notebooks/storage.spec.ts b/test/lib/notebooks/storage.spec.ts new file mode 100644 index 0000000..1df0c79 --- /dev/null +++ b/test/lib/notebooks/storage.spec.ts @@ -0,0 +1,190 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from "vitest"; +import {generate} from "short-uuid"; +import {loadNotebooksFromStorage, saveNotebooksToStorage} from "../../../lib/notebooks/storage.ts"; +import {createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, snapshotKey} from "../../../lib/notebooks/utils.ts"; +import type {Notebook, Snapshot} from "../../../lib/notebooks/schema.ts"; + +const generatedIds = ["gen-1", "gen-2", "gen-3", "gen-4", "gen-5", "gen-6", "gen-7", "gen-8", "gen-9", "gen-10"]; +let idIndex = 0; + +vi.mock("short-uuid", () => ({ + generate: vi.fn(() => generatedIds[idIndex++] ?? `gen-${idIndex + 1}`), +})); + +function persistSnapshot(snapshot: Snapshot) { + localStorage.setItem(snapshotKey(snapshot.id), JSON.stringify(snapshot)); +} + +function makeSnapshot(content: string, name: string | null = null, created?: number): Snapshot { + const snapshot = createSnapshot(content, name); + if (created !== undefined) snapshot.created = created; + persistSnapshot(snapshot); + return snapshot; +} + +beforeEach(() => { + idIndex = 0; + localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("loadNotebooksFromStorage", () => { + it("creates a snapshot when only content is present", () => { + const notebookId = generate(); + const raw = [ + { + id: notebookId, + title: "Only Content", + created: 1, + updated: 2, + content: "hello world", + autoRun: false, + runtime: "js", + }, + ]; + localStorage.setItem(RECHO_FILES_KEY, JSON.stringify(raw)); + + const [notebook] = loadNotebooksFromStorage(); + + expect(notebook.title).toBe("Only Content"); + expect(notebook.snapshots).toHaveLength(1); + const [snapshot] = notebook.snapshots; + expect(snapshot.content).toBe("hello world"); + expect(JSON.parse(localStorage.getItem(snapshotKey(snapshot.id))!)).toMatchObject({ + id: snapshot.id, + content: "hello world", + }); + }); + + it("loads snapshots when only snapshot ids are present and keeps ordering rules", () => { + const latest = makeSnapshot("latest content", null, 500); + const olderA = makeSnapshot("older a", null, 100); + const olderB = makeSnapshot("older b", "older b", 300); + const notebookId = generate(); + + const raw = [ + { + id: notebookId, + title: "Snapshots Only", + created: 10, + updated: 11, + snapshots: [latest.id, olderA.id, olderB.id], + autoRun: true, + runtime: "py", + }, + ]; + localStorage.setItem(RECHO_FILES_KEY, JSON.stringify(raw)); + + const [notebook] = loadNotebooksFromStorage(); + + expect(notebook.snapshots.map((s) => s.id)).toEqual([latest.id, olderB.id, olderA.id]); + expect(notebook.snapshots[1]?.name).toBe("older b"); + }); + + it("prepends a new snapshot when both content and snapshots are present", () => { + const existing = makeSnapshot("old", null, 123); + const notebookId = generate(); + + const raw = [ + { + id: notebookId, + title: "Mixed", + created: 20, + updated: 21, + content: "new content", + snapshots: [existing.id], + autoRun: false, + runtime: "js", + }, + ]; + localStorage.setItem(RECHO_FILES_KEY, JSON.stringify(raw)); + + const [notebook] = loadNotebooksFromStorage(); + + expect(notebook.snapshots).toHaveLength(2); + expect(notebook.snapshots[0]?.content).toBe("new content"); + expect(notebook.snapshots[1]?.id).toBe(existing.id); + expect(JSON.parse(localStorage.getItem(snapshotKey(notebook.snapshots[0]!.id))!)).toMatchObject({ + content: "new content", + }); + }); + + it("initializes a default snapshot when neither content nor snapshots are present", () => { + const notebookId = generate(); + const raw = [ + { + id: notebookId, + title: "Empty", + created: 30, + updated: 31, + autoRun: true, + runtime: "js", + }, + ]; + localStorage.setItem(RECHO_FILES_KEY, JSON.stringify(raw)); + + const [notebook] = loadNotebooksFromStorage(); + + expect(notebook.snapshots).toHaveLength(1); + expect(notebook.snapshots[0]?.content).toBe(DEFAULT_CONTENT); + }); +}); + +describe("saveNotebooksToStorage", () => { + it("serializes notebooks to JSON and saves snapshot contents", () => { + const notebooks: Notebook[] = [ + { + id: generate(), + title: "Save 1", + created: 1, + updated: 2, + autoRun: false, + runtime: "js", + snapshots: [makeSnapshot("content-1", "first", 1), makeSnapshot("content-2", null, 2)], + }, + { + id: generate(), + title: "Save 2", + created: 3, + updated: 4, + autoRun: true, + runtime: "py", + snapshots: [makeSnapshot("content-3", null, 3)], + }, + { + id: generate(), + title: "Save 3", + created: 3, + updated: 7, + autoRun: true, + runtime: "py", + snapshots: [makeSnapshot("content-3", null, 3)], + }, + ]; + + saveNotebooksToStorage(notebooks, []); + + const storedRaw = localStorage.getItem(RECHO_FILES_KEY); + expect(storedRaw).not.toBeNull(); + + const parsed = JSON.parse(storedRaw!); + const notebooksWithSnapshotIds = notebooks.map(({snapshots, ...rest}) => ({ + ...rest, + snapshots: snapshots.map((s) => s.id), + })); + expect(parsed).toEqual(notebooksWithSnapshotIds); + + const snapshots = notebooks.flatMap((n) => n.snapshots); + + for (const snapshot of snapshots) { + expect(JSON.parse(localStorage.getItem(snapshotKey(snapshot.id))!)).toMatchObject({ + content: snapshot.content, + }); + } + }); +}); From 534a97ff60ada84df246d27716f068ec2381dd8c Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:08:39 +0800 Subject: [PATCH 03/13] Fix import file extensions --- components/ui/button.tsx | 2 +- components/ui/dialog.tsx | 2 +- components/ui/input.tsx | 2 +- components/ui/separator.tsx | 2 +- components/ui/sheet.tsx | 2 +- components/ui/sidebar.tsx | 16 ++++++++-------- components/ui/skeleton.tsx | 2 +- components/ui/tooltip.tsx | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 47f3a55..34b924c 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import {Slot} from "@radix-ui/react-slot"; import {cva, type VariantProps} from "class-variance-authority"; -import {cn} from "@/lib/utils"; +import {cn} from "@/lib/utils.ts"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index df2268a..f273d5a 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import {XIcon} from "lucide-react"; -import {cn} from "@/lib/utils"; +import {cn} from "@/lib/utils.ts"; function Dialog({...props}: React.ComponentProps) { return ; diff --git a/components/ui/input.tsx b/components/ui/input.tsx index f66f04c..65738d0 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import {cn} from "@/lib/utils"; +import {cn} from "@/lib/utils.ts"; function Input({className, type, ...props}: React.ComponentProps<"input">) { return ( diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx index f2e02d6..6b1bd04 100644 --- a/components/ui/separator.tsx +++ b/components/ui/separator.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import {cn} from "@/lib/utils"; +import {cn} from "@/lib/utils.ts"; function Separator({ className, diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx index 23a641d..7381d21 100644 --- a/components/ui/sheet.tsx +++ b/components/ui/sheet.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import * as SheetPrimitive from "@radix-ui/react-dialog"; import {XIcon} from "lucide-react"; -import {cn} from "@/lib/utils"; +import {cn} from "@/lib/utils.ts"; function Sheet({...props}: React.ComponentProps) { return ; diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index 6ff9563..93ba0ee 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -5,14 +5,14 @@ import {Slot} from "@radix-ui/react-slot"; import {cva, type VariantProps} from "class-variance-authority"; import {PanelLeftIcon} from "lucide-react"; -import {useIsMobile} from "@/hooks/use-mobile"; -import {cn} from "@/lib/utils"; -import {Button} from "@/components/ui/button"; -import {Input} from "@/components/ui/input"; -import {Separator} from "@/components/ui/separator"; -import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"; -import {Skeleton} from "@/components/ui/skeleton"; -import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; +import {useIsMobile} from "@/hooks/use-mobile.ts"; +import {cn} from "@/lib/utils.ts"; +import {Button} from "@/components/ui/button.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {Separator} from "@/components/ui/separator.tsx"; +import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet.tsx"; +import {Skeleton} from "@/components/ui/skeleton.tsx"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx index e7d8545..fb6b4d0 100644 --- a/components/ui/skeleton.tsx +++ b/components/ui/skeleton.tsx @@ -1,4 +1,4 @@ -import {cn} from "@/lib/utils"; +import {cn} from "@/lib/utils.ts"; function Skeleton({className, ...props}: React.ComponentProps<"div">) { return
; diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx index 544443d..3782c0d 100644 --- a/components/ui/tooltip.tsx +++ b/components/ui/tooltip.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import {cn} from "@/lib/utils"; +import {cn} from "@/lib/utils.ts"; function TooltipProvider({delayDuration = 0, ...props}: React.ComponentProps) { return ; From cb2fd7d13bf51c0027cfc27574552d334cd83e2d Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:04:48 +0800 Subject: [PATCH 04/13] Fix localStorage data migration --- lib/notebooks/schema.ts | 6 +- test/lib/notebooks/migration.spec.ts | 119 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 test/lib/notebooks/migration.spec.ts diff --git a/lib/notebooks/schema.ts b/lib/notebooks/schema.ts index de10624..872d7d1 100644 --- a/lib/notebooks/schema.ts +++ b/lib/notebooks/schema.ts @@ -1,13 +1,15 @@ import Type from "typebox"; +const TimestampSchema = Type.Union([Type.Number(), Type.Decode(Type.String(), (value) => new Date(value).getTime())]); + /** * A schema for loading data from localStorage. */ export const NotebookSchema = Type.Object({ id: Type.String(), title: Type.String(), - created: Type.Number(), - updated: Type.Number(), + created: TimestampSchema, + updated: TimestampSchema, // For backward compatibility with old notebooks, we make `snapshots` and // `content` optional. diff --git a/test/lib/notebooks/migration.spec.ts b/test/lib/notebooks/migration.spec.ts new file mode 100644 index 0000000..ac4ebc5 --- /dev/null +++ b/test/lib/notebooks/migration.spec.ts @@ -0,0 +1,119 @@ +import {loadNotebooksFromStorage} from "../../../lib/notebooks/storage.ts"; +import {RECHO_FILES_KEY} from "../../../lib/notebooks/utils.ts"; +import {describe, it, expect} from "vitest"; + +describe("loadNotebooksFromStorage", () => { + it("should be able to load old data from localStorage", () => { + localStorage.setItem(RECHO_FILES_KEY, JSON.stringify(sampleData)); + + const notebooks = loadNotebooksFromStorage(); + + expect(notebooks).toHaveLength(sampleData.length); + + for (let i = 0, n = sampleData.length; i < n; i++) { + const actual = notebooks[i]; + const expected = sampleData[i]; + expect(actual.id).toBe(expected.id); + expect(actual.title).toBe(expected.title); + expect(actual.created).toBe(expected.created); + expect(actual.updated).toBe(expected.updated); + expect(actual.autoRun).toBe(expected.autoRun); + expect(actual.runtime).toBe(expected.runtime); + expect(actual.snapshots).toHaveLength(1); + expect(actual.snapshots[0].content).toBe(expected.content); + } + }); +}); + +const sampleData = [ + { + id: "5PW6twZuTgkWzMCY6q3Vmv", + title: "w-a.js", + created: "2025-12-20T13:45:25.050Z", + updated: "2026-01-07T06:20:43.023Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "dszL4HiSgYtWsEAccQfnhb", + title: "v-s.js", + created: "2025-12-22T12:31:02.947Z", + updated: "2026-01-07T06:20:40.647Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "bKjmrjw11aSPLAzZiYDHy4", + title: "r-h.js", + created: "2026-01-07T04:09:17.400Z", + updated: "2026-01-07T06:20:35.027Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "sYJTPySp4MZiKatDErSHMb", + title: "s-n.js", + created: "2025-12-20T17:24:43.604Z", + updated: "2026-01-07T06:20:27.557Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "vi5Ag14X3BH4d92EBiJJL7", + title: "e-a.js", + created: "2025-12-20T18:26:39.993Z", + updated: "2026-01-07T06:20:23.621Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "pcqVuHbiLtx14W8eQoSV24", + title: "f-p.js", + created: "2026-01-06T09:31:32.977Z", + updated: "2026-01-06T09:31:32.977Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "hCWCRqo1XVGYLth4Gsxavk", + title: "e-v.js", + created: "2025-12-20T13:44:23.250Z", + updated: "2025-12-20T13:44:37.239Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "d4Wjrx8d53nVoio4YnKHWw", + title: "p-s.js", + created: "2025-12-20T13:36:21.175Z", + updated: "2025-12-20T13:43:19.175Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "pgkVDcraor1D33bsZbfdwN", + title: "c-s.js", + created: "2025-12-05T14:36:03.366Z", + updated: "2025-12-05T17:07:32.715Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, + { + id: "9o9u6EUQcv3zmSvqhVm6RT", + title: "s-k.js", + created: "2025-12-05T08:14:26.335Z", + updated: "2025-12-05T08:14:26.335Z", + content: "const PLACEHOLDER = 42;", + autoRun: true, + runtime: "javascript@0.1.0", + }, +]; From 82ae66e501a204fd691c965aec2f192da8222374 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:14:22 +0800 Subject: [PATCH 05/13] Should use `Decode` instead of `Parse` --- lib/notebooks/storage.ts | 2 +- test/lib/notebooks/migration.spec.ts | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/notebooks/storage.ts b/lib/notebooks/storage.ts index 2d0a43b..885c931 100644 --- a/lib/notebooks/storage.ts +++ b/lib/notebooks/storage.ts @@ -27,7 +27,7 @@ export function loadNotebooksFromStorage(): Notebook[] { try { const rawObject = JSON.parse(rawString); // Parse the notebook first. - const rawNotebooks = Value.Parse(NotebooksSchema, rawObject); + const rawNotebooks = Value.Decode(NotebooksSchema, rawObject); const notebooks: Notebook[] = []; for (let i = 0, n = rawNotebooks.length; i < n; i++) { let snapshots: Snapshot[]; diff --git a/test/lib/notebooks/migration.spec.ts b/test/lib/notebooks/migration.spec.ts index ac4ebc5..38025d4 100644 --- a/test/lib/notebooks/migration.spec.ts +++ b/test/lib/notebooks/migration.spec.ts @@ -1,5 +1,5 @@ -import {loadNotebooksFromStorage} from "../../../lib/notebooks/storage.ts"; -import {RECHO_FILES_KEY} from "../../../lib/notebooks/utils.ts"; +import {loadNotebooksFromStorage} from "@/lib/notebooks/storage.ts"; +import {RECHO_FILES_KEY} from "@/lib/notebooks/utils.ts"; import {describe, it, expect} from "vitest"; describe("loadNotebooksFromStorage", () => { @@ -11,16 +11,16 @@ describe("loadNotebooksFromStorage", () => { expect(notebooks).toHaveLength(sampleData.length); for (let i = 0, n = sampleData.length; i < n; i++) { - const actual = notebooks[i]; - const expected = sampleData[i]; - expect(actual.id).toBe(expected.id); - expect(actual.title).toBe(expected.title); - expect(actual.created).toBe(expected.created); - expect(actual.updated).toBe(expected.updated); - expect(actual.autoRun).toBe(expected.autoRun); - expect(actual.runtime).toBe(expected.runtime); - expect(actual.snapshots).toHaveLength(1); - expect(actual.snapshots[0].content).toBe(expected.content); + const result = notebooks[i]; + const original = sampleData[i]; + expect(result.id).toBe(original.id); + expect(result.title).toBe(original.title); + expect(result.created).toBe(new Date(original.created).getTime()); + expect(result.updated).toBe(new Date(original.updated).getTime()); + expect(result.autoRun).toBe(original.autoRun); + expect(result.runtime).toBe(original.runtime); + expect(result.snapshots).toHaveLength(1); + expect(result.snapshots[0].content).toBe(original.content); } }); }); From 6df73ce888ed72e0ea3cdc1d29b92639c1c74e46 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:22:37 +0800 Subject: [PATCH 06/13] Add type check to the test command --- .gitignore | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index cc239aa..b562531 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules dist .next out -.vercel \ No newline at end of file +.vercel +*.tsbuildinfo diff --git a/package.json b/package.json index 1d93b06..b122521 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "app:dev": "next dev", "app:build": "next build", "app:start": "next start", - "test": "npm run test:lint && npm run test:format && npm run test:js", + "test": "tsc --noEmit && npm run test:lint && npm run test:format && npm run test:js", "test:js": "TZ=America/New_York vitest --config vite.config.js", "test:format": "prettier --check editor runtime test app", "test:lint": "eslint" From 270f6bca4c36d6763386eb9bb60646a22c192ed6 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:02:36 +0800 Subject: [PATCH 07/13] Click the new button to renew the notebook --- app/Nav.jsx | 2 +- app/SafeLink.jsx | 35 -------------------- app/store.js | 43 ------------------------- components/SafeLink.tsx | 33 +++++++++++++++++++ components/notebooks/EditorPageHero.tsx | 2 +- components/notebooks/NotebookTitle.tsx | 16 +++++---- lib/notebooks/atom.ts | 2 ++ lib/notebooks/hooks.ts | 40 +++++++++++++++++------ lib/notebooks/storage.ts | 7 ++++ tsconfig.json | 4 +-- 10 files changed, 85 insertions(+), 99 deletions(-) delete mode 100644 app/SafeLink.jsx delete mode 100644 app/store.js create mode 100644 components/SafeLink.tsx diff --git a/app/Nav.jsx b/app/Nav.jsx index 62c215c..3f50656 100644 --- a/app/Nav.jsx +++ b/app/Nav.jsx @@ -2,7 +2,7 @@ import {usePathname} from "next/navigation"; import {useState, useEffect, useRef} from "react"; -import {SafeLink} from "./SafeLink.jsx"; +import {SafeLink} from "../components/SafeLink.tsx"; import {cn} from "./cn.js"; import {Plus, Share, Github, Menu, FolderCode} from "lucide-react"; import {Tooltip} from "react-tooltip"; diff --git a/app/SafeLink.jsx b/app/SafeLink.jsx deleted file mode 100644 index e978ddd..0000000 --- a/app/SafeLink.jsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; -import {useSyncExternalStore} from "react"; -import {useRouter} from "next/navigation"; -import {isDirtyStore, countStore} from "./store.js"; -import Link from "next/link"; - -export function SafeLink({href, children, className, onClick = () => {}, ...props}) { - const router = useRouter(); - const isDirty = useSyncExternalStore( - isDirtyStore.subscribe, - isDirtyStore.getSnapshot, - isDirtyStore.getServerSnapshot, - ); - - const handleClick = (e) => { - e.preventDefault(); - if (isDirty) { - const confirmLeave = window.confirm("Your changes will be lost."); - if (!confirmLeave) return; - } - isDirtyStore.setDirty(false); - if (href === "/") { - console.log(`SafeLink (href = "${href}", count = ${countStore.getSnapshot()})`); - countStore.increment(); - } - router.push(href); - onClick?.(); - }; - - return ( - - {children} - - ); -} diff --git a/app/store.js b/app/store.js deleted file mode 100644 index a4b72a9..0000000 --- a/app/store.js +++ /dev/null @@ -1,43 +0,0 @@ -function emitChange(listeners) { - for (const listener of listeners) listener(); -} - -let count = 0; -const counterListeners = new Set(); - -export const countStore = { - increment() { - count++; - emitChange(counterListeners); - }, - subscribe(listener) { - counterListeners.add(listener); - return () => counterListeners.delete(listener); - }, - getSnapshot() { - return count; - }, - getServerSnapshot() { - return 0; - }, -}; - -let isDirty = false; -const dirtyListeners = new Set(); - -export const isDirtyStore = { - setDirty(dirty) { - isDirty = dirty; - emitChange(dirtyListeners); - }, - subscribe(listener) { - dirtyListeners.add(listener); - return () => dirtyListeners.delete(listener); - }, - getSnapshot() { - return isDirty; - }, - getServerSnapshot() { - return false; - }, -}; diff --git a/components/SafeLink.tsx b/components/SafeLink.tsx new file mode 100644 index 0000000..582b671 --- /dev/null +++ b/components/SafeLink.tsx @@ -0,0 +1,33 @@ +"use client"; + +import {useRouter} from "next/navigation"; +import Link, {type LinkProps} from "next/link"; +import { useIsDirty } from "@/lib/notebooks/storage.ts"; + +export type SafeLinkProps = { + href: string; + children: React.ReactNode; + className?: string; + onClick?: () => void; +} & LinkProps; + +export function SafeLink({href, children, className, onClick = () => {}, ...props}: SafeLinkProps) { + const router = useRouter(); + const [isDirty, setIsDirty] = useIsDirty(); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (isDirty && !window.confirm("Your changes will be lost.")) return; + setIsDirty(false); + // If the link is to the new notebook page, we need to renew the notebook. + if (href === "/") window.dispatchEvent(new Event("renew-notebook")); + router.push(href); + onClick?.(); + }; + + return ( + + {children} + + ); +} diff --git a/components/notebooks/EditorPageHero.tsx b/components/notebooks/EditorPageHero.tsx index 9aa4e25..4b76663 100644 --- a/components/notebooks/EditorPageHero.tsx +++ b/components/notebooks/EditorPageHero.tsx @@ -1,5 +1,5 @@ import {useLatestNotebooks} from "@/lib/notebooks/hooks.ts"; -import {SafeLink} from "../../app/SafeLink.jsx"; +import {SafeLink} from "../SafeLink.tsx"; import {cn} from "../../app/cn.js"; export type EditorPageHeroProps = { diff --git a/components/notebooks/NotebookTitle.tsx b/components/notebooks/NotebookTitle.tsx index 0c60edd..449d348 100644 --- a/components/notebooks/NotebookTitle.tsx +++ b/components/notebooks/NotebookTitle.tsx @@ -75,13 +75,15 @@ export function NotebookTitle({title, setTitle, isDraft, isDirty, onCreate}: Not ) : ( {title} )} - {isDraft || isDirty ? ( - + {isDraft ? ( + isDirty ? ( + + ) : null ) : ( ], void saveNotebooksToStorage(newValue, oldValue); }, ); + +export const isDirtyAtom = atom(false); diff --git a/lib/notebooks/hooks.ts b/lib/notebooks/hooks.ts index ece28d8..64cb510 100644 --- a/lib/notebooks/hooks.ts +++ b/lib/notebooks/hooks.ts @@ -1,8 +1,8 @@ "use client"; import {useAtom, useAtomValue} from "jotai"; -import {useCallback, useMemo, useRef, useState, type Dispatch} from "react"; -import {notebooksAtom} from "./atom.ts"; +import {useCallback, useEffect, useMemo, useRef, useState, type Dispatch} from "react"; +import {isDirtyAtom, notebooksAtom} from "./atom.ts"; import {updateNotebook} from "./operations.ts"; import type {Notebook, Snapshot} from "./schema.ts"; import {createNotebook, createSnapshot, DEFAULT_CONTENT} from "./utils.ts"; @@ -127,6 +127,12 @@ const notFoundResult: UseNotebookResult = Object.freeze({ export function useNotebook(id: string | undefined): UseNotebookResult { const [notebooks, setNotebooks] = useAtom(notebooksAtom); + // The ID of the notebook that we are actually editing. + // - If the URL contains an ID, this is set to the ID. + // - If the URL does not contain an ID, this is set to `undefined`, which + // means that we are editing a draft notebook. + // - When the draft notebook is saved, this is set to the ID of the notebook. + // - When the user creates a new notebook, this is set to `undefined` again. const [actualId, setActualId] = useState(id); // The notebook found by the ID. @@ -142,17 +148,31 @@ export function useNotebook(id: string | undefined): UseNotebookResult { // Initialize the last edit timestamp to the last saved timestamp. const lastEditTimestampRef = useRef(lastSavedTimestamp); - const [isDirty, setIsDirty] = useState(false); + const [isDirty, setIsDirty] = useAtom(isDirtyAtom); const [initialContent, setInitialContent] = useState(foundNotebook?.snapshots[0].content ?? DEFAULT_CONTENT); - const updateContent = useCallback((content: string) => { - contentRef.current = content; - lastEditTimestampRef.current = Date.now(); - setIsDirty(true); - }, []); + const updateContent = useCallback( + (content: string) => { + contentRef.current = content; + lastEditTimestampRef.current = Date.now(); + setIsDirty(true); + }, + [setIsDirty], + ); + + // When we receive the `renew-notebook` event, we need to create a new draft. + useEffect(() => { + const onRenewNotebook = (e: Event) => { + e.stopPropagation(); + setIsDirty(false); + setActualId(undefined); + setDraftNotebook(createNotebook()); + }; + window.addEventListener("renew-notebook", onRenewNotebook); + return () => window.removeEventListener("renew-notebook", onRenewNotebook); + }, [setIsDirty]); - // The notebook that is currently being edited. return useMemo(() => { if (foundNotebook === undefined) { if (typeof actualId === "string") { @@ -244,7 +264,7 @@ export function useNotebook(id: string | undefined): UseNotebookResult { }, }; } - }, [actualId, isDirty, draftNotebook, foundNotebook, setNotebooks, initialContent, updateContent]); + }, [actualId, isDirty, setIsDirty, draftNotebook, foundNotebook, setNotebooks, initialContent, updateContent]); } export type UseSnapshotsResult = { diff --git a/lib/notebooks/storage.ts b/lib/notebooks/storage.ts index 885c931..e66a96c 100644 --- a/lib/notebooks/storage.ts +++ b/lib/notebooks/storage.ts @@ -1,6 +1,9 @@ import Value from "typebox/value"; import {NotebooksSchema, type Notebook, type Snapshot} from "./schema.ts"; import {createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, saveSnapshot, snapshotKey} from "./utils.ts"; +import { isDirtyAtom } from "./atom.ts"; +import type { Dispatch, SetStateAction } from "react"; +import { useAtom } from "jotai"; export function loadSnapshotsFromStorage(ids: string[]): Snapshot[] { const snapshots: Snapshot[] = []; @@ -117,3 +120,7 @@ export function saveNotebooksToStorage(notebooks: Notebook[], previousNotebooks: } } } + +export function useIsDirty(): [boolean, Dispatch>] { + return useAtom(isDirtyAtom); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b9d3e17..391e89d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,10 @@ // "outDir": "./dist", // Environment Settings // See also https://aka.ms/tsconfig/module - "module": "nodenext", + "module": "preserve", "target": "esnext", "types": [], - "moduleResolution": "nodenext", + "moduleResolution": "bundler", "allowImportingTsExtensions": true, // For nodejs: "lib": ["esnext", "DOM"], From e0317f3820e7185c9206389ea2bddf31f21d7ee4 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:16:39 +0800 Subject: [PATCH 08/13] Support renaming snapshots --- components/notebooks/SnapshotEntry.tsx | 185 +++++++++++++++++++++++ components/notebooks/SnapshotsDialog.tsx | 82 ++-------- lib/notebooks/hooks.ts | 18 ++- lib/notebooks/operations.ts | 8 + 4 files changed, 221 insertions(+), 72 deletions(-) create mode 100644 components/notebooks/SnapshotEntry.tsx diff --git a/components/notebooks/SnapshotEntry.tsx b/components/notebooks/SnapshotEntry.tsx new file mode 100644 index 0000000..6103ccb --- /dev/null +++ b/components/notebooks/SnapshotEntry.tsx @@ -0,0 +1,185 @@ +"use client"; +import {useState, useRef, useEffect} from "react"; +import {Trash2, RotateCcw, Pencil, Check, X} from "lucide-react"; +import {Button} from "../ui/button.tsx"; +import {Input} from "../ui/input.tsx"; +import {cn} from "../../app/cn.js"; +import type {Snapshot} from "@/lib/notebooks/schema.ts"; + +export type SnapshotEntryProps = { + snapshot: Snapshot; + index: number; + isSelected: boolean; + onSelect: (snapshot: Snapshot) => void; + onDelete: (snapshotId: string, e: React.MouseEvent) => void; + onRestore: (snapshotId: string, e: React.MouseEvent) => void; + onRename: (snapshotId: string, newName: string) => void; +}; + +export function SnapshotEntry({ + snapshot, + index, + isSelected, + onSelect, + onDelete, + onRestore, + onRename, +}: SnapshotEntryProps) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(snapshot.name ?? ""); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + function handleStartEdit(e: React.MouseEvent) { + e.stopPropagation(); + setEditName(snapshot.name ?? ""); + setIsEditing(true); + } + + function handleSave(e?: React.MouseEvent) { + e?.stopPropagation(); + const trimmedName = editName.trim(); + if (trimmedName && trimmedName !== snapshot.name) { + onRename(snapshot.id, trimmedName); + } + setIsEditing(false); + } + + function handleCancel(e?: React.MouseEvent) { + e?.stopPropagation(); + setIsEditing(false); + setEditName(snapshot.name ?? ""); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + handleCancel(); + } + } + + const displayName = index === 0 ? "The Last Saved Version" : snapshot.name ?? "Untitled snapshot"; + const canEdit = index !== 0; // Don't allow editing the first/latest snapshot + + return ( +
!isEditing && onSelect(snapshot)} + > +
+
+ {isEditing ? ( +
e.stopPropagation()}> + setEditName(e.target.value)} + onKeyDown={handleKeyDown} + className={cn("h-7 text-sm")} + placeholder="Snapshot name" + /> + + +
+ ) : ( + <> +
+
+ {displayName} +
+ {canEdit && ( + + )} +
+
{formatDate(snapshot.created)}
+ + )} +
+ {!isEditing && ( +
+ + +
+ )} +
+
+ ); +} + +export function formatDate(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + // Create relative time formatter + const rtf = new Intl.RelativeTimeFormat("en", {numeric: "auto"}); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return rtf.format(-diffMins, "minute"); + if (diffHours < 24) return rtf.format(-diffHours, "hour"); + if (diffDays < 7) return rtf.format(-diffDays, "day"); + + // For older dates, use DateTimeFormat + return new Intl.DateTimeFormat("en", { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + diff --git a/components/notebooks/SnapshotsDialog.tsx b/components/notebooks/SnapshotsDialog.tsx index c240d8c..54b782d 100644 --- a/components/notebooks/SnapshotsDialog.tsx +++ b/components/notebooks/SnapshotsDialog.tsx @@ -1,6 +1,6 @@ "use client"; import {useState, useEffect} from "react"; -import {Camera, Trash2, RotateCcw} from "lucide-react"; +import {Camera, RotateCcw} from "lucide-react"; import { Dialog, DialogContent, @@ -14,6 +14,7 @@ import {cn} from "../../app/cn.js"; import {Editor} from "../../app/Editor.jsx"; import {useSnapshots} from "@/lib/notebooks/hooks.ts"; import type {Notebook, Snapshot} from "@/lib/notebooks/schema.ts"; +import {SnapshotEntry, formatDate} from "./SnapshotEntry.tsx"; export type SnapshotsDialogProps = { notebook: Readonly; @@ -25,7 +26,7 @@ export type SnapshotsDialogProps = { }; export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, onRestore}: SnapshotsDialogProps) { - const {snapshots, deleteSnapshot} = useSnapshots(notebook.id); + const {snapshots, renameSnapshot, deleteSnapshot} = useSnapshots(notebook.id); const [selectedSnapshot, setSelectedSnapshot] = useState(null); const [isCreating, setIsCreating] = useState(false); @@ -96,57 +97,21 @@ export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, o

No snapshots yet. Create your first snapshot to get started.

) : ( -
+
{snapshots.map((snapshot, index) => ( -
setSelectedSnapshot(snapshot)} - > -
-
-
- {index === 0 ? "The Last Saved Version" : (snapshot.name ?? "Untitled snapshot")} -
-
{formatDate(snapshot.created)}
-
-
- - -
-
-
+ snapshot={snapshot} + index={index} + isSelected={selectedSnapshot?.id === snapshot.id} + onSelect={setSelectedSnapshot} + onDelete={handleDelete} + onRestore={(id, e) => { + e.stopPropagation(); + handleRestore(id); + }} + onRename={renameSnapshot} + /> ))}
)} @@ -200,18 +165,3 @@ export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, o ); } - -function formatDate(timestamp: number): string { - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`; - if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`; - return date.toLocaleDateString() + " " + date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"}); -} diff --git a/lib/notebooks/hooks.ts b/lib/notebooks/hooks.ts index 64cb510..90fb449 100644 --- a/lib/notebooks/hooks.ts +++ b/lib/notebooks/hooks.ts @@ -3,7 +3,7 @@ import {useAtom, useAtomValue} from "jotai"; import {useCallback, useEffect, useMemo, useRef, useState, type Dispatch} from "react"; import {isDirtyAtom, notebooksAtom} from "./atom.ts"; -import {updateNotebook} from "./operations.ts"; +import {removeItem, updateItem, updateNotebook} from "./operations.ts"; import type {Notebook, Snapshot} from "./schema.ts"; import {createNotebook, createSnapshot, DEFAULT_CONTENT} from "./utils.ts"; import {generate} from "short-uuid"; @@ -269,7 +269,7 @@ export function useNotebook(id: string | undefined): UseNotebookResult { export type UseSnapshotsResult = { snapshots: Readonly[]; - addSnapshot: (snapshot: Snapshot) => void; + renameSnapshot: (snapshotId: string, name: string) => void; deleteSnapshot: (snapshotId: string) => void; }; @@ -287,18 +287,24 @@ export function useSnapshots(id: string): UseSnapshotsResult { if (notebook === undefined) { return { snapshots: [], - addSnapshot: () => {}, + renameSnapshot: () => {}, deleteSnapshot: () => {}, }; } else { return { snapshots: notebook.snapshots, - addSnapshot: (snapshot: Snapshot) => { - setNotebooks(notebooks.map((n) => (n.id === id ? {...n, snapshots: [snapshot, ...n.snapshots]} : n))); + renameSnapshot: (snapshotId: string, name: string) => { + setNotebooks((oldNotebooks) => + updateItem(oldNotebooks, id, (oldNotebook) => ({ + snapshots: updateItem(oldNotebook.snapshots, snapshotId, {name}), + })), + ); }, deleteSnapshot: (snapshotId: string) => { setNotebooks( - notebooks.map((n) => (n.id === id ? {...n, snapshots: n.snapshots.filter((s) => s.id !== snapshotId)} : n)), + updateItem(notebooks, id, (oldNotebook) => ({ + snapshots: removeItem(oldNotebook.snapshots, snapshotId), + })), ); }, }; diff --git a/lib/notebooks/operations.ts b/lib/notebooks/operations.ts index 7d45862..6741b24 100644 --- a/lib/notebooks/operations.ts +++ b/lib/notebooks/operations.ts @@ -7,3 +7,11 @@ export function setNotebook(notebooks: Notebook[], id: string, notebook: Noteboo export function updateNotebook(notebooks: Notebook[], id: string, fields: Partial): Notebook[] { return notebooks.map((n) => (n.id === id ? {...n, ...fields} : n)); } + +export function updateItem(items: T[], id: string, update: Partial | ((item: T) => Partial)): T[] { + return items.map((item) => (item.id === id ? {...item, ...(typeof update === "function" ? update(item) : update)} : item)); +} + +export function removeItem(items: T[], id: string): T[] { + return items.filter((item) => item.id === id); +} From 28e1e92ee794eb69c7bfaeb86665a4d654755376 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 8 Jan 2026 01:38:45 +0800 Subject: [PATCH 09/13] Run Prettier --- components/SafeLink.tsx | 2 +- components/notebooks/SnapshotEntry.tsx | 3 +-- lib/notebooks/operations.ts | 10 ++++++++-- lib/notebooks/storage.ts | 8 ++++---- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/components/SafeLink.tsx b/components/SafeLink.tsx index 582b671..892b15e 100644 --- a/components/SafeLink.tsx +++ b/components/SafeLink.tsx @@ -2,7 +2,7 @@ import {useRouter} from "next/navigation"; import Link, {type LinkProps} from "next/link"; -import { useIsDirty } from "@/lib/notebooks/storage.ts"; +import {useIsDirty} from "@/lib/notebooks/storage.ts"; export type SafeLinkProps = { href: string; diff --git a/components/notebooks/SnapshotEntry.tsx b/components/notebooks/SnapshotEntry.tsx index 6103ccb..66c733a 100644 --- a/components/notebooks/SnapshotEntry.tsx +++ b/components/notebooks/SnapshotEntry.tsx @@ -65,7 +65,7 @@ export function SnapshotEntry({ } } - const displayName = index === 0 ? "The Last Saved Version" : snapshot.name ?? "Untitled snapshot"; + const displayName = index === 0 ? "The Last Saved Version" : (snapshot.name ?? "Untitled snapshot"); const canEdit = index !== 0; // Don't allow editing the first/latest snapshot return ( @@ -182,4 +182,3 @@ export function formatDate(timestamp: number): string { minute: "2-digit", }).format(date); } - diff --git a/lib/notebooks/operations.ts b/lib/notebooks/operations.ts index 6741b24..6c64d9a 100644 --- a/lib/notebooks/operations.ts +++ b/lib/notebooks/operations.ts @@ -8,8 +8,14 @@ export function updateNotebook(notebooks: Notebook[], id: string, fields: Partia return notebooks.map((n) => (n.id === id ? {...n, ...fields} : n)); } -export function updateItem(items: T[], id: string, update: Partial | ((item: T) => Partial)): T[] { - return items.map((item) => (item.id === id ? {...item, ...(typeof update === "function" ? update(item) : update)} : item)); +export function updateItem( + items: T[], + id: string, + update: Partial | ((item: T) => Partial), +): T[] { + return items.map((item) => + item.id === id ? {...item, ...(typeof update === "function" ? update(item) : update)} : item, + ); } export function removeItem(items: T[], id: string): T[] { diff --git a/lib/notebooks/storage.ts b/lib/notebooks/storage.ts index e66a96c..29f46c5 100644 --- a/lib/notebooks/storage.ts +++ b/lib/notebooks/storage.ts @@ -1,9 +1,9 @@ import Value from "typebox/value"; import {NotebooksSchema, type Notebook, type Snapshot} from "./schema.ts"; import {createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, saveSnapshot, snapshotKey} from "./utils.ts"; -import { isDirtyAtom } from "./atom.ts"; -import type { Dispatch, SetStateAction } from "react"; -import { useAtom } from "jotai"; +import {isDirtyAtom} from "./atom.ts"; +import type {Dispatch, SetStateAction} from "react"; +import {useAtom} from "jotai"; export function loadSnapshotsFromStorage(ids: string[]): Snapshot[] { const snapshots: Snapshot[] = []; @@ -123,4 +123,4 @@ export function saveNotebooksToStorage(notebooks: Notebook[], previousNotebooks: export function useIsDirty(): [boolean, Dispatch>] { return useAtom(isDirtyAtom); -} \ No newline at end of file +} From 4c1cd615755ca5784e3cd0700263dac757d61b72 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 8 Jan 2026 01:50:57 +0800 Subject: [PATCH 10/13] Bypass SSR issues using dynamic import --- app/page.jsx | 17 ++++++++++++++++- lib/atoms/isDirty.ts | 3 +++ lib/notebooks/atom.ts | 2 -- lib/notebooks/hooks.ts | 15 ++++++++------- lib/notebooks/storage.ts | 10 +++++----- 5 files changed, 32 insertions(+), 15 deletions(-) create mode 100644 lib/atoms/isDirty.ts diff --git a/app/page.jsx b/app/page.jsx index 87879e4..ef0e2b4 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -1,4 +1,19 @@ -import {EditorPage} from "./EditorPage.jsx"; +"use client"; + +import dynamic from "next/dynamic"; + +const EditorPage = dynamic(() => import("./EditorPage.jsx").then((mod) => ({default: mod.EditorPage})), { + loading: () => , + ssr: false, +}); + +function LoadingIndicator() { + return ( +
+
+
+ ); +} export default function Page() { return ; diff --git a/lib/atoms/isDirty.ts b/lib/atoms/isDirty.ts new file mode 100644 index 0000000..18df2da --- /dev/null +++ b/lib/atoms/isDirty.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const isDirtyAtom = atom(false); diff --git a/lib/notebooks/atom.ts b/lib/notebooks/atom.ts index 6e5c102..908ebd2 100644 --- a/lib/notebooks/atom.ts +++ b/lib/notebooks/atom.ts @@ -14,5 +14,3 @@ export const notebooksAtom = atom], void saveNotebooksToStorage(newValue, oldValue); }, ); - -export const isDirtyAtom = atom(false); diff --git a/lib/notebooks/hooks.ts b/lib/notebooks/hooks.ts index 90fb449..c7f20ca 100644 --- a/lib/notebooks/hooks.ts +++ b/lib/notebooks/hooks.ts @@ -1,12 +1,13 @@ "use client"; -import {useAtom, useAtomValue} from "jotai"; -import {useCallback, useEffect, useMemo, useRef, useState, type Dispatch} from "react"; -import {isDirtyAtom, notebooksAtom} from "./atom.ts"; -import {removeItem, updateItem, updateNotebook} from "./operations.ts"; -import type {Notebook, Snapshot} from "./schema.ts"; -import {createNotebook, createSnapshot, DEFAULT_CONTENT} from "./utils.ts"; -import {generate} from "short-uuid"; +import { isDirtyAtom } from "@/lib/atoms/isDirty.ts"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch } from "react"; +import { generate } from "short-uuid"; +import { notebooksAtom } from "./atom.ts"; +import { removeItem, updateItem, updateNotebook } from "./operations.ts"; +import type { Notebook, Snapshot } from "./schema.ts"; +import { createNotebook, createSnapshot, DEFAULT_CONTENT } from "./utils.ts"; export type UseNotebookResult = { /** diff --git a/lib/notebooks/storage.ts b/lib/notebooks/storage.ts index 29f46c5..0845010 100644 --- a/lib/notebooks/storage.ts +++ b/lib/notebooks/storage.ts @@ -1,9 +1,9 @@ +import { isDirtyAtom } from "@/lib/atoms/isDirty.ts"; +import { useAtom } from "jotai"; +import type { Dispatch, SetStateAction } from "react"; import Value from "typebox/value"; -import {NotebooksSchema, type Notebook, type Snapshot} from "./schema.ts"; -import {createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, saveSnapshot, snapshotKey} from "./utils.ts"; -import {isDirtyAtom} from "./atom.ts"; -import type {Dispatch, SetStateAction} from "react"; -import {useAtom} from "jotai"; +import { NotebooksSchema, type Notebook, type Snapshot } from "./schema.ts"; +import { createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, saveSnapshot, snapshotKey } from "./utils.ts"; export function loadSnapshotsFromStorage(ids: string[]): Snapshot[] { const snapshots: Snapshot[] = []; From 862df3008ee97ffdabe5b630c479aaa137beee23 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:45:43 +0800 Subject: [PATCH 11/13] Prevent the pages from being SSR'ed --- app/page.jsx | 2 + app/works/WorksPage.jsx | 75 ++++++++++++++++++++++++++++++++++++ app/works/page.jsx | 82 +++++++--------------------------------- lib/atoms/isDirty.ts | 2 +- lib/notebooks/hooks.ts | 16 ++++---- lib/notebooks/storage.ts | 10 ++--- 6 files changed, 105 insertions(+), 82 deletions(-) create mode 100644 app/works/WorksPage.jsx diff --git a/app/page.jsx b/app/page.jsx index ef0e2b4..6ba16c0 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -4,6 +4,8 @@ import dynamic from "next/dynamic"; const EditorPage = dynamic(() => import("./EditorPage.jsx").then((mod) => ({default: mod.EditorPage})), { loading: () => , + // As we are reading the notebooks from the localStorage, we don't need to + // render this page on the server side. ssr: false, }); diff --git a/app/works/WorksPage.jsx b/app/works/WorksPage.jsx new file mode 100644 index 0000000..78593ab --- /dev/null +++ b/app/works/WorksPage.jsx @@ -0,0 +1,75 @@ +"use client"; +import {useEffect} from "react"; +import Link from "next/link"; +import {Trash} from "lucide-react"; +import {ThumbnailClient} from "../ThumbnailClient.js"; +import {findFirstOutputRange} from "../shared.js"; +import {cn} from "../cn.js"; +import {useNotebooks} from "@/lib/notebooks/hooks.ts"; + +export function WorksPage() { + const [notebooks, setNotebooks] = useNotebooks(); + const isEmpty = notebooks.length === 0; + + useEffect(() => { + document.title = "Notebooks | Recho Notebook"; + }, []); + + function onDelete(id) { + // deleteNotebook(id); + const newNotebooks = notebooks.filter((notebook) => notebook.id !== id); + setNotebooks(newNotebooks); + } + + if (isEmpty) { + return ( +
+

No notebooks found.

+ + New + +
+ ); + } + + return ( +
+
+ {notebooks.map((notebook) => { + const code = notebook.snapshots[0].content; + return ( +
+
+
+ + {notebook.title} + +
+ Created {new Date(notebook.created).toLocaleDateString()} +
+
+ +
+
+
+ +
+
+
+ ); + })} +
+
+ ); +} diff --git a/app/works/page.jsx b/app/works/page.jsx index 42bc01f..58c9be1 100644 --- a/app/works/page.jsx +++ b/app/works/page.jsx @@ -1,76 +1,22 @@ "use client"; -import {useState, useEffect} from "react"; -import Link from "next/link"; -import {Trash} from "lucide-react"; -import {ThumbnailClient} from "../ThumbnailClient.js"; -// import {getNotebooks, deleteNotebook, getLatestSnapshotContent} from "../api.js"; -import {findFirstOutputRange} from "../shared.js"; -import {cn} from "../cn.js"; -import {useNotebooks} from "@/lib/notebooks/hooks.ts"; -export default function Page() { - const [notebooks, setNotebooks] = useNotebooks(); - const isEmpty = notebooks.length === 0; - - useEffect(() => { - document.title = "Notebooks | Recho Notebook"; - }, []); - - function onDelete(id) { - // deleteNotebook(id); - const newNotebooks = notebooks.filter((notebook) => notebook.id !== id); - setNotebooks(newNotebooks); - } +import dynamic from "next/dynamic"; - if (isEmpty) { - return ( -
-

No notebooks found.

- - New - -
- ); - } +const WorksPage = dynamic(() => import("./WorksPage.jsx").then((mod) => ({default: mod.WorksPage})), { + loading: () => , + // As we are reading the notebooks from the localStorage, we don't need to + // render this page on the server side. + ssr: false, +}); +function LoadingIndicator() { return ( -
-
- {notebooks.map((notebook) => { - const code = notebook.snapshots[0].content; - return ( -
-
-
- - {notebook.title} - -
- Created {new Date(notebook.created).toLocaleDateString()} -
-
- -
-
-
- -
-
-
- ); - })} -
+
+
); } + +export default function Page() { + return ; +} diff --git a/lib/atoms/isDirty.ts b/lib/atoms/isDirty.ts index 18df2da..2e02c0f 100644 --- a/lib/atoms/isDirty.ts +++ b/lib/atoms/isDirty.ts @@ -1,3 +1,3 @@ -import { atom } from "jotai"; +import {atom} from "jotai"; export const isDirtyAtom = atom(false); diff --git a/lib/notebooks/hooks.ts b/lib/notebooks/hooks.ts index c7f20ca..2072182 100644 --- a/lib/notebooks/hooks.ts +++ b/lib/notebooks/hooks.ts @@ -1,13 +1,13 @@ "use client"; -import { isDirtyAtom } from "@/lib/atoms/isDirty.ts"; -import { useAtom, useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch } from "react"; -import { generate } from "short-uuid"; -import { notebooksAtom } from "./atom.ts"; -import { removeItem, updateItem, updateNotebook } from "./operations.ts"; -import type { Notebook, Snapshot } from "./schema.ts"; -import { createNotebook, createSnapshot, DEFAULT_CONTENT } from "./utils.ts"; +import {isDirtyAtom} from "@/lib/atoms/isDirty.ts"; +import {useAtom, useAtomValue} from "jotai"; +import {useCallback, useEffect, useMemo, useRef, useState, type Dispatch} from "react"; +import {generate} from "short-uuid"; +import {notebooksAtom} from "./atom.ts"; +import {removeItem, updateItem, updateNotebook} from "./operations.ts"; +import type {Notebook, Snapshot} from "./schema.ts"; +import {createNotebook, createSnapshot, DEFAULT_CONTENT} from "./utils.ts"; export type UseNotebookResult = { /** diff --git a/lib/notebooks/storage.ts b/lib/notebooks/storage.ts index 0845010..21f6c7f 100644 --- a/lib/notebooks/storage.ts +++ b/lib/notebooks/storage.ts @@ -1,9 +1,9 @@ -import { isDirtyAtom } from "@/lib/atoms/isDirty.ts"; -import { useAtom } from "jotai"; -import type { Dispatch, SetStateAction } from "react"; +import {isDirtyAtom} from "@/lib/atoms/isDirty.ts"; +import {useAtom} from "jotai"; +import type {Dispatch, SetStateAction} from "react"; import Value from "typebox/value"; -import { NotebooksSchema, type Notebook, type Snapshot } from "./schema.ts"; -import { createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, saveSnapshot, snapshotKey } from "./utils.ts"; +import {NotebooksSchema, type Notebook, type Snapshot} from "./schema.ts"; +import {createSnapshot, DEFAULT_CONTENT, RECHO_FILES_KEY, saveSnapshot, snapshotKey} from "./utils.ts"; export function loadSnapshotsFromStorage(ids: string[]): Snapshot[] { const snapshots: Snapshot[] = []; From 5d867f41140be3f583c8b5a31cc710ef852933e0 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:59:14 +0800 Subject: [PATCH 12/13] Show placeholders when loading dynamic components --- app/page.jsx | 61 ++++++++++++++++++++++++++++++++++++++++++++-- app/works/page.jsx | 23 +++++++++++++++-- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/app/page.jsx b/app/page.jsx index 6ba16c0..02c11eb 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -11,8 +11,65 @@ const EditorPage = dynamic(() => import("./EditorPage.jsx").then((mod) => ({defa function LoadingIndicator() { return ( -
-
+
+ {/* Hero section skeleton */} +
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+
+
+
+
+ + {/* Editor container skeleton */} +
+ {/* Toolbar skeleton */} +
+
+
+
+
+
+
+
+
+ + {/* Editor area skeleton */} +
+ {/* Code editor header */} +
+
+
+ {/* Code lines skeleton */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* Output area skeleton */} +
+
+
+
+
+
+
+
+
); } diff --git a/app/works/page.jsx b/app/works/page.jsx index 58c9be1..8dfdb68 100644 --- a/app/works/page.jsx +++ b/app/works/page.jsx @@ -9,10 +9,29 @@ const WorksPage = dynamic(() => import("./WorksPage.jsx").then((mod) => ({defaul ssr: false, }); +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + function LoadingIndicator() { return ( -
-
+
+
+ {[...Array(6)].map((_, i) => ( + + ))} +
); } From 09f575ad9a841ec8d763ffdb9e673b94192c5090 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:15:22 +0800 Subject: [PATCH 13/13] Display the latest snapshot differently --- components/notebooks/SnapshotEntry.tsx | 54 +++++++++++++++++++++--- components/notebooks/SnapshotsDialog.tsx | 21 ++++++++- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/components/notebooks/SnapshotEntry.tsx b/components/notebooks/SnapshotEntry.tsx index 66c733a..7495630 100644 --- a/components/notebooks/SnapshotEntry.tsx +++ b/components/notebooks/SnapshotEntry.tsx @@ -1,6 +1,6 @@ "use client"; import {useState, useRef, useEffect} from "react"; -import {Trash2, RotateCcw, Pencil, Check, X} from "lucide-react"; +import {Trash2, RotateCcw, Pencil, Check, X, FileCheck} from "lucide-react"; import {Button} from "../ui/button.tsx"; import {Input} from "../ui/input.tsx"; import {cn} from "../../app/cn.js"; @@ -65,9 +65,52 @@ export function SnapshotEntry({ } } - const displayName = index === 0 ? "The Last Saved Version" : (snapshot.name ?? "Untitled snapshot"); - const canEdit = index !== 0; // Don't allow editing the first/latest snapshot + const isCurrentVersion = index === 0; + const displayName = snapshot.name ?? "Untitled snapshot"; + const canEdit = !isCurrentVersion; // Don't allow editing the current version + // Current version (first entry) - distinct styling, no name (like git staging area) + if (isCurrentVersion) { + return ( +
onSelect(snapshot)} + > +
+
+
+ + Current +
+ {formatDate(snapshot.created)} +
+
+ +
+
+
+ ); + } + + // Historical snapshots - original styling return (
{displayName}
diff --git a/components/notebooks/SnapshotsDialog.tsx b/components/notebooks/SnapshotsDialog.tsx index 54b782d..5702f32 100644 --- a/components/notebooks/SnapshotsDialog.tsx +++ b/components/notebooks/SnapshotsDialog.tsx @@ -10,6 +10,7 @@ import { DialogTitle, } from "../ui/dialog.tsx"; import {Button} from "../ui/button.tsx"; +import {Input} from "../ui/input.tsx"; import {cn} from "../../app/cn.js"; import {Editor} from "../../app/Editor.jsx"; import {useSnapshots} from "@/lib/notebooks/hooks.ts"; @@ -29,6 +30,7 @@ export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, o const {snapshots, renameSnapshot, deleteSnapshot} = useSnapshots(notebook.id); const [selectedSnapshot, setSelectedSnapshot] = useState(null); const [isCreating, setIsCreating] = useState(false); + const [snapshotName, setSnapshotName] = useState(""); useEffect(() => { if (open && notebook?.id) { @@ -44,8 +46,10 @@ export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, o if (!notebook) return; setIsCreating(true); setTimeout(() => { - const snapshot = createSnapshot(null); + const name = snapshotName.trim() || null; + const snapshot = createSnapshot(name); setSelectedSnapshot(snapshot); + setSnapshotName(""); setIsCreating(false); }, 100); } @@ -79,7 +83,20 @@ export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, o
{/* Sidebar */}
-
+
+ setSnapshotName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isCreating && notebook) { + handleCreateSnapshot(); + } + }} + disabled={isCreating || !notebook} + className={cn("h-8 text-sm")} + />