diff --git a/src/app/services/Spyglass.ts b/src/app/services/Spyglass.ts index f36a3cc2..028ef21d 100644 --- a/src/app/services/Spyglass.ts +++ b/src/app/services/Spyglass.ts @@ -1,5 +1,4 @@ import * as core from '@spyglassmc/core' -import { ErrorReporter } from '@spyglassmc/core' import { BrowserExternals } from '@spyglassmc/core/lib/browser.js' import type { McmetaSummary } from '@spyglassmc/java-edition/lib/dependency/index.js' import { Fluids, ReleaseVersion, symbolRegistrar } from '@spyglassmc/java-edition/lib/dependency/index.js' @@ -11,7 +10,6 @@ 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 { Position, Range } from 'vscode-languageserver-textdocument' import { TextDocument } from 'vscode-languageserver-textdocument' import type { ConfigGenerator } from '../Config.js' import siteConfig from '../Config.js' @@ -52,6 +50,7 @@ export class SpyglassClient { ...BrowserExternals.archive, decompressBall, }, + fs: new SpyglassFileSystem(), } public readonly documents = new Map() @@ -78,7 +77,7 @@ export class SpyglassService { } public getCheckerContext(doc: TextDocument, errors: core.LanguageError[]) { - const err = new ErrorReporter() + const err = new core.ErrorReporter() err.errors = errors return core.CheckerContext.create(this.service.project, { doc, err }) } @@ -421,10 +420,258 @@ function registerAttributes(meta: core.MetaRegistry, release: ReleaseVersion, ve }) } -function getLsPosition(offset: number, doc: TextDocument): Position { - return doc.positionAt(offset) +class SpyglassFileSystem implements core.ExternalFileSystem { + public static readonly dbName = 'misode-spyglass-fs' + public static readonly dbVersion = 1 + public static readonly storeName = 'files' + + private readonly db: Promise + + constructor() { + this.db = new Promise((res, rej) => { + const request = indexedDB.open(SpyglassFileSystem.dbName, SpyglassFileSystem.dbVersion) + request.onerror = (e) => { + console.warn('Database error', message((e.target as any)?.error)) + rej() + } + request.onsuccess = () => { + res(request.result) + } + request.onupgradeneeded = (event) => { + const db = (event.target as any).result as IDBDatabase + db.createObjectStore(SpyglassFileSystem.storeName, { keyPath: 'uri' }) + } + }) + } + + async chmod(_location: core.FsLocation, _mode: number): Promise { + return + } + + async mkdir( + location: core.FsLocation, + _options?: { mode?: number | undefined, recursive?: boolean | undefined } | undefined, + ): Promise { + location = core.fileUtil.ensureEndingSlash(location.toString()) + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(SpyglassFileSystem.storeName, 'readwrite') + const store = transaction.objectStore(SpyglassFileSystem.storeName) + const getRequest = store.get(location) + getRequest.onsuccess = () => { + const entry = getRequest.result + if (entry !== undefined) { + rej(new Error(`EEXIST: ${location}`)) + } else { + const putRequest = store.put({ uri: location, type: 'directory' }) + putRequest.onsuccess = () => { + res() + } + putRequest.onerror = () => { + rej() + } + } + } + getRequest.onerror = () => { + rej() + } + }) + } + async readdir(location: core.FsLocation): Promise<{ name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean }[]> { + location = core.fileUtil.ensureEndingSlash(location.toString()) + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(SpyglassFileSystem.storeName, 'readonly') + const store = transaction.objectStore(SpyglassFileSystem.storeName) + // TODO: specify range + const request = store.openCursor() + const result: { name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean }[] = [] + request.onsuccess = () => { + if (request.result) { + const entry = request.result.value + result.push({ + name: request.result.key.toString(), + isDirectory: () => entry.type === 'directory', + isFile: () => entry.type === 'file', + isSymbolicLink: () => false, + }) + request.result.continue() + } else { + res(result) + } + } + request.onerror = () => { + rej() + } + }) + } + async readFile(location: core.FsLocation): Promise { + location = location.toString() + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(SpyglassFileSystem.storeName, 'readonly') + const store = transaction.objectStore(SpyglassFileSystem.storeName) + const request = store.get(location) + request.onsuccess = () => { + const entry = request.result + if (!entry) { + rej(new Error(`ENOENT: ${location}`)) + } else if (entry.type === 'directory') { + rej(new Error(`EISDIR: ${location}`)) + } else { + res(entry.content) + } + } + request.onerror = () => { + rej() + } + }) + } + async showFile(_location: core.FsLocation): Promise { + throw new Error('showFile not supported on browser') + } + async stat(location: core.FsLocation): Promise<{ isDirectory(): boolean, isFile(): boolean }> { + location = location.toString() + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(SpyglassFileSystem.storeName, 'readonly') + const store = transaction.objectStore(SpyglassFileSystem.storeName) + const request = store.get(location) + request.onsuccess = () => { + const entry = request.result + if (!entry) { + rej(new Error(`ENOENT: ${location}`)) + } else { + res({ + isDirectory: () => entry.type === 'directory', + isFile: () => entry.type === 'file', + }) + } + } + request.onerror = () => { + rej() + } + }) + } + async unlink(location: core.FsLocation): Promise { + location = location.toString() + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(SpyglassFileSystem.storeName, 'readwrite') + const store = transaction.objectStore(SpyglassFileSystem.storeName) + const getRequest = store.get(location) + getRequest.onsuccess = () => { + const entry = getRequest.result + if (!entry) { + rej(new Error(`ENOENT: ${location}`)) + } else { + const deleteRequest = store.delete(location) + deleteRequest.onsuccess = () => { + res() + } + deleteRequest.onerror = () => { + rej() + } + } + } + getRequest.onerror = () => { + rej() + } + }) + } + watch(locations: core.FsLocation[], _options: { usePolling?: boolean | undefined }): core.FsWatcher { + return new SpyglassWatcher(this.db, locations) + } + async writeFile( + location: core.FsLocation, + data: string | Uint8Array, + _options?: { mode: number } | undefined, + ): Promise { + location = location.toString() + if (typeof data === 'string') { + data = new TextEncoder().encode(data) + } + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(SpyglassFileSystem.storeName, 'readwrite') + const store = transaction.objectStore(SpyglassFileSystem.storeName) + const request = store.put({ uri: location, type: 'file', content: data }) + request.onsuccess = () => { + res() + } + request.onerror = () => { + rej() + } + }) + } } -export function getLsRange(range: core.Range, doc: TextDocument): Range { - return { start: getLsPosition(range.start, doc), end: getLsPosition(range.end, doc) } +// Copied from spyglass because it isn't exported +type Listener = (...args: any[]) => any +class BrowserEventEmitter implements core.ExternalEventEmitter { + readonly #listeners = new Map, once: Set }>() + + emit(eventName: string, ...args: any[]): boolean { + const listeners = this.#listeners.get(eventName) + if (!listeners?.all?.size) { + return false + } + for (const listener of listeners.all) { + listener(...args) + if (listeners.once.has(listener)) { + listeners.all.delete(listener) + listeners.once.delete(listener) + } + } + return false + } + + on(eventName: string, listener: Listener): this { + if (!this.#listeners.has(eventName)) { + this.#listeners.set(eventName, { all: new Set(), once: new Set() }) + } + const listeners = this.#listeners.get(eventName)! + listeners.all.add(listener) + return this + } + + once(eventName: string, listener: Listener): this { + if (!this.#listeners.has(eventName)) { + this.#listeners.set(eventName, { all: new Set(), once: new Set() }) + } + const listeners = this.#listeners.get(eventName)! + listeners.all.add(listener) + listeners.once.add(listener) + return this + } +} + +class SpyglassWatcher extends BrowserEventEmitter implements core.FsWatcher { + constructor(dbPromise: Promise, locations: core.FsLocation[]) { + super() + dbPromise.then((db) => { + const transaction = db.transaction(SpyglassFileSystem.storeName, 'readonly') + const store = transaction.objectStore(SpyglassFileSystem.storeName) + const request = store.openKeyCursor() + request.onsuccess = () => { + if (request.result) { + const uri = request.result.key.toString() + for (const location of locations) { + if (uri.startsWith(location)) { + this.emit('add', uri) + break + } + } + request.result.continue() + } else { + this.emit('ready') + } + } + request.onerror = () => { + this.emit('error', new Error('Watcher error')) + } + }) + } + + async close(): Promise {} }