Implement IndexedDB file system

This commit is contained in:
Misode
2024-11-01 05:08:23 +01:00
parent 00f0c09a34
commit a380999afb
+254 -7
View File
@@ -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<string, ClientDocument>()
@@ -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<IDBDatabase>
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<void> {
return
}
async mkdir(
location: core.FsLocation,
_options?: { mode?: number | undefined, recursive?: boolean | undefined } | undefined,
): Promise<void> {
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<Uint8Array> {
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<void> {
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<void> {
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<void> {
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<string, { all: Set<Listener>, once: Set<Listener> }>()
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<IDBDatabase>, 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<void> {}
}