From d541518ca03381f8ef7359ef678e35f8bf58837e Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Fri, 31 Oct 2025 15:29:42 +0000 Subject: [PATCH 1/8] WIP using the project persistence blob --- package-lock.json | 91 ++++++++ package.json | 3 + src/App.tsx | 5 +- src/pages/NewPage.tsx | 105 ++++----- src/project-persistence/ProjectItem.tsx | 74 +++++++ .../ProjectStorageProvider.tsx | 201 ++++++++++++++++++ src/project-persistence/project-list-db.ts | 65 ++++++ src/project-persistence/project-store.ts | 66 ++++++ src/project-persistence/utils.ts | 24 +++ src/store-persistence-hooks.ts | 46 ++++ src/store-persistence.ts | 69 ++++++ src/store.ts | 5 +- 12 files changed, 686 insertions(+), 68 deletions(-) create mode 100644 src/project-persistence/ProjectItem.tsx create mode 100644 src/project-persistence/ProjectStorageProvider.tsx create mode 100644 src/project-persistence/project-list-db.ts create mode 100644 src/project-persistence/project-store.ts create mode 100644 src/project-persistence/utils.ts create mode 100644 src/store-persistence-hooks.ts create mode 100644 src/store-persistence.ts diff --git a/package-lock.json b/package-lock.json index b958b1b96..1c5c04e80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,9 @@ "react-intl": "^6.6.8", "react-router": "^6.24.0", "react-router-dom": "^6.24.0", + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "^13.6.27", "zustand": "^4.5.5" }, "devDependencies": { @@ -8496,6 +8499,16 @@ "is-stream": "^1.0.1" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/iterator.prototype": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", @@ -9223,6 +9236,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.114", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", + "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -13149,6 +13183,46 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -13188,6 +13262,23 @@ "node": ">=10" } }, + "node_modules/yjs": { + "version": "13.6.27", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index acfc2076d..4ce4015f7 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,9 @@ "react-intl": "^6.6.8", "react-router": "^6.24.0", "react-router-dom": "^6.24.0", + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "^13.6.27", "zustand": "^4.5.5" } } diff --git a/src/App.tsx b/src/App.tsx index 838020655..aad8011ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,7 @@ import { createNewPageUrl, createTestingModelPageUrl, } from "./urls"; +import { ProjectStorageProvider } from "./project-persistence/ProjectStorageProvider"; export interface ProviderLayoutProps { children: ReactNode; @@ -93,7 +94,9 @@ const Providers = ({ children }: ProviderLayoutProps) => { - {children} + + {children} + diff --git a/src/pages/NewPage.tsx b/src/pages/NewPage.tsx index 5c66d2bf7..3c8ac2baf 100644 --- a/src/pages/NewPage.tsx +++ b/src/pages/NewPage.tsx @@ -5,17 +5,16 @@ * SPDX-License-Identifier: MIT */ import { - Box, Container, + Grid, Heading, HStack, Icon, - Stack, Text, VStack, } from "@chakra-ui/react"; -import { ReactNode, useCallback, useRef } from "react"; -import { RiAddLine, RiFolderOpenLine, RiRestartLine } from "react-icons/ri"; +import { useCallback, useRef } from "react"; +import { RiAddLine, RiFolderOpenLine } from "react-icons/ri"; import { FormattedMessage, useIntl } from "react-intl"; import { useNavigate } from "react-router"; import DefaultPageLayout, { @@ -29,14 +28,20 @@ import NewPageChoice from "../components/NewPageChoice"; import { useLogging } from "../logging/logging-hooks"; import { useStore } from "../store"; import { createDataSamplesPageUrl } from "../urls"; -import { useProjectName } from "../hooks/project-hooks"; +import { useStoreProjects } from "../store-persistence-hooks"; +import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; +import { ProjectItem } from "../project-persistence/ProjectItem"; const NewPage = () => { - const existingSessionTimestamp = useStore((s) => s.timestamp); - const projectName = useProjectName(); const newSession = useStore((s) => s.newSession); const navigate = useNavigate(); const logging = useLogging(); + const { loadProject, newProject } = useStoreProjects(); + + const { projectList, deleteProject } = useProjectStorage(); + + (window as any).newProject = newProject; + (window as any).loadProject = loadProject; const handleOpenLastSession = useCallback(() => { logging.event({ @@ -50,10 +55,11 @@ const NewPage = () => { loadProjectRef.current?.chooseFile(); }, []); - const handleStartNewSession = useCallback(() => { + const handleStartNewSession = useCallback(async () => { logging.event({ type: "session-open-new", }); + await newProject(); newSession(); navigate(createDataSamplesPageUrl()); }, [logging, newSession, navigate]); @@ -92,47 +98,14 @@ const NewPage = () => { flexDir={{ base: "column", lg: "row" }} > } + onClick={handleStartNewSession} + label={newSessionTitle} + disabled={false} + icon={} > - {existingSessionTimestamp ? ( - - - ( - - {chunks} - - ), - name: projectName, - }} - /> - - - ( - - {chunks} - - ), - date: new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - }).format(existingSessionTimestamp), - }} - /> - - - ) : ( - - - - )} + + + { + - + Your projects - - } - > - - - - - - + {projectList?.map((proj) => ( + { + await loadProject(proj.id); + handleOpenLastSession(); // TODO: handleOpenSession + }} + deleteProject={deleteProject} + /> + ))} + diff --git a/src/project-persistence/ProjectItem.tsx b/src/project-persistence/ProjectItem.tsx new file mode 100644 index 000000000..60e7e55d9 --- /dev/null +++ b/src/project-persistence/ProjectItem.tsx @@ -0,0 +1,74 @@ +import { CloseButton, GridItem, Heading, HStack, Text } from "@chakra-ui/react"; +import { ReactNode } from "react"; +import { ProjectEntry } from "./project-list-db"; +import { timeAgo } from "./utils"; + +interface ProjectItemProps { + project: ProjectEntry; + loadProject: (projectId: string) => void; + deleteProject: (projectId: string) => void; +} + +interface ProjectItemBaseProps { + children: ReactNode; + onClick: () => void; +} + +const ProjectItemBase = ({ onClick, children }: ProjectItemBaseProps) => ( + + {children} + +); + +export const ProjectItem = ({project, loadProject, deleteProject}: ProjectItemProps) => ( + loadProject(project.id)}> + + + {project.projectName} + + + {timeAgo(new Date(project.modifiedDate))} + + { + deleteProject(project.id); + e.stopPropagation(); + e.preventDefault(); + }} + /> + +) + +interface AddProjectItemProps { + newProject: () => void; +} + +export const AddProjectItem = ({newProject}: AddProjectItemProps) => + + + New project + + Click to create + diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx new file mode 100644 index 000000000..a4bf9f0c0 --- /dev/null +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -0,0 +1,201 @@ +// ProjectContext.tsx +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import * as Y from "yjs"; +import { Awareness } from "y-protocols/awareness"; +import { ProjectList, withProjectDb } from "./project-list-db"; +import { ProjectStore } from "./project-store"; + +export interface NewStoredDoc { + id: string; + ydoc: Y.Doc; +} + +export interface RestoredStoredDoc { + projectName: string; + ydoc: Y.Doc; +} + +interface ProjectContextValue { + projectId: string | null; + projectList: ProjectList | null; + newStoredProject: () => Promise; + restoreStoredProject: (id: string) => Promise; + deleteProject: (id: string) => Promise; + ydoc: Y.Doc | null; + awareness: Awareness | null; + getFile: (filename: string) => Y.Text | null; + setProjectName: (id: string, name: string) => Promise; +} + +const ProjectStorageContext = createContext(null); + +export function ProjectStorageProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [projectList, setProjectList] = useState(null); + const [projectStore, setProjectStoreImpl] = useState( + null + ); + const setProjectStore = (newProjectStore: ProjectStore) => { + if (projectStore) { + projectStore.destroy(); + } + setProjectStoreImpl(newProjectStore); + }; + + const restoreStoredProject: ( + projectId: string + ) => Promise = useCallback( + async (projectId: string) => { + const newProjectStore = new ProjectStore(projectId, () => + modifyProject(projectId) + ); + await newProjectStore.init(); + setProjectStore(newProjectStore); + return { + ydoc: newProjectStore.ydoc, + projectName: projectList!.find((prj) => prj.id === projectId)! + .projectName, + }; + }, + [projectList] + ); + + const newStoredProject: () => Promise = + useCallback(async () => { + const newProjectId = makeUID(); + await withProjectDb("readwrite", async (store) => { + store.add({ + id: newProjectId, + projectName: "Untitled project", + modifiedDate: new Date().valueOf(), + }); + return Promise.resolve(); + }); + const newProjectStore = new ProjectStore(newProjectId, () => + modifyProject(newProjectId) + ); + await newProjectStore.init(); + setProjectStore(newProjectStore); + return { ydoc: newProjectStore.ydoc, id: newProjectId }; + }, []); + + const deleteProject: (id: string) => Promise = useCallback( + async (id) => { + await withProjectDb("readwrite", async (store) => { + store.delete(id); + return refreshProjects(); + }); + }, + [] + ); + + // TODO: Get rid of debug hooks + (window as unknown as any).projectList = projectList; + (window as unknown as any).newProjectStore = newStoredProject; + (window as unknown as any).restoreProjectStore = restoreStoredProject; + (window as unknown as any).deleteProject = deleteProject; + + const refreshProjects = async () => { + const projectList = await withProjectDb("readonly", async (store) => { + const projectList = await new Promise((res, rej) => { + const query = store.index("modifiedDate").getAll(); + query.onsuccess = () => res(query.result); + }); + return projectList; + }); + setProjectList((projectList as ProjectList).reverse()); + }; + + useEffect(() => { + if (window.navigator.storage?.persist) { + window.navigator.storage.persist(); + } + void refreshProjects(); + }, []); + + // Helper to access files + const getFile = (filename: string) => { + if (!projectStore) { + return null; + } + const files = projectStore.ydoc.getMap("files"); + if (!files.has(filename)) files.set(filename, new Y.Text()); + return files.get(filename)!; + }; + + const modifyProject = useCallback( + async (id: string) => { + await withProjectDb("readwrite", async (store) => { + await new Promise((res, rej) => { + const getQuery = store.get(id); + getQuery.onsuccess = () => { + const putQuery = store.put({ + ...getQuery.result, + modifiedDate: new Date().valueOf(), + }); + putQuery.onsuccess = () => res(getQuery.result); + }; + }); + }); + }, + [projectStore] + ); + + const setProjectName = useCallback( + async (id: string, projectName: string) => { + await withProjectDb("readwrite", async (store) => { + await new Promise((res, rej) => { + const query = store.put({ + id, + projectName, + modifiedDate: new Date().valueOf(), + }); + query.onsuccess = () => res(query.result); + }); + }); + }, + [projectStore] + ); + + return ( + + {children} + + ); +} + +export function useProjectStorage() { + const ctx = useContext(ProjectStorageContext); + if (!ctx) + throw new Error( + "useProjectStorage must be used within a ProjectStorageProvider" + ); + return ctx; +} + +// TODO: WORLDS UGLIEST UIDS +const makeUID = () => { + return `${Math.random()}`; +}; + diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts new file mode 100644 index 000000000..cc8778b8c --- /dev/null +++ b/src/project-persistence/project-list-db.ts @@ -0,0 +1,65 @@ + +export interface ProjectEntry { + projectName: string; + id: string; + modifiedDate: number; +} + +export type ProjectList = ProjectEntry[]; + +type ProjectDbWrapper = ( + accessMode: "readonly" | "readwrite", + callback: (projects: IDBObjectStore) => Promise +) => Promise; + +export const withProjectDb: ProjectDbWrapper = async (accessMode, callback) => { + return new Promise((res, rej) => { + // TODO: what if multiple users? I think MakeCode just keeps everything... + const openRequest = indexedDB.open("UserProjects", 2); + openRequest.onupgradeneeded = (evt: IDBVersionChangeEvent) => { + const db = openRequest.result; + // NB: a more robust way to write migrations would be to get the current stored + // db.version and open it repeatedly with an ascending version number until the + // db is up to date. That would be more boilerplate though. + const tx = (evt.target as IDBOpenDBRequest).transaction; + // if the data object store doesn't exist, create it + + let projects: IDBObjectStore; + if (!db.objectStoreNames.contains("projects")) { + projects = db.createObjectStore("projects", { keyPath: "id" }); + // no indexes at present, get the whole db each time + } else { + projects = tx!.objectStore("projects"); + } + if (!projects.indexNames.contains("modifiedDate")) { + projects.createIndex("modifiedDate", "modifiedDate"); + const now = new Date().valueOf(); + const updateProjectData = projects.getAll(); + updateProjectData.onsuccess = () => { + updateProjectData.result.forEach((project) => { + if (!('modifiedDate' in project)) { + project.modifiedDate = now; + projects.put(project); + } + }); + }; + }; + }; + + openRequest.onsuccess = async () => { + const db = openRequest.result; + + const tx = db.transaction("projects", accessMode); + const store = tx.objectStore("projects"); + tx.onabort = rej; + tx.onerror = rej; + + const result = await callback(store); + + // got the result, but don't return until the transaction is complete + tx.oncomplete = () => res(result); + }; + + openRequest.onerror = rej; + }); +}; diff --git a/src/project-persistence/project-store.ts b/src/project-persistence/project-store.ts new file mode 100644 index 000000000..1e1443cf3 --- /dev/null +++ b/src/project-persistence/project-store.ts @@ -0,0 +1,66 @@ +import { IndexeddbPersistence } from "y-indexeddb"; +import { Awareness } from "y-protocols/awareness.js"; +import * as Y from "yjs"; + +/** + * Because the ydoc persistence/sync needs to clean itself up from time to time + * it is in a class with the following state. It is agnostic in itself whether the project with the + * specified UID exists. + * + * constructor - sets up the state + * init - connects the persistence store, and local sync broadcast. Asynchronous, so you can await it + * destroy - disconnects everything that was connected in init, cleans up the persistence store + */ +export class ProjectStore { + public ydoc: Y.Doc; + public awareness: Awareness; + private broadcastHandler: (e: MessageEvent) => void; + private persistence: IndexeddbPersistence; + private updates: BroadcastChannel; + private updatePoster: (update: Uint8Array) => void; + + constructor(public projectId: string, projectChangedListener: () => void) { + const ydoc = new Y.Doc(); + this.ydoc = ydoc; + this.awareness = new Awareness(this.ydoc); + + this.persistence = new IndexeddbPersistence(this.projectId, this.ydoc); + + const clientId = `${Math.random()}`; // Used by the broadcasthandler to know whether we sent a data update + this.broadcastHandler = ({ data }: MessageEvent) => { + if (data.clientId !== clientId && data.projectId === projectId) { + Y.applyUpdate(ydoc, data.update); + } + }; + + this.updates = new BroadcastChannel("yjs"); + this.updatePoster = ((update: Uint8Array) => { + this.updates.postMessage({ clientId, update, projectId }); + projectChangedListener(); + }).bind(this); + } + + public async init() { + this.ydoc.on("update", this.updatePoster); + this.updates.addEventListener("message", this.broadcastHandler); + await new Promise((res) => this.persistence.once("synced", res)); + migrate(this.ydoc); + } + + public destroy() { + this.ydoc.off("update", this.updatePoster); + this.updates.removeEventListener("message", this.broadcastHandler); + this.updates.close(); + void this.persistence.destroy(); + } +} + + +const migrate = (doc: Y.Doc) => { + const meta = doc.getMap("meta"); + if (!meta.has("version")) { + // This could be a per-app handler + meta.set("version", 1); + meta.set("projectName", "default"); // TODO: get this from the last loaded project name + } +}; diff --git a/src/project-persistence/utils.ts b/src/project-persistence/utils.ts new file mode 100644 index 000000000..4aa522475 --- /dev/null +++ b/src/project-persistence/utils.ts @@ -0,0 +1,24 @@ +export function timeAgo(date: Date): string { + const now = new Date(); + const seconds = Math.round((+now - +date) / 1000); + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + + const divisions: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ + { amount: 60, unit: 'second' }, + { amount: 60, unit: 'minute' }, + { amount: 24, unit: 'hour' }, + { amount: 7, unit: 'day' }, + { amount: 4.34524, unit: 'week' }, // approx + { amount: 12, unit: 'month' } + ]; + + let duration = seconds; + for (const division of divisions) { + if (Math.abs(duration) < division.amount) { + return rtf.format(-Math.round(duration), division.unit); + } + duration /= division.amount; + } + + return rtf.format(-Math.round(duration), "year"); +} \ No newline at end of file diff --git a/src/store-persistence-hooks.ts b/src/store-persistence-hooks.ts new file mode 100644 index 000000000..36ace987f --- /dev/null +++ b/src/store-persistence-hooks.ts @@ -0,0 +1,46 @@ +import { useProjectStorage } from "./project-persistence/ProjectStorageProvider"; +import { useStore } from "./store"; +import { loadNewDoc } from "./store-persistence"; + +export const useStoreProjects = () => { + // storeprojects relates to projects of type Store + // projectstorage stores projects + // simple? + // TODO: improve naming + const proj = useProjectStorage(); + // TODO: hooks that + // Y - create new project + // - attach to the new project button + // - load most recent project + // - ensure runs on startup + // new hooks must prompt zustand to hydrate, though they live underneath it? + // possibly split this file out into store-persistence and store-persistence-hooks? + // Y TODO: see if CreateAI can be made to boot up with no project + // Y disable hydrate on first load, as there is no current project! + + const newProject = async () => { + const newProjectImpl = async () => { + const { ydoc } = await proj.newStoredProject(); + return ydoc; + } + const newProjectPromise = newProjectImpl(); + loadNewDoc(newProjectPromise); + await newProjectPromise; + // TODO: currently the new session setup is done from within NewPage + // seems like it could be here... + } + const loadProject = async (projectId: string) => { + const loadProjectImpl = async () => { + const { ydoc } = await proj.restoreStoredProject(projectId); + return ydoc; + } + const loadProjectPromise = loadProjectImpl(); + loadNewDoc(loadProjectPromise); + await loadProjectPromise; + useStore.persist.rehydrate(); // TODO: better type? + let unregister: () => void; + await new Promise(res => unregister = useStore.persist.onFinishHydration(res)); + unregister!(); + } + return {loadProject, newProject}; +} \ No newline at end of file diff --git a/src/store-persistence.ts b/src/store-persistence.ts new file mode 100644 index 000000000..0e3c4b26a --- /dev/null +++ b/src/store-persistence.ts @@ -0,0 +1,69 @@ +import { PersistStorage, StorageValue } from "zustand/middleware"; +import * as Y from "yjs"; + +/* +type PersistImpl = ( + storeInitializer: StateCreator, + options: PersistOptions, +) => StateCreator + +interface Storable { + +} +*/ + +interface ProjectState { + doc: Y.Doc | null; + loadingPromise: Promise | null; +} + +let activeState : ProjectState = { + doc: null, + loadingPromise: null +} + +export const loadNewDoc = async (loadingPromise : Promise) => { + activeState.doc = null; + activeState.loadingPromise = loadingPromise; + activeState.doc = await loadingPromise; +} + +export const projectStorage = () => { + + const getItem = (name: string) => { + if (activeState.doc) { + const result = JSON.parse(activeState.doc.getText(name).toString()) as T + return result; + } else { + return async () => { + await activeState.loadingPromise; + const result = JSON.parse(activeState.doc!.getText(name).toString()) as T + return result + } + } + } + + const setItem = (name: string, value: StorageValue) => { + if (activeState.doc) { + const storeText = activeState.doc.getText(name); + storeText.delete(0, storeText.length); + storeText.insert(0, JSON.stringify(value)); + } else { + return async () => { + await activeState.loadingPromise; + const storeText = activeState.doc!.getText(name); + storeText.delete(0, storeText.length); + storeText.insert(0, JSON.stringify(value)); + } + } + } + const removeItem = (_name: string) => { + // TODO: + } + + return { + getItem, + setItem, + removeItem + } as PersistStorage; +} diff --git a/src/store.ts b/src/store.ts index 6120e675a..aa2238abe 100644 --- a/src/store.ts +++ b/src/store.ts @@ -45,6 +45,7 @@ import { BufferedData } from "./buffered-data"; import { getDetectedAction } from "./utils/prediction"; import { getTour as getTourSpec } from "./tours"; import { createPromise, PromiseInfo } from "./hooks/use-promise-ref"; +import { projectStorage } from "./store-persistence"; export const modelUrl = "indexeddb://micro:bit-ai-creator-model"; @@ -1312,7 +1313,9 @@ const createMlStore = (logging: Logging) => { }, }; }, - } + storage: projectStorage(), + skipHydration: true + }, ), { enabled: flags.devtools } ) From 415dd736f498c026bb73f2311b4048e64e5e048c Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Tue, 4 Nov 2025 10:09:24 +0000 Subject: [PATCH 2/8] WIP sample of a Y.Text inside a Zustand store --- src/model.ts | 6 ++++ src/store-persistence-hooks.ts | 8 +++-- src/store-persistence.ts | 53 ++++++++++++++++++++++------------ src/store.ts | 5 +++- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/model.ts b/src/model.ts index 3e05c4828..3769ed8a7 100644 --- a/src/model.ts +++ b/src/model.ts @@ -9,6 +9,7 @@ import { MakeCodeIcon } from "./utils/icons"; import { ReactNode } from "react"; import { SpotlightStyle } from "./pages/TourOverlay"; import { PlacementWithLogical, ThemingProps } from "@chakra-ui/react"; +import * as Y from "yjs"; export interface XYZData { x: number[]; @@ -32,6 +33,11 @@ export interface ActionData extends Action { recordings: RecordingData[]; } +// TODO: how much do we hate these types? +// TODO: maybe use zod? +export type RecordingDataY = Y.Map; +export type ActionDataY = Y.Array>>; + export interface DatasetEditorJsonFormat { data: ActionData[]; } diff --git a/src/store-persistence-hooks.ts b/src/store-persistence-hooks.ts index 36ace987f..50761f413 100644 --- a/src/store-persistence-hooks.ts +++ b/src/store-persistence-hooks.ts @@ -25,7 +25,9 @@ export const useStoreProjects = () => { } const newProjectPromise = newProjectImpl(); loadNewDoc(newProjectPromise); - await newProjectPromise; + // TODO: testing adding a Y datatype to Zustand + const newDoc = await newProjectPromise; + (window as unknown as any).actions2 = newDoc.getMap("files").get("actions2"); // TODO: currently the new session setup is done from within NewPage // seems like it could be here... } @@ -36,7 +38,9 @@ export const useStoreProjects = () => { } const loadProjectPromise = loadProjectImpl(); loadNewDoc(loadProjectPromise); - await loadProjectPromise; + const newDoc = await loadProjectPromise; + + (window as unknown as any).actions2 = newDoc.getMap("files").get("actions2"); useStore.persist.rehydrate(); // TODO: better type? let unregister: () => void; await new Promise(res => unregister = useStore.persist.onFinishHydration(res)); diff --git a/src/store-persistence.ts b/src/store-persistence.ts index 0e3c4b26a..f2760a7b9 100644 --- a/src/store-persistence.ts +++ b/src/store-persistence.ts @@ -1,16 +1,7 @@ import { PersistStorage, StorageValue } from "zustand/middleware"; import * as Y from "yjs"; +import { ActionData } from "./model"; -/* -type PersistImpl = ( - storeInitializer: StateCreator, - options: PersistOptions, -) => StateCreator - -interface Storable { - -} -*/ interface ProjectState { doc: Y.Doc | null; @@ -26,32 +17,50 @@ export const loadNewDoc = async (loadingPromise : Promise) => { activeState.doc = null; activeState.loadingPromise = loadingPromise; activeState.doc = await loadingPromise; + // TODO: testing accessing a Y.Text through Zustand + if (!activeState.doc.getMap("files").has("actions2")) { + activeState.doc.getMap("files").set("actions2", new Y.Text("defaultvalue")); + } } +// This storage system ignores that Zustand supports multiple datastores, because it exists +// in the specific context that we want to blend the existing Zustand data with a Y.js +// backend, and we know what the data should be so genericism goes out of the window. +// Anything that is not handled as a special case (e.g. actions are special) becomes a +// simple json doc in the same way that Zustand persist conventionally does it. +const BASE_DOC_NAME = "ml"; + +// TODO: Think about what versioning should relate to. +// The zustand store has a version, and this also has structurally-sensitive things in +// its storeState mapper. export const projectStorage = () => { - const getItem = (name: string) => { + const getItemImpl = (ydoc: Y.Doc) => { + const doc = JSON.parse(ydoc.getText(BASE_DOC_NAME).toString()) as T; + (doc as undefined as any).actions2 = ydoc.getMap("files").get("actions2"); + return doc; + } + + const getItem = (_name: string) => { if (activeState.doc) { - const result = JSON.parse(activeState.doc.getText(name).toString()) as T - return result; + return getItemImpl(activeState.doc); } else { return async () => { await activeState.loadingPromise; - const result = JSON.parse(activeState.doc!.getText(name).toString()) as T - return result + return getItemImpl(activeState.doc!); } } } - const setItem = (name: string, value: StorageValue) => { + const setItem = (_name: string, value: StorageValue) => { if (activeState.doc) { - const storeText = activeState.doc.getText(name); + const storeText = activeState.doc.getText(BASE_DOC_NAME); storeText.delete(0, storeText.length); storeText.insert(0, JSON.stringify(value)); } else { return async () => { await activeState.loadingPromise; - const storeText = activeState.doc!.getText(name); + const storeText = activeState.doc!.getText(BASE_DOC_NAME); storeText.delete(0, storeText.length); storeText.insert(0, JSON.stringify(value)); } @@ -67,3 +76,11 @@ export const projectStorage = () => { removeItem } as PersistStorage; } + +interface CustomStructures { + actions: ActionData[]; +} + +const storeState = (state: CustomStructures, doc: Y.Doc) => { + const { actions, ...unfiltered } = state; +} diff --git a/src/store.ts b/src/store.ts index aa2238abe..757afe536 100644 --- a/src/store.ts +++ b/src/store.ts @@ -35,6 +35,7 @@ import { EditorStartUp, TourTriggerName, tourSequence, + ActionDataY, } from "./model"; import { defaultSettings, Settings } from "./settings"; import { getTotalNumSamples } from "./utils/actions"; @@ -46,6 +47,7 @@ import { getDetectedAction } from "./utils/prediction"; import { getTour as getTourSpec } from "./tours"; import { createPromise, PromiseInfo } from "./hooks/use-promise-ref"; import { projectStorage } from "./store-persistence"; +import * as Y from "yjs"; export const modelUrl = "indexeddb://micro:bit-ai-creator-model"; @@ -308,7 +310,7 @@ export interface Actions { isNonConnectionDialogOpen(): boolean; } -type Store = State & Actions; +type Store = State & Actions & { actions2: Y.Text }; const createMlStore = (logging: Logging) => { return create()( @@ -317,6 +319,7 @@ const createMlStore = (logging: Logging) => { (set, get) => ({ timestamp: undefined, actions: [], + actions2: new Y.Text(), dataWindow: currentDataWindow, isRecording: false, project: createUntitledProject(), From 9cc0e749d631a90bb09e415af6694da87584ea31 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Fri, 7 Nov 2025 14:18:17 +0000 Subject: [PATCH 3/8] WIP Action recordings are now LWW --- src/components/DataSamplesMenu.tsx | 3 +- src/components/DataSamplesTable.tsx | 3 +- src/components/DownloadDialogs.tsx | 3 +- src/components/TestingModelTable.tsx | 3 +- src/hooks/project-hooks.tsx | 3 +- src/model.ts | 6 +- src/pages/DataSamplesPage.tsx | 14 +- src/store-persistence-hooks.ts | 80 ++++--- src/store-persistence.ts | 34 ++- src/store.ts | 309 +++++++++++++-------------- src/utils/actions.ts | 18 +- src/utils/prediction.ts | 10 +- 12 files changed, 264 insertions(+), 222 deletions(-) diff --git a/src/components/DataSamplesMenu.tsx b/src/components/DataSamplesMenu.tsx index 92a1a5d74..6ba3aed05 100644 --- a/src/components/DataSamplesMenu.tsx +++ b/src/components/DataSamplesMenu.tsx @@ -30,11 +30,12 @@ import LoadProjectMenuItem from "./LoadProjectMenuItem"; import { NameProjectDialog } from "./NameProjectDialog"; import ViewDataFeaturesMenuItem from "./ViewDataFeaturesMenuItem"; import { useProjectIsUntitled } from "../hooks/project-hooks"; +import { useActions } from "../store-persistence-hooks"; const DataSamplesMenu = () => { const intl = useIntl(); const logging = useLogging(); - const actions = useStore((s) => s.actions); + const actions = useActions(); const downloadDataset = useStore((s) => s.downloadDataset); const isDeleteAllActionsDialogOpen = useStore( (s) => s.isDeleteAllActionsDialogOpen diff --git a/src/components/DataSamplesTable.tsx b/src/components/DataSamplesTable.tsx index 1f6d7ffcf..c9fafe3b9 100644 --- a/src/components/DataSamplesTable.tsx +++ b/src/components/DataSamplesTable.tsx @@ -40,6 +40,7 @@ import { ConfirmDialog } from "./ConfirmDialog"; import { actionNameInputId } from "./ActionNameCard"; import { recordButtonId } from "./ActionDataSamplesCard"; import { keyboardShortcuts, useShortcut } from "../keyboard-shortcut-hooks"; +import { useActions } from "../store-persistence-hooks"; const gridCommonProps: Partial = { gridTemplateColumns: "290px 1fr", @@ -74,7 +75,7 @@ const DataSamplesTable = ({ selectedActionIdx: selectedActionIdx, setSelectedActionIdx: setSelectedActionIdx, }: DataSamplesTableProps) => { - const actions = useStore((s) => s.actions); + const actions = useActions().toJSON(); // Default to first action being selected if last action is deleted. const selectedAction: ActionData = actions[selectedActionIdx] ?? actions[0]; diff --git a/src/components/DownloadDialogs.tsx b/src/components/DownloadDialogs.tsx index 55f74cac6..34750db50 100644 --- a/src/components/DownloadDialogs.tsx +++ b/src/components/DownloadDialogs.tsx @@ -7,6 +7,7 @@ import { useDownloadActions } from "../hooks/download-hooks"; import { useLogging } from "../logging/logging-hooks"; import { DownloadStep } from "../model"; import { useStore } from "../store"; +import { useActions } from "../store-persistence-hooks"; import { getTotalNumSamples } from "../utils/actions"; import ConnectCableDialog from "./ConnectCableDialog"; import ConnectRadioDataCollectionMicrobitDialog from "./ConnectRadioDataCollectionMicrobitDialog"; @@ -22,7 +23,7 @@ const DownloadDialogs = () => { const downloadActions = useDownloadActions(); const stage = useStore((s) => s.download); const flashingProgress = useStore((s) => s.downloadFlashingProgress); - const actions = useStore((s) => s.actions); + const actions = useActions(); const logging = useLogging(); switch (stage.step) { diff --git a/src/components/TestingModelTable.tsx b/src/components/TestingModelTable.tsx index a6c77a459..6b5a2a140 100644 --- a/src/components/TestingModelTable.tsx +++ b/src/components/TestingModelTable.tsx @@ -27,6 +27,7 @@ import ActionNameCard, { ActionCardNameViewMode } from "./ActionNameCard"; import CodeViewCard from "./CodeViewCard"; import CodeViewDefaultBlockCard from "./CodeViewDefaultBlockCard"; import HeadingGrid from "./HeadingGrid"; +import { useActions } from "../store-persistence-hooks"; const gridCommonProps: Partial = { gridTemplateColumns: "290px 360px 40px auto", @@ -52,7 +53,7 @@ const headings = [ ]; const TestingModelTable = () => { - const actions = useStore((s) => s.actions); + const actions = useActions().toJSON(); const setRequiredConfidence = useStore((s) => s.setRequiredConfidence); const { project, projectEdited } = useProject(); const { isConnected } = useConnectionStage(); diff --git a/src/hooks/project-hooks.tsx b/src/hooks/project-hooks.tsx index 1108951d8..6abc3652d 100644 --- a/src/hooks/project-hooks.tsx +++ b/src/hooks/project-hooks.tsx @@ -42,6 +42,7 @@ import { readFileAsText, } from "../utils/fs-util"; import { useDownloadActions } from "./download-hooks"; +import { useActions } from "../store-persistence-hooks"; class CodeEditorError extends Error {} @@ -361,7 +362,7 @@ export const ProjectProvider = ({ const setSave = useStore((s) => s.setSave); const save = useStore((s) => s.save); const settings = useStore((s) => s.settings); - const actions = useStore((s) => s.actions); + const actions = useActions(); const saveNextDownloadRef = useRef(false); const translatedUntitled = useDefaultProjectName(); const saveHex = useCallback( diff --git a/src/model.ts b/src/model.ts index 3769ed8a7..1577a0ead 100644 --- a/src/model.ts +++ b/src/model.ts @@ -35,8 +35,10 @@ export interface ActionData extends Action { // TODO: how much do we hate these types? // TODO: maybe use zod? -export type RecordingDataY = Y.Map; -export type ActionDataY = Y.Array>>; +export type RecordingDatumY = Y.Map; +export type RecordingDataY = Y.Array; +export type ActionDatumY = Y.Map; +export type ActionDataY = Y.Array; export interface DatasetEditorJsonFormat { data: ActionData[]; diff --git a/src/pages/DataSamplesPage.tsx b/src/pages/DataSamplesPage.tsx index 584aa3221..6776d3715 100644 --- a/src/pages/DataSamplesPage.tsx +++ b/src/pages/DataSamplesPage.tsx @@ -18,12 +18,17 @@ import LiveGraphPanel from "../components/LiveGraphPanel"; import TrainModelDialogs from "../components/TrainModelFlowDialogs"; import { useConnectionStage } from "../connection-stage-hooks"; import { keyboardShortcuts, useShortcut } from "../keyboard-shortcut-hooks"; -import { useHasSufficientDataForTraining, useStore } from "../store"; +import { useStore } from "../store"; import { tourElClassname } from "../tours"; import { createTestingModelPageUrl } from "../urls"; +import { forSomeAction } from "../utils/actions"; +import { + useActions, + useHasSufficientDataForTraining, +} from "../store-persistence-hooks"; const DataSamplesPage = () => { - const actions = useStore((s) => s.actions); + const actions = useActions(); const addNewAction = useStore((s) => s.addNewAction); const model = useStore((s) => s.model); const [selectedActionIdx, setSelectedActionIdx] = useState(0); @@ -41,7 +46,10 @@ const DataSamplesPage = () => { }, [isConnected, tourStart]); const hasSufficientData = useHasSufficientDataForTraining(); - const isAddNewActionDisabled = actions.some((a) => a.name.length === 0); + const isAddNewActionDisabled = forSomeAction( + actions, + (a) => (a.get("name") as string).length === 0 + ); const handleNavigateToModel = useCallback(() => { navigate(createTestingModelPageUrl()); diff --git a/src/store-persistence-hooks.ts b/src/store-persistence-hooks.ts index 50761f413..074a3f9db 100644 --- a/src/store-persistence-hooks.ts +++ b/src/store-persistence-hooks.ts @@ -1,50 +1,76 @@ +import { useEffect, useState } from "react"; +import { ActionDataY, RecordingDataY } from "./model"; import { useProjectStorage } from "./project-persistence/ProjectStorageProvider"; import { useStore } from "./store"; -import { loadNewDoc } from "./store-persistence"; +import { BASE_DOC_NAME, loadNewDoc } from "./store-persistence"; +import * as Y from "yjs"; +import { forSomeAction, hasSufficientDataForTraining } from "./utils/actions"; export const useStoreProjects = () => { // storeprojects relates to projects of type Store // projectstorage stores projects // simple? // TODO: improve naming - const proj = useProjectStorage(); - // TODO: hooks that - // Y - create new project - // - attach to the new project button - // - load most recent project - // - ensure runs on startup - // new hooks must prompt zustand to hydrate, though they live underneath it? - // possibly split this file out into store-persistence and store-persistence-hooks? - // Y TODO: see if CreateAI can be made to boot up with no project - // Y disable hydrate on first load, as there is no current project! - + const { newStoredProject, restoreStoredProject } = useProjectStorage(); const newProject = async () => { const newProjectImpl = async () => { - const { ydoc } = await proj.newStoredProject(); + const { ydoc } = await newStoredProject(); + ydoc.getText(BASE_DOC_NAME).insert(0, "{}"); + ydoc.getMap("files").set("actions", new Y.Array); return ydoc; } const newProjectPromise = newProjectImpl(); loadNewDoc(newProjectPromise); - // TODO: testing adding a Y datatype to Zustand - const newDoc = await newProjectPromise; - (window as unknown as any).actions2 = newDoc.getMap("files").get("actions2"); - // TODO: currently the new session setup is done from within NewPage - // seems like it could be here... + await newProjectPromise; + // Needed to attach Y types + await useStore.persist.rehydrate(); } const loadProject = async (projectId: string) => { const loadProjectImpl = async () => { - const { ydoc } = await proj.restoreStoredProject(projectId); + const { ydoc } = await restoreStoredProject(projectId); return ydoc; } const loadProjectPromise = loadProjectImpl(); loadNewDoc(loadProjectPromise); - const newDoc = await loadProjectPromise; + await loadProjectPromise; - (window as unknown as any).actions2 = newDoc.getMap("files").get("actions2"); - useStore.persist.rehydrate(); // TODO: better type? - let unregister: () => void; - await new Promise(res => unregister = useStore.persist.onFinishHydration(res)); - unregister!(); + await useStore.persist.rehydrate(); } - return {loadProject, newProject}; -} \ No newline at end of file + return { loadProject, newProject }; +} + +export const useActions = () => { + const [actionsRev, setActionsRev] = useState(0); + const { ydoc } = useProjectStorage(); + const actions = ydoc?.getMap("files").get("actions") as ActionDataY; // TODO: what happens when you don't got actions? + useEffect(() => { + const actionsInner = ydoc?.getMap("files").get("actions") as ActionDataY; + if (!actionsInner) { + return; + } + const observer = () => setActionsRev(actionsRev + 1); + actionsInner.observeDeep(observer) + return () => actionsInner.unobserveDeep(observer); + }, [ydoc]); + return actions; +} + +export const useHasActions = () => { + const actions = useActions(); + return ( + (actions.length > 0 && (actions.get(0).get("name") as string).length > 0) || + (actions.get(0).get("recordings") as RecordingDataY).length > 0 + ); +}; + +export const useHasSufficientDataForTraining = (): boolean => { + const actions = useActions(); + return hasSufficientDataForTraining(actions); +}; + +export const useHasNoStoredData = (): boolean => { + const actions = useActions(); + return !( + actions.length !== 0 && forSomeAction(actions, (a) => (a.get("recordings") as RecordingDataY).length > 0) + ); +}; diff --git a/src/store-persistence.ts b/src/store-persistence.ts index f2760a7b9..2dd4430c6 100644 --- a/src/store-persistence.ts +++ b/src/store-persistence.ts @@ -1,6 +1,5 @@ import { PersistStorage, StorageValue } from "zustand/middleware"; import * as Y from "yjs"; -import { ActionData } from "./model"; interface ProjectState { @@ -17,10 +16,6 @@ export const loadNewDoc = async (loadingPromise : Promise) => { activeState.doc = null; activeState.loadingPromise = loadingPromise; activeState.doc = await loadingPromise; - // TODO: testing accessing a Y.Text through Zustand - if (!activeState.doc.getMap("files").has("actions2")) { - activeState.doc.getMap("files").set("actions2", new Y.Text("defaultvalue")); - } } // This storage system ignores that Zustand supports multiple datastores, because it exists @@ -28,17 +23,20 @@ export const loadNewDoc = async (loadingPromise : Promise) => { // backend, and we know what the data should be so genericism goes out of the window. // Anything that is not handled as a special case (e.g. actions are special) becomes a // simple json doc in the same way that Zustand persist conventionally does it. -const BASE_DOC_NAME = "ml"; +export const BASE_DOC_NAME = "ml"; // TODO: Think about what versioning should relate to. // The zustand store has a version, and this also has structurally-sensitive things in // its storeState mapper. +// store.ts currently has a lot of controller logic, and it could be pared out and synced +// more loosely with the yjs-ified data. E.g. project syncing could be done at a level above +// the store, with a subscription. export const projectStorage = () => { const getItemImpl = (ydoc: Y.Doc) => { - const doc = JSON.parse(ydoc.getText(BASE_DOC_NAME).toString()) as T; - (doc as undefined as any).actions2 = ydoc.getMap("files").get("actions2"); - return doc; + const state = JSON.parse(ydoc.getText(BASE_DOC_NAME).toString()) as T; + (state as undefined as any).actions = ydoc.getMap("files").get("actions"); + return { state, version: 2 }; } const getItem = (_name: string) => { @@ -52,7 +50,11 @@ export const projectStorage = () => { } } - const setItem = (_name: string, value: StorageValue) => { + const setItem = (_name: string, valueFull: StorageValue) => { + const { state: { actions, ...state }, version } = valueFull as StorageValue; + + const value = { state, version }; + if (activeState.doc) { const storeText = activeState.doc.getText(BASE_DOC_NAME); storeText.delete(0, storeText.length); @@ -67,7 +69,7 @@ export const projectStorage = () => { } } const removeItem = (_name: string) => { - // TODO: + // Don't remove things through Zustand, use ProjectStorage } return { @@ -75,12 +77,4 @@ export const projectStorage = () => { setItem, removeItem } as PersistStorage; -} - -interface CustomStructures { - actions: ActionData[]; -} - -const storeState = (state: CustomStructures, doc: Y.Doc) => { - const { actions, ...unfiltered } = state; -} +} \ No newline at end of file diff --git a/src/store.ts b/src/store.ts index 757afe536..4d496cec3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -36,9 +36,13 @@ import { TourTriggerName, tourSequence, ActionDataY, + RecordingDatumY, + XYZData, + ActionDatumY, + RecordingDataY, } from "./model"; import { defaultSettings, Settings } from "./settings"; -import { getTotalNumSamples } from "./utils/actions"; +import { getTotalNumSamples, hasSufficientDataForTraining } from "./utils/actions"; import { defaultIcons, MakeCodeIcon } from "./utils/icons"; import { untitledProjectName } from "./project-name"; import { mlSettings } from "./mlConfig"; @@ -51,12 +55,14 @@ import * as Y from "yjs"; export const modelUrl = "indexeddb://micro:bit-ai-creator-model"; -const createFirstAction = () => ({ - icon: defaultIcons[0], - ID: Date.now(), - name: "", - recordings: [], -}); +const createFirstAction = (actions: ActionDataY) => { + const newAction = new Y.Map>(); + newAction.set("icon", defaultIcons[0]); + newAction.set("ID", Date.now()); + newAction.set("name", ""); + newAction.set("recordings", new Y.Array()); + actions.push([newAction]); +}; export interface DataWindow { duration: number; // Duration of recording @@ -119,11 +125,11 @@ const createUntitledProject = (): MakeCodeProject => ({ const updateProject = ( project: MakeCodeProject, projectEdited: boolean, - actions: ActionData[], + actions: ActionDataY, model: tf.LayersModel | undefined, dataWindow: DataWindow ): Partial => { - const actionsData = { data: actions }; + const actionsData = { data: actions.toJSON() }; const updatedProject = { ...project, text: { @@ -146,7 +152,7 @@ const updateProject = ( }; export interface State { - actions: ActionData[]; + actions: ActionDataY; dataWindow: DataWindow; model: tf.LayersModel | undefined; @@ -310,7 +316,7 @@ export interface Actions { isNonConnectionDialogOpen(): boolean; } -type Store = State & Actions & { actions2: Y.Text }; +type Store = State & Actions; const createMlStore = (logging: Logging) => { return create()( @@ -318,8 +324,7 @@ const createMlStore = (logging: Logging) => { persist( (set, get) => ({ timestamp: undefined, - actions: [], - actions2: new Y.Text(), + actions: new Y.Array(), dataWindow: currentDataWindow, isRecording: false, project: createUntitledProject(), @@ -411,7 +416,6 @@ const createMlStore = (logging: Logging) => { const untitledProject = createUntitledProject(); set( { - actions: [], dataWindow: currentDataWindow, model: undefined, project: projectName @@ -448,26 +452,23 @@ const createMlStore = (logging: Logging) => { }, addNewAction() { - return set(({ project, projectEdited, actions, dataWindow }) => { - const newActions = [ - ...actions, - { - icon: actionIcon({ - isFirstAction: actions.length === 0, - existingActions: actions, - }), - ID: Date.now(), - name: "", - recordings: [], - }, - ]; + return set(({ project, actions, projectEdited, dataWindow }) => { + const newAction = new Y.Map>(); + const existingIcons = actions.map(action => action.get("icon") as MakeCodeIcon); + newAction.set("icon", actionIcon({ + isFirstAction: actions.length === 0, + existingIcons, + })); + newAction.set("ID", Date.now()); + newAction.set("name", ""); + newAction.set("recordings", new Y.Array>()); + actions.push([newAction]); return { - actions: newActions, model: undefined, ...updateProject( project, projectEdited, - newActions, + actions, undefined, dataWindow ), @@ -476,37 +477,40 @@ const createMlStore = (logging: Logging) => { }, addActionRecordings(id: ActionData["ID"], recs: RecordingData[]) { - return set(({ actions }) => { - const updatedActions = actions.map((action) => { - if (action.ID === id) { - return { - ...action, - recordings: [...recs, ...action.recordings], - }; - } - return action; - }); - return { - actions: updatedActions, - model: undefined, - }; - }); + const { actions } = get(); + for (const action of actions) { + if (action.get("ID") === id) { + const recsY = recs.map(rec => { + const recY = new Y.Map(); + recY.set("ID", rec.ID); + recY.set("data", rec.data); + return recY; + }); + (action.get("recordings") as Y.Array).push(recsY); + } + } }, deleteAction(id: ActionData["ID"]) { + const { actions } = get(); + withActionIndex(id, actions, (actionIndex) => { + actions.delete(actionIndex) + }); + if (actions.length === 0) { + // TODO: Port this to Y mode + createFirstAction(actions); + } + return set(({ project, projectEdited, actions, dataWindow }) => { - const newActions = actions.filter((a) => a.ID !== id); const newDataWindow = - newActions.length === 0 ? currentDataWindow : dataWindow; + actions.length === 0 ? currentDataWindow : dataWindow; return { - actions: - newActions.length === 0 ? [createFirstAction()] : newActions, dataWindow: newDataWindow, model: undefined, ...updateProject( project, projectEdited, - newActions, + actions, undefined, newDataWindow ), @@ -515,17 +519,18 @@ const createMlStore = (logging: Logging) => { }, setActionName(id: ActionData["ID"], name: string) { + const { actions } = get(); + withActionIndex(id, actions, (actionIndex) => { + actions.get(actionIndex).set("name", name); + }); return set( ({ project, projectEdited, actions, model, dataWindow }) => { - const newActions = actions.map((action) => - id !== action.ID ? action : { ...action, name } - ); + return { - actions: newActions, ...updateProject( project, projectEdited, - newActions, + actions, model, dataWindow ), @@ -535,29 +540,32 @@ const createMlStore = (logging: Logging) => { }, setActionIcon(id: ActionData["ID"], icon: MakeCodeIcon) { + const { actions } = get(); + + withActionIndex(id, actions, (actionIndex) => { + const action = actions.get(actionIndex); + const currentIcon = action.get("icon"); + action.set("icon", icon); + actions.forEach((maybeClashingAction, maybeClashingActionIndex) => { + if (maybeClashingActionIndex === actionIndex) { + return; + } + const maybeClashingIcon = maybeClashingAction.get("icon"); + if (maybeClashingIcon === icon) { + maybeClashingAction.set("icon", currentIcon!); + } + }); + }); return set( ({ project, projectEdited, actions, model, dataWindow }) => { // If we're changing the action to use an icon that's already in use // then we update the action that's using the icon to use the action's current icon - const currentIcon = actions.find((a) => a.ID === id)?.icon; - const newActions = actions.map((action) => { - if (action.ID === id) { - return { ...action, icon }; - } else if ( - action.ID !== id && - action.icon === icon && - currentIcon - ) { - return { ...action, icon: currentIcon }; - } - return action; - }); + return { - actions: newActions, ...updateProject( project, projectEdited, - newActions, + actions, model, dataWindow ), @@ -567,17 +575,17 @@ const createMlStore = (logging: Logging) => { }, setRequiredConfidence(id: ActionData["ID"], value: number) { + const { actions } = get(); + withActionIndex(id, actions, (actionIndex) => { + actions.get(actionIndex).set("requiredConfidence", value); + }); return set( ({ project, projectEdited, actions, model, dataWindow }) => { - const newActions = actions.map((a) => - id !== a.ID ? a : { ...a, requiredConfidence: value } - ); return { - actions: newActions, ...updateProject( project, projectEdited, - newActions, + actions, model, dataWindow ), @@ -587,30 +595,24 @@ const createMlStore = (logging: Logging) => { }, deleteActionRecording(id: ActionData["ID"], recordingIdx: number) { + const { actions } = get(); + let hasRecordings: boolean = false; + for (const action of actions) { + if (action.get("ID") === id) { + (action.get("recordings") as RecordingDataY).delete(recordingIdx); + } else { + hasRecordings ||= (action.get("recordings") as RecordingDataY).length > 0; + } + } + return set(({ project, projectEdited, actions, dataWindow }) => { - const newActions = actions.map((action) => { - if (id !== action.ID) { - return action; - } - const recordings = action.recordings.filter( - (_r, i) => i !== recordingIdx - ); - return { ...action, recordings }; - }); - const numRecordings = newActions.reduce( - (acc, curr) => acc + curr.recordings.length, - 0 - ); - const newDataWindow = - numRecordings === 0 ? currentDataWindow : dataWindow; + const newDataWindow: DataWindow = hasRecordings ? currentDataWindow : dataWindow; return { - actions: newActions, dataWindow: newDataWindow, - model: undefined, ...updateProject( project, projectEdited, - newActions, + actions, undefined, newDataWindow ), @@ -619,14 +621,15 @@ const createMlStore = (logging: Logging) => { }, deleteAllActions() { - return set(({ project, projectEdited }) => ({ - actions: [createFirstAction()], + const { actions } = get(); + actions.delete(0, actions.length); + return set(({ actions, project, projectEdited }) => ({ dataWindow: currentDataWindow, model: undefined, ...updateProject( project, projectEdited, - [], + actions, undefined, currentDataWindow ), @@ -650,6 +653,11 @@ const createMlStore = (logging: Logging) => { }, loadDataset(newActions: ActionData[]) { + const { actions } = get(); + actions.delete(0, actions.length); + let newActionsY: ActionDatumY[] = actionDataToY(newActions); + + actions.push(newActionsY); set(({ project, projectEdited, settings }) => { const dataWindow = getDataWindowFromActions(newActions); return { @@ -659,25 +667,13 @@ const createMlStore = (logging: Logging) => { new Set([...settings.toursCompleted, "DataSamplesRecorded"]) ), }, - actions: (() => { - const copy = newActions.map((a) => ({ ...a })); - for (const a of copy) { - if (!a.icon) { - a.icon = actionIcon({ - isFirstAction: false, - existingActions: copy, - }); - } - } - return copy; - })(), dataWindow, model: undefined, timestamp: Date.now(), ...updateProject( project, projectEdited, - newActions, + actions, undefined, dataWindow ), @@ -709,7 +705,6 @@ const createMlStore = (logging: Logging) => { new Set([...settings.toursCompleted, "DataSamplesRecorded"]) ), }, - actions: newActions, dataWindow: getDataWindowFromActions(newActions), model: undefined, project, @@ -765,7 +760,7 @@ const createMlStore = (logging: Logging) => { // can block the UI. 50 ms is not sufficient, so use 100 for now. await new Promise((res) => setTimeout(res, 100)); const trainingResult = await trainModel( - actions, + actions.toJSON(), dataWindow, (trainModelProgress) => set({ trainModelProgress }, false, "trainModelProgress") @@ -806,7 +801,7 @@ const createMlStore = (logging: Logging) => { ...previousProject.text, ...generateProject( previousProject.header?.name ?? untitledProjectName, - { data: actions }, + { data: actions.toJSON() }, model, dataWindow ).text, @@ -944,7 +939,6 @@ const createMlStore = (logging: Logging) => { timestamp, // New project loaded externally so we can't know whether its edited. projectEdited: true, - actions: newActions, dataWindow: getDataWindowFromActions(newActions), model: undefined, isEditorOpen: false, @@ -1028,18 +1022,20 @@ const createMlStore = (logging: Logging) => { ); }, dataCollectionMicrobitConnected() { + const { actions } = get(); + if (actions.length === 0) { + createFirstAction(actions); + } set( ({ actions, tourState, postConnectTourTrigger }) => { return { - actions: - actions.length === 0 ? [createFirstAction()] : actions, // If a tour has been explicitly requested, do that. // Other tours are triggered by callbacks or effects on the relevant page so they run only on the correct screen. tourState: postConnectTourTrigger ? { index: 0, - ...getTourSpec(postConnectTourTrigger, actions), + ...getTourSpec(postConnectTourTrigger, actions.toJSON()), } : tourState, postConnectTourTrigger: undefined, @@ -1057,7 +1053,7 @@ const createMlStore = (logging: Logging) => { (!state.tourState && !state.settings.toursCompleted.includes(trigger.name)) ) { - const tourSpec = getTourSpec(trigger, state.actions); + const tourSpec = getTourSpec(trigger, state.actions.toJSON()); const result = { tourState: { ...tourSpec, @@ -1143,7 +1139,7 @@ const createMlStore = (logging: Logging) => { const input = { model, data: buffer.getSamples(startTime), - classificationIds: actions.map((a) => a.ID), + classificationIds: actions.map((a) => a.get("ID") as number), }; if (input.data.x.length > dataWindow.minSamples) { const result = predict(input, dataWindow); @@ -1156,7 +1152,7 @@ const createMlStore = (logging: Logging) => { // recognition point are realised. get().actions, result.confidences - ); + )?.toJSON() as Action; set({ predictionResult: { detected, @@ -1296,7 +1292,13 @@ const createMlStore = (logging: Logging) => { } const stateV0 = persistedStateUnknown as StateV0; const { gestures, ...rest } = stateV0; - return { actions: gestures, ...rest } as State; + + // TODO: spicy problem as we can't see the Y.js actions from here + //const newActions = actionDataToY(gestures as ActionData[]); + //actions.delete(0, actions.length); + //actions.push(newActions); + + return { ...rest } as State; } default: return persistedStateUnknown; @@ -1312,7 +1314,7 @@ const createMlStore = (logging: Logging) => { // Make sure we have any new settings defaulted ...defaultSettings, ...currentState.settings, - ...persistedState.settings, + ...persistedState?.settings, }, }; }, @@ -1334,14 +1336,6 @@ const getDataWindowFromActions = (actions: ActionData[]): DataWindow => { : currentDataWindow; }; -// Get data window from actions on app load. -const { actions } = useStore.getState(); -useStore.setState( - { dataWindow: getDataWindowFromActions(actions) }, - false, - "setDataWindow" -); - tf.loadLayersModel(modelUrl) .then((model) => { if (model) { @@ -1368,30 +1362,6 @@ useStore.subscribe((state, prevState) => { } }); -export const useHasActions = () => { - const actions = useStore((s) => s.actions); - return ( - (actions.length > 0 && actions[0].name.length > 0) || - actions[0]?.recordings.length > 0 - ); -}; - -const hasSufficientDataForTraining = (actions: ActionData[]): boolean => { - return actions.length >= 2 && actions.every((a) => a.recordings.length >= 3); -}; - -export const useHasSufficientDataForTraining = (): boolean => { - const actions = useStore((s) => s.actions); - return hasSufficientDataForTraining(actions); -}; - -export const useHasNoStoredData = (): boolean => { - const actions = useStore((s) => s.actions); - return !( - actions.length !== 0 && actions.some((a) => a.recordings.length > 0) - ); -}; - type UseSettingsReturn = [Settings, (settings: Partial) => void]; const inContextTranslationLangId = "lol"; @@ -1408,18 +1378,17 @@ export const useSettings = (): UseSettingsReturn => { const actionIcon = ({ isFirstAction, - existingActions, + existingIcons, }: { - isFirstAction: boolean; - existingActions: Action[]; + isFirstAction: boolean; + existingIcons: MakeCodeIcon[]; }) => { if (isFirstAction) { return defaultIcons[0]; } - const iconsInUse = existingActions.map((a) => a.icon); const useableIcons: MakeCodeIcon[] = []; for (const icon of defaultIcons) { - if (!iconsInUse.includes(icon)) { + if (!existingIcons.includes(icon)) { useableIcons.push(icon); } } @@ -1464,3 +1433,29 @@ const renameProject = ( }, }; }; + +const withActionIndex = (actionID: number, actions: ActionDataY, cb: (actionIndex: number) => void) => { + let actionIndex; + for (actionIndex = 0; actionIndex < actions.length; ++actionIndex) { + if (actions.get(actionIndex).get("ID") === actionID) { + break; + } + } + if (actionIndex === actions.length) return; + cb(actionIndex); +} + +const actionDataToY = (newActions: ActionData[]) => { + let existingIcons: MakeCodeIcon[] = []; + let newActionsY: ActionDatumY[] = []; + for (const a of newActions) { + const newActionY: ActionDatumY = new Y.Map(); + newActionY.set("ID", a.ID); + newActionY.set("name", a.name); + const newIcon = a.icon ? a.icon : actionIcon({ isFirstAction: false, existingIcons }); + existingIcons.push(newIcon); + newActionY.set("icon", newIcon); + newActionsY.push(newActionY); + } + return newActionsY; +} diff --git a/src/utils/actions.ts b/src/utils/actions.ts index 9d8c79e3e..7771163eb 100644 --- a/src/utils/actions.ts +++ b/src/utils/actions.ts @@ -3,7 +3,19 @@ * * SPDX-License-Identifier: MIT */ -import { ActionData } from "../model"; +import { ActionDataY, ActionDatumY, RecordingDataY, } from "../model"; -export const getTotalNumSamples = (actions: ActionData[]) => - actions.map((a) => a.recordings).reduce((acc, curr) => acc + curr.length, 0); +export const getTotalNumSamples = (actions: ActionDataY) => + actions.map((a) => (a.get("recordings") as RecordingDataY).length).reduce((acc, curr) => acc + curr, 0); + +// Y has no "every" +export const forSomeAction = (actions: ActionDataY, cb: (action: ActionDatumY) => boolean) => { + for (const action of actions) { + if (cb(action)) return true; + } + return false; +} + +export const hasSufficientDataForTraining = (actions: ActionDataY): boolean => { + return actions.length >= 2 && actions.map(action => (action.get("recordings") as RecordingDataY).length).reduce((p, c) => p && c >= 3, true); +}; \ No newline at end of file diff --git a/src/utils/prediction.ts b/src/utils/prediction.ts index 39acf7413..a3333858f 100644 --- a/src/utils/prediction.ts +++ b/src/utils/prediction.ts @@ -6,12 +6,12 @@ */ import { Confidences } from "../ml"; import { mlSettings } from "../mlConfig"; -import { Action } from "../model"; +import { ActionDataY, ActionDatumY } from "../model"; export const getDetectedAction = ( - actions: Action[], + actions: ActionDataY, confidences: Confidences | undefined -): Action | undefined => { +): ActionDatumY | undefined => { if (!confidences) { return undefined; } @@ -21,8 +21,8 @@ export const getDetectedAction = ( .map((action) => ({ action, thresholdDelta: - confidences[action.ID] - - (action.requiredConfidence ?? mlSettings.defaultRequiredConfidence), + confidences[action.get("ID") as number] - + (action.get("requiredConfidence") as number ?? mlSettings.defaultRequiredConfidence), })) .sort((left, right) => { const a = left.thresholdDelta; From 406afd4e9e029cba4768c1edb9f4b5706ad61094 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Thu, 13 Nov 2025 10:25:29 +0000 Subject: [PATCH 4/8] WIP project history --- src/pages/NewPage.tsx | 71 ++++++++-- src/project-persistence/ProjectItem.tsx | 89 +++++++----- .../ProjectStorageProvider.tsx | 128 ++++++++++++++++-- src/project-persistence/project-history-db.ts | 57 ++++++++ src/project-persistence/project-list-db.ts | 1 + 5 files changed, 291 insertions(+), 55 deletions(-) create mode 100644 src/project-persistence/project-history-db.ts diff --git a/src/pages/NewPage.tsx b/src/pages/NewPage.tsx index 3c8ac2baf..86b24f98c 100644 --- a/src/pages/NewPage.tsx +++ b/src/pages/NewPage.tsx @@ -5,15 +5,23 @@ * SPDX-License-Identifier: MIT */ import { + Button, Container, Grid, Heading, HStack, Icon, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, Text, VStack, } from "@chakra-ui/react"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { RiAddLine, RiFolderOpenLine } from "react-icons/ri"; import { FormattedMessage, useIntl } from "react-intl"; import { useNavigate } from "react-router"; @@ -31,21 +39,39 @@ import { createDataSamplesPageUrl } from "../urls"; import { useStoreProjects } from "../store-persistence-hooks"; import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; import { ProjectItem } from "../project-persistence/ProjectItem"; +import { HistoryList } from "../project-persistence/project-history-db"; const NewPage = () => { const newSession = useStore((s) => s.newSession); const navigate = useNavigate(); const logging = useLogging(); const { loadProject, newProject } = useStoreProjects(); + const [showProjectHistory, setShowProjectHistory] = useState( + null + ); + const [projectHistoryList, setProjectHistoryList] = + useState(); - const { projectList, deleteProject } = useProjectStorage(); + const { projectList, deleteProject, getHistory } = useProjectStorage(); (window as any).newProject = newProject; (window as any).loadProject = loadProject; - const handleOpenLastSession = useCallback(() => { + useEffect(() => { + const getProjectHistory = async () => { + if (showProjectHistory === null) { + setProjectHistoryList(null); + return; + } + const historyList = await getHistory(showProjectHistory); + setProjectHistoryList(historyList); + }; + void getProjectHistory(); + }, [showProjectHistory]); + + const handleOpenSession = useCallback(() => { logging.event({ - type: "session-open-last", + type: "session-open-saved", }); navigate(createDataSamplesPageUrl()); }, [logging, navigate]); @@ -65,9 +91,6 @@ const NewPage = () => { }, [logging, newSession, navigate]); const intl = useIntl(); - const lastSessionTitle = intl.formatMessage({ - id: "newpage-last-session-title", - }); const continueSessionTitle = intl.formatMessage({ id: "newpage-continue-session-title", }); @@ -132,15 +155,47 @@ const NewPage = () => { project={proj} loadProject={async () => { await loadProject(proj.id); - handleOpenLastSession(); // TODO: handleOpenSession + handleOpenSession(); }} deleteProject={deleteProject} + showHistory={() => setShowProjectHistory(proj.id)} /> ))} + + setShowProjectHistory(null)} + > + + + Project history + + + {showProjectHistory} :{" "} + {JSON.stringify( + projectHistoryList?.map((ph) => ({ + timestamp: ph.timestamp, + revisionId: ph.revisionId, + parentId: ph.parentId, + })) + )} + + + + + + + ); }; diff --git a/src/project-persistence/ProjectItem.tsx b/src/project-persistence/ProjectItem.tsx index 60e7e55d9..8b73309ad 100644 --- a/src/project-persistence/ProjectItem.tsx +++ b/src/project-persistence/ProjectItem.tsx @@ -1,12 +1,21 @@ -import { CloseButton, GridItem, Heading, HStack, Text } from "@chakra-ui/react"; +import { + CloseButton, + GridItem, + Heading, + HStack, + Icon, + Text, +} from "@chakra-ui/react"; import { ReactNode } from "react"; import { ProjectEntry } from "./project-list-db"; import { timeAgo } from "./utils"; +import { RiHistoryFill } from "react-icons/ri"; interface ProjectItemProps { - project: ProjectEntry; - loadProject: (projectId: string) => void; - deleteProject: (projectId: string) => void; + project: ProjectEntry; + showHistory: (projectId: string) => void; + loadProject: (projectId: string) => void; + deleteProject: (projectId: string) => void; } interface ProjectItemBaseProps { @@ -37,38 +46,50 @@ const ProjectItemBase = ({ onClick, children }: ProjectItemBaseProps) => ( ); -export const ProjectItem = ({project, loadProject, deleteProject}: ProjectItemProps) => ( - loadProject(project.id)}> - - - {project.projectName} - - - {timeAgo(new Date(project.modifiedDate))} +export const ProjectItem = ({ + project, + loadProject, + deleteProject, + showHistory, +}: ProjectItemProps) => ( + loadProject(project.id)}> + + + {project.projectName} + + + {timeAgo(new Date(project.modifiedDate))} - { - deleteProject(project.id); - e.stopPropagation(); - e.preventDefault(); - }} - /> - -) + { + showHistory(project.id); + e.stopPropagation(); + e.preventDefault(); + }} + /> + { + deleteProject(project.id); + e.stopPropagation(); + e.preventDefault(); + }} + /> + +); interface AddProjectItemProps { - newProject: () => void; + newProject: () => void; } -export const AddProjectItem = ({newProject}: AddProjectItemProps) => - - - New project - - Click to create - +export const AddProjectItem = ({ newProject }: AddProjectItemProps) => ( + + + New project + + Click to create + +); diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx index a4bf9f0c0..c0931ea4d 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -8,8 +8,9 @@ import React, { } from "react"; import * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; -import { ProjectList, withProjectDb } from "./project-list-db"; +import { ProjectEntry, ProjectList, withProjectDb } from "./project-list-db"; import { ProjectStore } from "./project-store"; +import { HistoryEntry, HistoryList, withHistoryDb } from "./project-history-db"; export interface NewStoredDoc { id: string; @@ -31,10 +32,19 @@ interface ProjectContextValue { awareness: Awareness | null; getFile: (filename: string) => Y.Text | null; setProjectName: (id: string, name: string) => Promise; + + getHistory: (projectId: string) => Promise; + loadRevision: (projectId: string, projectRevision: string) => Promise; + saveRevision: (projectId: string) => Promise; } const ProjectStorageContext = createContext(null); +/** + * Note on how projects are stored. The HEAD document is a Y document and maintains + * its state using y-indexeddb persistence. Revisions are stored as state deltas using + * the update format, and loading one reconstructs the HEAD document. + */ export function ProjectStorageProvider({ children, }: { @@ -106,7 +116,7 @@ export function ProjectStorageProvider({ const refreshProjects = async () => { const projectList = await withProjectDb("readonly", async (store) => { - const projectList = await new Promise((res, rej) => { + const projectList = await new Promise((res, _rej) => { const query = store.index("modifiedDate").getAll(); query.onsuccess = () => res(query.result); }); @@ -133,13 +143,14 @@ export function ProjectStorageProvider({ }; const modifyProject = useCallback( - async (id: string) => { + async (id: string, extras?: Partial) => { await withProjectDb("readwrite", async (store) => { await new Promise((res, rej) => { const getQuery = store.get(id); getQuery.onsuccess = () => { const putQuery = store.put({ ...getQuery.result, + ...extras, modifiedDate: new Date().valueOf(), }); putQuery.onsuccess = () => res(getQuery.result); @@ -152,20 +163,108 @@ export function ProjectStorageProvider({ const setProjectName = useCallback( async (id: string, projectName: string) => { - await withProjectDb("readwrite", async (store) => { - await new Promise((res, rej) => { - const query = store.put({ - id, - projectName, - modifiedDate: new Date().valueOf(), - }); - query.onsuccess = () => res(query.result); - }); - }); + await modifyProject(id, { projectName }); }, [projectStore] ); + // Revision history stuff + + const getUpdateAtRevision = async (projectId: string, revision: string) => { + let deltas: HistoryEntry[] = []; + let parentRevision = revision; + do { + const delta = await withHistoryDb("readonly", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions + .index("projectRevision") + .get([projectId, parentRevision]); + query.onsuccess = () => res(query.result as HistoryEntry); + }); + }); + parentRevision = delta.parentId; + deltas.unshift(delta); + } while (parentRevision); + return Y.mergeUpdatesV2(deltas.map((d) => d.data)); + }; + + const getProjectInfo = (projectId: string) => + withProjectDb("readwrite", async (store) => { + return new Promise((res, _rej) => { + const query = store.get(projectId); + query.onsuccess = () => res(query.result); + }); + }); + + const loadRevision = async (projectId: string, projectRevision: string) => { + const projectInfo = await getProjectInfo(projectId); + const { ydoc, id: forkId } = await newStoredProject(); + await modifyProject(forkId, { + projectName: `${projectInfo.projectName} revision`, + parentRevision: forkId, + }); + const updates = await getUpdateAtRevision(projectId, projectRevision); + Y.applyUpdateV2(ydoc, updates); + }; + + const saveRevision = async () => { + if (!projectStore) return; + const projectInfo = await getProjectInfo(projectStore.projectId); + + if (projectInfo.parentRevision) { + const previousUpdate = await getUpdateAtRevision( + projectInfo.id, + projectInfo.parentRevision + ); + const newUpdate = Y.encodeStateAsUpdateV2( + projectStore.ydoc, + previousUpdate + ); + const newRevision = makeUID(); + await withHistoryDb("readwrite", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions.put({ + projectId: projectInfo.id, + revisionId: newRevision, + parentId: projectInfo.parentRevision, + data: newUpdate, + timestamp: new Date(), + }); + query.onsuccess = () => res(); + }); + }); + await modifyProject(projectInfo.id, { parentRevision: newRevision }); + } else { + const newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc); + const newRevision = makeUID(); + await withHistoryDb("readwrite", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions.put({ + projectId: projectInfo.id, + revisionId: newRevision, + data: newUpdate, + timestamp: new Date(), + }); + query.onsuccess = () => res(); + }); + }); + await modifyProject(projectInfo.id, { parentRevision: newRevision }); + } + }; + + const getHistory = async (projectId: string) => + withHistoryDb("readonly", async (store) => { + const revisionList = await new Promise((res, _rej) => { + const query = store.index("projectId").getAll(projectId); + query.onsuccess = () => res(query.result); + }); + return revisionList; + }); + + (window as any).loadRevision = loadRevision; + (window as any).saveRevision = saveRevision; + (window as any).getHistory = getHistory; + return ( {children} diff --git a/src/project-persistence/project-history-db.ts b/src/project-persistence/project-history-db.ts new file mode 100644 index 000000000..10764cf7b --- /dev/null +++ b/src/project-persistence/project-history-db.ts @@ -0,0 +1,57 @@ + +export interface HistoryEntry { + projectId: string; + revisionId: string; + parentId: string; + data: Uint8Array; + timestamp: number; +} + +export type HistoryList = HistoryEntry[]; + +type HistoryDbWrapper = ( + accessMode: "readonly" | "readwrite", + callback: (revisions: IDBObjectStore) => Promise +) => Promise; + +export const withHistoryDb: HistoryDbWrapper = async (accessMode, callback) => { + return new Promise((res, rej) => { + const openRequest = indexedDB.open("UserProjectHistory", 1); + openRequest.onupgradeneeded = (evt: IDBVersionChangeEvent) => { + const db = openRequest.result; + const tx = (evt.target as IDBOpenDBRequest).transaction; + + let revisions: IDBObjectStore; + if (!db.objectStoreNames.contains("revisions")) { + revisions = db.createObjectStore("revisions", { autoIncrement:true }); + } else { + revisions = tx!.objectStore("revisions"); + } + if (!revisions.indexNames.contains("projectRevision")) { + revisions.createIndex("projectRevision", ["projectId", "revisionId"]); + } + if (!revisions.indexNames.contains("projectParent")) { + revisions.createIndex("projectParent", ["projectId", "parentId"]); + } + if (!revisions.indexNames.contains("projectId")) { + revisions.createIndex("projectId", "projectId"); + } + }; + + openRequest.onsuccess = async () => { + const db = openRequest.result; + + const tx = db.transaction("revisions", accessMode); + const store = tx.objectStore("revisions"); + tx.onabort = rej; + tx.onerror = rej; + + const result = await callback(store); + + // got the result, but don't return until the transaction is complete + tx.oncomplete = () => res(result); + }; + + openRequest.onerror = rej; + }); +}; diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts index cc8778b8c..4a91d34f3 100644 --- a/src/project-persistence/project-list-db.ts +++ b/src/project-persistence/project-list-db.ts @@ -3,6 +3,7 @@ export interface ProjectEntry { projectName: string; id: string; modifiedDate: number; + parentRevision?: string; } export type ProjectList = ProjectEntry[]; From 474439ebee8f862b88c420e472551b4ec277684c Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Thu, 13 Nov 2025 16:29:02 +0000 Subject: [PATCH 5/8] WIP Some more interactive project editing and history --- src/components/ProjectHistoryModal.tsx | 91 +++++++++++++++ src/components/RenameProjectModal.tsx | 61 ++++++++++ src/pages/NewPage.tsx | 108 ++++++++---------- src/project-persistence/ProjectItem.tsx | 27 ++++- .../ProjectStorageProvider.tsx | 64 +++++------ src/project-persistence/project-store.ts | 14 ++- 6 files changed, 261 insertions(+), 104 deletions(-) create mode 100644 src/components/ProjectHistoryModal.tsx create mode 100644 src/components/RenameProjectModal.tsx diff --git a/src/components/ProjectHistoryModal.tsx b/src/components/ProjectHistoryModal.tsx new file mode 100644 index 000000000..82fe47aa3 --- /dev/null +++ b/src/components/ProjectHistoryModal.tsx @@ -0,0 +1,91 @@ +import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, List, ListItem, Heading, Button, ModalFooter } from "@chakra-ui/react"; +import { HistoryList } from "../project-persistence/project-history-db"; +import { ProjectEntry } from "../project-persistence/project-list-db"; +import { useEffect, useState } from "react"; +import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; + +interface ProjectHistoryModalProps { + onLoadRequest: (projectId: string, revisionId: string) => void; + isOpen: boolean; + onDismiss: () => void; + projectInfo: ProjectEntry | null; +} + +const ProjectHistoryModal = ({ + onLoadRequest, + isOpen, + onDismiss, + projectInfo +}: ProjectHistoryModalProps) => { + const [projectHistoryList, setProjectHistoryList] = useState(null); + const {getHistory, saveRevision} = useProjectStorage(); + + const getProjectHistory = async () => { + if (projectInfo === null) { + setProjectHistoryList(null); + return; + } + const historyList = await getHistory(projectInfo.id); + setProjectHistoryList(historyList.sort(h => -h.timestamp)); + }; + + useEffect(() => { + void getProjectHistory(); + }, [projectInfo]); + + return ( + + + Project history + + + {projectInfo && ( + + + + {projectInfo.projectName} + + + {projectHistoryList?.map((ph) => ( + + + {new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(new Date(ph.timestamp))} + + + + ))} + + )} + + + + + + + ) + } + +export default ProjectHistoryModal; \ No newline at end of file diff --git a/src/components/RenameProjectModal.tsx b/src/components/RenameProjectModal.tsx new file mode 100644 index 000000000..0afab94ed --- /dev/null +++ b/src/components/RenameProjectModal.tsx @@ -0,0 +1,61 @@ +import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, Button, ModalFooter, Input } from "@chakra-ui/react"; +import { ProjectEntry } from "../project-persistence/project-list-db"; +import { useEffect, useState } from "react"; + +interface ProjectHistoryModalProps { + handleRename: (projectId: string, projectName: string) => void; + isOpen: boolean; + onDismiss: () => void; + projectInfo: ProjectEntry | null; +} + +const RenameProjectModal = ({ + handleRename, + isOpen, + onDismiss, + projectInfo +}: ProjectHistoryModalProps) => { + const [projectName, setProjectName] = useState(projectInfo?.projectName || ""); + + useEffect(() => { + if (!projectInfo) { + return; + } + setProjectName(projectInfo.projectName); + }, [projectInfo]); + + return ( + + + Project history + + + {projectInfo && ( + + setProjectName(e.target.value)} /> + )} + + + + + + + + ) + } + +export default RenameProjectModal; \ No newline at end of file diff --git a/src/pages/NewPage.tsx b/src/pages/NewPage.tsx index 86b24f98c..3bd57499d 100644 --- a/src/pages/NewPage.tsx +++ b/src/pages/NewPage.tsx @@ -5,12 +5,16 @@ * SPDX-License-Identifier: MIT */ import { + Box, Button, Container, Grid, + GridItem, Heading, HStack, Icon, + List, + ListItem, Modal, ModalBody, ModalCloseButton, @@ -40,41 +44,45 @@ import { useStoreProjects } from "../store-persistence-hooks"; import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; import { ProjectItem } from "../project-persistence/ProjectItem"; import { HistoryList } from "../project-persistence/project-history-db"; +import ProjectHistoryModal from "../components/ProjectHistoryModal"; +import { ProjectEntry } from "../project-persistence/project-list-db"; +import RenameProjectModal from "../components/RenameProjectModal"; const NewPage = () => { const newSession = useStore((s) => s.newSession); const navigate = useNavigate(); const logging = useLogging(); const { loadProject, newProject } = useStoreProjects(); - const [showProjectHistory, setShowProjectHistory] = useState( - null - ); - const [projectHistoryList, setProjectHistoryList] = - useState(); + const [showProjectHistory, setShowProjectHistory] = + useState(null); + const [showProjectRename, setShowProjectRename] = + useState(null); - const { projectList, deleteProject, getHistory } = useProjectStorage(); + const { projectList, deleteProject, loadRevision, setProjectName } = + useProjectStorage(); - (window as any).newProject = newProject; - (window as any).loadProject = loadProject; + const handleOpenSession = useCallback( + async (projectId: string) => { + logging.event({ + type: "session-open-saved", + }); + await loadProject(projectId); + navigate(createDataSamplesPageUrl()); + }, + [logging, navigate] + ); - useEffect(() => { - const getProjectHistory = async () => { - if (showProjectHistory === null) { - setProjectHistoryList(null); - return; - } - const historyList = await getHistory(showProjectHistory); - setProjectHistoryList(historyList); - }; - void getProjectHistory(); - }, [showProjectHistory]); + const handleOpenRevision = useCallback( + async (projectId: string, revisionId: string) => { + logging.event({ + type: "session-open-revision", + }); - const handleOpenSession = useCallback(() => { - logging.event({ - type: "session-open-saved", - }); - navigate(createDataSamplesPageUrl()); - }, [logging, navigate]); + await loadRevision(projectId, revisionId); + navigate(createDataSamplesPageUrl()); + }, + [logging, navigate] + ); const loadProjectRef = useRef(null); const handleContinueSessionFromFile = useCallback(() => { @@ -154,48 +162,32 @@ const NewPage = () => { key={proj.id} project={proj} loadProject={async () => { - await loadProject(proj.id); - handleOpenSession(); + void handleOpenSession(proj.id); }} deleteProject={deleteProject} - showHistory={() => setShowProjectHistory(proj.id)} + renameProject={() => setShowProjectRename(proj)} + showHistory={() => setShowProjectHistory(proj)} /> ))} - - setShowProjectHistory(null)} - > - - - Project history - - - {showProjectHistory} :{" "} - {JSON.stringify( - projectHistoryList?.map((ph) => ({ - timestamp: ph.timestamp, - revisionId: ph.revisionId, - parentId: ph.parentId, - })) - )} - - - - - - - + onLoadRequest={handleOpenRevision} + onDismiss={() => setShowProjectHistory(null)} + projectInfo={showProjectHistory} + /> + setShowProjectRename(null)} + projectInfo={showProjectRename} + handleRename={(projectId, projectName) => { + setProjectName(projectId, projectName); + setShowProjectRename(null); + }} + /> ); }; diff --git a/src/project-persistence/ProjectItem.tsx b/src/project-persistence/ProjectItem.tsx index 8b73309ad..c1a4be901 100644 --- a/src/project-persistence/ProjectItem.tsx +++ b/src/project-persistence/ProjectItem.tsx @@ -3,19 +3,20 @@ import { GridItem, Heading, HStack, - Icon, + IconButton, Text, } from "@chakra-ui/react"; import { ReactNode } from "react"; import { ProjectEntry } from "./project-list-db"; import { timeAgo } from "./utils"; -import { RiHistoryFill } from "react-icons/ri"; +import { RiEditFill, RiHistoryFill } from "react-icons/ri"; interface ProjectItemProps { project: ProjectEntry; showHistory: (projectId: string) => void; loadProject: (projectId: string) => void; deleteProject: (projectId: string) => void; + renameProject: (projectId: string) => void; } interface ProjectItemBaseProps { @@ -50,6 +51,7 @@ export const ProjectItem = ({ project, loadProject, deleteProject, + renameProject, showHistory, }: ProjectItemProps) => ( loadProject(project.id)}> @@ -60,13 +62,30 @@ export const ProjectItem = ({ {timeAgo(new Date(project.modifiedDate))} - } + mr="2" onClick={(e) => { showHistory(project.id); e.stopPropagation(); e.preventDefault(); }} + size="lg" + title="Project history" + variant="outline" + /> + } + onClick={(e) => { + renameProject(project.id); + e.stopPropagation(); + e.preventDefault(); + }} + size="lg" + title="Rename" + variant="outline" /> Promise; loadRevision: (projectId: string, projectRevision: string) => Promise; - saveRevision: (projectId: string) => Promise; + saveRevision: (projectInfo: ProjectEntry) => Promise; } const ProjectStorageContext = createContext(null); @@ -68,7 +68,8 @@ export function ProjectStorageProvider({ const newProjectStore = new ProjectStore(projectId, () => modifyProject(projectId) ); - await newProjectStore.init(); + await newProjectStore.persist(); + newProjectStore.startSyncing(); setProjectStore(newProjectStore); return { ydoc: newProjectStore.ydoc, @@ -93,7 +94,8 @@ export function ProjectStorageProvider({ const newProjectStore = new ProjectStore(newProjectId, () => modifyProject(newProjectId) ); - await newProjectStore.init(); + await newProjectStore.persist(); + newProjectStore.startSyncing(); setProjectStore(newProjectStore); return { ydoc: newProjectStore.ydoc, id: newProjectId }; }, []); @@ -164,6 +166,7 @@ export function ProjectStorageProvider({ const setProjectName = useCallback( async (id: string, projectName: string) => { await modifyProject(id, { projectName }); + await refreshProjects(); }, [projectStore] ); @@ -207,49 +210,33 @@ export function ProjectStorageProvider({ Y.applyUpdateV2(ydoc, updates); }; - const saveRevision = async () => { - if (!projectStore) return; - const projectInfo = await getProjectInfo(projectStore.projectId); - + const saveRevision = async (projectInfo: ProjectEntry) => { + const projectStore = new ProjectStore(projectInfo.id, () => {}); + await projectStore.persist(); + let newUpdate: Uint8Array; if (projectInfo.parentRevision) { const previousUpdate = await getUpdateAtRevision( projectInfo.id, projectInfo.parentRevision ); - const newUpdate = Y.encodeStateAsUpdateV2( - projectStore.ydoc, - previousUpdate - ); - const newRevision = makeUID(); - await withHistoryDb("readwrite", async (revisions) => { - return new Promise((res, _rej) => { - const query = revisions.put({ - projectId: projectInfo.id, - revisionId: newRevision, - parentId: projectInfo.parentRevision, - data: newUpdate, - timestamp: new Date(), - }); - query.onsuccess = () => res(); - }); - }); - await modifyProject(projectInfo.id, { parentRevision: newRevision }); + newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc, previousUpdate); } else { - const newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc); - const newRevision = makeUID(); - await withHistoryDb("readwrite", async (revisions) => { - return new Promise((res, _rej) => { - const query = revisions.put({ - projectId: projectInfo.id, - revisionId: newRevision, - data: newUpdate, - timestamp: new Date(), - }); - query.onsuccess = () => res(); + newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc); + } + const newRevision = makeUID(); + await withHistoryDb("readwrite", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions.put({ + projectId: projectInfo.id, + revisionId: newRevision, + parentId: projectInfo.parentRevision, + data: newUpdate, + timestamp: new Date(), }); + query.onsuccess = () => res(); }); - await modifyProject(projectInfo.id, { parentRevision: newRevision }); - } + }); + await modifyProject(projectInfo.id, { parentRevision: newRevision }); }; const getHistory = async (projectId: string) => @@ -261,6 +248,7 @@ export function ProjectStorageProvider({ return revisionList; }); + // TODO: remove debug stuff (window as any).loadRevision = loadRevision; (window as any).saveRevision = saveRevision; (window as any).getHistory = getHistory; diff --git a/src/project-persistence/project-store.ts b/src/project-persistence/project-store.ts index 1e1443cf3..e02a73ca0 100644 --- a/src/project-persistence/project-store.ts +++ b/src/project-persistence/project-store.ts @@ -40,13 +40,16 @@ export class ProjectStore { }).bind(this); } - public async init() { - this.ydoc.on("update", this.updatePoster); - this.updates.addEventListener("message", this.broadcastHandler); + public async persist() { await new Promise((res) => this.persistence.once("synced", res)); migrate(this.ydoc); } + public startSyncing() { + this.ydoc.on("update", this.updatePoster); + this.updates.addEventListener("message", this.broadcastHandler); + } + public destroy() { this.ydoc.off("update", this.updatePoster); this.updates.removeEventListener("message", this.broadcastHandler); @@ -55,10 +58,13 @@ export class ProjectStore { } } - +/** + * This is a kind of example of what migration could look like. It's not a designed approach at this point. + */ const migrate = (doc: Y.Doc) => { const meta = doc.getMap("meta"); if (!meta.has("version")) { + // If the project has no version, assume it's from whatever this app did before ProjectStorageProvider // This could be a per-app handler meta.set("version", 1); meta.set("projectName", "default"); // TODO: get this from the last loaded project name From a1552cb76b77a90d4863ca4f47ed2b18bfb55066 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Thu, 13 Nov 2025 17:39:26 +0000 Subject: [PATCH 6/8] WIP --- src/pages/NewPage.tsx | 4 ++-- .../ProjectHistoryModal.tsx | 17 +++++++-------- .../RenameProjectModal.tsx | 2 +- src/project-persistence/utils.ts | 21 +++++++++++++++++++ 4 files changed, 32 insertions(+), 12 deletions(-) rename src/{components => project-persistence}/ProjectHistoryModal.tsx (82%) rename src/{components => project-persistence}/RenameProjectModal.tsx (96%) diff --git a/src/pages/NewPage.tsx b/src/pages/NewPage.tsx index 3bd57499d..c48707c43 100644 --- a/src/pages/NewPage.tsx +++ b/src/pages/NewPage.tsx @@ -44,9 +44,9 @@ import { useStoreProjects } from "../store-persistence-hooks"; import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; import { ProjectItem } from "../project-persistence/ProjectItem"; import { HistoryList } from "../project-persistence/project-history-db"; -import ProjectHistoryModal from "../components/ProjectHistoryModal"; +import ProjectHistoryModal from "../project-persistence/ProjectHistoryModal"; import { ProjectEntry } from "../project-persistence/project-list-db"; -import RenameProjectModal from "../components/RenameProjectModal"; +import RenameProjectModal from "../project-persistence/RenameProjectModal"; const NewPage = () => { const newSession = useStore((s) => s.newSession); diff --git a/src/components/ProjectHistoryModal.tsx b/src/project-persistence/ProjectHistoryModal.tsx similarity index 82% rename from src/components/ProjectHistoryModal.tsx rename to src/project-persistence/ProjectHistoryModal.tsx index 82fe47aa3..c1a600ab3 100644 --- a/src/components/ProjectHistoryModal.tsx +++ b/src/project-persistence/ProjectHistoryModal.tsx @@ -1,8 +1,9 @@ import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, List, ListItem, Heading, Button, ModalFooter } from "@chakra-ui/react"; -import { HistoryList } from "../project-persistence/project-history-db"; -import { ProjectEntry } from "../project-persistence/project-list-db"; +import { HistoryList } from "./project-history-db"; +import { ProjectEntry } from "./project-list-db"; import { useEffect, useState } from "react"; -import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; +import { useProjectStorage } from "./ProjectStorageProvider"; +import { significantDateUnits } from "./utils"; interface ProjectHistoryModalProps { onLoadRequest: (projectId: string, revisionId: string) => void; @@ -44,9 +45,10 @@ const ProjectHistoryModal = ({ {projectInfo && ( + {projectInfo.projectName} - {projectInfo.projectName} + Latest + Latest + {projectHistoryList?.map((ph) => ( @@ -71,20 +70,18 @@ const ProjectHistoryModal = ({ ))} - )} - + + )} + - - - - - ) - } + + + + + + ); +}; export default ProjectHistoryModal; \ No newline at end of file diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx index fbe715021..5a2e3b8da 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -1,49 +1,29 @@ // ProjectContext.tsx -import React, { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; -import * as Y from "yjs"; -import { Awareness } from "y-protocols/awareness"; -import { ProjectEntry, ProjectList, withProjectDb } from "./project-list-db"; +import React, { createContext, useCallback, useContext, useState } from "react"; +import { ProjectList } from "./project-list-db"; import { ProjectStore } from "./project-store"; -import { HistoryEntry, HistoryList, withHistoryDb } from "./project-history-db"; - -export interface NewStoredDoc { - id: string; - ydoc: Y.Doc; -} - -export interface RestoredStoredDoc { - projectName: string; - ydoc: Y.Doc; -} interface ProjectContextValue { projectId: string | null; projectList: ProjectList | null; - newStoredProject: () => Promise; - restoreStoredProject: (id: string) => Promise; - deleteProject: (id: string) => Promise; - ydoc: Y.Doc | null; - awareness: Awareness | null; - getFile: (filename: string) => Y.Text | null; - setProjectName: (id: string, name: string) => Promise; - - getHistory: (projectId: string) => Promise; - loadRevision: (projectId: string, projectRevision: string) => Promise; - saveRevision: (projectInfo: ProjectEntry) => Promise; + setProjectList: (projectList: ProjectList) => void; + projectStore: ProjectStore | null; + setProjectStore: (projectStore: ProjectStore) => void; } -const ProjectStorageContext = createContext(null); +export const ProjectStorageContext = createContext( + null +); /** - * Note on how projects are stored. The HEAD document is a Y document and maintains - * its state using y-indexeddb persistence. Revisions are stored as state deltas using - * the update format, and loading one reconstructs the HEAD document. + * The ProjectStorageProvider is intended to be used only through the hooks in + * + * - project-list-hooks.ts: information about hooks that does not require an open project + * - persistent-project-hooks.ts: manages a currently open project + * - project-history-hooks.ts: manages project history and revisions + * + * This structure is helpful for working out what parts of project persistence are used + * where. */ export function ProjectStorageProvider({ children, @@ -54,220 +34,24 @@ export function ProjectStorageProvider({ const [projectStore, setProjectStoreImpl] = useState( null ); - const setProjectStore = (newProjectStore: ProjectStore) => { - if (projectStore) { - projectStore.destroy(); - } - setProjectStoreImpl(newProjectStore); - }; - - const restoreStoredProject: ( - projectId: string - ) => Promise = useCallback( - async (projectId: string) => { - const newProjectStore = new ProjectStore(projectId, () => - modifyProject(projectId) - ); - await newProjectStore.persist(); - newProjectStore.startSyncing(); - setProjectStore(newProjectStore); - return { - ydoc: newProjectStore.ydoc, - projectName: projectList!.find((prj) => prj.id === projectId)! - .projectName, - }; - }, - [projectList] - ); - - const newStoredProject: () => Promise = - useCallback(async () => { - const newProjectId = makeUID(); - await withProjectDb("readwrite", async (store) => { - store.add({ - id: newProjectId, - projectName: "Untitled project", - modifiedDate: new Date().valueOf(), - }); - return Promise.resolve(); - }); - const newProjectStore = new ProjectStore(newProjectId, () => - modifyProject(newProjectId) - ); - await newProjectStore.persist(); - newProjectStore.startSyncing(); - setProjectStore(newProjectStore); - return { ydoc: newProjectStore.ydoc, id: newProjectId }; - }, []); - - const deleteProject: (id: string) => Promise = useCallback( - async (id) => { - await withProjectDb("readwrite", async (store) => { - store.delete(id); - return refreshProjects(); - }); - }, - [] - ); - - // TODO: Get rid of debug hooks - (window as unknown as any).projectList = projectList; - (window as unknown as any).newProjectStore = newStoredProject; - (window as unknown as any).restoreProjectStore = restoreStoredProject; - (window as unknown as any).deleteProject = deleteProject; - - const refreshProjects = async () => { - const projectList = await withProjectDb("readonly", async (store) => { - const projectList = await new Promise((res, _rej) => { - const query = store.index("modifiedDate").getAll(); - query.onsuccess = () => res(query.result); - }); - return projectList; - }); - setProjectList((projectList as ProjectList).reverse()); - }; - - useEffect(() => { - if (window.navigator.storage?.persist) { - window.navigator.storage.persist(); - } - void refreshProjects(); - }, []); - - // Helper to access files - const getFile = (filename: string) => { - if (!projectStore) { - return null; - } - const files = projectStore.ydoc.getMap("files"); - if (!files.has(filename)) files.set(filename, new Y.Text()); - return files.get(filename)!; - }; - - const modifyProject = useCallback( - async (id: string, extras?: Partial) => { - await withProjectDb("readwrite", async (store) => { - await new Promise((res, rej) => { - const getQuery = store.get(id); - getQuery.onsuccess = () => { - const putQuery = store.put({ - ...getQuery.result, - ...extras, - modifiedDate: new Date().valueOf(), - }); - putQuery.onsuccess = () => res(getQuery.result); - }; - }); - }); - }, - [projectStore] - ); - - const setProjectName = useCallback( - async (id: string, projectName: string) => { - await modifyProject(id, { projectName }); - await refreshProjects(); + const setProjectStore = useCallback( + (newProjectStore: ProjectStore) => { + if (projectStore) { + projectStore.destroy(); + } + setProjectStoreImpl(newProjectStore); }, [projectStore] ); - // Revision history stuff - - const getUpdateAtRevision = async (projectId: string, revision: string) => { - let deltas: HistoryEntry[] = []; - let parentRevision = revision; - do { - const delta = await withHistoryDb("readonly", async (revisions) => { - return new Promise((res, _rej) => { - const query = revisions - .index("projectRevision") - .get([projectId, parentRevision]); - query.onsuccess = () => res(query.result as HistoryEntry); - }); - }); - parentRevision = delta.parentId; - deltas.unshift(delta); - } while (parentRevision); - return Y.mergeUpdatesV2(deltas.map((d) => d.data)); - }; - - const getProjectInfo = (projectId: string) => - withProjectDb("readwrite", async (store) => { - return new Promise((res, _rej) => { - const query = store.get(projectId); - query.onsuccess = () => res(query.result); - }); - }); - - const loadRevision = async (projectId: string, projectRevision: string) => { - const projectInfo = await getProjectInfo(projectId); - const { ydoc, id: forkId } = await newStoredProject(); - await modifyProject(forkId, { - projectName: `${projectInfo.projectName} revision`, - parentRevision: forkId, - }); - const updates = await getUpdateAtRevision(projectId, projectRevision); - Y.applyUpdateV2(ydoc, updates); - }; - - const saveRevision = async (projectInfo: ProjectEntry) => { - const projectStore = new ProjectStore(projectInfo.id, () => {}); - await projectStore.persist(); - let newUpdate: Uint8Array; - if (projectInfo.parentRevision) { - const previousUpdate = await getUpdateAtRevision( - projectInfo.id, - projectInfo.parentRevision - ); - newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc, previousUpdate); - } else { - newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc); - } - const newRevision = makeUID(); - await withHistoryDb("readwrite", async (revisions) => { - return new Promise((res, _rej) => { - const query = revisions.put({ - projectId: projectInfo.id, - revisionId: newRevision, - parentId: projectInfo.parentRevision, - data: newUpdate, - timestamp: new Date(), - }); - query.onsuccess = () => res(); - }); - }); - await modifyProject(projectInfo.id, { parentRevision: newRevision }); - }; - - const getHistory = async (projectId: string) => - withHistoryDb("readonly", async (store) => { - const revisionList = await new Promise((res, _rej) => { - const query = store.index("projectId").getAll(projectId); - query.onsuccess = () => res(query.result); - }); - return revisionList; - }); - - // TODO: remove debug stuff - (window as any).loadRevision = loadRevision; - (window as any).saveRevision = saveRevision; - (window as any).getHistory = getHistory; - return ( {children} @@ -283,9 +67,3 @@ export function useProjectStorage() { ); return ctx; } - -// TODO: WORLDS UGLIEST UIDS -const makeUID = () => { - return `${Math.random()}`; -}; - diff --git a/src/project-persistence/persistent-project-hooks.ts b/src/project-persistence/persistent-project-hooks.ts new file mode 100644 index 000000000..c9096f274 --- /dev/null +++ b/src/project-persistence/persistent-project-hooks.ts @@ -0,0 +1,22 @@ +import { useContext } from "react"; +import { ProjectStorageContext } from "./ProjectStorageProvider"; +import * as Y from "yjs"; +import { Awareness } from "y-protocols/awareness.js"; + +interface PersistentProjectActions { + ydoc?: Y.Doc; + awareness?: Awareness; +} + +export const usePersistentProject = (): PersistentProjectActions => { + + const ctx = useContext(ProjectStorageContext); + if (!ctx) + throw new Error( + "usePersistentProject must be used within a ProjectStorageProvider" + ); + return { + ydoc: ctx.projectStore?.ydoc, + awareness: ctx.projectStore?.awareness + }; +} diff --git a/src/project-persistence/project-history-hooks.ts b/src/project-persistence/project-history-hooks.ts new file mode 100644 index 000000000..8e416f5fb --- /dev/null +++ b/src/project-persistence/project-history-hooks.ts @@ -0,0 +1,115 @@ +import { useCallback, useContext } from "react"; +import { ProjectStorageContext } from "./ProjectStorageProvider"; +import { HistoryEntry, HistoryList, withHistoryDb } from "./project-history-db"; +import { modifyProject, ProjectEntry, withProjectDb } from "./project-list-db"; +import { makeUID } from "./utils"; +import * as Y from "yjs"; +import { ProjectStore } from "./project-store"; +import { useProjectList } from "./project-list-hooks"; + +/** + * Each project has a "head" which is a Y.Doc, and a series of revisions which are Y.js Update deltas. + */ +interface ProjectHistoryActions { + getHistory: (projectId: string) => Promise; + /** + * Note that loading a revision creates a new instance of the project at that revision. + * + * TODO: if a user loads a revision and doesn't modify it, should we even keep it around? + */ + loadRevision: (projectId: string, projectRevision: string) => Promise; + /** + * Converts the head of the given project into a revision. + * + * TODO: prevent creating empty revisions if nothing changes. + */ + saveRevision: (projectInfo: ProjectEntry) => Promise; +} + +export const useProjectHistory = (): ProjectHistoryActions => { + const ctx = useContext(ProjectStorageContext); + if (!ctx) { + throw new Error( + "useProjectHistory must be used within a ProjectStorageProvider" + ); + } + const { newStoredProject } = useProjectList(); + + const getUpdateAtRevision = useCallback(async (projectId: string, revision: string) => { + const deltas: HistoryEntry[] = []; + let parentRevision = revision; + do { + const delta = await withHistoryDb("readonly", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions + .index("projectRevision") + .get([projectId, parentRevision]); + query.onsuccess = () => res(query.result as HistoryEntry); + }); + }); + parentRevision = delta.parentId; + deltas.unshift(delta); + } while (parentRevision); + return Y.mergeUpdatesV2(deltas.map((d) => d.data)); + }, []); + + const getProjectInfo = (projectId: string) => + withProjectDb("readwrite", async (store) => { + return new Promise((res, _rej) => { + const query = store.get(projectId); + query.onsuccess = () => res(query.result as ProjectEntry); + }); + }); + + const loadRevision = useCallback(async (projectId: string, projectRevision: string) => { + const projectInfo = await getProjectInfo(projectId); + const { ydoc, id: forkId } = await newStoredProject(); + await modifyProject(forkId, { + projectName: `${projectInfo.projectName} revision`, + parentRevision: forkId, + }); + const updates = await getUpdateAtRevision(projectId, projectRevision); + Y.applyUpdateV2(ydoc, updates); + }, [getUpdateAtRevision, newStoredProject]); + + const saveRevision = useCallback(async (projectInfo: ProjectEntry) => { + const projectStore = new ProjectStore(projectInfo.id, () => { }); + await projectStore.persist(); + let newUpdate: Uint8Array; + if (projectInfo.parentRevision) { + const previousUpdate = await getUpdateAtRevision( + projectInfo.id, + projectInfo.parentRevision + ); + newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc, previousUpdate); + } else { + newUpdate = Y.encodeStateAsUpdateV2(projectStore.ydoc); + } + const newRevision = makeUID(); + await withHistoryDb("readwrite", async (revisions) => { + return new Promise((res, _rej) => { + const query = revisions.put({ + projectId: projectInfo.id, + revisionId: newRevision, + parentId: projectInfo.parentRevision, + data: newUpdate, + timestamp: new Date(), + }); + query.onsuccess = () => res(); + }); + }); + await modifyProject(projectInfo.id, { parentRevision: newRevision }); + }, [getUpdateAtRevision]); + + const getHistory = useCallback(async (projectId: string) => + withHistoryDb("readonly", async (store) => { + const revisionList = await new Promise((res, _rej) => { + const query = store.index("projectId").getAll(projectId); + query.onsuccess = () => res(query.result); + }); + return revisionList; + }), []); + + + return { getHistory, loadRevision, saveRevision }; +} diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts index 4a91d34f3..3a937e70e 100644 --- a/src/project-persistence/project-list-db.ts +++ b/src/project-persistence/project-list-db.ts @@ -39,12 +39,11 @@ export const withProjectDb: ProjectDbWrapper = async (accessMode, callback) => { updateProjectData.onsuccess = () => { updateProjectData.result.forEach((project) => { if (!('modifiedDate' in project)) { - project.modifiedDate = now; - projects.put(project); + projects.put({ ...project, modifiedDate: now }); } }); }; - }; + } }; openRequest.onsuccess = async () => { @@ -64,3 +63,20 @@ export const withProjectDb: ProjectDbWrapper = async (accessMode, callback) => { openRequest.onerror = rej; }); }; + + +export const modifyProject = async (id: string, extras?: Partial) => { + await withProjectDb("readwrite", async (store) => { + await new Promise((res, _rej) => { + const getQuery = store.get(id); + getQuery.onsuccess = () => { + const putQuery = store.put({ + ...getQuery.result, + ...extras, + modifiedDate: new Date().valueOf(), + }); + putQuery.onsuccess = () => res(getQuery.result); + }; + }); + }); +} diff --git a/src/project-persistence/project-list-hooks.ts b/src/project-persistence/project-list-hooks.ts new file mode 100644 index 000000000..bd8cb0681 --- /dev/null +++ b/src/project-persistence/project-list-hooks.ts @@ -0,0 +1,120 @@ +import { useCallback, useContext, useEffect } from "react"; +import { ProjectStorageContext } from "./ProjectStorageProvider"; +import * as Y from "yjs"; +import { ProjectStore } from "./project-store"; +import { modifyProject, ProjectList, withProjectDb } from "./project-list-db"; +import { makeUID } from "./utils"; + +export interface NewStoredDoc { + id: string; + ydoc: Y.Doc; +} + +export interface RestoredStoredDoc { + projectName: string; + ydoc: Y.Doc; +} + +interface ProjectListActions { + newStoredProject: () => Promise; + restoreStoredProject: (id: string) => Promise; + deleteProject: (id: string) => Promise; + setProjectName: (id: string, name: string) => Promise; + projectList: ProjectList | null; +} + +export const useProjectList = (): ProjectListActions => { + + const ctx = useContext(ProjectStorageContext); + + if (!ctx) { + throw new Error( + "useProjectList must be used within a ProjectStorageProvider" + ); + } + + const { setProjectList, projectList, setProjectStore } = ctx; + + const refreshProjects = useCallback(async () => { + const projectList = await withProjectDb("readonly", async (store) => { + const projectList = await new Promise((res, _rej) => { + const query = store.index("modifiedDate").getAll(); + query.onsuccess = () => res(query.result); + }); + return projectList; + }); + setProjectList((projectList as ProjectList).reverse()); + }, [setProjectList]); + + useEffect(() => { + if (window.navigator.storage?.persist) { + void window.navigator.storage.persist(); + } + void refreshProjects(); + }, [refreshProjects]); + + const setProjectName = useCallback( + async (id: string, projectName: string) => { + await modifyProject(id, { projectName }); + await refreshProjects(); + }, + [refreshProjects] + ); + + const restoreStoredProject: ( + projectId: string + ) => Promise = useCallback( + async (projectId: string) => { + const newProjectStore = new ProjectStore(projectId, () => + modifyProject(projectId) + ); + await newProjectStore.persist(); + newProjectStore.startSyncing(); + setProjectStore(newProjectStore); + return { + ydoc: newProjectStore.ydoc, + projectName: projectList!.find((prj) => prj.id === projectId)! + .projectName, + }; + }, + [projectList, setProjectStore] + ); + + const newStoredProject: () => Promise = + useCallback(async () => { + const newProjectId = makeUID(); + await withProjectDb("readwrite", async (store) => { + store.add({ + id: newProjectId, + projectName: "Untitled project", + modifiedDate: new Date().valueOf(), + }); + return Promise.resolve(); + }); + const newProjectStore = new ProjectStore(newProjectId, () => + modifyProject(newProjectId) + ); + await newProjectStore.persist(); + newProjectStore.startSyncing(); + setProjectStore(newProjectStore); + return { ydoc: newProjectStore.ydoc, id: newProjectId }; + }, [ setProjectStore]); + + const deleteProject: (id: string) => Promise = useCallback( + async (id) => { + await withProjectDb("readwrite", async (store) => { + store.delete(id); + return refreshProjects(); + }); + }, + [refreshProjects] + ); + + return { + restoreStoredProject, + newStoredProject, + deleteProject, + setProjectName, + projectList + }; +} diff --git a/src/project-persistence/project-store.ts b/src/project-persistence/project-store.ts index e02a73ca0..813b45172 100644 --- a/src/project-persistence/project-store.ts +++ b/src/project-persistence/project-store.ts @@ -14,7 +14,7 @@ import * as Y from "yjs"; export class ProjectStore { public ydoc: Y.Doc; public awareness: Awareness; - private broadcastHandler: (e: MessageEvent) => void; + private broadcastHandler: (e: MessageEvent) => void; private persistence: IndexeddbPersistence; private updates: BroadcastChannel; private updatePoster: (update: Uint8Array) => void; @@ -27,7 +27,7 @@ export class ProjectStore { this.persistence = new IndexeddbPersistence(this.projectId, this.ydoc); const clientId = `${Math.random()}`; // Used by the broadcasthandler to know whether we sent a data update - this.broadcastHandler = ({ data }: MessageEvent) => { + this.broadcastHandler = ({ data }: MessageEvent) => { if (data.clientId !== clientId && data.projectId === projectId) { Y.applyUpdate(ydoc, data.update); } @@ -70,3 +70,9 @@ const migrate = (doc: Y.Doc) => { meta.set("projectName", "default"); // TODO: get this from the last loaded project name } }; + +interface SyncMessage { + clientId: string; + projectId: string; + update: Uint8Array; +} \ No newline at end of file diff --git a/src/project-persistence/utils.ts b/src/project-persistence/utils.ts index d445d2ded..bc3a774cf 100644 --- a/src/project-persistence/utils.ts +++ b/src/project-persistence/utils.ts @@ -28,7 +28,7 @@ export function significantDateUnits(date: Date): string { let dateTimeOptions: Intl.DateTimeFormatOptions = { month: "short", year: "2-digit" }; - let daysDifferent = Math.round((+now - +date) / (1000 * 60 * 60 * 24)); + const daysDifferent = Math.round((+now - +date) / (1000 * 60 * 60 * 24)); if (daysDifferent < 1 && date.getDay() === now.getDay()) { dateTimeOptions = { hour: 'numeric', @@ -42,4 +42,10 @@ export function significantDateUnits(date: Date): string { } return Intl.DateTimeFormat(undefined, dateTimeOptions).format(date); -} \ No newline at end of file +} + +// TODO: WORLDS UGLIEST UIDS +export const makeUID = () => { + return `${Math.random()}`; +}; + diff --git a/src/store-persistence-hooks.ts b/src/store-persistence-hooks.ts index 3b681037b..42eb2fdd9 100644 --- a/src/store-persistence-hooks.ts +++ b/src/store-persistence-hooks.ts @@ -1,16 +1,17 @@ import { useEffect, useState } from "react"; import { ActionDataY, RecordingDataY } from "./model"; -import { useProjectStorage } from "./project-persistence/ProjectStorageProvider"; import { useStore } from "./store"; import { BASE_DOC_NAME, loadNewDoc } from "./store-persistence"; import * as Y from "yjs"; +import { useProjectList } from "./project-persistence/project-list-hooks"; +import { usePersistentProject } from "./project-persistence/persistent-project-hooks"; export const useStoreProjects = () => { // storeprojects relates to projects of type Store // projectstorage stores projects // simple? // TODO: improve naming - const { newStoredProject, restoreStoredProject } = useProjectStorage(); + const { newStoredProject, restoreStoredProject } = useProjectList(); const newProject = async () => { const newProjectImpl = async () => { const { ydoc } = await newStoredProject(); @@ -18,9 +19,7 @@ export const useStoreProjects = () => { ydoc.getMap("files").set("actions", new Y.Array); return ydoc; } - const newProjectPromise = newProjectImpl(); - loadNewDoc(newProjectPromise); - await newProjectPromise; + await loadNewDoc(newProjectImpl()); // Needed to attach Y types await useStore.persist.rehydrate(); } @@ -29,10 +28,7 @@ export const useStoreProjects = () => { const { ydoc } = await restoreStoredProject(projectId); return ydoc; } - const loadProjectPromise = loadProjectImpl(); - loadNewDoc(loadProjectPromise); - await loadProjectPromise; - + await loadNewDoc(loadProjectImpl()); await useStore.persist.rehydrate(); } return { loadProject, newProject }; @@ -40,7 +36,7 @@ export const useStoreProjects = () => { export const useActions = () => { const [actionsRev, setActionsRev] = useState(0); - const { ydoc } = useProjectStorage(); + const { ydoc } = usePersistentProject(); const actions = ydoc?.getMap("files").get("actions") as ActionDataY; // TODO: what happens when you don't got actions? useEffect(() => { const actionsInner = ydoc?.getMap("files").get("actions") as ActionDataY; @@ -50,7 +46,7 @@ export const useActions = () => { const observer = () => setActionsRev(actionsRev + 1); actionsInner.observeDeep(observer) return () => actionsInner.unobserveDeep(observer); - }, [ydoc]); + }, [ydoc, actionsRev]); return actions; } diff --git a/src/store-persistence.ts b/src/store-persistence.ts index 2dd4430c6..97f3d437a 100644 --- a/src/store-persistence.ts +++ b/src/store-persistence.ts @@ -1,5 +1,6 @@ import { PersistStorage, StorageValue } from "zustand/middleware"; import * as Y from "yjs"; +import { ActionDataY } from "./model"; interface ProjectState { @@ -7,7 +8,7 @@ interface ProjectState { loadingPromise: Promise | null; } -let activeState : ProjectState = { +const activeState: ProjectState = { doc: null, loadingPromise: null } @@ -31,11 +32,11 @@ export const BASE_DOC_NAME = "ml"; // store.ts currently has a lot of controller logic, and it could be pared out and synced // more loosely with the yjs-ified data. E.g. project syncing could be done at a level above // the store, with a subscription. -export const projectStorage = () => { +export const projectStorage = () => { const getItemImpl = (ydoc: Y.Doc) => { - const state = JSON.parse(ydoc.getText(BASE_DOC_NAME).toString()) as T; - (state as undefined as any).actions = ydoc.getMap("files").get("actions"); + const state = JSON.parse(ydoc.getText(BASE_DOC_NAME).toJSON()) as T; + state.actions = ydoc.getMap("files").get("actions") as ActionDataY; return { state, version: 2 }; } @@ -51,7 +52,8 @@ export const projectStorage = () => { } const setItem = (_name: string, valueFull: StorageValue) => { - const { state: { actions, ...state }, version } = valueFull as StorageValue; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { state: { actions, ...state }, version } = valueFull as StorageValue; const value = { state, version }; diff --git a/src/store.ts b/src/store.ts index 73f3c2333..32e29f4af 100644 --- a/src/store.ts +++ b/src/store.ts @@ -655,7 +655,7 @@ const createMlStore = (logging: Logging) => { loadDataset(newActions: ActionData[]) { const { actions } = get(); actions.delete(0, actions.length); - let newActionsY: ActionDatumY[] = actionDataToY(newActions); + const newActionsY: ActionDatumY[] = actionDataToY(newActions); actions.push(newActionsY); set(({ project, projectEdited, settings }) => { @@ -760,7 +760,7 @@ const createMlStore = (logging: Logging) => { // can block the UI. 50 ms is not sufficient, so use 100 for now. await new Promise((res) => setTimeout(res, 100)); const trainingResult = await trainModel( - actions.toJSON(), + actions.toJSON() as ActionData[], dataWindow, (trainModelProgress) => set({ trainModelProgress }, false, "trainModelProgress") @@ -801,7 +801,7 @@ const createMlStore = (logging: Logging) => { ...previousProject.text, ...generateProject( previousProject.header?.name ?? untitledProjectName, - { data: actions.toJSON() }, + { data: actions.toJSON() as ActionData[] }, model, dataWindow ).text, @@ -1035,7 +1035,7 @@ const createMlStore = (logging: Logging) => { tourState: postConnectTourTrigger ? { index: 0, - ...getTourSpec(postConnectTourTrigger, actions.toJSON()), + ...getTourSpec(postConnectTourTrigger, actions.toJSON() as ActionData[]), } : tourState, postConnectTourTrigger: undefined, @@ -1053,7 +1053,7 @@ const createMlStore = (logging: Logging) => { (!state.tourState && !state.settings.toursCompleted.includes(trigger.name)) ) { - const tourSpec = getTourSpec(trigger, state.actions.toJSON()); + const tourSpec = getTourSpec(trigger, state.actions.toJSON() as ActionData[]); const result = { tourState: { ...tourSpec, @@ -1291,9 +1291,11 @@ const createMlStore = (logging: Logging) => { gestures?: ActionData[]; } const stateV0 = persistedStateUnknown as StateV0; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { gestures, ...rest } = stateV0; - // TODO: spicy problem as we can't see the Y.js actions from here + // TODO: Poke gestures through //const newActions = actionDataToY(gestures as ActionData[]); //actions.delete(0, actions.length); //actions.push(newActions); @@ -1446,8 +1448,8 @@ const withActionIndex = (actionID: number, actions: ActionDataY, cb: (actionInde } const actionDataToY = (newActions: ActionData[]) => { - let existingIcons: MakeCodeIcon[] = []; - let newActionsY: ActionDatumY[] = []; + const existingIcons: MakeCodeIcon[] = []; + const newActionsY: ActionDatumY[] = []; for (const a of newActions) { const newActionY: ActionDatumY = new Y.Map(); newActionY.set("ID", a.ID);