From 2366716cae4ec8533d25119b18685fac33ccd0bd Mon Sep 17 00:00:00 2001 From: Misode Date: Wed, 13 Nov 2024 05:29:06 +0100 Subject: [PATCH] Refactor projects to use indexeddb --- package-lock.json | 130 ----------- src/app/Analytics.ts | 31 +-- src/app/Store.ts | 6 +- src/app/components/ErrorPanel.tsx | 7 +- src/app/components/generator/FileCreation.tsx | 46 ++-- src/app/components/generator/FileRenaming.tsx | 14 +- .../components/generator/ProjectCreation.tsx | 47 ++-- .../components/generator/ProjectDeletion.tsx | 9 +- src/app/components/generator/ProjectPanel.tsx | 141 +++++------- .../components/generator/SchemaGenerator.tsx | 63 +++--- src/app/components/generator/Tree.tsx | 22 +- .../previews/BiomeSourcePreview.tsx | 4 +- .../previews/DensityFunctionPreview.tsx | 4 +- .../previews/NoiseSettingsPreview.tsx | 4 +- src/app/contexts/Project.tsx | 202 ++++++------------ src/app/services/FileSystem.ts | 9 +- src/app/services/Spyglass.ts | 27 ++- 17 files changed, 265 insertions(+), 501 deletions(-) diff --git a/package-lock.json b/package-lock.json index e089bc66..a024e9d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -616,136 +616,6 @@ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.4.2.tgz", "integrity": "sha512-VMOxsWh/QDwrxPsgkSQnuZ+8mfNy1OTjzzUdLBvvZtpahwPTHTeVZ51RZRqO4xfKVrR+btIPA8D01IL3xeG66w==" }, - "node_modules/@mcschema/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@mcschema/core/-/core-0.13.0.tgz", - "integrity": "sha512-nJRDvdEI2Z7Yw7eWKcbkuLFKtPIyeCc1d04E7EeUOpwxfHffgL5a0VBD2PX692z3igfTHGNXgPkzrleV91Q/ww==" - }, - "node_modules/@mcschema/java-1.15": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.15/-/java-1.15-0.2.13.tgz", - "integrity": "sha512-f3NanEsd48svxZfqquSylXrHZDd5vjvi8D6r15blrCi2J2C42032Ir0dMPD38hLxu9ey0eIrgucDApn/GXO1uA==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.16": { - "version": "0.6.20", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.16/-/java-1.16-0.6.20.tgz", - "integrity": "sha512-pJihtCYPloVAk6Tq2JVvfk666rPLaNmXkHhD6/a1kk/AUaP2wk/3YuYdXyRqz7/lmxkBpJK/BvhAO4ZtsQfNtg==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.17": { - "version": "0.2.40", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.17/-/java-1.17-0.2.40.tgz", - "integrity": "sha512-aFlH9APa4JEMsz0y3BKjYGILXn6QQcQDOCHA+qgdXDjc0xsrHffO1l/TVVDi5GQCCqEgGA15guqfLVV+7Vtb2A==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.18": { - "version": "0.3.16", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.18/-/java-1.18-0.3.16.tgz", - "integrity": "sha512-crxXl0OW6BYlD+Cjaa8cJEsoehnBa6whrE6JBG37nZ1nQBl0e9vun4fbaZi0buzFjeJGZ0st7Lm0LsFJRe7FEw==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.18.2": { - "version": "0.1.26", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.18.2/-/java-1.18.2-0.1.26.tgz", - "integrity": "sha512-GN6gCyEyTTJTiv0vn4cTVMYVgG2BJpDXJh2KPrg5cT/7gCpHUrt4PJAyzEaYLkDZoW17p2gWuY12gIM/yfuiDA==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.19": { - "version": "0.1.54", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.19/-/java-1.19-0.1.54.tgz", - "integrity": "sha512-eh8kTBs3EAGlWzTbmGzoIRtePd2dUkttooGsmXmjadvwf/yT19Ew+P+U4Y6LLFLnNoWJxruCVhznS1KxzoT0wA==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.19.3": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.19.3/-/java-1.19.3-0.0.17.tgz", - "integrity": "sha512-8V3UpdmVPb2JZJ5A3Mi6J5Lf5dicmY9w11dq8VQm9jSEPwY7IXSnE4l9J3AmFkIgAhiaNTQ+/lbmliR7LNUAlQ==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.19.4": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.19.4/-/java-1.19.4-0.1.21.tgz", - "integrity": "sha512-QZhe7L6t7/Uf6vK0pXWSJYoGA0D+VAv10wrMrwG6jTHoCI1iPkFhet9JqUnOwOv0Ql+jwh7zDNQjTHJIkHwfMQ==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.20": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.20/-/java-1.20-0.0.24.tgz", - "integrity": "sha512-Bcm4n7YTXQ/6mGmj7l9KcRXl8ta9nokNZDyrgEeexsrE9jzj1ef6TsNra2bI9kCka9l4s4XzZ1VDuM/Ag/Ud0g==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.20.2": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.20.2/-/java-1.20.2-0.0.15.tgz", - "integrity": "sha512-crGOWodFnWT5XGjp2b8kZeXKsHpdQHDaES09NYf/VNWUIdGNYD7hXlIVvQEsqbZt1ABuisIKWvNYZJwH7Fhdew==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.20.3": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.20.3/-/java-1.20.3-0.0.16.tgz", - "integrity": "sha512-XNX02G7RHB8u/ibwU0GSo+lsz/5rcduHnWKk/BJcGH6Q2NswLKJDiyt8Ow5KrcmtCBxprCLjg5pJaj/Ql6aKoQ==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.20.5": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.20.5/-/java-1.20.5-0.0.42.tgz", - "integrity": "sha512-LgVeCvHQPMQUMPPiJ5tkm8RG7EKowvw6eHxu8RPakeX/Del5OYkQI252hkP2RC3AA46NHE+iKqtsk8A+lvCuow==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.21": { - "version": "0.0.27", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.21/-/java-1.21-0.0.27.tgz", - "integrity": "sha512-X2o8VJouEv5ZixLrpngyq0srwYB8Bp0lmIBKRJvJ+7/5RB6Mrf9I/Z4y7BkPjpKjw8TME1r5Tw/g7sjrYXvEUQ==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.21.2": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.21.2/-/java-1.21.2-0.0.16.tgz", - "integrity": "sha512-LExJ3pvpkVE1wfKr//KemkX6PFrMS4MxrOoyxEi7NvWzxNVeXDCsLsW1MkkoJ2QZ3hAjsuT50g1kivEBbd2jEQ==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/java-1.21.4": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@mcschema/java-1.21.4/-/java-1.21.4-0.0.3.tgz", - "integrity": "sha512-1KPHjCDkyMk0DtyrpFiTmpXMG/9XCBHCjMkE1ZvdBMYDLqbHFNPJwGnwDljpNgNJCOfKkfeVpmTBZv0ur9jpIg==", - "dependencies": { - "@mcschema/core": "^0.13.0" - } - }, - "node_modules/@mcschema/locales": { - "version": "0.1.104", - "resolved": "https://registry.npmjs.org/@mcschema/locales/-/locales-0.1.104.tgz", - "integrity": "sha512-fZ9zzb4OvMnxRFtVYpPuFDugd2LGYMfGRsYzl+RG6wFZAUuB6pe+igNmK5o8VfX1ldO2W5FWkddgh/MjtemFOA==" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/src/app/Analytics.ts b/src/app/Analytics.ts index 33b28851..f9db0c24 100644 --- a/src/app/Analytics.ts +++ b/src/app/Analytics.ts @@ -1,7 +1,7 @@ import type { ColormapType } from './components/previews/Colormap.js' import type { VersionId } from './services/index.js' -type Method = 'menu' | 'hotkey' +export type Method = 'menu' | 'hotkey' export namespace Analytics { @@ -226,61 +226,44 @@ export namespace Analytics { }) } - export function showProject(file_type: string, projects_count: number, project_size: number, method: Method) { + export function showProject(method: Method) { event(ID_GENERATOR, 'show-project', legacyMethod(method)) gtag('event', 'show_project', { - file_type, - projects_count, - project_size, method, }) } - export function hideProject(file_type: string, projects_count: number, project_size: number, method: Method) { + export function hideProject(method: Method) { event(ID_GENERATOR, 'hide-project', legacyMethod(method)) gtag('event', 'hide_project', { - file_type, - projects_count, - project_size, method, }) } - export function saveProjectFile(file_type: string, projects_count: number, project_size: number, method: Method) { + export function saveProjectFile(method: Method) { event(ID_GENERATOR, 'save-project-file', legacyMethod(method)) gtag('event', 'save_project_file', { - file_type, - projects_count, - project_size, method, }) } - export function deleteProjectFile(file_type: string, projects_count: number, project_size: number, method: Method) { + export function deleteProjectFile(method: Method) { event(ID_GENERATOR, 'delete-project-file', legacyMethod(method)) gtag('event', 'delete_project_file', { - file_type, - projects_count, - project_size, method, }) } - export function renameProjectFile(file_type: string, projects_count: number, project_size: number, method: Method) { + export function renameProjectFile(method: Method) { event(ID_GENERATOR, 'rename-project-file', legacyMethod(method)) gtag('event', 'rename_project_file', { - file_type, - projects_count, - project_size, method, }) } - export function deleteProject(projects_count: number, project_size: number, method: Method) { + export function deleteProject(method: Method) { event(ID_GENERATOR, 'delete-project', legacyMethod(method)) gtag('event', 'delete_project', { - projects_count, - project_size, method, }) } diff --git a/src/app/Store.ts b/src/app/Store.ts index aa39637f..a4a8153f 100644 --- a/src/app/Store.ts +++ b/src/app/Store.ts @@ -1,6 +1,6 @@ import type { ColormapType } from './components/previews/Colormap.js' import { ColormapTypes } from './components/previews/Colormap.js' -import type { Project } from './contexts/index.js' +import type { ProjectMeta } from './contexts/index.js' import { DRAFT_PROJECT } from './contexts/index.js' import type { VersionId } from './services/index.js' import { DEFAULT_VERSION, VersionIds } from './services/index.js' @@ -63,7 +63,7 @@ export namespace Store { return localStorage.getItem(ID_SOUNDS_VERSION) ?? 'latest' } - export function getProjects(): Project[] { + export function getProjects(): ProjectMeta[] { const projects = localStorage.getItem(ID_PROJECTS) if (projects) { return safeJsonParse(projects) ?? [] @@ -132,7 +132,7 @@ export namespace Store { if (version) localStorage.setItem(ID_SOUNDS_VERSION, version) } - export function setProjects(projects: Project[] | undefined) { + export function setProjects(projects: ProjectMeta[] | undefined) { if (projects) localStorage.setItem(ID_PROJECTS, JSON.stringify(projects)) } diff --git a/src/app/components/ErrorPanel.tsx b/src/app/components/ErrorPanel.tsx index 689158bc..5b6c49f5 100644 --- a/src/app/components/ErrorPanel.tsx +++ b/src/app/components/ErrorPanel.tsx @@ -1,6 +1,7 @@ import type { ComponentChildren } from 'preact' import { getCurrentUrl } from 'preact-router' import { useEffect, useMemo, useState } from 'preact/hooks' +import { useProject } from '../contexts/Project.jsx' import { useSpyglass } from '../contexts/Spyglass.jsx' import { useVersion } from '../contexts/Version.jsx' import { useAsync } from '../hooks/useAsync.js' @@ -19,6 +20,7 @@ type ErrorPanelProps = { export function ErrorPanel({ error, prefix, reportable, onDismiss, body: body_, children }: ErrorPanelProps) { const { version } = useVersion() const { service } = useSpyglass() + const { projectUri } = useProject() const [stackVisible, setStackVisible] = useState(false) const [stack, setStack] = useState(undefined) @@ -28,13 +30,12 @@ export function ErrorPanel({ error, prefix, reportable, onDismiss, body: body_, if (!service || !gen) { return undefined } - // TODO: read project file if open - const uri = service.getUnsavedFileUri(gen) + const uri = projectUri ?? service.getUnsavedFileUri(gen) if (!uri) { return undefined } return await service.readFile(uri) - }, [service, version, gen]) + }, [service, version, projectUri, gen]) useEffect(() => { if (error instanceof Error) { diff --git a/src/app/components/generator/FileCreation.tsx b/src/app/components/generator/FileCreation.tsx index 4afd93cf..b5952b9c 100644 --- a/src/app/components/generator/FileCreation.tsx +++ b/src/app/components/generator/FileCreation.tsx @@ -1,46 +1,56 @@ import type { DocAndNode } from '@spyglassmc/core' -import { useState } from 'preact/hooks' +import { Identifier } from 'deepslate' +import { useCallback, useState } from 'preact/hooks' +import type { Method } from '../../Analytics.js' import { Analytics } from '../../Analytics.js' -import { useLocale, useProject } from '../../contexts/index.js' -import { safeJsonParse } from '../../Utils.js' +import type { ConfigGenerator } from '../../Config.js' +import { getProjectRoot, useLocale, useProject, useVersion } from '../../contexts/index.js' +import { useSpyglass } from '../../contexts/Spyglass.jsx' +import { genPath, message } from '../../Utils.js' import { Btn } from '../Btn.js' import { TextInput } from '../forms/index.js' import { Modal } from '../Modal.js' interface Props { docAndNode: DocAndNode, - id: string, - method: string, + gen: ConfigGenerator, + method: Method, + onCreate: (uri: string) => void, onClose: () => void, } -export function FileCreation({ docAndNode, id, method, onClose }: Props) { +export function FileCreation({ docAndNode, gen, method, onCreate, onClose }: Props) { const { locale } = useLocale() - const { projects, project, updateFile } = useProject() - const [fileId, setFileId] = useState(id === 'pack_mcmeta' ? 'pack' : '') - const [error, setError] = useState() + const { version } = useVersion() + const { project } = useProject() + const { client } = useSpyglass() + const [fileId, setFileId] = useState(gen.id === 'pack_mcmeta' ? 'pack' : '') + const [error, setError] = useState() + const changeFileId = (str: string) => { setError(undefined) setFileId(str) } - const doSave = () => { + const doSave = useCallback(() => { if (!fileId.match(/^([a-z0-9_.-]+:)?[a-z0-9/_.-]+$/)) { setError('Invalid resource location') return } - Analytics.saveProjectFile(id, projects.length, project.files.length, method as any) + const id = Identifier.parse(fileId.includes(':') || project.namespace === undefined ? fileId : `${project.namespace}:${fileId}`) + const uri = `${getProjectRoot(project)}data/${id.namespace}/${genPath(gen, version)}/${id.path}.json` + Analytics.saveProjectFile(method) const text = docAndNode.doc.getText() - const data = safeJsonParse(text) - if (data !== undefined) { - updateFile(id, undefined, { type: id, id: fileId, data }) - } - onClose() - } + client.fs.writeFile(uri, text).then(() => { + onCreate(uri) + }).catch((e) => { + setError(message(e)) + }) + }, [version, project, client, fileId ]) return

{locale('project.save_current_file')}

- + {error !== undefined && {error}}
diff --git a/src/app/components/generator/FileRenaming.tsx b/src/app/components/generator/FileRenaming.tsx index 20269483..019c12d9 100644 --- a/src/app/components/generator/FileRenaming.tsx +++ b/src/app/components/generator/FileRenaming.tsx @@ -1,19 +1,17 @@ import { useState } from 'preact/hooks' import { Analytics } from '../../Analytics.js' -import { useLocale, useProject } from '../../contexts/index.js' +import { useLocale } from '../../contexts/index.js' import { Btn } from '../Btn.js' import { TextInput } from '../forms/index.js' import { Modal } from '../Modal.js' interface Props { - id: string, - name: string, + uri: string, onClose: () => void, } -export function FileRenaming({ id, name, onClose }: Props) { +export function FileRenaming({ uri, onClose }: Props) { const { locale } = useLocale() - const { projects, project, updateFile } = useProject() - const [fileId, setFileId] = useState(name) + const [fileId, setFileId] = useState(uri) // TODO: get original file id const [error, setError] = useState() const changeFileId = (str: string) => { @@ -26,8 +24,8 @@ export function FileRenaming({ id, name, onClose }: Props) { setError('Invalid resource location') return } - Analytics.renameProjectFile(id, projects.length, project.files.length, 'menu') - updateFile(id, name, { type: id, id: fileId }) + Analytics.renameProjectFile('menu') + // TODO: rename file onClose() } diff --git a/src/app/components/generator/ProjectCreation.tsx b/src/app/components/generator/ProjectCreation.tsx index 02346547..6287ddd7 100644 --- a/src/app/components/generator/ProjectCreation.tsx +++ b/src/app/components/generator/ProjectCreation.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'preact/hooks' +import { useCallback, useMemo, useState } from 'preact/hooks' import config from '../../Config.js' -import type { Project } from '../../contexts/index.js' -import { disectFilePath, useLocale, useProject } from '../../contexts/index.js' +import { useLocale, useProject } from '../../contexts/index.js' +import { useSpyglass } from '../../contexts/Spyglass.jsx' import type { VersionId } from '../../services/index.js' -import { DEFAULT_VERSION, parseSource } from '../../services/index.js' -import { message, readZip } from '../../Utils.js' +import { DEFAULT_VERSION } from '../../services/index.js' +import { PROJECTS_URI } from '../../services/Spyglass.js' +import { hexId, readZip } from '../../Utils.js' import { Btn, BtnMenu, FileUpload, Octicon, TextInput } from '../index.js' import { Modal } from '../Modal.js' @@ -13,7 +14,8 @@ interface Props { } export function ProjectCreation({ onClose }: Props) { const { locale } = useLocale() - const { projects, createProject, changeProject, updateProject } = useProject() + const { projects, createProject, changeProject } = useProject() + const { client } = useSpyglass() const [name, setName] = useState('') const [namespace, setNamespace] = useState('') @@ -32,36 +34,17 @@ export function ProjectCreation({ onClose }: Props) { } } - const projectUpdater = useRef(updateProject) - useEffect(() => { - projectUpdater.current = updateProject - }, [updateProject]) - - const onCreate = () => { + const onCreate = useCallback(() => { setCreating(true) - createProject(name, namespace || undefined, version) + const rootUri = `${PROJECTS_URI}${hexId()}/` + createProject({ name, namespace, version, storage: { type: 'indexeddb', rootUri } }) changeProject(name) if (file) { readZip(file).then(async (entries) => { - const project: Partial = { files: [] } - await Promise.all(entries.map(async (entry) => { - const file = disectFilePath(entry[0], version) - if (file) { - try { - const text = await parseSource(entry[1], 'json') - const data = JSON.parse(text) - project.files!.push({ ...file, data }) - return - } catch (e) { - console.warn(`Failed parsing ${file.type} ${file.id}: ${message(e)}`) - } - } - if (project.unknownFiles === undefined) { - project.unknownFiles = [] - } - project.unknownFiles.push({ path: entry[0], data: entry[1] }) + await Promise.all(entries.map((entry) => { + const path = entry[0].startsWith('/') ? entry[0].slice(1) : entry[0] + return client.fs.writeFile(rootUri + path, entry[1]) })) - projectUpdater.current(project) onClose() }).catch(() => { onClose() @@ -69,7 +52,7 @@ export function ProjectCreation({ onClose }: Props) { } else { onClose() } - } + }, [createProject, changeProject, client, version, name, namespace, file]) const invalidName = useMemo(() => { return projects.map(p => p.name.trim().toLowerCase()).includes(name.trim().toLowerCase()) diff --git a/src/app/components/generator/ProjectDeletion.tsx b/src/app/components/generator/ProjectDeletion.tsx index c3e09114..04856d33 100644 --- a/src/app/components/generator/ProjectDeletion.tsx +++ b/src/app/components/generator/ProjectDeletion.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'preact/hooks' import { Analytics } from '../../Analytics.js' import { useLocale, useProject } from '../../contexts/index.js' import { Btn } from '../Btn.js' @@ -8,13 +9,13 @@ interface Props { } export function ProjectDeletion({ onClose }: Props) { const { locale } = useLocale() - const { projects, project, deleteProject } = useProject() + const { project, deleteProject } = useProject() - const doSave = () => { - Analytics.deleteProject(projects.length, project.files.length, 'menu') + const doSave = useCallback(() => { + Analytics.deleteProject('menu') deleteProject(project.name) onClose() - } + }, [onClose, deleteProject]) return

{locale('project.delete_confirm.1', project.name)}

diff --git a/src/app/components/generator/ProjectPanel.tsx b/src/app/components/generator/ProjectPanel.tsx index 2cc8f6d8..e43d545a 100644 --- a/src/app/components/generator/ProjectPanel.tsx +++ b/src/app/components/generator/ProjectPanel.tsx @@ -1,11 +1,11 @@ -import { useCallback, useMemo, useRef, useState } from 'preact/hooks' -import { Analytics } from '../../Analytics.js' +import { route } from 'preact-router' +import { useCallback, useMemo, useRef } from 'preact/hooks' import config from '../../Config.js' -import { disectFilePath, DRAFT_PROJECT, getFilePath, useLocale, useProject, useVersion } from '../../contexts/index.js' +import { DRAFT_PROJECT, getProjectRoot, useLocale, useProject } from '../../contexts/index.js' +import { useSpyglass } from '../../contexts/Spyglass.jsx' +import { useAsync } from '../../hooks/useAsync.js' import { useFocus } from '../../hooks/useFocus.js' -import { stringifySource } from '../../services/index.js' -import { Store } from '../../Store.js' -import { writeZip } from '../../Utils.js' +import { cleanUrl, writeZip } from '../../Utils.js' import { Btn } from '../Btn.js' import { BtnMenu } from '../BtnMenu.js' import { Octicon } from '../Octicon.jsx' @@ -13,73 +13,35 @@ import type { TreeViewGroupRenderer, TreeViewLeafRenderer } from '../TreeView.js import { TreeView } from '../TreeView.js' interface Props { - onError: (message: string) => unknown, - onRename: (file: { type: string, id: string }) => unknown, - onCreate: () => unknown, - onDeleteProject: () => unknown, + onError: (message: string) => void, + onRename: (uri: string) => void, + onCreateProject: () => void, + onDeleteProject: () => void, } -export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) { +export function ProjectPanel({ onRename, onCreateProject, onDeleteProject}: Props) { const { locale } = useLocale() - const { version } = useVersion() - const { projects, project, changeProject, file, openFile, updateFile } = useProject() + const { projects, project, projectUri, setProjectUri, changeProject } = useProject() + const { client, service } = useSpyglass() - const [treeViewMode, setTreeViewMode] = useState(Store.getTreeViewMode()) + const projectRoot = getProjectRoot(project) - const changeTreeViewMode = useCallback((mode: string) => { - Store.setTreeViewMode(mode) - Analytics.setTreeViewMode(mode) - setTreeViewMode(mode) - }, []) - - const disectEntry = useCallback((entry: string) => { - if (treeViewMode === 'resources' && entry !== 'pack.mcmeta') { - const [type, id] = entry.split('/') - return { - type: type.replaceAll('\u2215', '/'), - id: id.replaceAll('\u2215', '/'), - } - } - return disectFilePath(entry, version) - }, [treeViewMode, version]) - - const entries = useMemo(() => project.files.flatMap(f => { - const path = getFilePath(f, version) - if (!path) return [] - if (f.type === 'pack_mcmeta') return 'pack.mcmeta' - if (treeViewMode === 'resources') { - return [`${f.type.replaceAll('/', '\u2215')}/${f.id.replaceAll('/', '\u2215')}`] - } - return [path] - }), [treeViewMode, version, ...project.files]) - - const selected = useMemo(() => file && getFilePath(file, version), [file, version]) - - const selectFile = useCallback((entry: string) => { - const file = disectEntry(entry) - if (file) { - openFile(file.type, file.id) - } - }, [disectEntry]) + const { value: entries } = useAsync(async () => { + const entries = await client.fs.readdir(projectRoot) + return entries.flatMap(e => { + return e.name.startsWith(projectRoot) ? [e.name.slice(projectRoot.length)] : [] + }) + }, [project]) const download = useRef(null) const onDownload = async () => { - if (!download.current) return - let hasPack = false - const entries = project.files.flatMap(file => { - const path = getFilePath(file, version) - if (path === undefined) return [] - if (path === 'pack.mcmeta') hasPack = true - return [[path, stringifySource(JSON.stringify(file.data))]] as [string, string][] - }) - project.unknownFiles?.forEach(({ path, data }) => { - entries.push([path, data]) - }) - if (!hasPack) { - const pack_format = config.versions.find(v => v.id === version)!.pack_format - entries.push(['pack.mcmeta', stringifySource(JSON.stringify({ pack: { pack_format, description: '' } }, null, 2))]) - } - const url = await writeZip(entries) + if (!download.current || entries === undefined) return + const zipEntries = await Promise.all(entries.map(async e => { + const data = await client.fs.readFile(projectRoot + e) + const text = new TextDecoder().decode(data) + return [e, text] as [string, string] + })) + const url = await writeZip(zipEntries) download.current.setAttribute('href', url) download.current.setAttribute('download', `${project.name.replaceAll(' ', '_')}.zip`) download.current.click() @@ -89,25 +51,20 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) { { icon: 'pencil', label: locale('project.rename_file'), - onAction: (entry: string) => { - const file = disectEntry(entry) - if (file) { - onRename(file) - } + onAction: (uri: string) => { + onRename(uri) }, }, { icon: 'trashcan', label: locale('project.delete_file'), - onAction: (entry: string) => { - const file = disectEntry(entry) - if (file) { - Analytics.deleteProjectFile(file.type, projects.length, project.files.length, 'menu') - updateFile(file.type, file.id, {}) - } + onAction: (uri: string) => { + client.fs.unlink(uri).then(() => { + setProjectUri(undefined) + }) }, }, - ], [disectEntry, updateFile, onRename]) + ], [client, onRename, projectRoot]) const FolderEntry: TreeViewGroupRenderer = useCallback(({ name, open, onClick }) => { return
@@ -118,23 +75,34 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) { const FileEntry: TreeViewLeafRenderer = useCallback(({ entry }) => { const [focused, setFocus] = useFocus() + const uri = projectRoot + entry const onContextMenu = (evt: MouseEvent) => { evt.preventDefault() setFocus() } - const file = disectEntry(entry) + const onClick = () => { + const category = uri.endsWith('/pack.mcmeta') + ? 'pack_mcmeta' + : service?.dissectUri(uri)?.category + const gen = config.generators.find(g => g.id === category) + if (!gen) { + throw new Error(`Cannot find generator for uri ${uri}`) + } + route(cleanUrl(gen.url)) + setProjectUri(uri) + } - return
selectFile(entry)} onContextMenu={onContextMenu} > + return
{Octicon.file} {entry.split('/').at(-1)} {focused &&
- {actions?.map(a =>
{ a.onAction(entry); e.stopPropagation(); setFocus(false) }}> + {actions?.map(a =>
{ a.onAction(uri); e.stopPropagation(); setFocus(false) }}> {(Octicon as any)[a.icon]} {a.label}
)}
}
- }, [actions, disectEntry]) + }, [service, actions, projectRoot, projectUri]) return <>
@@ -143,15 +111,16 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) { - - changeTreeViewMode(treeViewMode === 'resources' ? 'files' : 'resources')} /> + {project.name !== DRAFT_PROJECT.name && }
- {entries.length === 0 - ? {locale('project.no_files')} - : path.split('/')} group={FolderEntry} leaf={FileEntry} />} + {entries === undefined + ? <> + : entries.length === 0 + ? {locale('project.no_files')} + : path.split('/')} group={FolderEntry} leaf={FileEntry} />}
diff --git a/src/app/components/generator/SchemaGenerator.tsx b/src/app/components/generator/SchemaGenerator.tsx index adb0cb1d..8c6d8fae 100644 --- a/src/app/components/generator/SchemaGenerator.tsx +++ b/src/app/components/generator/SchemaGenerator.tsx @@ -1,5 +1,6 @@ import { route } from 'preact-router' import { useCallback, useEffect, useErrorBoundary, useMemo, useRef, useState } from 'preact/hooks' +import type { Method } from '../../Analytics.js' import { Analytics } from '../../Analytics.js' import type { ConfigGenerator } from '../../Config.js' import config from '../../Config.js' @@ -8,8 +9,9 @@ import { useSpyglass, watchSpyglassUri } from '../../contexts/Spyglass.jsx' import { AsyncCancel, useActiveTimeout, useAsync, useSearchParam } from '../../hooks/index.js' import type { VersionId } from '../../services/index.js' import { checkVersion, fetchDependencyMcdoc, fetchPreset, fetchRegistries, getSnippet, shareSnippet } from '../../services/index.js' +import { DEPENDENCY_URI } from '../../services/Spyglass.js' import { Store } from '../../Store.js' -import { cleanUrl, genPath, safeJsonParse } from '../../Utils.js' +import { cleanUrl, genPath } from '../../Utils.js' import { Ad, Btn, BtnMenu, ErrorPanel, FileCreation, FileRenaming, Footer, HasPreview, Octicon, PreviewPanel, ProjectCreation, ProjectDeletion, ProjectPanel, SearchList, SourcePanel, TextInput, Tree, VersionSwitcher } from '../index.js' import { getRootDefault } from './McdocHelpers.js' @@ -23,7 +25,7 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { const { locale } = useLocale() const { version, changeVersion, changeTargetVersion } = useVersion() const { service } = useSpyglass() - const { projects, project, file, updateProject, updateFile, closeFile } = useProject() + const { project, projectUri, setProjectUri, updateProject } = useProject() const [error, setError] = useState(null) const [errorBoundary, errorRetry] = useErrorBoundary() if (errorBoundary) { @@ -34,9 +36,21 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { useEffect(() => Store.visitGenerator(gen.id), [gen.id]) const uri = useMemo(() => { - // TODO: return different uri when project file is open - return service?.getUnsavedFileUri(gen) - }, [service, version, gen]) + if (!service) { + return undefined + } + if (projectUri) { + const category = projectUri.endsWith('/pack.mcmeta') + ? 'pack_mcmeta' + : service.dissectUri(projectUri)?.category + if (category === gen.id) { + return projectUri + } else { + setProjectUri(undefined) + } + } + return service.getUnsavedFileUri(gen) + }, [service, version, gen, projectUri]) const [currentPreset, setCurrentPreset] = useSearchParam('preset') const [sharedSnippetId, setSharedSnippetId] = useSearchParam(SHARE_KEY) @@ -75,20 +89,13 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { Analytics.openSnippet(gen.id, sharedSnippetId, version) ignoreChange.current = true text = snippet.text - } else if (file) { - if (project.version && project.version !== version) { - changeVersion(project.version, false) - return AsyncCancel - } - ignoreChange.current = true - text = JSON.stringify(file.data, null, 2) } if (!service || !uri) { return AsyncCancel } if (gen.dependency) { const dependency = await fetchDependencyMcdoc(gen.dependency) - const dependencyUri = `file:///project/mcdoc/${gen.dependency}.mcdoc` + const dependencyUri = `${DEPENDENCY_URI}${gen.dependency}.mcdoc` await service.writeFile(dependencyUri, dependency) } if (text !== undefined) { @@ -104,24 +111,18 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { const docAndNode = await service.openFile(uri) Analytics.setGenerator(gen.id) return docAndNode - }, [gen.id, version, sharedSnippetId, currentPreset, project.name, file?.id, service]) + }, [gen.id, version, sharedSnippetId, currentPreset, project.name, service, uri]) const { doc } = docAndNode ?? {} - watchSpyglassUri(uri, ({ doc }) => { + watchSpyglassUri(uri, () => { if (!ignoreChange.current) { setCurrentPreset(undefined, true) setSharedSnippetId(undefined, true) } - if (file) { - const data = safeJsonParse(doc.getText()) - if (data !== undefined) { - updateFile(gen.id, file.id, { id: file.id, data }) - } - } ignoreChange.current = false setError(null) - }, [updateFile]) + }, []) const reset = async () => { if (!service || !uri) { @@ -304,9 +305,9 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { const [projectShown, setProjectShown] = useState(Store.getProjectPanelOpen() ?? window.innerWidth > 1000) const toggleProjectShown = useCallback(() => { if (projectShown) { - Analytics.hideProject(gen.id, projects.length, project.files.length, 'menu') + Analytics.hideProject('menu') } else { - Analytics.showProject(gen.id, projects.length, project.files.length, 'menu') + Analytics.showProject('menu') } Store.setProjectPanelOpen(!projectShown) setProjectShown(!projectShown) @@ -314,13 +315,13 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { const [projectCreating, setProjectCreating] = useState(false) const [projectDeleting, setprojectDeleting] = useState(false) - const [fileSaving, setFileSaving] = useState(undefined) - const [fileRenaming, setFileRenaming] = useState<{ type: string, id: string } | undefined>(undefined) + const [fileSaving, setFileSaving] = useState(undefined) + const [fileRenaming, setFileRenaming] = useState(undefined) const onNewFile = useCallback(() => { - closeFile() + setProjectUri(undefined) // TODO: create new file with default contents - }, [closeFile]) + }, [setProjectUri]) return <>
@@ -379,11 +380,11 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) {
- setprojectDeleting(true)} onRename={setFileRenaming} onCreate={() => setProjectCreating(true)} /> + setprojectDeleting(true)} onCreateProject={() => setProjectCreating(true)} />
{projectCreating && setProjectCreating(false)} />} {projectDeleting && setprojectDeleting(false)} />} - {docAndNode && fileSaving && setFileSaving(undefined)} />} - {fileRenaming && setFileRenaming(undefined)} />} + {docAndNode && fileSaving && {setFileSaving(undefined); setProjectUri(uri)}} onClose={() => setFileSaving(undefined)} />} + {fileRenaming && setFileRenaming(undefined)} />} } diff --git a/src/app/components/generator/Tree.tsx b/src/app/components/generator/Tree.tsx index 0f3bf77a..f8314f7b 100644 --- a/src/app/components/generator/Tree.tsx +++ b/src/app/components/generator/Tree.tsx @@ -1,8 +1,9 @@ import type { DocAndNode, Range } from '@spyglassmc/core' +import { dissectUri } from '@spyglassmc/java-edition/lib/binder/index.js' import type { JsonNode } from '@spyglassmc/json' import { JsonFileNode } from '@spyglassmc/json' import { useCallback, useErrorBoundary, useMemo } from 'preact/hooks' -import { disectFilePath, useLocale, useVersion } from '../../contexts/index.js' +import { useLocale } from '../../contexts/index.js' import { useDocAndNode, useSpyglass } from '../../contexts/Spyglass.jsx' import { getRootType, simplifyType } from './McdocHelpers.js' import type { McdocContext } from './McdocRenderer.jsx' @@ -14,7 +15,6 @@ type TreePanelProps = { } export function Tree({ docAndNode: original, onError }: TreePanelProps) { const { lang } = useLocale() - const { version } = useVersion() const { service } = useSpyglass() if (lang === 'none') return <> @@ -61,19 +61,23 @@ export function Tree({ docAndNode: original, onError }: TreePanelProps) { }, [docAndNode, service]) const resourceType = useMemo(() => { - const path = original.doc.uri - .replace(/^file:\/\/\/project\//, '') - .replace(/\.json$/, '') - const res = disectFilePath(path, version) - return res?.type - }, [original, version]) + if (original.doc.uri.endsWith('/pack.mcmeta')) { + return 'pack_mcmeta' + } + if (ctx === undefined) { + return undefined + } + const res = dissectUri(original.doc.uri, ctx) + return res?.category + }, [original, ctx]) const mcdocType = useMemo(() => { if (!ctx || !resourceType) { return undefined } const rootType = getRootType(resourceType) - return simplifyType(rootType, ctx) + const type = simplifyType(rootType, ctx) + return type }, [resourceType, ctx]) return
diff --git a/src/app/components/previews/BiomeSourcePreview.tsx b/src/app/components/previews/BiomeSourcePreview.tsx index 6d38092b..a767b189 100644 --- a/src/app/components/previews/BiomeSourcePreview.tsx +++ b/src/app/components/previews/BiomeSourcePreview.tsx @@ -1,7 +1,7 @@ import { clampedMap } from 'deepslate' import { mat3 } from 'gl-matrix' import { useCallback, useRef, useState } from 'preact/hooks' -import { getProjectData, useLocale, useProject, useStore, useVersion } from '../../contexts/index.js' +import { useLocale, useProject, useStore, useVersion } from '../../contexts/index.js' import { useAsync } from '../../hooks/index.js' import { checkVersion } from '../../services/Versions.js' import { Store } from '../../Store.js' @@ -37,7 +37,7 @@ export const BiomeSourcePreview = ({ docAndNode, shown }: PreviewProps) => { const hasRandomness = type === 'multi_noise' || type === 'the_end' const { value } = useAsync(async function loadBiomeSource() { - await DEEPSLATE.loadVersion(version, getProjectData(project)) + await DEEPSLATE.loadVersion(version, {}) // TODO: get project data await DEEPSLATE.loadChunkGenerator(data?.generator?.settings, data?.generator?.biome_source, seed) return { biomeSource: { loaded: true }, diff --git a/src/app/components/previews/DensityFunctionPreview.tsx b/src/app/components/previews/DensityFunctionPreview.tsx index d6bcac04..a8b3fdca 100644 --- a/src/app/components/previews/DensityFunctionPreview.tsx +++ b/src/app/components/previews/DensityFunctionPreview.tsx @@ -2,7 +2,7 @@ import type { Voxel } from 'deepslate/render' import { clampedMap, VoxelRenderer } from 'deepslate/render' import type { mat3, mat4 } from 'gl-matrix' import { useCallback, useEffect, useRef, useState } from 'preact/hooks' -import { getProjectData, useLocale, useProject, useVersion } from '../../contexts/index.js' +import { useLocale, useProject, useVersion } from '../../contexts/index.js' import { useAsync } from '../../hooks/useAsync.js' import { useLocalStorage } from '../../hooks/useLocalStorage.js' import { Store } from '../../Store.js' @@ -32,7 +32,7 @@ export const DensityFunctionPreview = ({ docAndNode, shown }: PreviewProps) => { const text = docAndNode.doc.getText() const { value: df } = useAsync(async () => { - await DEEPSLATE.loadVersion(version, getProjectData(project)) + await DEEPSLATE.loadVersion(version, {}) // TODO: get project data const df = DEEPSLATE.loadDensityFunction(safeJsonParse(text) ?? {}, minY, height, seed) return df }, [version, project, minY, height, seed, text]) diff --git a/src/app/components/previews/NoiseSettingsPreview.tsx b/src/app/components/previews/NoiseSettingsPreview.tsx index ae6d5ff3..e06dc14a 100644 --- a/src/app/components/previews/NoiseSettingsPreview.tsx +++ b/src/app/components/previews/NoiseSettingsPreview.tsx @@ -2,7 +2,7 @@ import { clampedMap } from 'deepslate' import type { mat3 } from 'gl-matrix' import { vec2 } from 'gl-matrix' import { useCallback, useRef, useState } from 'preact/hooks' -import { getProjectData, useLocale, useProject, useVersion } from '../../contexts/index.js' +import { useLocale, useProject, useVersion } from '../../contexts/index.js' import { useAsync } from '../../hooks/index.js' import { fetchRegistries } from '../../services/index.js' import { Store } from '../../Store.js' @@ -27,7 +27,7 @@ export const NoiseSettingsPreview = ({ docAndNode, shown }: PreviewProps) => { const { value, error } = useAsync(async () => { const data = safeJsonParse(text) ?? {} - await DEEPSLATE.loadVersion(version, getProjectData(project)) + await DEEPSLATE.loadVersion(version, {}) // TODO: get project data const biomeSource = { type: 'fixed', biome } await DEEPSLATE.loadChunkGenerator(data, biomeSource, seed) const noiseSettings = DEEPSLATE.getNoiseSettings() diff --git a/src/app/contexts/Project.tsx b/src/app/contexts/Project.tsx index 39007dfb..7462e8ed 100644 --- a/src/app/contexts/Project.tsx +++ b/src/app/contexts/Project.tsx @@ -1,97 +1,96 @@ import type { ComponentChildren } from 'preact' import { createContext } from 'preact' -import { route } from 'preact-router' -import { useCallback, useContext, useMemo, useState } from 'preact/hooks' -import config from '../Config.js' +import { useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks' import type { VersionId } from '../services/index.js' +import { ROOT_URI } from '../services/Spyglass.js' import { Store } from '../Store.js' -import { cleanUrl, genPath } from '../Utils.js' -import { useVersion } from './Version.jsx' -export type Project = { +export type ProjectMeta = { name: string, namespace?: string, version?: VersionId, - files: ProjectFile[], + storage?: ProjectStorage, + /** @deprecated */ + files?: ProjectFile[], + /** @deprecated */ unknownFiles?: UnknownFile[], } -export const DRAFT_PROJECT: Project = { - name: 'Drafts', - namespace: 'draft', - files: [], + +export type ProjectStorage = { + type: 'indexeddb', + rootUri: string, } -export type ProjectFile = { +type ProjectFile = { type: string, id: string, data: any, } -export type UnknownFile = { +type UnknownFile = { path: string, data: string, } -export const FilePatterns = [ - 'immersive_weathering/[a-z_]+', - 'neoforge/[a-z_]+', - 'ohthetreesyoullgrow/[a-z_]+', - 'worldgen/[a-z_]+', - 'tags/worldgen/[a-z_]+', - 'tags/[a-z_]+', - '[a-z_]+', -].map(e => RegExp(`^data/([a-z0-9._-]+)/(${e})/([a-z0-9/._-]+)$`)) +export const DRAFT_PROJECT: ProjectMeta = { + name: 'Drafts', + namespace: 'draft', + storage: { + type: 'indexeddb', + rootUri: `${ROOT_URI}drafts/`, + }, +} interface ProjectContext { - projects: Project[], - project: Project, - file?: ProjectFile, - createProject: (name: string, namespace?: string, version?: VersionId) => unknown, - deleteProject: (name: string) => unknown, - changeProject: (name: string) => unknown, - updateProject: (project: Partial) => unknown, - updateFile: (type: string, id: string | undefined, file: Partial) => boolean, - openFile: (type: string, id: string) => unknown, - closeFile: () => unknown, + projects: ProjectMeta[], + project: ProjectMeta, + projectUri: string | undefined, + setProjectUri: (uri: string | undefined) => void, + createProject: (project: ProjectMeta) => void, + deleteProject: (name: string) => void, + changeProject: (name: string) => void, + updateProject: (project: Partial) => void, } -const Project = createContext({ - projects: [DRAFT_PROJECT], - project: DRAFT_PROJECT, - createProject: () => {}, - deleteProject: () => {}, - changeProject: () => {}, - updateProject: () => {}, - updateFile: () => false, - openFile: () => {}, - closeFile: () => {}, -}) +const Project = createContext(undefined) export function useProject() { - return useContext(Project) + const context = useContext(Project) + if (context === undefined) { + throw new Error('Cannot use project outside of provider') + } + return context } export function ProjectProvider({ children }: { children: ComponentChildren }) { - const [projects, setProjects] = useState(Store.getProjects()) - const { version } = useVersion() + const [projects, setProjects] = useState(Store.getProjects()) + const [openProject, setProjectName] = useState() + + useEffect(() => { + const initialProject = Store.getOpenProject() + const project = projects.find(p => p.name === initialProject) + if (!project) { + return + } + if (project.storage !== undefined) { + setProjectName(initialProject) + return + } + // TODO: Import legacy projects + }, []) - const [projectName, setProjectName] = useState(Store.getOpenProject()) const project = useMemo(() => { - return projects.find(p => p.name === projectName) ?? DRAFT_PROJECT - }, [projects, projectName]) + return projects.find(p => p.name === openProject) ?? DRAFT_PROJECT + }, [projects, openProject]) - const [fileId, setFileId] = useState<[string, string] | undefined>(undefined) - const file = useMemo(() => { - if (!fileId) return undefined - return project.files.find(f => f.type === fileId[0] && f.id === fileId[1]) - }, [project, fileId]) + const [projectUri, setProjectUri] = useState() - const changeProjects = useCallback((projects: Project[]) => { + const changeProjects = useCallback((projects: ProjectMeta[]) => { Store.setProjects(projects) setProjects(projects) }, []) - const createProject = useCallback((name: string, namespace?: string, version?: VersionId) => { - changeProjects([...projects, { name, namespace, version, files: [] }]) + const createProject = useCallback((project: ProjectMeta) => { + changeProjects([...projects, project]) }, [projects]) const deleteProject = useCallback((name: string) => { @@ -104,55 +103,19 @@ export function ProjectProvider({ children }: { children: ComponentChildren }) { setProjectName(name) }, []) - const updateProject = useCallback((edits: Partial) => { - changeProjects(projects.map(p => p.name === projectName ? { ...p, ...edits } : p)) - }, [projects, projectName]) - - const updateFile = useCallback((type: string, id: string | undefined, edits: Partial) => { - if (!edits.id) { // remove - updateProject({ files: project.files.filter(f => f.type !== type || f.id !== id) }) - } else { - const newId = type === 'pack_mcmeta' ? 'pack' : edits.id.includes(':') ? edits.id : `${project.namespace ?? 'minecraft'}:${edits.id}` - const exists = project.files.some(f => f.type === type && f.id === newId) - if (!id) { // create - if (exists) return false - updateProject({ files: [...project.files, { type, id: newId, data: edits.data ?? {} } ]}) - setFileId([type, newId]) - } else { // rename or update data - if (file?.id === id && id !== newId && exists) { - return false - } - updateProject({ files: project.files.map(f => f.type === type && f.id === id ? { ...f, ...edits, id: newId } : f)}) - if (file?.id === id) setFileId([type, newId]) - } - } - return true - }, [updateProject, project, file]) - - const openFile = useCallback((type: string, id: string) => { - const gen = config.generators.find(g => g.id === type || genPath(g, version) === type) - if (!gen) { - throw new Error(`Cannot find generator of type ${type}`) - } - setFileId([gen.id, id]) - route(cleanUrl(gen.url)) - }, [version]) - - const closeFile = useCallback(() => { - setFileId(undefined) - }, []) + const updateProject = useCallback((edits: Partial) => { + changeProjects(projects.map(p => p.name === openProject ? { ...p, ...edits } : p)) + }, [projects, openProject]) const value: ProjectContext = { projects, project, - file, + projectUri, + setProjectUri, createProject, changeProject, deleteProject, updateProject, - updateFile, - openFile, - closeFile, } return @@ -160,44 +123,9 @@ export function ProjectProvider({ children }: { children: ComponentChildren }) { } -export function getFilePath(file: { id: string, type: string }, version: VersionId) { - const [namespace, id] = file.id.includes(':') ? file.id.split(':') : ['minecraft', file.id] - if (file.type === 'pack_mcmeta') { - if (file.id === 'pack') return 'pack.mcmeta' - return undefined +export function getProjectRoot(project: ProjectMeta) { + if (project.storage?.type === 'indexeddb') { + return project.storage.rootUri } - const gen = config.generators.find(g => g.id === file.type) - if (!gen) { - return undefined - } - return `data/${namespace}/${genPath(gen, version)}/${id}.json` -} - -export function disectFilePath(path: string, version: VersionId) { - if (path === 'pack.mcmeta') { - return { type: 'pack_mcmeta', id: 'pack' } - } - for (const p of FilePatterns) { - const match = path.match(p) - if (!match) continue - const gen = config.generators.find(g => (genPath(g, version) ?? g.id) === match[2]) - if (!gen) continue - const namespace = match[1] - const name = match[3].replace(/\.[a-z]+$/, '') - return { - type: gen.id, - id: `${namespace}:${name}`, - } - } - return undefined -} - -export function getProjectData(project: Project) { - return Object.fromEntries(['worldgen/noise_settings', 'worldgen/noise', 'worldgen/density_function'].map(type => { - const resources = Object.fromEntries( - project.files.filter(file => file.type === type) - .map<[string, unknown]>(file => [file.id, file.data]) - ) - return [type, resources] - })) + throw new Error(`Unsupported project storage ${project.storage?.type}`) } diff --git a/src/app/services/FileSystem.ts b/src/app/services/FileSystem.ts index 1a09966a..e13d4fbd 100644 --- a/src/app/services/FileSystem.ts +++ b/src/app/services/FileSystem.ts @@ -52,8 +52,13 @@ export class MixedFileSystem implements core.ExternalFileSystem { } async setOverlay(prefix: string, fs: core.ExternalFileSystem) { - this.overlays.push({ prefix, fs }) - this.overlays.sort((a, b) => b.prefix.length - a.prefix.length) + const index = this.overlays.findIndex(overlay => overlay.prefix === prefix) + if (index !== -1) { + this.overlays.splice(index, 1, { prefix, fs }) + } else { + this.overlays.push({ prefix, fs }) + this.overlays.sort((a, b) => b.prefix.length - a.prefix.length) + } if (this.watcher) { await this.watcher.withOverlay(prefix, fs) } diff --git a/src/app/services/Spyglass.ts b/src/app/services/Spyglass.ts index 9424926e..ca71ee69 100644 --- a/src/app/services/Spyglass.ts +++ b/src/app/services/Spyglass.ts @@ -1,6 +1,7 @@ import * as core from '@spyglassmc/core' import { FormatterContext } from '@spyglassmc/core' import { BrowserExternals } from '@spyglassmc/core/lib/browser.js' +import { dissectUri } from '@spyglassmc/java-edition/lib/binder/index.js' import type { McmetaSummary } from '@spyglassmc/java-edition/lib/dependency/index.js' import { Fluids, ReleaseVersion, symbolRegistrar } from '@spyglassmc/java-edition/lib/dependency/index.js' import * as jeJson from '@spyglassmc/java-edition/lib/json/index.js' @@ -17,9 +18,15 @@ import siteConfig from '../Config.js' import { computeIfAbsent, genPath, message } from '../Utils.js' import type { VersionMeta } from './DataFetcher.js' import { fetchBlockStates, fetchRegistries, fetchVanillaMcdoc, fetchVersions, getVersionChecksum } from './DataFetcher.js' -import { IndexedDbFileSystem, MemoryFileSystem, MixedFileSystem } from './FileSystem.js' +import { IndexedDbFileSystem, MixedFileSystem } from './FileSystem.js' import type { VersionId } from './Versions.js' +export const CACHE_URI = 'file:///cache/' +export const ROOT_URI = 'file:///root/' +export const DEPENDENCY_URI = `${ROOT_URI}dependency/` +export const UNSAVED_URI = `${ROOT_URI}unsaved/` +export const PROJECTS_URI = `${ROOT_URI}projects/` + const builtinMcdoc = ` use ::java::server::util::text::Text use ::java::data::worldgen::dimension::Dimension @@ -47,7 +54,7 @@ interface ClientDocument { export class SpyglassClient { public readonly fs = new MixedFileSystem(new IndexedDbFileSystem(), [ - { prefix: 'file:///project/mcdoc/', fs: new MemoryFileSystem() }, + // { prefix: DEPENDENCY_URI, fs: new MemoryFileSystem() }, ]) public readonly externals: core.Externals = { ...BrowserExternals, @@ -83,7 +90,7 @@ export class SpyglassService { public getCheckerContext(doc?: TextDocument, errors?: core.LanguageError[]) { if (!doc) { - doc = TextDocument.create('file:///temp.json', 'json', 1, '') + doc = TextDocument.create('file:///unknown.json', 'json', 1, '') } const err = new core.ErrorReporter() if (errors) { @@ -92,6 +99,10 @@ export class SpyglassService { return core.CheckerContext.create(this.service.project, { doc, err }) } + public dissectUri(uri: string) { + return dissectUri(uri, this.getCheckerContext(TextDocument.create(uri, 'json', 1, ''))) + } + public async openFile(uri: string) { const lang = core.fileUtil.extname(uri)?.slice(1) ?? 'txt' const content = await this.readFile(uri) @@ -193,9 +204,9 @@ export class SpyglassService { public getUnsavedFileUri(gen: ConfigGenerator) { if (gen.id === 'pack_mcmeta') { - return 'file:///project/pack.mcmeta' + return `${UNSAVED_URI}pack.mcmeta` } - return `file:///project/data/draft/${genPath(gen, this.version)}/unsaved.json` + return `${UNSAVED_URI}data/draft/${genPath(gen, this.version)}/draft.json` } public watchFile(uri: string, handler: (docAndNode: core.DocAndNode) => void) { @@ -222,8 +233,8 @@ export class SpyglassService { 'project#ready#bind', ]), project: { - cacheRoot: 'file:///cache/', - projectRoots: ['file:///project/'], + cacheRoot: CACHE_URI, + projectRoots: [ROOT_URI], externals: client.externals, defaultConfig: core.ConfigService.merge(core.VanillaConfig, { env: { @@ -251,7 +262,7 @@ export class SpyglassService { }, // Partner resources ...Object.fromEntries(siteConfig.generators.filter(gen => gen.dependency).map(gen => - [gen.path, { + [gen.path ?? gen.id, { category: gen.id, }] )),