From c009662ccdbaba5fbd42f68d967a3ef952ae1711 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Mon, 20 Oct 2025 12:24:07 +0100 Subject: [PATCH 1/7] WIP spike of localstorage with y.js --- package-lock.json | 112 +++++++++++- package.json | 6 +- src/App.tsx | 24 ++- src/ProjectPageRouting.tsx | 16 ++ src/editor/EditorContainer.tsx | 25 ++- src/editor/codemirror/CodeMirror.tsx | 31 ++-- src/fs/fs.ts | 12 ++ src/project-persistence/ProjectBrowser.tsx | 63 +++++++ .../ProjectStorageProvider.tsx | 172 ++++++++++++++++++ src/project-persistence/project-list-db.ts | 36 ++++ src/project/project-actions.tsx | 27 ++- src/router-hooks.tsx | 5 +- 12 files changed, 500 insertions(+), 29 deletions(-) create mode 100644 src/ProjectPageRouting.tsx create mode 100644 src/project-persistence/ProjectBrowser.tsx create mode 100644 src/project-persistence/ProjectStorageProvider.tsx create mode 100644 src/project-persistence/project-list-db.ts diff --git a/package-lock.json b/package-lock.json index e9fa1800e..276d73e42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,11 @@ "vscode-languageserver-protocol": "^3.16.0", "web-vitals": "^1.1.1", "xterm": "4.14.1", - "xterm-addon-fit": "^0.5.0" + "xterm-addon-fit": "^0.5.0", + "y-codemirror.next": "^0.3.5", + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "^13.6.27" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", @@ -8633,6 +8637,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "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", @@ -9394,6 +9408,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", @@ -12895,6 +12930,64 @@ "xterm": "^4.0.0" } }, + "node_modules/y-codemirror.next": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/y-codemirror.next/-/y-codemirror.next-0.3.5.tgz", + "integrity": "sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "yjs": "^13.5.6" + } + }, + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -12914,6 +13007,23 @@ "node": ">= 14.6" } }, + "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/zod": { "version": "3.24.2", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", diff --git a/package.json b/package.json index 3957f72b2..aac511fc0 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,11 @@ "vscode-languageserver-protocol": "^3.16.0", "web-vitals": "^1.1.1", "xterm": "4.14.1", - "xterm-addon-fit": "^0.5.0" + "xterm-addon-fit": "^0.5.0", + "y-codemirror.next": "^0.3.5", + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "^13.6.27" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", diff --git a/src/App.tsx b/src/App.tsx index 813fea79d..8a7788e20 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,8 @@ import SettingsProvider from "./settings/settings"; import BeforeUnloadDirtyCheck from "./workbench/BeforeUnloadDirtyCheck"; import { SelectionProvider } from "./workbench/use-selection"; import Workbench from "./workbench/Workbench"; +import { ProjectStorageProvider } from "./project-persistence/ProjectStorageProvider"; +import ProjectPageRouting from "./ProjectPageRouting"; const isMockDeviceMode = () => // We use a cookie set from the e2e tests. Avoids having separate test and live builds. @@ -79,15 +81,19 @@ const App = () => { - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/ProjectPageRouting.tsx b/src/ProjectPageRouting.tsx new file mode 100644 index 000000000..f7e6e68a1 --- /dev/null +++ b/src/ProjectPageRouting.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from "react" +import { useRouterState } from "./router-hooks" +import ProjectBrowser from "./project-persistence/ProjectBrowser"; + +interface ProjectPageRoutingProps { + children: ReactNode +} +const ProjectPageRouting = ({children} : ProjectPageRoutingProps) => { + const [{tab}] = useRouterState(); + if (typeof tab === "undefined") { + return + } + return children; +} + +export default ProjectPageRouting; diff --git a/src/editor/EditorContainer.tsx b/src/editor/EditorContainer.tsx index bb21a25ac..726456152 100644 --- a/src/editor/EditorContainer.tsx +++ b/src/editor/EditorContainer.tsx @@ -3,12 +3,14 @@ * * SPDX-License-Identifier: MIT */ -import { useCallback } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useProjectFileText } from "../project/project-hooks"; import { useSettings } from "../settings/settings"; import { WorkbenchSelection } from "../workbench/use-selection"; import Editor from "./codemirror/CodeMirror"; import ModuleOverlay from "./ModuleOverlay"; +import { Awareness } from "y-protocols/awareness.js"; +import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; interface EditorContainerProps { selection: WorkbenchSelection; @@ -24,24 +26,39 @@ const EditorContainer = ({ selection }: EditorContainerProps) => { setSettings({ ...settings, warnForApiUnsupportedByDevice: false }); }, [setSettings, settings]); // Note fileInfo is not updated for ordinary text edits. - const [fileInfo, onFileChange] = useProjectFileText(selection.file); + const [fileInfo, _] = useProjectFileText(selection.file); + const { ydoc, getFile } = useProjectStorage(); + + const ytext = getFile(selection.file); + + // TODO: Overengineered until we have sync in mind + const awareness = useMemo(() => { + if (!ydoc) return null; + return new Awareness(ydoc) + }, [ydoc]); + + if (ytext === null) return null; if (fileInfo === undefined) { return null; } + awareness!.setLocalStateField("user", { name: "micro:bit tester", color: "yellow" }); + + // TODO: represent fileInfo in project? + return fileInfo.isThirdPartyModule && !settings.allowEditingThirdPartyModules ? ( ) : ( ); }; diff --git a/src/editor/codemirror/CodeMirror.tsx b/src/editor/codemirror/CodeMirror.tsx index e2aa339a6..b90c6942a 100644 --- a/src/editor/codemirror/CodeMirror.tsx +++ b/src/editor/codemirror/CodeMirror.tsx @@ -41,12 +41,14 @@ import { lintGutter } from "./lint/lint"; import { codeStructure } from "./structure-highlighting"; import themeExtensions from "./themeExtensions"; import { useDevice } from "../../device/device-hooks"; +import * as Y from "yjs"; +import { Awareness } from 'y-protocols/awareness.js' +import { yCollab } from "y-codemirror.next"; interface CodeMirrorProps { className?: string; - defaultValue: string; - onChange: (doc: string) => void; - + text: Y.Text; + awareness: Awareness; selection: WorkbenchSelection; fontSize: number; codeStructureOption: CodeStructureOption; @@ -64,9 +66,9 @@ interface CodeMirrorProps { * (e.g. based on the file being edited). */ const CodeMirror = ({ - defaultValue, className, - onChange, + text, + awareness, selection, fontSize, codeStructureOption, @@ -87,6 +89,7 @@ const CodeMirror = ({ const [sessionSettings, setSessionSettings] = useSessionSettings(); const { apiReferenceMap } = useDocumentation(); const device = useDevice(); + const textRef = useRef(); // Reset undo/redo events on file change. useEffect(() => { @@ -108,11 +111,16 @@ const CodeMirror = ({ ); useEffect(() => { - const initializing = !viewRef.current; + let initializing = !viewRef.current; + // Recreate if the text doc changes + if (!initializing && textRef.current !== text) { + elementRef.current?.replaceChildren(); + initializing = true; + } if (initializing) { + textRef.current = text; const notify = EditorView.updateListener.of((update) => { if (update.docChanged) { - onChange(update.state.sliceDoc(0)); setEditorInfo({ undo: undoDepth(view.state), redo: redoDepth(view.state), @@ -121,8 +129,9 @@ const CodeMirror = ({ } }); const state = EditorState.create({ - doc: defaultValue, + doc: text.toString(), extensions: [ + yCollab(text, awareness), notify, editorConfig, // Extension requires external state. @@ -165,17 +174,14 @@ const CodeMirror = ({ state, parent: elementRef.current!, }); - viewRef.current = view; setActiveEditor(new EditorActions(view, logging, actionFeedback, intl)); } }, [ actionFeedback, client, - defaultValue, intl, logging, - onChange, options, setActiveEditor, setEditorInfo, @@ -186,6 +192,7 @@ const CodeMirror = ({ apiReferenceMap, device, disableV2OnlyFeaturesWarning, + text ]); useEffect(() => { // Do this separately as we don't want to destroy the view whenever options needed for initialization change. @@ -235,7 +242,7 @@ const CodeMirror = ({ uri, apiReferenceMap, device, - disableV2OnlyFeaturesWarning, + disableV2OnlyFeaturesWarning ]); const { location } = selection; diff --git a/src/fs/fs.ts b/src/fs/fs.ts index ba93f24e5..420dd518b 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -364,6 +364,18 @@ export class FileSystem extends TypedEventTarget { await this.replaceCommon(project.projectName); } + async replaceWithMultipleStrings(project: { + files: Record, + projectName: string + }): Promise { + const fs = await this.initialize(); + fs.ls().forEach((f) => fs.remove(f)); + for (const key in project.files) { + fs.write(key, new TextEncoder().encode(project.files[key])); + } + await this.replaceCommon(project.projectName); + } + async replaceWithHexContents( projectName: string, hex: string diff --git a/src/project-persistence/ProjectBrowser.tsx b/src/project-persistence/ProjectBrowser.tsx new file mode 100644 index 000000000..0bb01e3c8 --- /dev/null +++ b/src/project-persistence/ProjectBrowser.tsx @@ -0,0 +1,63 @@ +import { + Flex, + Grid, + GridItem, + Heading, + HStack, + Stack, + Text, +} from "@chakra-ui/react"; +import { useProjectStorage } from "./ProjectStorageProvider"; +import { useRouterState } from "../router-hooks"; + +const ProjectBrowser = () => { + const { projectList, setProjectById } = useProjectStorage(); + const [_, setParams] = useRouterState(); + return ( + + {projectList?.map((proj) => ( + + + { + setProjectById(proj.id); + setParams({ tab: "project" }); + }} + > + + + {proj.projectName} + + Here is a test box + + + + + ))} + + ); +}; + +export default ProjectBrowser; diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx new file mode 100644 index 000000000..9d34c05fd --- /dev/null +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -0,0 +1,172 @@ +// ProjectContext.tsx +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import * as Y from "yjs"; +import { IndexeddbPersistence } from "y-indexeddb"; +import { Awareness } from "y-protocols/awareness"; +import { useProjectActions } from "../project/project-hooks"; +import { withProjectDb } from "./project-list-db"; + +interface ProjectContextValue { + projectId: string; + projectList: ProjectList | null; + ydoc: Y.Doc | null; + awareness: Awareness | null; + getFile: (filename: string) => Y.Text | null; + setProjectById: (id: string) => void; +} + +const ProjectStorageContext = createContext(null); + +type ProjectEntry = { projectName: string; id: string }; +type ProjectList = [ProjectEntry]; + +export function ProjectStorageProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [projectList, setProjectList] = useState(null); + const [ydoc, setYdoc] = useState(null); + const awareness = useMemo(() => (ydoc ? new Awareness(ydoc) : null), [ydoc]); + const clientId = useMemo(() => `${Math.random()}`, []); + const [projectId, setProjectById] = useState("yjs-store"); + const pa = useProjectActions(); + + // Set up addProject hook to add a known project to the list if it does not exist + const newProject: () => Promise = useCallback(async () => { + const newProjectId = makeUID(); + await withProjectDb("readwrite", async (store) => { + store.add({ id: newProjectId, projectName: "Untitled project" }); + return Promise.resolve(); + }); + setProjectById(newProjectId); + return newProjectId; + }, []); + + // TODO: Get rid of debug hooks + (window as unknown as any).projectList = projectList; + (window as unknown as any).newProject = newProject; + (window as unknown as any).setProjectById = setProjectById; + + useEffect(() => { + const getProjectsAsync = async () => { + const projectList = await withProjectDb("readonly", async (store) => { + const projectList = await new Promise((res, rej) => { + const query = store.getAll(); + query.onsuccess = () => res(query.result); + query.onerror = rej; + }); + return projectList; + }); + setProjectList(projectList as ProjectList); + }; + void getProjectsAsync(); + }); + + // Y.Doc works synchronously, but the persistence and broadcast channel will need cleanup + useEffect(() => { + let ydoc: Y.Doc; + let broadcastHandler: (e: MessageEvent) => void; + let persistence: IndexeddbPersistence; + + const updates = new BroadcastChannel("yjs"); + const updatePoster = (update: Uint8Array) => { + updates.postMessage({ clientId, update }); + }; + + const initializeYDoc = async () => { + ydoc = new Y.Doc(); + persistence = new IndexeddbPersistence(projectId, ydoc); + ydoc.on("update", updatePoster); + + broadcastHandler = ({ data }: MessageEvent) => { + if (data.clientId !== clientId) { + Y.applyUpdate(ydoc, data.update); + } + }; + + updates.addEventListener("message", broadcastHandler); + + await new Promise((res) => persistence.once("synced", res)); + + migrate(ydoc); + + let files = {} as Record; + for (const filename of ydoc.getMap("files").keys()) { + const contents = ( + ydoc.getMap("files").get(filename) as Y.Text + ).toString(); + files[filename] = contents; + } + pa.openProjectPlaintext({ + files, + projectName: ydoc.getMap("meta").get("projectName") as string, + }); + setYdoc(ydoc); + }; + void initializeYDoc(); + return () => { + ydoc.off("update", updatePoster); + updates.removeEventListener("message", broadcastHandler); + updates.close(); + void persistence.destroy(); + }; + }, [projectId]); + + // Helper to access files + const getFile = (filename: string) => { + if (!ydoc) { + return null; + } + const files = ydoc.getMap("files"); + if (!files.has(filename)) files.set(filename, new Y.Text()); + return files.get(filename)!; + }; + + return ( + + {children} + + ); +} + +export function useProjectStorage() { + const ctx = useContext(ProjectStorageContext); + if (!ctx) + throw new Error( + "useProjectStorage must be used within a ProjectStorageProvider" + ); + return ctx; +} + +const migrate = (doc: Y.Doc) => { + const meta = doc.getMap("meta"); + if (!meta.has("version")) { + // TODO: migrate from session store. + // This could be a per-app handler + meta.set("version", 1); + meta.set("projectName", "default"); // TODO: get this from the last loaded project name + } +}; + +// 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..b4c997b63 --- /dev/null +++ b/src/project-persistence/project-list-db.ts @@ -0,0 +1,36 @@ +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", 1); + openRequest.onupgradeneeded = () => { + const db = openRequest.result; + + // if the data object store doesn't exist, create it + if (!db.objectStoreNames.contains("projects")) { + db.createObjectStore("projects", { keyPath: "id" }); + // no indexes at present, get the whole db each time + } + }; + + 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/project-actions.tsx b/src/project/project-actions.tsx index 6ac24c17e..4bcee0330 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -71,6 +71,8 @@ import ProjectNameQuestion from "./ProjectNameQuestion"; import WebUSBErrorDialog from "../workbench/connect-dialogs/WebUSBErrorDialog"; import reconnectWebm from "../workbench/connect-dialogs/reconnect.webm"; import reconnectMp4 from "../workbench/connect-dialogs/reconnect.mp4"; +import * as Y from "yjs"; +import { toByteArray } from "base64-js"; /** * Distinguishes the different ways to trigger the load action. @@ -369,6 +371,7 @@ export class ProjectActions { /** * Open a project, asking for confirmation if required. + * Used for openIdea, open default project on reset * * @param project The project. * @param confirmPrompt Optional custom confirmation prompt. @@ -376,15 +379,37 @@ export class ProjectActions { */ private openProject = async ( project: PythonProject, - confirmPrompt?: string + confirmPrompt?: string, + ydoc?: Y.Doc // TODO: leaving my options open here, work out how we will import a Python doc to Yjs ): Promise => { const confirmed = await this.confirmReplace(confirmPrompt); if (confirmed) { await this.fs.replaceWithMultipleFiles(project); + if (ydoc) { + ydoc.getMap("meta").set("version", 1); + ydoc.getMap("meta").set("projectName", project.projectName); + const files = ydoc.getMap("files"); + files.clear(); + + const decoder = new TextDecoder(); + for (const fileName of Object.keys(project.files)) { + let text = project.files[fileName]; + text = decoder.decode(toByteArray(text)); + const ytext = new Y.Text(text); + files.set(fileName, ytext); + } + } } return confirmed; }; + openProjectPlaintext = async (project: { + files: Record; + projectName: string; + }): Promise => { + await this.fs.replaceWithMultipleStrings(project); + }; + openIdea = async (slug: string | undefined, code: string, title: string) => { this.logging.event({ type: "idea-open", diff --git a/src/router-hooks.tsx b/src/router-hooks.tsx index 0ccf1bac9..18e4226fc 100644 --- a/src/router-hooks.tsx +++ b/src/router-hooks.tsx @@ -22,7 +22,7 @@ import { import { baseUrl } from "./base"; import { useLogging } from "./logging/logging-hooks"; -export type TabName = "api" | "ideas" | "reference" | "project"; +export type TabName = "root" | "api" | "ideas" | "reference" | "project"; /** * An anchor-like navigation used for scroll positions. @@ -60,6 +60,9 @@ const parse = (pathname: string): RouterState => { if (pathname) { const parts = pathname.split("/"); const tab = parts[0]; + if (tab === "") { + return { tab: "root" }; + } if ( tab === "api" || tab === "reference" || From ec56cefb0ab007dc39648612e7ab950a287fb02a Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Tue, 21 Oct 2025 16:15:54 +0100 Subject: [PATCH 2/7] WIP spike of localstorage with y.js, moved some python editor logic out of storage implementation --- src/project-persistence/ProjectBrowser.tsx | 97 ++++++---- .../ProjectStorageProvider.tsx | 172 +++++++++--------- src/project-persistence/project-store.ts | 65 +++++++ src/project/project-actions.tsx | 86 ++++++--- src/project/project-hooks.tsx | 18 +- 5 files changed, 290 insertions(+), 148 deletions(-) create mode 100644 src/project-persistence/project-store.ts diff --git a/src/project-persistence/ProjectBrowser.tsx b/src/project-persistence/ProjectBrowser.tsx index 0bb01e3c8..22689b46d 100644 --- a/src/project-persistence/ProjectBrowser.tsx +++ b/src/project-persistence/ProjectBrowser.tsx @@ -9,9 +9,12 @@ import { } from "@chakra-ui/react"; import { useProjectStorage } from "./ProjectStorageProvider"; import { useRouterState } from "../router-hooks"; +import { ReactNode } from "react"; +import { useProjectActions } from "../project/project-hooks"; const ProjectBrowser = () => { - const { projectList, setProjectById } = useProjectStorage(); + const { projectList } = useProjectStorage(); + const { newProject, loadProject } = useProjectActions(); const [_, setParams] = useRouterState(); return ( { templateColumns="repeat(auto-fill, 400px)" pb={[0, 5, 20]} > + { + newProject(); + setParams({ tab: "project" }); + }} + > + + New project + + Click to create + {projectList?.map((proj) => ( - - - { - setProjectById(proj.id); - setParams({ tab: "project" }); - }} - > - - - {proj.projectName} - - Here is a test box - - - - + { + loadProject(proj.id); + setParams({ tab: "project" }); + }} + > + + {proj.projectName} + + Here is a test box + ))} ); }; +interface ProjectItemProps { + children: ReactNode; + onClick: () => void; +} + +const ProjectItem = ({ onClick, children }: ProjectItemProps) => ( + + + + {children} + + + +); + export default ProjectBrowser; diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx index 9d34c05fd..8685d4a55 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -4,27 +4,40 @@ import React, { useCallback, useContext, useEffect, - useMemo, useState, } from "react"; import * as Y from "yjs"; -import { IndexeddbPersistence } from "y-indexeddb"; import { Awareness } from "y-protocols/awareness"; -import { useProjectActions } from "../project/project-hooks"; import { 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; + projectId: string | null; projectList: ProjectList | null; + newStoredProject: () => Promise; + restoreStoredProject: (id: string) => Promise; ydoc: Y.Doc | null; awareness: Awareness | null; getFile: (filename: string) => Y.Text | null; - setProjectById: (id: string) => void; + setProjectName: (id: string, name: string) => Promise; } const ProjectStorageContext = createContext(null); -type ProjectEntry = { projectName: string; id: string }; +interface ProjectEntry { + projectName: string; + id: string; +} type ProjectList = [ProjectEntry]; export function ProjectStorageProvider({ @@ -33,27 +46,49 @@ export function ProjectStorageProvider({ children: React.ReactNode; }) { const [projectList, setProjectList] = useState(null); - const [ydoc, setYdoc] = useState(null); - const awareness = useMemo(() => (ydoc ? new Awareness(ydoc) : null), [ydoc]); - const clientId = useMemo(() => `${Math.random()}`, []); - const [projectId, setProjectById] = useState("yjs-store"); - const pa = useProjectActions(); - - // Set up addProject hook to add a known project to the list if it does not exist - const newProject: () => Promise = useCallback(async () => { - const newProjectId = makeUID(); - await withProjectDb("readwrite", async (store) => { - store.add({ id: newProjectId, projectName: "Untitled project" }); - return Promise.resolve(); - }); - setProjectById(newProjectId); - return newProjectId; - }, []); + 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); + 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" }); + return Promise.resolve(); + }); + const newProjectStore = new ProjectStore(newProjectId); + await newProjectStore.init(); + setProjectStore(newProjectStore); + return { ydoc: newProjectStore.ydoc, id: newProjectId }; + }, []); // TODO: Get rid of debug hooks (window as unknown as any).projectList = projectList; - (window as unknown as any).newProject = newProject; - (window as unknown as any).setProjectById = setProjectById; + (window as unknown as any).newProjectStore = newStoredProject; + (window as unknown as any).restoreProjectStore = restoreStoredProject; useEffect(() => { const getProjectsAsync = async () => { @@ -68,77 +103,42 @@ export function ProjectStorageProvider({ setProjectList(projectList as ProjectList); }; void getProjectsAsync(); - }); - - // Y.Doc works synchronously, but the persistence and broadcast channel will need cleanup - useEffect(() => { - let ydoc: Y.Doc; - let broadcastHandler: (e: MessageEvent) => void; - let persistence: IndexeddbPersistence; - - const updates = new BroadcastChannel("yjs"); - const updatePoster = (update: Uint8Array) => { - updates.postMessage({ clientId, update }); - }; - - const initializeYDoc = async () => { - ydoc = new Y.Doc(); - persistence = new IndexeddbPersistence(projectId, ydoc); - ydoc.on("update", updatePoster); - - broadcastHandler = ({ data }: MessageEvent) => { - if (data.clientId !== clientId) { - Y.applyUpdate(ydoc, data.update); - } - }; - - updates.addEventListener("message", broadcastHandler); - - await new Promise((res) => persistence.once("synced", res)); - - migrate(ydoc); - - let files = {} as Record; - for (const filename of ydoc.getMap("files").keys()) { - const contents = ( - ydoc.getMap("files").get(filename) as Y.Text - ).toString(); - files[filename] = contents; - } - pa.openProjectPlaintext({ - files, - projectName: ydoc.getMap("meta").get("projectName") as string, - }); - setYdoc(ydoc); - }; - void initializeYDoc(); - return () => { - ydoc.off("update", updatePoster); - updates.removeEventListener("message", broadcastHandler); - updates.close(); - void persistence.destroy(); - }; - }, [projectId]); + }, []); // Helper to access files const getFile = (filename: string) => { - if (!ydoc) { + if (!projectStore) { return null; } - const files = ydoc.getMap("files"); + const files = projectStore.ydoc.getMap("files"); if (!files.has(filename)) files.set(filename, new Y.Text()); return files.get(filename)!; }; + const setProjectName = useCallback( + async (id: string, projectName: string) => { + await withProjectDb("readwrite", async (store) => { + await new Promise((res, rej) => { + const query = store.put({ id, projectName }); + query.onsuccess = () => res(query.result); + query.onerror = rej; + }); + }); + }, + [projectStore] + ); + return ( {children} @@ -155,16 +155,6 @@ export function useProjectStorage() { return ctx; } -const migrate = (doc: Y.Doc) => { - const meta = doc.getMap("meta"); - if (!meta.has("version")) { - // TODO: migrate from session store. - // This could be a per-app handler - meta.set("version", 1); - meta.set("projectName", "default"); // TODO: get this from the last loaded project name - } -}; - // TODO: WORLDS UGLIEST UIDS const makeUID = () => { return `${Math.random()}`; diff --git a/src/project-persistence/project-store.ts b/src/project-persistence/project-store.ts new file mode 100644 index 000000000..670b71cff --- /dev/null +++ b/src/project-persistence/project-store.ts @@ -0,0 +1,65 @@ +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) { + 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 }); + }).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/project-actions.tsx b/src/project/project-actions.tsx index 4bcee0330..fdb119cff 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -73,6 +73,10 @@ import reconnectWebm from "../workbench/connect-dialogs/reconnect.webm"; import reconnectMp4 from "../workbench/connect-dialogs/reconnect.mp4"; import * as Y from "yjs"; import { toByteArray } from "base64-js"; +import { + NewStoredDoc, + RestoredStoredDoc, +} from "../project-persistence/ProjectStorageProvider"; /** * Distinguishes the different ways to trigger the load action. @@ -121,7 +125,14 @@ export class ProjectActions { }, private intl: IntlShape, private logging: Logging, - private client: LanguageServerClient | undefined + private client: LanguageServerClient | undefined, + private ydoc: Y.Doc | null, + private projectId: string | null, + private newStoredProject: () => Promise, + private restoreStoredProject: ( + projectId: string + ) => Promise, + private setStoredProjectName: (id: string, name: string) => void ) {} private get project(): DefaultedProject { @@ -371,7 +382,10 @@ export class ProjectActions { /** * Open a project, asking for confirmation if required. - * Used for openIdea, open default project on reset + * Used for openIdea, open default project on reset, not used for localstorage. + * + * Just pass in a ydoc, because there are times when a user might + * reset their project without creating a new project * * @param project The project. * @param confirmPrompt Optional custom confirmation prompt. @@ -379,31 +393,27 @@ export class ProjectActions { */ private openProject = async ( project: PythonProject, - confirmPrompt?: string, - ydoc?: Y.Doc // TODO: leaving my options open here, work out how we will import a Python doc to Yjs + ydoc: Y.Doc, + confirmPrompt?: string ): Promise => { const confirmed = await this.confirmReplace(confirmPrompt); if (confirmed) { await this.fs.replaceWithMultipleFiles(project); - if (ydoc) { - ydoc.getMap("meta").set("version", 1); - ydoc.getMap("meta").set("projectName", project.projectName); - const files = ydoc.getMap("files"); - files.clear(); - - const decoder = new TextDecoder(); - for (const fileName of Object.keys(project.files)) { - let text = project.files[fileName]; - text = decoder.decode(toByteArray(text)); - const ytext = new Y.Text(text); - files.set(fileName, ytext); - } + const files = ydoc.getMap("files"); + files.clear(); + + const decoder = new TextDecoder(); + for (const fileName of Object.keys(project.files)) { + let text = project.files[fileName]; + text = decoder.decode(toByteArray(text)); + const ytext = new Y.Text(text); + files.set(fileName, ytext); } } return confirmed; }; - openProjectPlaintext = async (project: { + private openProjectPlaintext = async (project: { files: Record; projectName: string; }): Promise => { @@ -425,7 +435,9 @@ export class ProjectActions { { id: "confirm-replace-with-idea" }, { ideaName: pythonProject.projectName } ); - if (await this.openProject(pythonProject, confirmPrompt)) { + const { ydoc, id } = await this.newStoredProject(); + this.setStoredProjectName(id, pythonProject.projectName || "Untitled Idea"); + if (await this.openProject(pythonProject, ydoc, confirmPrompt)) { this.actionFeedback.success({ title: this.intl.formatMessage( { id: "loaded-file-feedback" }, @@ -435,14 +447,41 @@ export class ProjectActions { } }; + newProject = async () => { + const { ydoc, id } = await this.newStoredProject(); + this.setStoredProjectName(id, "Untitled project"); + await this.openProject(defaultInitialProject, ydoc); + }; + + loadProject = async (projectId: string) => { + const { projectName, ydoc } = await this.restoreStoredProject(projectId); + let files = {} as Record; + for (const filename of ydoc.getMap("files").keys()) { + const contents = ( + ydoc.getMap("files").get(filename) as Y.Text + ).toString(); + files[filename] = contents; + } + this.openProjectPlaintext({ + files, + projectName, + }); + }; + reset = async () => { + if (!this.ydoc) { + throw new Error("Attempted to reset a project with no storage backing"); + } this.logging.event({ type: "reset-project", }); const confirmPrompt = this.intl.formatMessage({ id: "confirm-replace-reset", }); - if (await this.openProject(defaultInitialProject, confirmPrompt)) { + + if ( + await this.openProject(defaultInitialProject, this.ydoc, confirmPrompt) + ) { this.actionFeedback.success({ title: this.intl.formatMessage({ id: "reset-project-feedback" }), }); @@ -800,6 +839,9 @@ export class ProjectActions { )); if (name) { await this.setProjectName(name); + if (this.projectId) { + await this.setStoredProjectName(this.projectId, name); + } return true; } return false; @@ -814,7 +856,9 @@ export class ProjectActions { this.logging.event({ type: "set-project-name", }); - + if (this.projectId) { + this.setStoredProjectName(this.projectId, name); + } return this.fs.setProjectName(name); }; diff --git a/src/project/project-hooks.tsx b/src/project/project-hooks.tsx index 3d3449ad9..06ba23bb5 100644 --- a/src/project/project-hooks.tsx +++ b/src/project/project-hooks.tsx @@ -22,6 +22,7 @@ import { useSessionSettings } from "../settings/session-settings"; import { useSettings } from "../settings/settings"; import { useSelection } from "../workbench/use-selection"; import { defaultedProject, ProjectActions } from "./project-actions"; +import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; /** * Hook exposing the main UI actions. @@ -37,6 +38,13 @@ export const useProjectActions = (): ProjectActions => { const client = useLanguageServerClient(); const [settings, setSettings] = useSettings(); const [sessionSettings, setSessionSettings] = useSessionSettings(); + const { + newStoredProject, + projectId, + restoreStoredProject, + setProjectName, + ydoc, + } = useProjectStorage(); const actions = useMemo( () => new ProjectActions( @@ -49,7 +57,12 @@ export const useProjectActions = (): ProjectActions => { { values: sessionSettings, setValues: setSessionSettings }, intl, logging, - client + client, + ydoc, + projectId, + newStoredProject, + restoreStoredProject, + setProjectName ), [ fs, @@ -64,6 +77,9 @@ export const useProjectActions = (): ProjectActions => { setSettings, sessionSettings, setSessionSettings, + ydoc, + newStoredProject, + setProjectName, ] ); return actions; From e525b63ef6b76153a3023bee822a431fa309325f Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Thu, 23 Oct 2025 17:01:13 +0100 Subject: [PATCH 3/7] Added modified dates --- src/project-persistence/ProjectBrowser.tsx | 6 ++- .../ProjectStorageProvider.tsx | 27 ++++++++------ src/project-persistence/project-list-db.ts | 37 +++++++++++++++++-- src/project-persistence/utils.ts | 24 ++++++++++++ 4 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 src/project-persistence/utils.ts diff --git a/src/project-persistence/ProjectBrowser.tsx b/src/project-persistence/ProjectBrowser.tsx index 22689b46d..85d264c8c 100644 --- a/src/project-persistence/ProjectBrowser.tsx +++ b/src/project-persistence/ProjectBrowser.tsx @@ -11,11 +11,15 @@ import { useProjectStorage } from "./ProjectStorageProvider"; import { useRouterState } from "../router-hooks"; import { ReactNode } from "react"; import { useProjectActions } from "../project/project-hooks"; +import { timeAgo } from "./utils"; const ProjectBrowser = () => { const { projectList } = useProjectStorage(); const { newProject, loadProject } = useProjectActions(); const [_, setParams] = useRouterState(); + + const rtf = new Intl.RelativeTimeFormat("en", { style: "short" }); + return ( { {proj.projectName} - Here is a test box + {timeAgo(new Date(proj.modifiedDate))} ))} diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx index 8685d4a55..c163b31b9 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -8,7 +8,7 @@ import React, { } from "react"; import * as Y from "yjs"; import { Awareness } from "y-protocols/awareness"; -import { withProjectDb } from "./project-list-db"; +import { ProjectList, withProjectDb } from "./project-list-db"; import { ProjectStore } from "./project-store"; export interface NewStoredDoc { @@ -34,12 +34,6 @@ interface ProjectContextValue { const ProjectStorageContext = createContext(null); -interface ProjectEntry { - projectName: string; - id: string; -} -type ProjectList = [ProjectEntry]; - export function ProjectStorageProvider({ children, }: { @@ -76,7 +70,11 @@ export function ProjectStorageProvider({ useCallback(async () => { const newProjectId = makeUID(); await withProjectDb("readwrite", async (store) => { - store.add({ id: newProjectId, projectName: "Untitled project" }); + store.add({ + id: newProjectId, + projectName: "Untitled project", + modifiedDate: new Date().valueOf(), + }); return Promise.resolve(); }); const newProjectStore = new ProjectStore(newProjectId); @@ -91,16 +89,19 @@ export function ProjectStorageProvider({ (window as unknown as any).restoreProjectStore = restoreStoredProject; useEffect(() => { + if (window.navigator.storage?.persist) { + window.navigator.storage.persist(); + } const getProjectsAsync = async () => { const projectList = await withProjectDb("readonly", async (store) => { const projectList = await new Promise((res, rej) => { - const query = store.getAll(); + const query = store.index("modifiedDate").getAll(); query.onsuccess = () => res(query.result); query.onerror = rej; }); return projectList; }); - setProjectList(projectList as ProjectList); + setProjectList((projectList as ProjectList).reverse()); }; void getProjectsAsync(); }, []); @@ -119,7 +120,11 @@ export function ProjectStorageProvider({ async (id: string, projectName: string) => { await withProjectDb("readwrite", async (store) => { await new Promise((res, rej) => { - const query = store.put({ id, projectName }); + const query = store.put({ + id, + projectName, + modifiedDate: new Date().valueOf(), + }); query.onsuccess = () => res(query.result); query.onerror = rej; }); diff --git a/src/project-persistence/project-list-db.ts b/src/project-persistence/project-list-db.ts index b4c997b63..cc8778b8c 100644 --- a/src/project-persistence/project-list-db.ts +++ b/src/project-persistence/project-list-db.ts @@ -1,3 +1,12 @@ + +export interface ProjectEntry { + projectName: string; + id: string; + modifiedDate: number; +} + +export type ProjectList = ProjectEntry[]; + type ProjectDbWrapper = ( accessMode: "readonly" | "readwrite", callback: (projects: IDBObjectStore) => Promise @@ -6,15 +15,35 @@ type ProjectDbWrapper = ( 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", 1); - openRequest.onupgradeneeded = () => { + 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")) { - db.createObjectStore("projects", { keyPath: "id" }); + 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 () => { 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 From 76069a5f432920852a2ff853d68d8a626afba0c7 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Fri, 24 Oct 2025 11:33:37 +0100 Subject: [PATCH 4/7] Project screen improvements, date sort, delete --- src/ProjectPageRouting.tsx | 2 +- src/editor/EditorContainer.tsx | 10 +- src/editor/codemirror/CodeMirror.tsx | 8 +- src/project-persistence/ProjectBrowser.tsx | 94 ------------------- src/project-persistence/ProjectItem.tsx | 74 +++++++++++++++ .../ProjectStorageProvider.tsx | 64 ++++++++++--- src/project-persistence/project-store.ts | 3 +- src/project/ProjectBrowser.tsx | 43 +++++++++ 8 files changed, 182 insertions(+), 116 deletions(-) delete mode 100644 src/project-persistence/ProjectBrowser.tsx create mode 100644 src/project-persistence/ProjectItem.tsx create mode 100644 src/project/ProjectBrowser.tsx diff --git a/src/ProjectPageRouting.tsx b/src/ProjectPageRouting.tsx index f7e6e68a1..a9f453056 100644 --- a/src/ProjectPageRouting.tsx +++ b/src/ProjectPageRouting.tsx @@ -1,6 +1,6 @@ import { ReactNode } from "react" import { useRouterState } from "./router-hooks" -import ProjectBrowser from "./project-persistence/ProjectBrowser"; +import ProjectBrowser from "./project/ProjectBrowser"; interface ProjectPageRoutingProps { children: ReactNode diff --git a/src/editor/EditorContainer.tsx b/src/editor/EditorContainer.tsx index 726456152..258529114 100644 --- a/src/editor/EditorContainer.tsx +++ b/src/editor/EditorContainer.tsx @@ -26,7 +26,7 @@ const EditorContainer = ({ selection }: EditorContainerProps) => { setSettings({ ...settings, warnForApiUnsupportedByDevice: false }); }, [setSettings, settings]); // Note fileInfo is not updated for ordinary text edits. - const [fileInfo, _] = useProjectFileText(selection.file); + const [fileInfo, onFileChange] = useProjectFileText(selection.file); const { ydoc, getFile } = useProjectStorage(); const ytext = getFile(selection.file); @@ -34,7 +34,7 @@ const EditorContainer = ({ selection }: EditorContainerProps) => { // TODO: Overengineered until we have sync in mind const awareness = useMemo(() => { if (!ydoc) return null; - return new Awareness(ydoc) + return new Awareness(ydoc); }, [ydoc]); if (ytext === null) return null; @@ -42,7 +42,10 @@ const EditorContainer = ({ selection }: EditorContainerProps) => { return null; } - awareness!.setLocalStateField("user", { name: "micro:bit tester", color: "yellow" }); + awareness!.setLocalStateField("user", { + name: "micro:bit tester", + color: "yellow", + }); // TODO: represent fileInfo in project? @@ -52,6 +55,7 @@ const EditorContainer = ({ selection }: EditorContainerProps) => { ) : ( void; text: Y.Text; awareness: Awareness; selection: WorkbenchSelection; @@ -67,6 +68,7 @@ interface CodeMirrorProps { */ const CodeMirror = ({ className, + onChange, text, awareness, selection, @@ -121,6 +123,7 @@ const CodeMirror = ({ textRef.current = text; const notify = EditorView.updateListener.of((update) => { if (update.docChanged) { + onChange(update.state.sliceDoc(0)); setEditorInfo({ undo: undoDepth(view.state), redo: redoDepth(view.state), @@ -182,6 +185,7 @@ const CodeMirror = ({ client, intl, logging, + onChange, options, setActiveEditor, setEditorInfo, @@ -192,7 +196,7 @@ const CodeMirror = ({ apiReferenceMap, device, disableV2OnlyFeaturesWarning, - text + text, ]); useEffect(() => { // Do this separately as we don't want to destroy the view whenever options needed for initialization change. @@ -242,7 +246,7 @@ const CodeMirror = ({ uri, apiReferenceMap, device, - disableV2OnlyFeaturesWarning + disableV2OnlyFeaturesWarning, ]); const { location } = selection; diff --git a/src/project-persistence/ProjectBrowser.tsx b/src/project-persistence/ProjectBrowser.tsx deleted file mode 100644 index 85d264c8c..000000000 --- a/src/project-persistence/ProjectBrowser.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { - Flex, - Grid, - GridItem, - Heading, - HStack, - Stack, - Text, -} from "@chakra-ui/react"; -import { useProjectStorage } from "./ProjectStorageProvider"; -import { useRouterState } from "../router-hooks"; -import { ReactNode } from "react"; -import { useProjectActions } from "../project/project-hooks"; -import { timeAgo } from "./utils"; - -const ProjectBrowser = () => { - const { projectList } = useProjectStorage(); - const { newProject, loadProject } = useProjectActions(); - const [_, setParams] = useRouterState(); - - const rtf = new Intl.RelativeTimeFormat("en", { style: "short" }); - - return ( - - { - newProject(); - setParams({ tab: "project" }); - }} - > - - New project - - Click to create - - {projectList?.map((proj) => ( - { - loadProject(proj.id); - setParams({ tab: "project" }); - }} - > - - {proj.projectName} - - {timeAgo(new Date(proj.modifiedDate))} - - ))} - - ); -}; - -interface ProjectItemProps { - children: ReactNode; - onClick: () => void; -} - -const ProjectItem = ({ onClick, children }: ProjectItemProps) => ( - - - - {children} - - - -); - -export default ProjectBrowser; 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 index c163b31b9..a4bf9f0c0 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -26,6 +26,7 @@ interface ProjectContextValue { 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; @@ -54,7 +55,9 @@ export function ProjectStorageProvider({ projectId: string ) => Promise = useCallback( async (projectId: string) => { - const newProjectStore = new ProjectStore(projectId); + const newProjectStore = new ProjectStore(projectId, () => + modifyProject(projectId) + ); await newProjectStore.init(); setProjectStore(newProjectStore); return { @@ -77,33 +80,46 @@ export function ProjectStorageProvider({ }); return Promise.resolve(); }); - const newProjectStore = new ProjectStore(newProjectId); + 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(); } - const getProjectsAsync = 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); - query.onerror = rej; - }); - return projectList; - }); - setProjectList((projectList as ProjectList).reverse()); - }; - void getProjectsAsync(); + void refreshProjects(); }, []); // Helper to access files @@ -116,6 +132,24 @@ export function ProjectStorageProvider({ 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) => { @@ -126,7 +160,6 @@ export function ProjectStorageProvider({ modifiedDate: new Date().valueOf(), }); query.onsuccess = () => res(query.result); - query.onerror = rej; }); }); }, @@ -143,6 +176,7 @@ export function ProjectStorageProvider({ getFile, newStoredProject, restoreStoredProject, + deleteProject, setProjectName, }} > diff --git a/src/project-persistence/project-store.ts b/src/project-persistence/project-store.ts index 670b71cff..1e1443cf3 100644 --- a/src/project-persistence/project-store.ts +++ b/src/project-persistence/project-store.ts @@ -19,7 +19,7 @@ export class ProjectStore { private updates: BroadcastChannel; private updatePoster: (update: Uint8Array) => void; - constructor(public projectId: string) { + constructor(public projectId: string, projectChangedListener: () => void) { const ydoc = new Y.Doc(); this.ydoc = ydoc; this.awareness = new Awareness(this.ydoc); @@ -36,6 +36,7 @@ export class ProjectStore { this.updates = new BroadcastChannel("yjs"); this.updatePoster = ((update: Uint8Array) => { this.updates.postMessage({ clientId, update, projectId }); + projectChangedListener(); }).bind(this); } diff --git a/src/project/ProjectBrowser.tsx b/src/project/ProjectBrowser.tsx new file mode 100644 index 000000000..8fccf1666 --- /dev/null +++ b/src/project/ProjectBrowser.tsx @@ -0,0 +1,43 @@ +import { Grid } from "@chakra-ui/react"; +import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; +import { useRouterState } from "../router-hooks"; +import { useProjectActions } from "./project-hooks"; +import { + ProjectItem, + AddProjectItem, +} from "../project-persistence/ProjectItem"; + +const ProjectBrowser = () => { + const { projectList, deleteProject } = useProjectStorage(); + const { newProject, loadProject } = useProjectActions(); + const [_, setParams] = useRouterState(); + + return ( + + { + await newProject(); + setParams({ tab: "project" }); + }} + /> + {projectList?.map((proj) => ( + { + await loadProject(proj.id); + setParams({ tab: "project" }); + }} + deleteProject={deleteProject} + /> + ))} + + ); +}; + +export default ProjectBrowser; From 908d9d9165f4f173818f24b556754d5676d2a011 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Tue, 28 Oct 2025 12:02:46 +0000 Subject: [PATCH 5/7] Slightly less flaky on reload --- src/ProjectPageRouting.tsx | 34 ++++++++++++++----- .../ProjectStorageProvider.tsx | 20 +++++++++-- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/ProjectPageRouting.tsx b/src/ProjectPageRouting.tsx index a9f453056..b59898117 100644 --- a/src/ProjectPageRouting.tsx +++ b/src/ProjectPageRouting.tsx @@ -1,16 +1,32 @@ -import { ReactNode } from "react" -import { useRouterState } from "./router-hooks" +import { ReactNode, useEffect } from "react"; +import { useRouterState } from "./router-hooks"; import ProjectBrowser from "./project/ProjectBrowser"; +import { useProjectStorage } from "./project-persistence/ProjectStorageProvider"; interface ProjectPageRoutingProps { - children: ReactNode + children: ReactNode; } -const ProjectPageRouting = ({children} : ProjectPageRoutingProps) => { - const [{tab}] = useRouterState(); - if (typeof tab === "undefined") { - return +const ProjectPageRouting = ({ children }: ProjectPageRoutingProps) => { + const [{ tab }, navigate] = useRouterState(); + const { projectId, restoreMostRecentProject } = useProjectStorage(); + + useEffect(() => { + if (!projectId) { + const restoreState = async () => { + const restoredProject = await restoreMostRecentProject(); + if (!restoredProject && typeof tab !== "undefined") { + history.replaceState(null, "", "/"); + window.dispatchEvent(new PopStateEvent("popstate")); + } + }; + void restoreState(); } - return children; -} + }, []); + + if (typeof tab === "undefined") { + return ; + } + return children; +}; export default ProjectPageRouting; diff --git a/src/project-persistence/ProjectStorageProvider.tsx b/src/project-persistence/ProjectStorageProvider.tsx index a4bf9f0c0..686dd460a 100644 --- a/src/project-persistence/ProjectStorageProvider.tsx +++ b/src/project-persistence/ProjectStorageProvider.tsx @@ -26,6 +26,7 @@ interface ProjectContextValue { projectList: ProjectList | null; newStoredProject: () => Promise; restoreStoredProject: (id: string) => Promise; + restoreMostRecentProject: () => Promise; deleteProject: (id: string) => Promise; ydoc: Y.Doc | null; awareness: Awareness | null; @@ -44,6 +45,7 @@ export function ProjectStorageProvider({ const [projectStore, setProjectStoreImpl] = useState( null ); + const setProjectStore = (newProjectStore: ProjectStore) => { if (projectStore) { projectStore.destroy(); @@ -69,6 +71,18 @@ export function ProjectStorageProvider({ [projectList] ); + const restoreMostRecentProject: () => Promise = + useCallback(async () => { + let localProjectList = projectList; + if (!localProjectList) { + localProjectList = await refreshProjects(); + } + if (!localProjectList || localProjectList.length === 0) { + return null; + } + return restoreStoredProject(localProjectList[0].id); + }, [restoreStoredProject, projectList]); + const newStoredProject: () => Promise = useCallback(async () => { const newProjectId = makeUID(); @@ -106,13 +120,14 @@ 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); }); return projectList; }); - setProjectList((projectList as ProjectList).reverse()); + setProjectList(projectList.reverse()); + return projectList; }; useEffect(() => { @@ -176,6 +191,7 @@ export function ProjectStorageProvider({ getFile, newStoredProject, restoreStoredProject, + restoreMostRecentProject, deleteProject, setProjectName, }} From f60155acb00160e23c34dab4c6a9bf2d6ca11bd1 Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Fri, 14 Nov 2025 10:11:38 +0000 Subject: [PATCH 6/7] Migrate latest project-persistence features to python editor --- src/ProjectPageRouting.tsx | 2 +- .../ProjectHistoryModal.tsx | 90 ++++++++++++ src/project-persistence/ProjectItem.tsx | 108 +++++++++----- .../ProjectStorageProvider.tsx | 138 ++++++++++++++---- .../RenameProjectModal.tsx | 61 ++++++++ src/project-persistence/project-history-db.ts | 57 ++++++++ src/project-persistence/project-list-db.ts | 3 + src/project-persistence/project-store.ts | 14 +- src/project-persistence/utils.ts | 21 +++ src/project/ProjectBrowser.tsx | 75 +++++++--- 10 files changed, 476 insertions(+), 93 deletions(-) create mode 100644 src/project-persistence/ProjectHistoryModal.tsx create mode 100644 src/project-persistence/RenameProjectModal.tsx create mode 100644 src/project-persistence/project-history-db.ts diff --git a/src/ProjectPageRouting.tsx b/src/ProjectPageRouting.tsx index b59898117..c935127fc 100644 --- a/src/ProjectPageRouting.tsx +++ b/src/ProjectPageRouting.tsx @@ -7,7 +7,7 @@ interface ProjectPageRoutingProps { children: ReactNode; } const ProjectPageRouting = ({ children }: ProjectPageRoutingProps) => { - const [{ tab }, navigate] = useRouterState(); + const [{ tab }] = useRouterState(); const { projectId, restoreMostRecentProject } = useProjectStorage(); useEffect(() => { diff --git a/src/project-persistence/ProjectHistoryModal.tsx b/src/project-persistence/ProjectHistoryModal.tsx new file mode 100644 index 000000000..c1a600ab3 --- /dev/null +++ b/src/project-persistence/ProjectHistoryModal.tsx @@ -0,0 +1,90 @@ +import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, List, ListItem, Heading, Button, ModalFooter } from "@chakra-ui/react"; +import { HistoryList } from "./project-history-db"; +import { ProjectEntry } from "./project-list-db"; +import { useEffect, useState } from "react"; +import { useProjectStorage } from "./ProjectStorageProvider"; +import { significantDateUnits } from "./utils"; + +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} + + + Latest + + + {projectHistoryList?.map((ph) => ( + + + Saved on {significantDateUnits(new Date(ph.timestamp))} + + + + ))} + + )} + + + + + + + ) + } + +export default ProjectHistoryModal; \ No newline at end of file diff --git a/src/project-persistence/ProjectItem.tsx b/src/project-persistence/ProjectItem.tsx index 60e7e55d9..c1a4be901 100644 --- a/src/project-persistence/ProjectItem.tsx +++ b/src/project-persistence/ProjectItem.tsx @@ -1,12 +1,22 @@ -import { CloseButton, GridItem, Heading, HStack, Text } from "@chakra-ui/react"; +import { + CloseButton, + GridItem, + Heading, + HStack, + IconButton, + Text, +} from "@chakra-ui/react"; import { ReactNode } from "react"; import { ProjectEntry } from "./project-list-db"; import { timeAgo } from "./utils"; +import { RiEditFill, 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; + renameProject: (projectId: string) => void; } interface ProjectItemBaseProps { @@ -37,38 +47,68 @@ 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, + renameProject, + showHistory, +}: ProjectItemProps) => ( + loadProject(project.id)}> + + + {project.projectName} + + + {timeAgo(new Date(project.modifiedDate))} - { - deleteProject(project.id); - e.stopPropagation(); - e.preventDefault(); - }} - /> - -) + } + 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" + /> + { + 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 686dd460a..fbe715021 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; @@ -26,16 +27,24 @@ interface ProjectContextValue { projectList: ProjectList | null; newStoredProject: () => Promise; restoreStoredProject: (id: string) => Promise; - restoreMostRecentProject: () => 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; } 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, }: { @@ -45,7 +54,6 @@ export function ProjectStorageProvider({ const [projectStore, setProjectStoreImpl] = useState( null ); - const setProjectStore = (newProjectStore: ProjectStore) => { if (projectStore) { projectStore.destroy(); @@ -60,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, @@ -71,18 +80,6 @@ export function ProjectStorageProvider({ [projectList] ); - const restoreMostRecentProject: () => Promise = - useCallback(async () => { - let localProjectList = projectList; - if (!localProjectList) { - localProjectList = await refreshProjects(); - } - if (!localProjectList || localProjectList.length === 0) { - return null; - } - return restoreStoredProject(localProjectList[0].id); - }, [restoreStoredProject, projectList]); - const newStoredProject: () => Promise = useCallback(async () => { const newProjectId = makeUID(); @@ -97,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 }; }, []); @@ -120,14 +118,13 @@ 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); }); return projectList; }); - setProjectList(projectList.reverse()); - return projectList; + setProjectList((projectList as ProjectList).reverse()); }; useEffect(() => { @@ -148,13 +145,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); @@ -167,20 +165,94 @@ 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 }); + await refreshProjects(); }, [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} diff --git a/src/project-persistence/RenameProjectModal.tsx b/src/project-persistence/RenameProjectModal.tsx new file mode 100644 index 000000000..003752329 --- /dev/null +++ b/src/project-persistence/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-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/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..0699490be 100644 --- a/src/project-persistence/project-list-db.ts +++ b/src/project-persistence/project-list-db.ts @@ -1,8 +1,11 @@ +export type ProjectType = "microbit:python" | "microbit:createai"; export interface ProjectEntry { projectName: string; id: string; modifiedDate: number; + parentRevision?: string; + // projectType: ProjectType; // TODOO: implement this when we have a better idea how project creation works } export type ProjectList = ProjectEntry[]; 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 diff --git a/src/project-persistence/utils.ts b/src/project-persistence/utils.ts index 4aa522475..d445d2ded 100644 --- a/src/project-persistence/utils.ts +++ b/src/project-persistence/utils.ts @@ -21,4 +21,25 @@ export function timeAgo(date: Date): string { } return rtf.format(-Math.round(duration), "year"); +} + +export function significantDateUnits(date: Date): string { + const now = new Date(); + + let dateTimeOptions: Intl.DateTimeFormatOptions = { month: "short", year: "2-digit" }; + + let daysDifferent = Math.round((+now - +date) / (1000 * 60 * 60 * 24)); + if (daysDifferent < 1 && date.getDay() === now.getDay()) { + dateTimeOptions = { + hour: 'numeric', + minute: 'numeric', + } + } else if (now.getFullYear() === date.getFullYear()) { + dateTimeOptions = { + day: 'numeric', + month: 'short' + } + } + + return Intl.DateTimeFormat(undefined, dateTimeOptions).format(date); } \ No newline at end of file diff --git a/src/project/ProjectBrowser.tsx b/src/project/ProjectBrowser.tsx index 8fccf1666..4242cf244 100644 --- a/src/project/ProjectBrowser.tsx +++ b/src/project/ProjectBrowser.tsx @@ -6,37 +6,68 @@ import { ProjectItem, AddProjectItem, } from "../project-persistence/ProjectItem"; +import RenameProjectModal from "../project-persistence/RenameProjectModal"; +import { useState } from "react"; +import { ProjectEntry } from "../project-persistence/project-list-db"; +import ProjectHistoryModal from "../project-persistence/ProjectHistoryModal"; const ProjectBrowser = () => { - const { projectList, deleteProject } = useProjectStorage(); + const { projectList, deleteProject, loadRevision, setProjectName } = + useProjectStorage(); const { newProject, loadProject } = useProjectActions(); const [_, setParams] = useRouterState(); + const [showProjectHistory, setShowProjectHistory] = + useState(null); + const [showProjectRename, setShowProjectRename] = + useState(null); return ( - - { - await newProject(); - setParams({ tab: "project" }); - }} - /> - {projectList?.map((proj) => ( - { - await loadProject(proj.id); + <> + + { + await newProject(); setParams({ tab: "project" }); }} - deleteProject={deleteProject} /> - ))} - + {projectList?.map((proj) => ( + { + await loadProject(proj.id); + setParams({ tab: "project" }); + }} + deleteProject={deleteProject} + renameProject={() => setShowProjectRename(proj)} + showHistory={() => setShowProjectHistory(proj)} + /> + ))} + + { + await loadRevision(projectId, revisionId); + setParams({ tab: "project" }); + }} + onDismiss={() => setShowProjectHistory(null)} + projectInfo={showProjectHistory} + /> + setShowProjectRename(null)} + projectInfo={showProjectRename} + handleRename={(projectId, projectName) => { + setProjectName(projectId, projectName); + setShowProjectRename(null); + }} + /> + ); }; From 8ded48c9492aad67f402bec147396436c531f06d Mon Sep 17 00:00:00 2001 From: Alex Shaw Date: Fri, 21 Nov 2025 14:09:07 +0000 Subject: [PATCH 7/7] Using the three-part persistent project hooks --- src/ProjectPageRouting.tsx | 12 +- src/editor/EditorContainer.tsx | 16 +- src/editor/codemirror/CodeMirror.tsx | 1 + .../ProjectHistoryModal.tsx | 109 ++++--- .../ProjectStorageProvider.tsx | 272 ++---------------- .../persistent-project-hooks.ts | 24 ++ .../project-history-hooks.ts | 115 ++++++++ src/project-persistence/project-list-db.ts | 24 +- src/project-persistence/project-list-hooks.ts | 120 ++++++++ src/project-persistence/project-store.ts | 10 +- src/project-persistence/utils.ts | 10 +- src/project/ProjectBrowser.tsx | 8 +- src/project/project-actions.tsx | 2 +- src/project/project-hooks.tsx | 20 +- 14 files changed, 402 insertions(+), 341 deletions(-) create mode 100644 src/project-persistence/persistent-project-hooks.ts create mode 100644 src/project-persistence/project-history-hooks.ts create mode 100644 src/project-persistence/project-list-hooks.ts diff --git a/src/ProjectPageRouting.tsx b/src/ProjectPageRouting.tsx index c935127fc..5be3f0ba3 100644 --- a/src/ProjectPageRouting.tsx +++ b/src/ProjectPageRouting.tsx @@ -1,19 +1,21 @@ import { ReactNode, useEffect } from "react"; import { useRouterState } from "./router-hooks"; import ProjectBrowser from "./project/ProjectBrowser"; -import { useProjectStorage } from "./project-persistence/ProjectStorageProvider"; +import { useProjectList } from "./project-persistence/project-list-hooks"; +import { usePersistentProject } from "./project-persistence/persistent-project-hooks"; interface ProjectPageRoutingProps { children: ReactNode; } const ProjectPageRouting = ({ children }: ProjectPageRoutingProps) => { const [{ tab }] = useRouterState(); - const { projectId, restoreMostRecentProject } = useProjectStorage(); + const { projectList, restoreStoredProject } = useProjectList(); + const { projectId } = usePersistentProject(); useEffect(() => { - if (!projectId) { + if (!projectId && projectList) { const restoreState = async () => { - const restoredProject = await restoreMostRecentProject(); + const restoredProject = await restoreStoredProject(projectList[0].id); if (!restoredProject && typeof tab !== "undefined") { history.replaceState(null, "", "/"); window.dispatchEvent(new PopStateEvent("popstate")); @@ -21,7 +23,7 @@ const ProjectPageRouting = ({ children }: ProjectPageRoutingProps) => { }; void restoreState(); } - }, []); + }, [projectId, projectList, restoreStoredProject, tab]); if (typeof tab === "undefined") { return ; diff --git a/src/editor/EditorContainer.tsx b/src/editor/EditorContainer.tsx index 258529114..1fc8e9f85 100644 --- a/src/editor/EditorContainer.tsx +++ b/src/editor/EditorContainer.tsx @@ -3,14 +3,14 @@ * * SPDX-License-Identifier: MIT */ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback } from "react"; import { useProjectFileText } from "../project/project-hooks"; import { useSettings } from "../settings/settings"; import { WorkbenchSelection } from "../workbench/use-selection"; import Editor from "./codemirror/CodeMirror"; import ModuleOverlay from "./ModuleOverlay"; -import { Awareness } from "y-protocols/awareness.js"; -import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; +import { usePersistentProject } from "../project-persistence/persistent-project-hooks"; +import * as Y from "yjs"; interface EditorContainerProps { selection: WorkbenchSelection; @@ -27,15 +27,9 @@ const EditorContainer = ({ selection }: EditorContainerProps) => { }, [setSettings, settings]); // Note fileInfo is not updated for ordinary text edits. const [fileInfo, onFileChange] = useProjectFileText(selection.file); - const { ydoc, getFile } = useProjectStorage(); + const { ydoc, awareness } = usePersistentProject(); - const ytext = getFile(selection.file); - - // TODO: Overengineered until we have sync in mind - const awareness = useMemo(() => { - if (!ydoc) return null; - return new Awareness(ydoc); - }, [ydoc]); + const ytext = ydoc?.getMap("files").get(selection.file) as Y.Text; if (ytext === null) return null; if (fileInfo === undefined) { diff --git a/src/editor/codemirror/CodeMirror.tsx b/src/editor/codemirror/CodeMirror.tsx index f9e35afb4..9ac4a9a67 100644 --- a/src/editor/codemirror/CodeMirror.tsx +++ b/src/editor/codemirror/CodeMirror.tsx @@ -181,6 +181,7 @@ const CodeMirror = ({ setActiveEditor(new EditorActions(view, logging, actionFeedback, intl)); } }, [ + awareness, actionFeedback, client, intl, diff --git a/src/project-persistence/ProjectHistoryModal.tsx b/src/project-persistence/ProjectHistoryModal.tsx index c1a600ab3..be6a397ee 100644 --- a/src/project-persistence/ProjectHistoryModal.tsx +++ b/src/project-persistence/ProjectHistoryModal.tsx @@ -1,62 +1,61 @@ import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, List, ListItem, Heading, Button, ModalFooter } from "@chakra-ui/react"; import { HistoryList } from "./project-history-db"; import { ProjectEntry } from "./project-list-db"; -import { useEffect, useState } from "react"; -import { useProjectStorage } from "./ProjectStorageProvider"; +import { useCallback, useEffect, useState } from "react"; import { significantDateUnits } from "./utils"; +import { useProjectHistory } from "./project-history-hooks"; interface ProjectHistoryModalProps { - onLoadRequest: (projectId: string, revisionId: string) => void; - isOpen: boolean; - onDismiss: () => void; - projectInfo: ProjectEntry | null; + onLoadRequest: (projectId: string, revisionId: string) => void; + isOpen: boolean; + onDismiss: () => void; + projectInfo: ProjectEntry | null; } const ProjectHistoryModal = ({ - onLoadRequest, - isOpen, - onDismiss, - projectInfo + onLoadRequest, + isOpen, + onDismiss, + projectInfo, }: ProjectHistoryModalProps) => { - const [projectHistoryList, setProjectHistoryList] = useState(null); - const {getHistory, saveRevision} = useProjectStorage(); + const [projectHistoryList, setProjectHistoryList] = + useState(null); + const { getHistory, saveRevision } = useProjectHistory(); - const getProjectHistory = async () => { - if (projectInfo === null) { - setProjectHistoryList(null); - return; - } - const historyList = await getHistory(projectInfo.id); - setProjectHistoryList(historyList.sort(h => -h.timestamp)); - }; + const getProjectHistory = useCallback(async () => { + if (projectInfo === null) { + setProjectHistoryList(null); + return; + } + const historyList = await getHistory(projectInfo.id); + setProjectHistoryList(historyList.sort((h) => -h.timestamp)); + }, [getHistory, projectInfo]); - useEffect(() => { - void getProjectHistory(); - }, [projectInfo]); + useEffect(() => { + void getProjectHistory(); + }, [projectInfo, getProjectHistory]); - return ( - - - Project history - - - {projectInfo && ( + return ( + + + + Project history + + + {projectInfo && ( {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..199a13ae6 --- /dev/null +++ b/src/project-persistence/persistent-project-hooks.ts @@ -0,0 +1,24 @@ +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; + projectId?: string; +} + +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, + projectId: ctx.projectStore?.projectId + }; +} 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 0699490be..3a937e70e 100644 --- a/src/project-persistence/project-list-db.ts +++ b/src/project-persistence/project-list-db.ts @@ -1,11 +1,9 @@ -export type ProjectType = "microbit:python" | "microbit:createai"; export interface ProjectEntry { projectName: string; id: string; modifiedDate: number; parentRevision?: string; - // projectType: ProjectType; // TODOO: implement this when we have a better idea how project creation works } export type ProjectList = ProjectEntry[]; @@ -41,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 () => { @@ -66,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/project/ProjectBrowser.tsx b/src/project/ProjectBrowser.tsx index 4242cf244..ad49bd6e3 100644 --- a/src/project/ProjectBrowser.tsx +++ b/src/project/ProjectBrowser.tsx @@ -10,10 +10,14 @@ import RenameProjectModal from "../project-persistence/RenameProjectModal"; import { useState } from "react"; import { ProjectEntry } from "../project-persistence/project-list-db"; import ProjectHistoryModal from "../project-persistence/ProjectHistoryModal"; +import { useProjectList } from "../project-persistence/project-list-hooks"; +import { useProjectHistory } from "../project-persistence/project-history-hooks"; const ProjectBrowser = () => { - const { projectList, deleteProject, loadRevision, setProjectName } = - useProjectStorage(); + const { projectList } = useProjectStorage(); + const { deleteProject, setProjectName } = useProjectList(); + const { loadRevision } = useProjectHistory(); + const { newProject, loadProject } = useProjectActions(); const [_, setParams] = useRouterState(); const [showProjectHistory, setShowProjectHistory] = diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx index fdb119cff..525ec2a8d 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -76,7 +76,7 @@ import { toByteArray } from "base64-js"; import { NewStoredDoc, RestoredStoredDoc, -} from "../project-persistence/ProjectStorageProvider"; +} from "../project-persistence/project-list-hooks"; /** * Distinguishes the different ways to trigger the load action. diff --git a/src/project/project-hooks.tsx b/src/project/project-hooks.tsx index 06ba23bb5..b86686e6a 100644 --- a/src/project/project-hooks.tsx +++ b/src/project/project-hooks.tsx @@ -22,7 +22,8 @@ import { useSessionSettings } from "../settings/session-settings"; import { useSettings } from "../settings/settings"; import { useSelection } from "../workbench/use-selection"; import { defaultedProject, ProjectActions } from "./project-actions"; -import { useProjectStorage } from "../project-persistence/ProjectStorageProvider"; +import { useProjectList } from "../project-persistence/project-list-hooks"; +import { usePersistentProject } from "../project-persistence/persistent-project-hooks"; /** * Hook exposing the main UI actions. @@ -38,13 +39,10 @@ export const useProjectActions = (): ProjectActions => { const client = useLanguageServerClient(); const [settings, setSettings] = useSettings(); const [sessionSettings, setSessionSettings] = useSessionSettings(); - const { - newStoredProject, - projectId, - restoreStoredProject, - setProjectName, - ydoc, - } = useProjectStorage(); + const { projectId, ydoc } = usePersistentProject(); + + const { newStoredProject, restoreStoredProject, setProjectName } = + useProjectList(); const actions = useMemo( () => new ProjectActions( @@ -58,8 +56,8 @@ export const useProjectActions = (): ProjectActions => { intl, logging, client, - ydoc, - projectId, + ydoc || null, + projectId || null, newStoredProject, restoreStoredProject, setProjectName @@ -77,8 +75,10 @@ export const useProjectActions = (): ProjectActions => { setSettings, sessionSettings, setSessionSettings, + projectId, ydoc, newStoredProject, + restoreStoredProject, setProjectName, ] );