Refactor projects to use indexeddb

This commit is contained in:
Misode
2024-11-13 05:29:06 +01:00
parent 26079d1188
commit 2366716cae
17 changed files with 265 additions and 501 deletions

130
package-lock.json generated
View File

@@ -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",

View File

@@ -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,
})
}

View File

@@ -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))
}

View File

@@ -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<string | undefined>(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) {

View File

@@ -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<string>()
const { version } = useVersion()
const { project } = useProject()
const { client } = useSpyglass()
const [fileId, setFileId] = useState(gen.id === 'pack_mcmeta' ? 'pack' : '')
const [error, setError] = useState<string>()
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 <Modal class="file-modal" onDismiss={onClose}>
<p>{locale('project.save_current_file')}</p>
<TextInput autofocus={id !== 'pack_mcmeta'} class="btn btn-input" value={fileId} onChange={changeFileId} onEnter={doSave} onCancel={onClose} placeholder={locale('resource_location')} spellcheck={false} readOnly={id === 'pack_mcmeta'} />
<TextInput autofocus={gen.id !== 'pack_mcmeta'} class="btn btn-input" value={fileId} onChange={changeFileId} onEnter={doSave} onCancel={onClose} placeholder={locale('resource_location')} spellcheck={false} readOnly={gen.id === 'pack_mcmeta'} />
{error !== undefined && <span class="invalid">{error}</span>}
<Btn icon="file" label={locale('project.save')} onClick={doSave} />
</Modal>

View File

@@ -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<string>()
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()
}

View File

@@ -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<Project> = { 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())

View File

@@ -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 <Modal class="file-modal" onDismiss={onClose}>
<p>{locale('project.delete_confirm.1', project.name)}</p>

View File

@@ -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<HTMLAnchorElement>(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 <div class="entry" onClick={onClick} >
@@ -118,23 +75,34 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
const FileEntry: TreeViewLeafRenderer<string> = 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 <div class={`entry ${file && getFilePath(file, version) === selected ? 'active' : ''} ${focused ? 'focused' : ''}`} onClick={() => selectFile(entry)} onContextMenu={onContextMenu} >
return <div class={`entry ${uri === projectUri ? 'active' : ''} ${focused ? 'focused' : ''}`} onClick={onClick} onContextMenu={onContextMenu} >
{Octicon.file}
<span>{entry.split('/').at(-1)}</span>
{focused && <div class="entry-menu">
{actions?.map(a => <div class="action [&>svg]:inline" onClick={e => { a.onAction(entry); e.stopPropagation(); setFocus(false) }}>
{actions?.map(a => <div class="action [&>svg]:inline" onClick={e => { a.onAction(uri); e.stopPropagation(); setFocus(false) }}>
{(Octicon as any)[a.icon]}
<span>{a.label}</span>
</div>)}
</div>}
</div>
}, [actions, disectEntry])
}, [service, actions, projectRoot, projectUri])
return <>
<div class="project-controls">
@@ -143,15 +111,16 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
</BtnMenu>
<BtnMenu icon="kebab_horizontal" >
<Btn icon="file_zip" label={locale('project.download')} onClick={onDownload} />
<Btn icon="plus_circle" label={locale('project.new')} onClick={onCreate} />
<Btn icon={treeViewMode === 'resources' ? 'three_bars' : 'rows'} label={locale(treeViewMode === 'resources' ? 'project.show_file_paths' : 'project.show_resources')} onClick={() => changeTreeViewMode(treeViewMode === 'resources' ? 'files' : 'resources')} />
<Btn icon="plus_circle" label={locale('project.new')} onClick={onCreateProject} />
{project.name !== DRAFT_PROJECT.name && <Btn icon="trashcan" label={locale('project.delete')} onClick={onDeleteProject} />}
</BtnMenu>
</div>
<div class="file-view">
{entries.length === 0
? <span>{locale('project.no_files')}</span>
: <TreeView entries={entries} split={path => path.split('/')} group={FolderEntry} leaf={FileEntry} />}
{entries === undefined
? <></>
: entries.length === 0
? <span>{locale('project.no_files')}</span>
: <TreeView entries={entries} split={path => path.split('/')} group={FolderEntry} leaf={FileEntry} />}
</div>
<a ref={download} style="display: none;"></a>
</>

View File

@@ -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<Error | string | null>(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<string | undefined>(undefined)
const [fileRenaming, setFileRenaming] = useState<{ type: string, id: string } | undefined>(undefined)
const [fileSaving, setFileSaving] = useState<Method | undefined>(undefined)
const [fileRenaming, setFileRenaming] = useState<string | undefined>(undefined)
const onNewFile = useCallback(() => {
closeFile()
setProjectUri(undefined)
// TODO: create new file with default contents
}, [closeFile])
}, [setProjectUri])
return <>
<main class={`${previewShown ? 'has-preview' : ''} ${projectShown ? 'has-project' : ''}`}>
@@ -379,11 +380,11 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) {
</div>
</div>
<div class={`popup-project${projectShown ? ' shown' : ''}`}>
<ProjectPanel onError={setError} onDeleteProject={() => setprojectDeleting(true)} onRename={setFileRenaming} onCreate={() => setProjectCreating(true)} />
<ProjectPanel onError={setError} onRename={setFileRenaming} onDeleteProject={() => setprojectDeleting(true)} onCreateProject={() => setProjectCreating(true)} />
</div>
{projectCreating && <ProjectCreation onClose={() => setProjectCreating(false)} />}
{projectDeleting && <ProjectDeletion onClose={() => setprojectDeleting(false)} />}
{docAndNode && fileSaving && <FileCreation id={gen.id} docAndNode={docAndNode} method={fileSaving} onClose={() => setFileSaving(undefined)} />}
{fileRenaming && <FileRenaming id={fileRenaming.type } name={fileRenaming.id} onClose={() => setFileRenaming(undefined)} />}
{docAndNode && fileSaving && <FileCreation gen={gen} docAndNode={docAndNode} method={fileSaving} onCreate={(uri) => {setFileSaving(undefined); setProjectUri(uri)}} onClose={() => setFileSaving(undefined)} />}
{fileRenaming && <FileRenaming uri={fileRenaming} onClose={() => setFileRenaming(undefined)} />}
</>
}

View File

@@ -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 <div class="tree node-root" data-cy="tree" data-category={getCategory(resourceType)}>

View File

@@ -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 },

View File

@@ -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])

View File

@@ -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()

View File

@@ -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<Project>) => unknown,
updateFile: (type: string, id: string | undefined, file: Partial<ProjectFile>) => 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<ProjectMeta>) => void,
}
const Project = createContext<ProjectContext>({
projects: [DRAFT_PROJECT],
project: DRAFT_PROJECT,
createProject: () => {},
deleteProject: () => {},
changeProject: () => {},
updateProject: () => {},
updateFile: () => false,
openFile: () => {},
closeFile: () => {},
})
const Project = createContext<ProjectContext | undefined>(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<Project[]>(Store.getProjects())
const { version } = useVersion()
const [projects, setProjects] = useState<ProjectMeta[]>(Store.getProjects())
const [openProject, setProjectName] = useState<string>()
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<string>(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<string>()
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<Project>) => {
changeProjects(projects.map(p => p.name === projectName ? { ...p, ...edits } : p))
}, [projects, projectName])
const updateFile = useCallback((type: string, id: string | undefined, edits: Partial<ProjectFile>) => {
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<ProjectMeta>) => {
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 <Project.Provider value={value}>
@@ -160,44 +123,9 @@ export function ProjectProvider({ children }: { children: ComponentChildren }) {
</Project.Provider>
}
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}`)
}

View File

@@ -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)
}

View File

@@ -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,
}]
)),