From 3e9f1f285d808f40dc3360d572c88283a128f9b3 Mon Sep 17 00:00:00 2001 From: Misode Date: Fri, 15 Nov 2024 05:34:25 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=9A=A7=20Start=20web=20extension=20su?= =?UTF-8?q?pport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 12 + .../src/common/externals/BrowserExternals.ts | 4 +- packages/language-server/src/server-web.ts | 413 ++++++++++++++++++ packages/vscode-extension/esbuild.mjs | 35 +- packages/vscode-extension/package.json | 5 + .../vscode-extension/src/extension-web.mts | 141 ++++++ packages/vscode-extension/tsconfig.json | 3 +- 7 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 packages/language-server/src/server-web.ts create mode 100644 packages/vscode-extension/src/extension-web.mts diff --git a/.vscode/launch.json b/.vscode/launch.json index 20a4cc65b..fbbcadba6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,6 +29,18 @@ "args": ["--timeout", "999999"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" + }, + { + "type": "extensionHost", + "request": "launch", + "name": "Launch Web Client", + "debugWebWorkerHost": true, + "args": [ + "--extensionDevelopmentPath=${workspaceRoot}/packages/vscode-extension", + "--extensionDevelopmentKind=web" + ], + "outFiles": ["${workspaceFolder}/packages/vscode-extension/dist/**/*.js"], + "preLaunchTask": "npm: watch" } ], "compounds": [ diff --git a/packages/core/src/common/externals/BrowserExternals.ts b/packages/core/src/common/externals/BrowserExternals.ts index 13a57553b..6011d0956 100644 --- a/packages/core/src/common/externals/BrowserExternals.ts +++ b/packages/core/src/common/externals/BrowserExternals.ts @@ -94,11 +94,11 @@ class BrowserFileSystem implements ExternalFileSystem { private states: Record constructor() { - this.states = JSON.parse(localStorage.getItem(BrowserFileSystem.LocalStorageKey) ?? '{}') + this.states = {} // JSON.parse(localStorage.getItem(BrowserFileSystem.LocalStorageKey) ?? '{}') } private saveStates() { - localStorage.setItem(BrowserFileSystem.LocalStorageKey, JSON.stringify(this.states)) + // localStorage.setItem(BrowserFileSystem.LocalStorageKey, JSON.stringify(this.states)) } async chmod(_location: FsLocation, _mode: number): Promise { diff --git a/packages/language-server/src/server-web.ts b/packages/language-server/src/server-web.ts new file mode 100644 index 000000000..7189de931 --- /dev/null +++ b/packages/language-server/src/server-web.ts @@ -0,0 +1,413 @@ +import * as core from '@spyglassmc/core' +import { fileUtil } from '@spyglassmc/core' +import { BrowserExternals } from '@spyglassmc/core/lib/browser.js' +import * as je from '@spyglassmc/java-edition' +import * as locales from '@spyglassmc/locales' +import * as mcdoc from '@spyglassmc/mcdoc' +import * as ls from 'vscode-languageserver/browser.js' +import type { + CustomInitializationOptions, + CustomServerCapabilities, + MyLspDataHackPubifyRequestParams, +} from './util/index.js' +import { toCore, toLS } from './util/index.js' + +export * from './util/types.js' + +const cacheRoot = 'file:///cache/spyglassmc/' + +const messageReader = new ls.BrowserMessageReader(self) +const messageWriter = new ls.BrowserMessageWriter(self) +const connection = ls.createConnection(messageReader, messageWriter) +let capabilities!: ls.ClientCapabilities +let workspaceFolders!: ls.WorkspaceFolder[] +let hasShutdown = false +let progressReporter: ls.WorkDoneProgressReporter | undefined + +const externals = BrowserExternals +const logger: core.Logger = { + error: (msg: any, ...args: any[]): void => connection.console.error(`${msg} ${args.join(' ')}`), + info: (msg: any, ...args: any[]): void => connection.console.info(`${msg} ${args.join(' ')}`), + log: (msg: any, ...args: any[]): void => connection.console.log(`${msg} ${args.join(' ')}`), + warn: (msg: any, ...args: any[]): void => connection.console.warn(`${msg} ${args.join(' ')}`), +} +let service!: core.Service + +connection.onInitialize(async (params) => { + const initializationOptions = params.initializationOptions as + | CustomInitializationOptions + | undefined + + logger.info(`[onInitialize] processId = ${JSON.stringify(params.processId)}`) + logger.info(`[onInitialize] clientInfo = ${JSON.stringify(params.clientInfo)}`) + logger.info(`[onInitialize] initializationOptions = ${JSON.stringify(initializationOptions)}`) + + capabilities = params.capabilities + workspaceFolders = params.workspaceFolders ?? [] + + if (initializationOptions?.inDevelopmentMode) { + await new Promise((resolve) => setTimeout(resolve, 3000)) + logger.warn( + 'Delayed 3 seconds manually. If you see this in production, it means SPGoding messed up.', + ) + } + + try { + await locales.loadLocale(params.locale) + } catch (e) { + logger.error('[loadLocale]', e) + } + + try { + service = new core.Service({ + isDebugging: initializationOptions?.inDevelopmentMode, + logger, + profilers: new core.ProfilerFactory(logger, [ + 'cache#load', + 'cache#save', + 'project#init', + 'project#ready', + 'project#ready#bind', + ]), + project: { + defaultConfig: core.ConfigService.merge(core.VanillaConfig, { + env: { gameVersion: initializationOptions?.gameVersion }, + }), + cacheRoot, + externals, + initializers: [mcdoc.initialize, je.initialize], + projectRoots: workspaceFolders.map(f => core.fileUtil.ensureEndingSlash(f.uri)), + }, + }) + service.project.on('documentErrored', async ({ errors, uri, version }) => { + try { + await connection.sendDiagnostics({ + diagnostics: toLS.diagnostics(errors), + uri, + version, + }) + } catch (e) { + console.error('[sendDiagnostics]', e) + } + }).on('ready', async () => { + await connection.sendProgress(ls.WorkDoneProgress.type, 'initialize', { kind: 'end' }) + }) + await service.project.init() + } catch (e) { + logger.error('[new Service]', e) + } + + const customCapabilities: CustomServerCapabilities = { + dataHackPubify: true, + resetProjectCache: true, + showCacheRoot: true, + } + + const ans: ls.InitializeResult = { + serverInfo: { name: 'Spyglass Language Server' }, + capabilities: { + colorProvider: {}, + completionProvider: { triggerCharacters: service.project.meta.getTriggerCharacters() }, + declarationProvider: {}, + definitionProvider: {}, + implementationProvider: {}, + // TODO: re-enable this + // documentFormattingProvider: {}, + referencesProvider: {}, + typeDefinitionProvider: {}, + documentHighlightProvider: {}, + documentSymbolProvider: { label: 'Spyglass' }, + hoverProvider: {}, + inlayHintProvider: {}, + semanticTokensProvider: { + documentSelector: toLS.documentSelector(service.project.meta), + legend: toLS.semanticTokensLegend(), + full: { delta: false }, + range: true, + }, + signatureHelpProvider: { triggerCharacters: [' '] }, + textDocumentSync: { change: ls.TextDocumentSyncKind.Incremental, openClose: true }, + workspaceSymbolProvider: {}, + experimental: { spyglassmc: customCapabilities }, + }, + } + + if (capabilities.workspace?.workspaceFolders) { + ans.capabilities.workspace = { + workspaceFolders: { supported: true, changeNotifications: true }, + } + } + + return ans +}) + +connection.onInitialized(async () => { + await service.project.ready() + if (capabilities.workspace?.workspaceFolders) { + connection.workspace.onDidChangeWorkspaceFolders(async () => { + // FIXME + // service.rawRoots = (await connection.workspace.getWorkspaceFolders() ?? []).map(r => r.uri) + }) + } +}) + +connection.onDidOpenTextDocument( + ({ textDocument: { text, uri, version, languageId: languageID } }) => { + return service.project.onDidOpen(uri, languageID, version, text) + }, +) +connection.onDidChangeTextDocument(({ contentChanges, textDocument: { uri, version } }) => { + return service.project.onDidChange(uri, contentChanges, version) +}) +connection.onDidCloseTextDocument(({ textDocument: { uri } }) => { + service.project.onDidClose(uri) +}) + +connection.workspace.onDidRenameFiles(({}) => {}) + +connection.onColorPresentation(async ({ textDocument: { uri }, color, range }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const presentation = service.getColorPresentation( + node, + doc, + toCore.range(range, doc), + toCore.color(color), + ) + return toLS.colorPresentationArray(presentation, doc) +}) +connection.onDocumentColor(async ({ textDocument: { uri } }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const info = service.getColorInfo(node, doc) + return toLS.colorInformationArray(info, doc) +}) + +connection.onCompletion(async ({ textDocument: { uri }, position, context }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const offset = toCore.offset(position, doc) + const items = service.complete(node, doc, offset, context?.triggerCharacter) + return items.map((item) => + toLS.completionItem( + item, + doc, + offset, + capabilities.textDocument?.completion?.completionItem?.insertReplaceSupport, + ) + ) +}) + +connection.onRequest( + 'spyglassmc/dataHackPubify', + ({ initialism }: MyLspDataHackPubifyRequestParams) => { + return service.dataHackPubify(initialism) + }, +) + +connection.onDeclaration(async ({ textDocument: { uri }, position }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const ans = await service.getSymbolLocations(node, doc, toCore.offset(position, doc), [ + 'declaration', + 'definition', + ]) + return toLS.locationLink(ans, doc, capabilities.textDocument?.declaration?.linkSupport) +}) +connection.onDefinition(async ({ textDocument: { uri }, position }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const ans = await service.getSymbolLocations(node, doc, toCore.offset(position, doc), [ + 'definition', + 'declaration', + 'implementation', + 'typeDefinition', + ]) + return toLS.locationLink(ans, doc, capabilities.textDocument?.definition?.linkSupport) +}) +connection.onImplementation(async ({ textDocument: { uri }, position }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const ans = await service.getSymbolLocations(node, doc, toCore.offset(position, doc), [ + 'implementation', + 'definition', + ]) + return toLS.locationLink(ans, doc, capabilities.textDocument?.implementation?.linkSupport) +}) +connection.onReferences( + async ({ textDocument: { uri }, position, context: { includeDeclaration } }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const ans = await service.getSymbolLocations( + node, + doc, + toCore.offset(position, doc), + includeDeclaration ? undefined : ['reference'], + ) + return toLS.locationLink(ans, doc, false) + }, +) +connection.onTypeDefinition(async ({ textDocument: { uri }, position }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const ans = await service.getSymbolLocations(node, doc, toCore.offset(position, doc), [ + 'typeDefinition', + ]) + return toLS.locationLink(ans, doc, capabilities.textDocument?.typeDefinition?.linkSupport) +}) + +connection.onDocumentHighlight(async ({ textDocument: { uri }, position }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const ans = await service.getSymbolLocations( + node, + doc, + toCore.offset(position, doc), + undefined, + true, + ) + return toLS.documentHighlight(ans) +}) + +connection.onDocumentSymbol(async ({ textDocument: { uri } }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + return toLS.documentSymbolsFromTables( + [service.project.symbols.global, ...core.AstNode.getLocalsToLeaves(node)], + doc, + capabilities.textDocument?.documentSymbol?.hierarchicalDocumentSymbolSupport, + capabilities.textDocument?.documentSymbol?.symbolKind?.valueSet, + ) +}) + +connection.onHover(async ({ textDocument: { uri }, position }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const ans = service.getHover(node, doc, toCore.offset(position, doc)) + return ans ? toLS.hover(ans, doc) : undefined +}) + +connection.languages.inlayHint.on(async ({ textDocument: { uri }, range }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return [] + } + const { doc, node } = docAndNode + const hints = service.getInlayHints(node, doc, toCore.range(range, doc)) + return toLS.inlayHints(hints, doc) +}) + +connection.onRequest('spyglassmc/resetProjectCache', async (): Promise => { + return service.project.resetCache() +}) + +connection.onRequest('spyglassmc/showCacheRoot', async (): Promise => { + return service.project.showCacheRoot() +}) + +connection.languages.semanticTokens.on(async ({ textDocument: { uri } }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return { data: [] } + } + const { doc, node } = docAndNode + const tokens = service.colorize(node, doc) + return toLS.semanticTokens( + tokens, + doc, + capabilities.textDocument?.semanticTokens?.multilineTokenSupport, + ) +}) +connection.languages.semanticTokens.onRange(async ({ textDocument: { uri }, range }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return { data: [] } + } + const { doc, node } = docAndNode + const tokens = service.colorize(node, doc, toCore.range(range, doc)) + return toLS.semanticTokens( + tokens, + doc, + capabilities.textDocument?.semanticTokens?.multilineTokenSupport, + ) +}) + +connection.onSignatureHelp(async ({ textDocument: { uri }, position }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + const help = service.getSignatureHelp(node, doc, toCore.offset(position, doc)) + return toLS.signatureHelp(help) +}) + +connection.onWorkspaceSymbol(({ query }) => { + return toLS.symbolInformationArrayFromTable( + service.project.symbols.global, + query, + capabilities.textDocument?.documentSymbol?.symbolKind?.valueSet, + ) +}) + +connection.onDocumentFormatting(async ({ textDocument: { uri }, options }) => { + const docAndNode = await service.project.ensureClientManagedChecked(uri) + if (!docAndNode) { + return undefined + } + const { doc, node } = docAndNode + let text = service.format(node, doc, options.tabSize, options.insertSpaces) + if (options.insertFinalNewline && text.charAt(text.length - 1) !== '\n') { + text += '\n' + } + return [toLS.textEdit(node.range, text, doc)] +}) + +connection.onShutdown(async (): Promise => { + await service.project.close() + hasShutdown = true +}) +connection.onExit((): void => { + connection.dispose() + if (!hasShutdown) { + console.error( + 'The server has not finished the shutdown request before receiving the exit request.', + ) + process.exitCode = 1 + } +}) + +connection.listen() diff --git a/packages/vscode-extension/esbuild.mjs b/packages/vscode-extension/esbuild.mjs index 8736fc2a8..3acac384f 100755 --- a/packages/vscode-extension/esbuild.mjs +++ b/packages/vscode-extension/esbuild.mjs @@ -9,7 +9,7 @@ try { const isDev = mode !== 'prod' console.info('Start building...') - const result = await esbuild.build({ + const nodeBuild = esbuild.build({ entryPoints: ['./out/extension.mjs', '../language-server/lib/server.js'], entryNames: '[name]', format: 'cjs', // https://github.com/microsoft/vscode/issues/130367 @@ -24,17 +24,42 @@ try { sourcemap: isDev, minify: !isDev, }) - logResult(result) + const webServer = esbuild.build({ + entryPoints: ['../language-server/lib/server-web.js'], + entryNames: '[name]', + format: 'iife', + platform: 'browser', + bundle: true, + outdir: './dist', + external: ['vscode'], + sourcesContent: false, + sourcemap: isDev, + minify: !isDev, + }) + const webClient = esbuild.build({ + entryPoints: ['./out/extension-web.mjs'], + entryNames: '[name]', + format: 'cjs', + platform: 'browser', + bundle: true, + outdir: './dist', + external: ['vscode'], + sourcesContent: false, + sourcemap: isDev, + minify: !isDev, + }) + const results = await Promise.all([nodeBuild, webServer, webClient]) + logResult(results) } catch (e) { console.error(e) process.exitCode = 1 } /** - * @param {esbuild.BuildResult} result + * @param {esbuild.BuildResult[]} results */ -function logResult(result) { - if (result.errors.length === 0) { +function logResult(results) { + if (results.every(result => result.errors.length === 0)) { console.info('Built successfully.') } } diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 66e586a9d..5ba0cfe3b 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -3,6 +3,7 @@ "version": "0.1.0-PLACEHOLDER", "type": "commonjs", "main": "dist/extension.js", + "browser": "./dist/extension-web.js", "author": "Spyglass Contributors", "publisher": "SPGoding", "license": "MIT", @@ -21,10 +22,12 @@ ], "files": [ "out/extension.mjs", + "out/extension-web.mjs", "../language-server/lib/server.js" ], "output": [ "dist/extension.js", + "dist/extension-web.js", "dist/server.js" ] }, @@ -35,10 +38,12 @@ ], "files": [ "out/extension.mjs", + "out/extension-web.mjs", "../language-server/lib/server.js" ], "output": [ "dist/extension.js", + "dist/extension-web.js", "dist/server.js" ] } diff --git a/packages/vscode-extension/src/extension-web.mts b/packages/vscode-extension/src/extension-web.mts new file mode 100644 index 000000000..6556457fb --- /dev/null +++ b/packages/vscode-extension/src/extension-web.mts @@ -0,0 +1,141 @@ +import type * as server from '@spyglassmc/language-server' +import { localize } from '@spyglassmc/locales' +import * as vsc from 'vscode' +import * as lc from 'vscode-languageclient/browser.js' + +let client: lc.LanguageClient + +export async function activate(context: vsc.ExtensionContext) { + if ((vsc.workspace.workspaceFolders ?? []).length === 0) { + // Don't start the language server without a workspace folder + return + } + + const serverMain = vsc.Uri.joinPath(context.extensionUri, 'dist/server-web.js') + const serverWorker = new Worker(serverMain.toString(true)) + + const documentSelector: lc.DocumentSelector = [ + { language: 'mcfunction' }, + { language: 'mcdoc' }, + { language: 'snbt' }, + { language: 'mcmeta' }, + { language: 'json', pattern: '**/data/*/*/**/*.json' }, + { language: 'json', pattern: '**/assets/*/*/**/*.json' }, + ] + + const gameVersion = vsc.workspace.getConfiguration('spyglassmc.env').get('gameVersion') + + const initializationOptions: server.CustomInitializationOptions = { + inDevelopmentMode: context.extensionMode === vsc.ExtensionMode.Development, + gameVersion: typeof gameVersion === 'string' ? gameVersion : undefined, + } + + // Options to control the language client + const clientOptions: lc.LanguageClientOptions = { + documentSelector, + initializationOptions, + } + + // Create the language client and start the client. + client = new lc.LanguageClient( + 'spyglassmc', + 'Spyglass Language Server', + clientOptions, + serverWorker, + ) + + await vsc.window.withProgress({ + location: vsc.ProgressLocation.Window, + title: localize('progress.initializing.title'), + }, async (progress) => { + try { + // Start the client. This will also launch the server + await client.start() + } catch (e) { + console.error('[client#start]', e) + } + + const customCapabilities: server.CustomServerCapabilities | undefined = client + .initializeResult?.capabilities.experimental?.spyglassmc + + if (customCapabilities?.dataHackPubify) { + context.subscriptions.push( + vsc.commands.registerCommand('spyglassmc.dataHackPubify', async () => { + try { + const initialism = await vsc.window.showInputBox({ placeHolder: 'DHP' }) + if (!initialism) { + return + } + + const params: server.MyLspDataHackPubifyRequestParams = { initialism } + const response: string = await client.sendRequest( + 'spyglassmc/dataHackPubify', + params, + ) + await vsc.window.showInformationMessage(response) + } catch (e) { + console.error('[client#dataHackPubify]', e) + } + }), + ) + } + + if (customCapabilities?.resetProjectCache) { + context.subscriptions.push( + vsc.commands.registerCommand('spyglassmc.resetProjectCache', async () => { + try { + await vsc.window.withProgress({ + location: vsc.ProgressLocation.Window, + title: localize('progress.reset-project-cache.title'), + }, async () => { + await client.sendRequest('spyglassmc/resetProjectCache') + }) + } catch (e) { + console.error('[client#resetProjectCache]', e) + } + }), + ) + } + + if (customCapabilities?.showCacheRoot) { + context.subscriptions.push( + vsc.commands.registerCommand('spyglassmc.showCacheRoot', async () => { + try { + await client.sendRequest('spyglassmc/showCacheRoot') + } catch (e) { + console.error('[client#showCacheRoot]', e) + } + }), + ) + } + + context.subscriptions.push( + vsc.commands.registerCommand('spyglassmc.showOutput', () => { + try { + client.outputChannel.show() + } catch (e) { + console.error('[client#showOutput]', e) + } + }), + ) + + return new Promise((resolve) => { + client.onProgress(lc.WorkDoneProgress.type, 'initialize', (params) => { + if (params.kind === 'begin') { + progress?.report({ increment: 0, message: params.message }) + } else if (params.kind === 'report') { + progress?.report({ increment: params.percentage, message: params.message }) + } else if (params.kind === 'end') { + resolve() + } + }) + }) + }) +} + +export function deactivate(): Thenable | undefined { + if (!client) { + return undefined + } + return client.stop() +} diff --git a/packages/vscode-extension/tsconfig.json b/packages/vscode-extension/tsconfig.json index 14242d768..4eb01fee0 100644 --- a/packages/vscode-extension/tsconfig.json +++ b/packages/vscode-extension/tsconfig.json @@ -7,7 +7,8 @@ "sourceMap": false }, "files": [ - "src/extension.mts" + "src/extension.mts", + "src/extension-web.mts" ], "references": [ { From 259cc996069fc288d5349dccaa8826b1402fb562 Mon Sep 17 00:00:00 2001 From: Misode Date: Thu, 2 Jan 2025 20:57:46 +0100 Subject: [PATCH 2/3] Switch to picomatch directly to fix browser support --- package-lock.json | 70 ++++++++++++++-------------- packages/core/package.json | 4 +- packages/core/src/service/Project.ts | 4 +- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61698cd45..54b22a591 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1959,12 +1959,6 @@ "integrity": "sha512-EDKtLYNMKrig22jEvhXq8TBFyFgVNSPmDF2b9UzJ7+eylPqdZVo17PCUMkn1jP6/1A/0u78VqYC6VrX6b8pDWA==", "dev": true }, - "node_modules/@types/braces": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.4.tgz", - "integrity": "sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==", - "dev": true - }, "node_modules/@types/decompress": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/decompress/-/decompress-4.2.3.tgz", @@ -2041,15 +2035,6 @@ "integrity": "sha512-wbw+IDRw/xY/RGy+BL6f4Eey4jsUgHQrMuA4Qj0CSG3x/7C2Oc57pmRoM2z3M4DkylWRz+G1pfX06sCXQm0J+w==", "dev": true }, - "node_modules/@types/micromatch": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", - "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", - "dev": true, - "dependencies": { - "@types/braces": "*" - } - }, "node_modules/@types/mocha": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", @@ -2067,6 +2052,12 @@ "integrity": "sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA==", "dev": true }, + "node_modules/@types/picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", + "dev": true + }, "node_modules/@types/snap-shot-core": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@types/snap-shot-core/-/snap-shot-core-10.2.0.tgz", @@ -9200,6 +9191,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -13790,8 +13782,8 @@ "chokidar": "^3.5.2", "decompress": "^4.2.1", "follow-redirects": "^1.14.8", - "micromatch": "^4.0.8", "pako": "^2.0.4", + "picomatch": "^4.0.2", "rfdc": "^1.3.0", "vscode-languageserver-textdocument": "^1.0.4", "whatwg-url": "^14.0.0" @@ -13799,11 +13791,22 @@ "devDependencies": { "@types/decompress": "^4.2.3", "@types/follow-redirects": "^1.14.1", - "@types/micromatch": "^4.0.9", "@types/pako": "^2.0.0", + "@types/picomatch": "^3.0.1", "@types/whatwg-url": "^11.0.4" } }, + "packages/core/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "packages/core/node_modules/tr46": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", @@ -13843,7 +13846,7 @@ "discord.js": "^14.16.3" }, "bin": { - "spyglassmc-discord-bot": "lib/index.js" + "spyglassmc-discord-bot": "bin/bot.js" }, "devDependencies": {}, "engines": { @@ -15308,21 +15311,26 @@ "requires": { "@types/decompress": "^4.2.3", "@types/follow-redirects": "^1.14.1", - "@types/micromatch": "^4.0.9", "@types/pako": "^2.0.0", + "@types/picomatch": "^3.0.1", "@types/whatwg-url": "^11.0.4", "base64-arraybuffer": "^1.0.2", "binary-search": "^1.3.6", "chokidar": "^3.5.2", "decompress": "^4.2.1", "follow-redirects": "^1.14.8", - "micromatch": "^4.0.8", "pako": "^2.0.4", + "picomatch": "^4.0.2", "rfdc": "^1.3.0", "vscode-languageserver-textdocument": "^1.0.4", "whatwg-url": "^14.0.0" }, "dependencies": { + "picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" + }, "tr46": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", @@ -15489,12 +15497,6 @@ "integrity": "sha512-EDKtLYNMKrig22jEvhXq8TBFyFgVNSPmDF2b9UzJ7+eylPqdZVo17PCUMkn1jP6/1A/0u78VqYC6VrX6b8pDWA==", "dev": true }, - "@types/braces": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.4.tgz", - "integrity": "sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==", - "dev": true - }, "@types/decompress": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/decompress/-/decompress-4.2.3.tgz", @@ -15571,15 +15573,6 @@ "integrity": "sha512-wbw+IDRw/xY/RGy+BL6f4Eey4jsUgHQrMuA4Qj0CSG3x/7C2Oc57pmRoM2z3M4DkylWRz+G1pfX06sCXQm0J+w==", "dev": true }, - "@types/micromatch": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", - "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", - "dev": true, - "requires": { - "@types/braces": "*" - } - }, "@types/mocha": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", @@ -15597,6 +15590,12 @@ "integrity": "sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA==", "dev": true }, + "@types/picomatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", + "dev": true + }, "@types/snap-shot-core": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@types/snap-shot-core/-/snap-shot-core-10.2.0.tgz", @@ -20807,6 +20806,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "requires": { "braces": "^3.0.3", "picomatch": "^2.3.1" diff --git a/packages/core/package.json b/packages/core/package.json index cc6c43634..44e7ea8ba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,7 +19,7 @@ "chokidar": "^3.5.2", "decompress": "^4.2.1", "follow-redirects": "^1.14.8", - "micromatch": "^4.0.8", + "picomatch": "^4.0.2", "pako": "^2.0.4", "rfdc": "^1.3.0", "vscode-languageserver-textdocument": "^1.0.4", @@ -28,7 +28,7 @@ "devDependencies": { "@types/decompress": "^4.2.3", "@types/follow-redirects": "^1.14.1", - "@types/micromatch": "^4.0.9", + "@types/picomatch": "^3.0.1", "@types/pako": "^2.0.0", "@types/whatwg-url": "^11.0.4" }, diff --git a/packages/core/src/service/Project.ts b/packages/core/src/service/Project.ts index 4f9680219..a90a0e38c 100644 --- a/packages/core/src/service/Project.ts +++ b/packages/core/src/service/Project.ts @@ -1,4 +1,4 @@ -import * as micromatch from 'micromatch' +import * as picomatch from 'picomatch' import type { TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument' import { TextDocument } from 'vscode-languageserver-textdocument' import type { ExternalEventEmitter, Externals, FsWatcher, IntervalId } from '../common/index.js' @@ -972,7 +972,7 @@ export class Project implements ExternalEventEmitter { return false } for (const rel of fileUtil.getRels(uri, this.projectRoots)) { - if (micromatch.any(rel, this.config.env.exclude)) { + if (picomatch.isMatch(rel, this.config.env.exclude)) { return true } } From ccf03799eebf8ac2b4dd8c24cbf2fda20edc3c20 Mon Sep 17 00:00:00 2001 From: Misode Date: Thu, 2 Jan 2025 21:12:19 +0100 Subject: [PATCH 3/3] Switch browser file system to use IndexedDB --- .../src/common/externals/BrowserExternals.ts | 267 ++++++++++++++---- 1 file changed, 214 insertions(+), 53 deletions(-) diff --git a/packages/core/src/common/externals/BrowserExternals.ts b/packages/core/src/common/externals/BrowserExternals.ts index a79843fcd..9da219722 100644 --- a/packages/core/src/common/externals/BrowserExternals.ts +++ b/packages/core/src/common/externals/BrowserExternals.ts @@ -14,11 +14,11 @@ import type { FsWatcher, } from './index.js' -type Listener = (...args: unknown[]) => unknown +type Listener = (...args: any[]) => any export class BrowserEventEmitter implements ExternalEventEmitter { readonly #listeners = new Map; once: Set }>() - emit(eventName: string, ...args: unknown[]): boolean { + emit(eventName: string, ...args: any[]): boolean { const listeners = this.#listeners.get(eventName) if (!listeners?.all?.size) { return false @@ -71,87 +71,227 @@ class BrowserExternalDownloader implements ExternalDownloader { } } -class BrowserFsWatcher implements FsWatcher { - on(event: string, listener: (...args: any[]) => unknown): this { - if (event === 'ready') { - listener() - } - return this +class BrowserFsWatcher extends BrowserEventEmitter implements FsWatcher { + constructor( + dbPromise: Promise, + private readonly locations: FsLocation[], + ) { + super() + // eslint-disable-next-line @typescript-eslint/no-floating-promises + dbPromise.then((db) => { + const transaction = db.transaction(BrowserFileSystem.storeName, 'readonly') + const store = transaction.objectStore(BrowserFileSystem.storeName) + const request = store.openKeyCursor() + request.onsuccess = () => { + if (request.result) { + const uri = request.result.key.toString() + this.tryEmit('add', uri) + request.result.continue() + } else { + this.emit('ready') + } + } + request.onerror = () => { + this.emit('error', new Error('Watcher error')) + } + }) } - once(event: unknown, listener: (...args: any[]) => unknown): this { - if (event === 'ready') { - listener() + tryEmit(eventName: string, uri: string) { + for (const location of this.locations) { + if (uri.startsWith(location.toString())) { + this.emit(eventName, uri) + break + } } - return this } async close(): Promise {} } -class BrowserFileSystem implements ExternalFileSystem { - private static readonly LocalStorageKey = 'spyglassmc-browser-fs' - private states: Record +export class BrowserFileSystem implements ExternalFileSystem { + public static readonly dbName = 'spyglassmc-browser-fs' + public static readonly dbVersion = 1 + public static readonly storeName = 'files' - constructor() { - this.states = {} // JSON.parse(localStorage.getItem(BrowserFileSystem.LocalStorageKey) ?? '{}') - } + private readonly db: Promise + private watcher: BrowserFsWatcher | undefined - private saveStates() { - // localStorage.setItem(BrowserFileSystem.LocalStorageKey, JSON.stringify(this.states)) + constructor() { + this.db = new Promise((res, rej) => { + const request = indexedDB.open(BrowserFileSystem.dbName, BrowserFileSystem.dbVersion) + request.onerror = (e) => { + console.warn('Database error', (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(BrowserFileSystem.storeName, { keyPath: 'uri' }) + } + }) } async chmod(_location: FsLocation, _mode: number): Promise { return } + async mkdir( location: FsLocation, _options?: { mode?: number | undefined; recursive?: boolean | undefined } | undefined, ): Promise { location = fileUtil.ensureEndingSlash(location.toString()) - if (this.states[location]) { - throw new Error(`EEXIST: ${location}`) - } - this.states[location] = { type: 'directory' } - this.saveStates() + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(BrowserFileSystem.storeName, 'readwrite') + const store = transaction.objectStore(BrowserFileSystem.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: FsLocation) { - // Not implemented - return [] + + async readdir( + location: FsLocation, + ): Promise< + { name: string; isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }[] + > { + location = fileUtil.ensureEndingSlash(location.toString()) + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(BrowserFileSystem.storeName, 'readonly') + const store = transaction.objectStore(BrowserFileSystem.storeName) + const request = store.openCursor(IDBKeyRange.bound(location, location + '\uffff')) + 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: FsLocation): Promise { location = location.toString() - const entry = this.states[location] - if (!entry) { - throw new Error(`ENOENT: ${location}`) - } else if (entry.type === 'directory') { - throw new Error(`EISDIR: ${location}`) - } - return new Uint8Array(arrayBufferFromBase64(entry.content)) + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(BrowserFileSystem.storeName, 'readonly') + const store = transaction.objectStore(BrowserFileSystem.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(_path: FsLocation): Promise { - throw new Error('showFile not supported on browser') + + async showFile(_location: FsLocation): Promise { + throw new Error('showFile not supported on browser.') } + async stat(location: FsLocation): Promise<{ isDirectory(): boolean; isFile(): boolean }> { location = location.toString() - const entry = this.states[location] - if (!entry) { - throw new Error(`ENOENT: ${location}`) - } - return { isDirectory: () => entry.type === 'directory', isFile: () => entry.type === 'file' } + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(BrowserFileSystem.storeName, 'readonly') + const store = transaction.objectStore(BrowserFileSystem.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: FsLocation): Promise { location = location.toString() - const entry = this.states[location] - if (!entry) { - throw new Error(`ENOENT: ${location}`) - } - delete this.states[location] - this.saveStates() + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(BrowserFileSystem.storeName, 'readwrite') + const store = transaction.objectStore(BrowserFileSystem.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 = () => { + this.watcher?.tryEmit('unlink', location) + res() + } + deleteRequest.onerror = () => { + rej() + } + } + } + getRequest.onerror = () => { + rej() + } + }) } - watch(_locations: FsLocation[]): FsWatcher { - return new BrowserFsWatcher() + + watch( + locations: FsLocation[], + _options: { usePolling?: boolean | undefined }, + ): FsWatcher { + this.watcher = new BrowserFsWatcher(this.db, locations) + return this.watcher } + async writeFile( location: FsLocation, data: string | Uint8Array, @@ -161,9 +301,30 @@ class BrowserFileSystem implements ExternalFileSystem { if (typeof data === 'string') { data = new TextEncoder().encode(data) } - data = arrayBufferToBase64(data) - this.states[location] = { type: 'file', content: data } - this.saveStates() + const db = await this.db + return new Promise((res, rej) => { + const transaction = db.transaction(BrowserFileSystem.storeName, 'readwrite') + const store = transaction.objectStore(BrowserFileSystem.storeName) + const getRequest = store.get(location) + getRequest.onsuccess = () => { + const entry = getRequest.result + const putRequest = store.put({ uri: location, type: 'file', content: data }) + putRequest.onsuccess = () => { + if (entry) { + this.watcher?.tryEmit('change', location) + } else { + this.watcher?.tryEmit('add', location) + } + res() + } + putRequest.onerror = () => { + rej() + } + } + getRequest.onerror = () => { + rej() + } + }) } }