diff --git a/src/app/components/ErrorPanel.tsx b/src/app/components/ErrorPanel.tsx index 3c75328a..3b8beca1 100644 --- a/src/app/components/ErrorPanel.tsx +++ b/src/app/components/ErrorPanel.tsx @@ -3,6 +3,7 @@ import { getCurrentUrl } from 'preact-router' import { useEffect, useMemo, useState } from 'preact/hooks' import { useSpyglass } from '../contexts/Spyglass.jsx' import { useVersion } from '../contexts/Version.jsx' +import { useAsync } from '../hooks/useAsync.js' import { latestVersion } from '../services/DataFetcher.js' import { getGenerator } from '../Utils.js' import { Octicon } from './index.js' @@ -21,9 +22,14 @@ export function ErrorPanel({ error, prefix, reportable, onDismiss, body: body_, const [stackVisible, setStackVisible] = useState(false) const [stack, setStack] = useState(undefined) - const gen = getGenerator(getCurrentUrl()) - const source = gen ? spyglass?.getFileContents(spyglass.getUnsavedFileUri(version, gen)) : undefined const name = (prefix ?? '') + (error instanceof Error ? error.message : error) + const gen = getGenerator(getCurrentUrl()) + const { value: source } = useAsync(async () => { + if (gen) { + return await spyglass?.readFile(spyglass.getUnsavedFileUri(version, gen)) + } + return undefined + }, [spyglass, version, gen]) useEffect(() => { if (error instanceof Error) { @@ -57,7 +63,7 @@ export function ErrorPanel({ error, prefix, reportable, onDismiss, body: body_, body += `\n### Stack trace\n\`\`\`\n${fullName}\n${stack}\n\`\`\`\n` } if (source) { - body += `\n### Generator JSON\n
\n
\n${JSON.stringify(source, null, 2)}\n
\n
\n` + body += `\n### Generator JSON\n
\n
\n${source}\n
\n
\n` } if (body_) { body += body_ diff --git a/src/app/components/generator/McdocRenderer.tsx b/src/app/components/generator/McdocRenderer.tsx index 58a4ab5b..fb914301 100644 --- a/src/app/components/generator/McdocRenderer.tsx +++ b/src/app/components/generator/McdocRenderer.tsx @@ -3,23 +3,25 @@ import { JsonArrayNode, JsonBooleanNode, JsonNumberNode, JsonObjectNode, JsonStr import type { ListType, LiteralType, McdocType } from '@spyglassmc/mcdoc' import type { SimplifiedStructType } from '@spyglassmc/mcdoc/lib/runtime/checker/index.js' import { useLocale } from '../../contexts/Locale.jsx' +import type { Edit } from '../../services/Spyglass.js' import { Octicon } from '../Octicon.jsx' interface Props { node: JsonNode | undefined + makeEdits: (edits: Edit[]) => void } -export function McdocRoot({ node } : Props) { +export function McdocRoot({ node, makeEdits } : Props) { const type = node?.typeDef ?? { kind: 'unsafe' } if (type.kind === 'struct') { - return + return } return <>
- +
- + } @@ -89,19 +91,23 @@ function Head({ simpleType, optional, node }: HeadProps) { interface BodyProps extends Props { simpleType: McdocType } -function Body({ simpleType, node }: BodyProps) { +function Body({ simpleType, node, makeEdits }: BodyProps) { const type = node?.typeDef ?? simpleType if (node?.typeDef?.kind === 'struct') { if (node.typeDef.fields.length === 0) { return <> } return
- +
} if (node?.typeDef?.kind === 'list') { + const fixedRange = node.typeDef.lengthRange?.min !== undefined && node.typeDef.lengthRange.min === node.typeDef.lengthRange.max + if (!fixedRange && node.children?.length === 0) { + return <> + } return
- +
} if (type.kind === 'byte' || type.kind === 'short' || type.kind === 'int' || type.kind === 'boolean') { @@ -114,7 +120,7 @@ function Body({ simpleType, node }: BodyProps) { interface StructBodyProps extends Props { type: SimplifiedStructType } -function StructBody({ type, node }: StructBodyProps) { +function StructBody({ type, node, makeEdits }: StructBodyProps) { if (!JsonObjectNode.is(node)) { return <> } @@ -133,9 +139,9 @@ function StructBody({ type, node }: StructBodyProps) { return
- +
- +
})} @@ -150,7 +156,7 @@ function Key({ label }: { label: string | number | boolean }) { interface ListBodyProps extends Props { type: ListType } -function ListBody({ type, node }: ListBodyProps) { +function ListBody({ type, node, makeEdits }: ListBodyProps) { const { locale } = useLocale() if (!JsonArrayNode.is(node)) { return <> @@ -160,7 +166,7 @@ function ListBody({ type, node }: ListBodyProps) { const child = item.value return
- {node.children.length > 1 &&
@@ -172,9 +178,9 @@ function ListBody({ type, node }: ListBodyProps) {
} - +
- +
})} diff --git a/src/app/components/generator/SchemaGenerator.tsx b/src/app/components/generator/SchemaGenerator.tsx index a4a7bbac..2ba62dd8 100644 --- a/src/app/components/generator/SchemaGenerator.tsx +++ b/src/app/components/generator/SchemaGenerator.tsx @@ -110,25 +110,25 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { Analytics.resetGenerator(gen.id, 1, 'menu') // TODO } - const undo = (e: MouseEvent) => { + const undo = async (e: MouseEvent) => { e.stopPropagation() Analytics.undoGenerator(gen.id, 1, 'menu') - // TODO + await spyglass.undoEdits(version, uri) } - const redo = (e: MouseEvent) => { + const redo = async (e: MouseEvent) => { e.stopPropagation() Analytics.redoGenerator(gen.id, 1, 'menu') - // TODO + await spyglass.redoEdits(version, uri) } useEffect(() => { - const onKeyUp = (e: KeyboardEvent) => { + const onKeyUp = async (e: KeyboardEvent) => { if (e.ctrlKey && e.key === 'z') { Analytics.undoGenerator(gen.id, 1, 'hotkey') - // TODO + await spyglass.undoEdits(version, uri) } else if (e.ctrlKey && e.key === 'y') { Analytics.redoGenerator(gen.id, 1, 'hotkey') - // TODO + await spyglass.redoEdits(version, uri) } } const onKeyDown = (e: KeyboardEvent) => { @@ -145,7 +145,7 @@ export function SchemaGenerator({ gen, allowedVersions }: Props) { document.removeEventListener('keyup', onKeyUp) document.removeEventListener('keydown', onKeyDown) } - }, [gen.id]) + }, [gen.id, spyglass, version, uri]) const { value: presets } = useAsync(async () => { const registries = await fetchRegistries(version) diff --git a/src/app/components/generator/Tree.tsx b/src/app/components/generator/Tree.tsx index 32c5278e..bc8bb060 100644 --- a/src/app/components/generator/Tree.tsx +++ b/src/app/components/generator/Tree.tsx @@ -1,8 +1,9 @@ import type { DocAndNode } from '@spyglassmc/core' import { JsonFileNode } from '@spyglassmc/json' -import { useErrorBoundary } from 'preact/hooks' -import { useLocale } from '../../contexts/index.js' -import { useDocAndNode } from '../../contexts/Spyglass.jsx' +import { useCallback, useErrorBoundary } from 'preact/hooks' +import { useLocale, useVersion } from '../../contexts/index.js' +import { useDocAndNode, useSpyglass } from '../../contexts/Spyglass.jsx' +import type { Edit } from '../../services/Spyglass.js' import { McdocRoot } from './McdocRenderer.jsx' type TreePanelProps = { @@ -11,6 +12,9 @@ type TreePanelProps = { } export function Tree({ docAndNode, onError }: TreePanelProps) { const { lang } = useLocale() + const { version } = useVersion() + const { spyglass } = useSpyglass() + if (lang === 'none') return <> const fileChild = useDocAndNode(docAndNode).node.children[0] @@ -24,7 +28,11 @@ export function Tree({ docAndNode, onError }: TreePanelProps) { }) if (error) return <> + const makeEdits = useCallback((edits: Edit[]) => { + spyglass.applyEdits(version, docAndNode.doc.uri, edits) + }, [spyglass, version, docAndNode]) + return
- +
} diff --git a/src/app/services/Spyglass.ts b/src/app/services/Spyglass.ts index 47210b03..2fdea40b 100644 --- a/src/app/services/Spyglass.ts +++ b/src/app/services/Spyglass.ts @@ -9,7 +9,7 @@ import { localize } from '@spyglassmc/locales' import * as mcdoc from '@spyglassmc/mcdoc' import * as nbt from '@spyglassmc/nbt' import * as zip from '@zip.js/zip.js' -import type { TextEdit } from 'vscode-languageserver-textdocument' +import type { Position, Range } from 'vscode-languageserver-textdocument' import { TextDocument } from 'vscode-languageserver-textdocument' import type { ConfigGenerator, ConfigVersion } from '../Config.js' import siteConfig from '../Config.js' @@ -17,10 +17,15 @@ import { computeIfAbsent, genPath } from '../Utils.js' import { fetchBlockStates, fetchRegistries, fetchVanillaMcdoc, getVersionChecksum } from './DataFetcher.js' import type { VersionId } from './Versions.js' +export interface Edit { + range?: core.Range + text: string +} + interface DocumentData { doc: TextDocument - undoStack: { edits: TextEdit[] }[] - redoStack: { edits: TextEdit[] }[] + undoStack: string[] + redoStack: string[] } export class Spyglass { @@ -42,22 +47,15 @@ export class Spyglass { const document = this.documents.get(uri) let docAndNode: core.DocAndNode | undefined if (document === undefined) { - let doc: TextDocument - try { - const buffer = await Spyglass.EXTERNALS.fs.readFile(uri) - const content = new TextDecoder().decode(buffer) - doc = TextDocument.create(uri, 'json', 1, content) - Spyglass.LOGGER.info(`[Spyglass#openFile] Opening file with content from fs: ${uri}`) - } catch (e) { - doc = TextDocument.create(uri, 'json', 1, emptyContent ? emptyContent() : '') - Spyglass.LOGGER.info(`[Spyglass#openFile] Opening empty file: ${uri}`) - } + Spyglass.LOGGER.info(`[Spyglass#openFile] Opening file with content from fs: ${uri}`) + const content = await this.readFile(uri) + const doc = TextDocument.create(uri, 'json', 1, content ?? (emptyContent ? emptyContent() : '')) this.documents.set(uri, { doc, undoStack: [], redoStack: [] }) await service.project.onDidOpen(doc.uri, doc.languageId, doc.version, doc.getText()) docAndNode = await service.project.ensureClientManagedChecked(uri) } else { - docAndNode = service.project.getClientManaged(uri) Spyglass.LOGGER.info(`[Spyglass#openFile] Opening already open file: ${uri}`) + docAndNode = service.project.getClientManaged(uri) } if (!docAndNode) { throw new Error(`[Spyglass#openFile] Cannot get doc and node: ${uri}`) @@ -65,22 +63,80 @@ export class Spyglass { return docAndNode } - public async writeFile(versionId: VersionId, uri: string, content: string) { - await Spyglass.EXTERNALS.fs.writeFile(uri, content) - Spyglass.LOGGER.info(`[Spyglass#writeFile] Writing file: ${uri} ${content.substring(0, 50)}`) - const doc = this.documents.get(uri)?.doc - if (doc !== undefined) { - const service = await this.getService(versionId) - await service.project.onDidChange(doc.uri, [{ text: content }], doc.version + 1) - const docAndNode = service.project.getClientManaged(doc.uri) - if (docAndNode) { - service.project.emit('documentUpdated', docAndNode) - } + public async readFile(uri: string): Promise { + try { + const buffer = await Spyglass.EXTERNALS.fs.readFile(uri) + return new TextDecoder().decode(buffer) + } catch (e) { + return undefined } } - public getFileContents(_uri: string): string | undefined { - return undefined // TODO + private async notifyChange(versionId: VersionId, doc: TextDocument) { + const service = await this.getService(versionId) + await service.project.onDidChange(doc.uri, [{ text: doc.getText() }], doc.version + 1) + const docAndNode = service.project.getClientManaged(doc.uri) + if (docAndNode) { + service.project.emit('documentUpdated', docAndNode) + } + return docAndNode + } + + public async writeFile(versionId: VersionId, uri: string, content: string) { + const document = this.documents.get(uri) + if (document !== undefined) { + document.undoStack.push(document.doc.getText()) + document.redoStack = [] + TextDocument.update(document.doc, [{ text: content }], document.doc.version + 1) + } + await Spyglass.EXTERNALS.fs.writeFile(uri, content) + if (document) { + await this.notifyChange(versionId, document.doc) + } + } + + public async applyEdits(versionId: VersionId, uri: string, edits: Edit[]) { + const document = this.documents.get(uri) + if (document !== undefined) { + document.undoStack.push(document.doc.getText()) + document.redoStack = [] + TextDocument.update(document.doc, edits.map(e => ({ + range: e.range ? getLsRange(e.range, document.doc) : undefined, + text: e.text, + })), document.doc.version + 1) + await Spyglass.EXTERNALS.fs.writeFile(uri, document.doc.getText()) + await this.notifyChange(versionId, document.doc) + } + } + + public async undoEdits(versionId: VersionId, uri: string) { + const document = this.documents.get(uri) + if (document === undefined) { + throw new Error(`[Spyglass#undoEdits] Document doesn't exist: ${uri}`) + } + const lastUndo = document.undoStack.pop() + if (lastUndo === undefined) { + return + } + document.redoStack.push(document.doc.getText()) + TextDocument.update(document.doc, [{ text: lastUndo }], document.doc.version + 1) + await Spyglass.EXTERNALS.fs.writeFile(uri, document.doc.getText()) + await this.notifyChange(versionId, document.doc) + } + + public async redoEdits(versionId: VersionId, uri: string) { + const document = this.documents.get(uri) + if (document === undefined) { + throw new Error(`[Spyglass#redoEdits] Document doesn't exist: ${uri}`) + } + const lastRedo = document.redoStack.pop() + if (lastRedo === undefined) { + return + } + document.undoStack.push(document.doc.getText()) + TextDocument.update(document.doc, [{ text: lastRedo }], document.doc.version + 1) + await Spyglass.EXTERNALS.fs.writeFile(uri, document.doc.getText()) + await this.notifyChange(versionId, document.doc) } public getUnsavedFileUri(versionId: VersionId, gen: ConfigGenerator) { @@ -272,3 +328,11 @@ function registerAttributes(meta: core.MetaRegistry, release: ReleaseVersion) { }, }) } + +function getLsPosition(offset: number, doc: TextDocument): Position { + return doc.positionAt(offset) +} + +export function getLsRange(range: core.Range, doc: TextDocument): Range { + return { start: getLsPosition(range.start, doc), end: getLsPosition(range.end, doc) } +}