From cf4a9dd9fe8a8f01032357fbf22b66e0cb1d9db5 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Mon, 15 Sep 2025 18:07:27 +0200 Subject: [PATCH 01/10] Support for multiple workspaces on 1 papge --- examples/js/files.mjs | 1 - examples/js/ssr-multiple-editors.mjs | 32 +++++ examples/js/workspace.mjs | 22 +++- examples/ssr-multiple-editors.html | 55 ++++++++ package.json | 2 +- scripts/dev.ts | 6 +- src/core.ts | 128 ++++++++++++++++--- src/lsp/css/setup.ts | 3 +- src/lsp/html/setup.ts | 3 +- src/lsp/index.ts | 3 +- src/lsp/json/setup.ts | 3 +- src/lsp/language-service.ts | 24 +++- src/lsp/typescript/setup.ts | 92 ++++++++------ src/multi-workspace.ts | 184 +++++++++++++++++++++++++++ src/render.ts | 1 + src/ssr/ssr.ts | 4 +- src/workspace.ts | 33 ++++- types/workspace.d.ts | 9 +- 18 files changed, 525 insertions(+), 80 deletions(-) create mode 100644 examples/js/ssr-multiple-editors.mjs create mode 100644 examples/ssr-multiple-editors.html create mode 100644 src/multi-workspace.ts diff --git a/examples/js/files.mjs b/examples/js/files.mjs index 3a063f1..13adb4d 100644 --- a/examples/js/files.mjs +++ b/examples/js/files.mjs @@ -24,7 +24,6 @@ export default function App() { ` export const files = { - "src/greeting.ts": "export const message = \"Hello world!\" as const;", "src/App.tsx": appTsx, "style/style.css": [ "h1 {", diff --git a/examples/js/ssr-multiple-editors.mjs b/examples/js/ssr-multiple-editors.mjs new file mode 100644 index 0000000..e0aaa8f --- /dev/null +++ b/examples/js/ssr-multiple-editors.mjs @@ -0,0 +1,32 @@ +import { renderToWebComponent } from "../../dist/ssr/index.mjs"; +import { files } from "./files.mjs"; + +const filename = "src/App.tsx"; + +export default { + async fetch(req) { + const ssrOutput = await renderToWebComponent( + { filename, code: files[filename] }, + { + padding: { top: 8, bottom: 8 }, + userAgent: req.headers.get("user-agent"), + workspace: "test" + } + ); + const ssrOutput2 = await renderToWebComponent( + { filename, code: files[filename] }, + { + padding: { top: 8, bottom: 8 }, + userAgent: req.headers.get("user-agent"), + workspace: "secondary" + } + ); + const html = await Deno.readTextFile(new URL("../ssr-multiple-editors.html", import.meta.url)); + return new Response(html.replace("{SSR}", ssrOutput).replace("{SSR2}", ssrOutput2), { + headers: { + "cache-control": "public, max-age=0, revalidate", + "content-type": "text/html; charset=utf-8", + }, + }); + } +} diff --git a/examples/js/workspace.mjs b/examples/js/workspace.mjs index 132b2a8..57be715 100644 --- a/examples/js/workspace.mjs +++ b/examples/js/workspace.mjs @@ -3,13 +3,29 @@ import { files } from "./files.mjs"; export const workspace = new Workspace({ name: "test", - initialFiles: files, + initialFiles: { + ...files, + "src/greeting.ts": 'export const message = "Hello test!" as const;', + }, entryFile: "index.html", }); export const workspaceWithBrowserHistory = new Workspace({ - name: "test", - initialFiles: files, + name: "browser-history", + initialFiles: { + ...files, + "src/greeting.ts": 'export const message = "Hello browser history!" as const;', + }, entryFile: "index.html", browserHistory: true, }); + +export const secondaryWorkspace = new Workspace({ + name: "secondary", + initialFiles: { + ...files, + "src/greeting.ts": 'export const message = "Hello secondary workspace!" as const;', + "src/App.tsx": files["src/App.tsx"], + }, + entryFile: "index.html", +}); diff --git a/examples/ssr-multiple-editors.html b/examples/ssr-multiple-editors.html new file mode 100644 index 0000000..da13661 --- /dev/null +++ b/examples/ssr-multiple-editors.html @@ -0,0 +1,55 @@ + + + + + + + Modern Monaco + + + + + +
+
+ {SSR} +
+
+ {SSR2} +
+
+ + + + diff --git a/package.json b/package.json index c83920c..1e89d6f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "modern-monaco", "description": "A modern version of Monaco Editor", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/scripts/dev.ts b/scripts/dev.ts index 7843a3a..adb8da9 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -42,7 +42,7 @@ async function servePages(url: URL, req: Request) { const { pathname } = url; let filename = "index.html"; if ( - pathname === "/ssr" || pathname === "/lazy" || pathname === "/manual" || pathname === "/manual-no-workspace" || pathname === "/compare" + pathname === "/ssr" || pathname === "/ssr-multiple-editors" || pathname === "/lazy" || pathname === "/manual" || pathname === "/manual-no-workspace" || pathname === "/compare" ) { filename = pathname.slice(1) + ".html"; } @@ -52,6 +52,10 @@ async function servePages(url: URL, req: Request) { const { default: ssr } = await import("../examples/js/ssr.mjs"); return ssr.fetch(req); } + if (filename === "ssr-multiple-editors.html") { + const { default: ssr } = await import("../examples/js/ssr-multiple-editors.mjs"); + return ssr.fetch(req); + } const headers = new Headers({ "transfer-encoding": "chunked", "cache-control": "public, max-age=0, revalidate", diff --git a/src/core.ts b/src/core.ts index a0c26ff..2b6f81e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,6 +1,8 @@ import type monacoNS from "monaco-editor-core"; import type { Highlighter, RenderOptions, ShikiInitOptions } from "./shiki.ts"; import type { LSPConfig, LSPProvider } from "./lsp/index.ts"; +import { createMultiWorkspaceFileSystem, WorkspaceURI } from "./multi-workspace.ts"; +import { WorkspaceInit, WorkspaceInitMultiple } from "../types/workspace"; // ! external modules, don't remove the `.js` extension import { getExtnameFromLanguageId, getLanguageIdFromPath, grammars, initShiki, setDefaultWasmLoader, themes } from "./shiki.js"; @@ -49,38 +51,95 @@ const errors = { const syntaxes: { name: string; scopeName: string }[] = []; const lspProviders: Record = {}; +const lspRegistrations = new Set(); const { promise: editorWorkerPromise, resolve: onDidEditorWorkerResolve } = promiseWithResolvers(); const attr = (el: HTMLElement, name: string): string | null => el.getAttribute(name); const style = (el: HTMLElement, style: Partial) => Object.assign(el.style, style); -export interface InitOptions extends ShikiInitOptions { +export interface InitOptionsSingleWorkspace extends ShikiInitOptions { /** - * Virtual file system to be used by the editor. - */ - workspace?: Workspace; + * Virtual file system to be used by the editor. + */ + workspace?: Workspace; /** - * Language server protocol configuration. - */ + * Language server protocol configuration. + */ lsp?: LSPConfig; } -/* Initialize and return the monaco editor namespace. */ +export interface InitOptionsMultipleWorkspaces extends ShikiInitOptions { + workspaces?: Workspace[]; + /** + * Language server protocol configuration. + */ + lsp?: LSPConfig; +} + +export type InitOptions = InitOptionsSingleWorkspace | InitOptionsMultipleWorkspaces; + + + +/** Initialize Monaco editor with optional multi-workspace support */ export async function init(options?: InitOptions): Promise { const langs = (options?.langs ?? []).concat(syntaxes as any[]); - const hightlighter = await initShiki({ ...options, langs }); - return loadMonaco(hightlighter, options?.workspace, options?.lsp); + const highlighter = await initShiki({ ...options, langs }); + + // Check if multi-workspace setup is requested + if (options && 'workspaces' in options && options.workspaces) { + return initMultiWorkspace(highlighter, options.workspaces, options.lsp); + } + + // Single workspace setup + const singleOptions = options as InitOptionsSingleWorkspace | undefined; + return loadMonaco(highlighter, singleOptions?.workspace, singleOptions?.lsp); +} + +/** Initialize multi-workspace Monaco setup */ +async function initMultiWorkspace( + highlighter: Highlighter, + workspaces: Workspace[], + lsp?: LSPConfig +): Promise { + const multiWorkspaceFS = createMultiWorkspaceFileSystem(workspaces); + const monaco = await loadMonaco(highlighter, multiWorkspaceFS as any, lsp); + + // Initialize each workspace with the Monaco instance + workspaces.forEach(workspace => workspace.setupMonaco(monaco)); + + return monaco; +} + +/** Get Monaco instance, handling shared instances for multi-workspace */ +async function getMonacoInstance( + options: InitOptions | undefined, + highlighter: Highlighter, + workspace: Workspace | undefined, + sharedPromise: Promise | null +): Promise { + if (options && 'workspaces' in options && options.workspaces) { + return sharedPromise || init(options); + } + return loadMonaco(highlighter, workspace, options?.lsp); } /** Render a mock editor, then load the monaco editor in background. */ export async function lazy(options?: InitOptions) { + // Shared Monaco promise for all editors when using multiple workspaces + let sharedMonacoPromise: Promise | null = null; + if (!customElements.get("monaco-editor")) { - let monacoPromise: Promise | null = null; customElements.define( "monaco-editor", class extends HTMLElement { async connectedCallback() { - const workspace = options?.workspace; + const workspaceName = this.getAttribute("workspace"); + let workspace: Workspace | undefined; + if (options && 'workspaces' in options && options.workspaces) { + workspace = options.workspaces.find((w) => w.name === workspaceName); + } else { + workspace = (options as InitOptionsSingleWorkspace | undefined)?.workspace; + } const renderOptions: RenderOptions = {}; // parse editor/render options from attributes @@ -260,7 +319,11 @@ export async function lazy(options?: InitOptions) { } async function createEditor() { - const monaco = await (monacoPromise ?? (monacoPromise = loadMonaco(highlighter, workspace, options?.lsp))); + // Get Monaco instance (shared for multiple workspaces) + const monaco = await getMonacoInstance(options, highlighter, workspace, sharedMonacoPromise); + if (options && 'workspaces' in options && options.workspaces && !sharedMonacoPromise) { + sharedMonacoPromise = Promise.resolve(monaco); + } const editor = monaco.editor.create(containerEl, renderOptions); if (workspace) { const storeViewState = () => { @@ -269,15 +332,25 @@ export async function lazy(options?: InitOptions) { const state = editor.saveViewState(); if (state) { state.viewState.scrollTop ??= editor.getScrollTop(); - workspace.viewState.save(currentModel.uri.toString(), Object.freeze(state)); + // Strip workspace prefix for viewState storage to use original URI + const uri = currentModel.uri.toString(); + const storageUri = WorkspaceURI.removeWorkspacePrefix(uri, workspace.name); + workspace.viewState.save(storageUri, Object.freeze(state)); } } }; editor.onDidChangeCursorSelection(debunce(storeViewState, 500)); editor.onDidScrollChange(debunce(storeViewState, 500)); workspace.history.onChange((state) => { - if (editor.getModel()?.uri.toString() !== state.current) { - workspace._openTextDocument(state.current, editor); + const currentModel = editor.getModel(); + if (currentModel) { + // Compare using the original URI (strip workspace prefix from current model) + const currentUri = currentModel.uri.toString(); + const originalUri = WorkspaceURI.removeWorkspacePrefix(currentUri, workspace.name); + + if (originalUri !== state.current) { + workspace._openTextDocument(state.current, editor); + } } }); } @@ -307,7 +380,14 @@ export async function lazy(options?: InitOptions) { } } else if ((code && (renderOptions.language || filename))) { // Check if model already exists to prevent duplicate creation - const modelUri = filename ? monaco.Uri.file(filename) : undefined; + let modelUri = filename ? monaco.Uri.file(filename) : undefined; + + // Create workspace-scoped URI for non-default workspaces + if (modelUri && workspace && workspace.name !== 'default') { + const transformedPath = WorkspaceURI.addWorkspacePrefix(modelUri.path, workspace.name); + modelUri = modelUri.with({ path: transformedPath }); + } + let model = modelUri ? monaco.editor.getModel(modelUri) : null; if (!model) { model = monaco.editor.createModel(code, renderOptions.language, modelUri); @@ -355,7 +435,7 @@ export function hydrate(options?: InitOptions) { /** Load monaco editor core. */ async function loadMonaco( highlighter: Highlighter, - workspace?: Workspace, + workspace?: Workspace, lsp?: LSPConfig, ): Promise { const monaco = await import("./editor-core.js"); @@ -411,7 +491,10 @@ async function loadMonaco( openCodeEditor: async (editor, resource, selectionOrPosition) => { if (workspace && resource.scheme === "file") { try { - await workspace._openTextDocument(resource.toString(), editor, selectionOrPosition); + // Strip workspace prefix to get the original file URI + const resourceUri = resource.toString(); + const originalUri = WorkspaceURI.removeWorkspacePrefix(resourceUri, workspace.name); + await workspace._openTextDocument(originalUri, editor, selectionOrPosition); return true; } catch (err) { if (err instanceof ErrorNotFound) { @@ -483,8 +566,13 @@ async function loadMonaco( [lspLabel, lspProvider] = alias; } } - if (lspProvider) { - lspProvider.import().then(({ setup }) => setup(monaco, id, lsp?.[lspLabel], lsp?.formatting, workspace)); + if (lspProvider && workspace) { + // Prevent duplicate language service registration in multi-workspace setups + const registrationKey = `${id}-${workspace.name}`; + if (!lspRegistrations.has(registrationKey)) { + lspRegistrations.add(registrationKey); + lspProvider.import().then(({ setup }) => setup(monaco, id, lsp?.[lspLabel], lsp?.formatting, workspace)); + } } }); }); diff --git a/src/lsp/css/setup.ts b/src/lsp/css/setup.ts index cfc0aa7..3887023 100644 --- a/src/lsp/css/setup.ts +++ b/src/lsp/css/setup.ts @@ -2,6 +2,7 @@ import type monacoNS from "monaco-editor-core"; import type { FormattingOptions } from "vscode-languageserver-types"; import type { Workspace } from "~/workspace.ts"; import type { CreateData, CSSWorker } from "./worker.ts"; +import { WorkspaceInit } from "../../../types/workspace"; // ! external modules, don't remove the `.js` extension import * as ls from "../language-service.js"; @@ -11,7 +12,7 @@ export async function setup( languageId: string, languageSettings?: Record, formattingOptions?: FormattingOptions, - workspace?: Workspace, + workspace?: Workspace, ) { const { tabSize, insertSpaces, insertFinalNewline, trimFinalNewlines } = formattingOptions ?? {}; const createData: CreateData = { diff --git a/src/lsp/html/setup.ts b/src/lsp/html/setup.ts index f02ad3e..4380db8 100644 --- a/src/lsp/html/setup.ts +++ b/src/lsp/html/setup.ts @@ -2,6 +2,7 @@ import type monacoNS from "monaco-editor-core"; import type { FormattingOptions } from "vscode-languageserver-types"; import type { Workspace } from "~/workspace.ts"; import type { CreateData, HTMLWorker } from "./worker.ts"; +import { WorkspaceInit } from "../../../types/workspace"; // ! external modules, don't remove the `.js` extension import * as ls from "../language-service.js"; @@ -11,7 +12,7 @@ export async function setup( languageId: string, languageSettings?: Record, formattingOptions?: FormattingOptions, - workspace?: Workspace, + workspace?: Workspace, ) { const { editor, languages } = monaco; const { tabSize, insertSpaces, insertFinalNewline, trimFinalNewlines } = formattingOptions ?? {}; diff --git a/src/lsp/index.ts b/src/lsp/index.ts index 9dcbb62..81f2f6b 100644 --- a/src/lsp/index.ts +++ b/src/lsp/index.ts @@ -1,6 +1,7 @@ import type monacoNS from "monaco-editor-core"; import type { FormattingOptions } from "vscode-languageserver-types"; import type { Workspace } from "~/workspace.ts"; +import { WorkspaceInit } from "../../types/workspace.js"; export interface LSPModule { setup: ( @@ -8,7 +9,7 @@ export interface LSPModule { languageId: string, langaugeSettings?: Record, formattingOptions?: FormattingOptions, - workspace?: Workspace, + workspace?: Workspace, ) => void | Promise; } diff --git a/src/lsp/json/setup.ts b/src/lsp/json/setup.ts index fe07754..bdb6ba1 100644 --- a/src/lsp/json/setup.ts +++ b/src/lsp/json/setup.ts @@ -4,6 +4,7 @@ import type { Workspace } from "~/workspace.ts"; import type { CreateData, JSONWorker } from "./worker.ts"; import { parseImportMapFromHtml, parseImportMapFromJson } from "@esm.sh/import-map"; import { schemas } from "./schemas.ts"; +import { WorkspaceInit } from "../../../types/workspace.js"; // ! external modules, don't remove the `.js` extension import * as ls from "../language-service.js"; @@ -13,7 +14,7 @@ export async function setup( languageId: string, languageSettings?: Record, formattingOptions?: FormattingOptions, - workspace?: Workspace, + workspace?: Workspace, ) { const { editor, languages } = monaco; const createData: CreateData = { diff --git a/src/lsp/language-service.ts b/src/lsp/language-service.ts index c695533..f757f9b 100644 --- a/src/lsp/language-service.ts +++ b/src/lsp/language-service.ts @@ -1,6 +1,7 @@ import type Monaco from "monaco-editor-core"; import type { Workspace } from "~/workspace.ts"; import * as lst from "vscode-languageserver-types"; +import { WorkspaceInit } from "../../types/workspace"; // ! external modules, don't remove the `.js` extension import { cache } from "../cache.js"; @@ -12,18 +13,31 @@ export function init(monacoNS: typeof Monaco): void { monaco = monacoNS; } +/** Strip workspace prefix from URI to get the actual file path */ +function stripWorkspacePrefix(uri: string): string { + // Match pattern: /workspace/{workspaceName}/actualPath + const workspaceMatch = uri.match(/^\/workspace\/[^\/]+(.*)$/); + return workspaceMatch ? workspaceMatch[1] : uri; +} + /** create a worker host with the given workspace. */ -export function createHost(workspace?: Workspace) { +export function createHost(workspace?: Workspace) { return workspace ? { fs_readDirectory: (uri: string) => { - return workspace.fs.readDirectory(uri); + // Strip workspace prefix to access the actual directory + const actualUri = stripWorkspacePrefix(uri); + return workspace.fs.readDirectory(actualUri); }, fs_stat: (uri: string) => { - return workspace.fs.stat(uri); + // Strip workspace prefix to access the actual file + const actualUri = stripWorkspacePrefix(uri); + return workspace.fs.stat(actualUri); }, fs_getContent: (uri: string): Promise => { - return workspace.fs.readTextFile(uri); + // Strip workspace prefix to access the actual file content + const actualUri = stripWorkspacePrefix(uri); + return workspace.fs.readTextFile(actualUri); }, } : Object.create(null); @@ -76,7 +90,7 @@ export function registerBasicFeatures< languageId: string, worker: Monaco.editor.MonacoWebWorker, completionTriggerCharacters: string[], - workspace?: Workspace, + workspace?: Workspace, ) { const { editor, languages } = monaco; diff --git a/src/lsp/typescript/setup.ts b/src/lsp/typescript/setup.ts index 174d577..947cbd6 100644 --- a/src/lsp/typescript/setup.ts +++ b/src/lsp/typescript/setup.ts @@ -16,32 +16,42 @@ import { import { cache } from "../../cache.js"; import { ErrorNotFound } from "../../workspace.js"; import * as ls from "../language-service.js"; +import { createHost } from "../language-service.js"; +import { WorkspaceInit } from "../../../types/workspace.js"; type TSWorker = monacoNS.editor.MonacoWebWorker; type CompilerOptions = { [key: string]: ts.CompilerOptionsValue }; // javascript and typescript share the same worker -let worker: TSWorker | Promise | null = null; +let workspaceWorkers: Record> = {}; export async function setup( monaco: typeof monacoNS, languageId: string, languageSettings?: Record, formattingOptions?: FormattingOptions & { semicolon?: "ignore" | "insert" | "remove" }, - workspace?: Workspace, + workspace?: Workspace, ) { - if (!worker) { - worker = createWorker(monaco, workspace, languageSettings, formattingOptions); + let usedWorker = workspace?.name ? workspaceWorkers[workspace.name] : workspaceWorkers.default; + + if (!usedWorker) { + if (workspace?.name) { + workspaceWorkers[workspace.name] = createWorker(monaco, workspace, languageSettings, formattingOptions); + usedWorker = workspaceWorkers[workspace.name]; + } else { + workspaceWorkers.default = createWorker(monaco, workspace, languageSettings, formattingOptions); + usedWorker = workspaceWorkers.default; + } } - if (worker instanceof Promise) { - worker = await worker; + if (usedWorker instanceof Promise) { + usedWorker = await usedWorker; } // register language features - ls.registerBasicFeatures(languageId, worker, [".", "/", '"', "'", "<"], workspace); - ls.registerAutoComplete(languageId, worker, [">", "/"]); - ls.registerSignatureHelp(languageId, worker, ["(", ","]); - ls.registerCodeAction(languageId, worker); + ls.registerBasicFeatures(languageId, usedWorker, [".", "/", '"', "'", "<"], workspace); + ls.registerAutoComplete(languageId, usedWorker, [">", "/"]); + ls.registerSignatureHelp(languageId, usedWorker, ["(", ","]); + ls.registerCodeAction(languageId, usedWorker); // unimplemented features // languages.registerOnTypeFormattingEditProvider(languageId, new lfs.FormatOnTypeAdapter(worker)); @@ -52,7 +62,7 @@ export async function setup( /** Create the typescript worker. */ async function createWorker( monaco: typeof monacoNS, - workspace?: Workspace, + workspace?: Workspace, languageSettings?: Record, formattingOptions?: FormattingOptions & { semicolon?: "ignore" | "insert" | "remove" }, ) { @@ -123,31 +133,37 @@ async function createWorker( const worker = monaco.editor.createWebWorker({ worker: getWorker(createData), keepIdleModels: true, - host: { - openModel: async (uri: string): Promise => { - if (!workspace) { - throw new Error("Workspace is undefined."); - } - try { - await workspace._openTextDocument(uri); - } catch (error) { - if (error instanceof ErrorNotFound) { - return false; + host: (() => { + const hostFunctions = createHost(workspace); + + return { + openModel: async (uri: string): Promise => { + if (!workspace) { + throw new Error("Workspace is undefined."); } - throw error; - } - return true; - }, - refreshDiagnostics: async (uri: string) => { - let model = monaco.editor.getModel(uri); - if (model && model.uri.path.includes(".(embedded).")) { - model = monaco.editor.getModel(model.uri.toString(true).split(".(embedded).")[0]); - } - if (model) { - Reflect.get(model, "refreshDiagnostics")?.(); - } - }, - } satisfies Host, + try { + await workspace._openTextDocument(uri); + } catch (error) { + if (error instanceof ErrorNotFound) { + return false; + } + throw error; + } + return true; + }, + refreshDiagnostics: async (uri: string) => { + let model = monaco.editor.getModel(uri); + if (model && model.uri.path.includes(".(embedded).")) { + model = monaco.editor.getModel(model.uri.toString(true).split(".(embedded).")[0]); + } + if (model) { + Reflect.get(model, "refreshDiagnostics")?.(); + } + }, + // Add fs hooks directly + ...hostFunctions, + }; + })(), }); if (fs) { @@ -301,7 +317,7 @@ class TypesSet { } /** load types defined in tsconfig.json */ - async load(compilerOptions: CompilerOptions, workspace?: Workspace): Promise { + async load(compilerOptions: CompilerOptions, workspace?: Workspace): Promise { const types = compilerOptions.types; if (Array.isArray(types)) { delete compilerOptions.types; @@ -347,7 +363,7 @@ class TypesSet { } /** Load compiler options from `tsconfig.json` in the workspace if exists. */ -async function loadCompilerOptions(workspace: Workspace) { +async function loadCompilerOptions(workspace: Workspace) { const compilerOptions: CompilerOptions = {}; try { const tsconfigJson = await workspace.fs.readTextFile("tsconfig.json"); @@ -365,7 +381,7 @@ async function loadCompilerOptions(workspace: Workspace) { } /** Load import maps from the root index.html or external json file in the workspace. */ -export async function loadImportMap(workspace: Workspace, validate: (im: ImportMap) => ImportMap) { +export async function loadImportMap(workspace: Workspace, validate: (im: ImportMap) => ImportMap) { let src: string | undefined; try { let indexHtml = await workspace.fs.readTextFile("index.html"); diff --git a/src/multi-workspace.ts b/src/multi-workspace.ts new file mode 100644 index 0000000..720a7c5 --- /dev/null +++ b/src/multi-workspace.ts @@ -0,0 +1,184 @@ +import type monacoNS from "monaco-editor-core"; +import type { Workspace } from "./workspace.js"; +import type { + FileSystemEntryType, + FileSystemWatchHandle, + WorkspaceInit, + WorkspaceInitMultiple, +} from "../types/workspace"; + +/** Workspace URI utilities */ +export const WorkspaceURI = { + addWorkspacePrefix(uri: string, workspaceName: string): string { + if (!workspaceName || workspaceName === "default") return uri; + return `/workspace/${workspaceName}${uri}`; + }, + + removeWorkspacePrefix(uri: string, workspaceName?: string): string { + if (!workspaceName || workspaceName === "default") return uri; + + const workspacePrefix = `/workspace/${workspaceName}`; + return uri.includes(workspacePrefix) + ? uri.replace(workspacePrefix, "") + : uri; + }, + + hasWorkspacePrefix(uri: string): boolean { + return /^(?:file:\/\/)?\/workspace\/[^\/]+/.test(uri); + }, + + extractWorkspaceName(uri: string): string | null { + const match = uri.match(/^(?:file:\/\/)?\/workspace\/([^\/]+)/); + return match ? match[1] : null; + }, + + transformForWorkspace(uri: string, workspaceName: string): string { + if (!workspaceName || workspaceName === "default") return uri; + + // If already has workspace prefix, return as-is + if (this.hasWorkspacePrefix(uri)) return uri; + + // Add workspace prefix + const isFileProtocol = uri.startsWith("file://"); + const path = isFileProtocol ? uri.replace("file://", "") : uri; + const prefixedPath = this.addWorkspacePrefix(path, workspaceName); + + return isFileProtocol ? `file://${prefixedPath}` : prefixedPath; + }, + + getOriginalURI(uri: string): string { + const workspaceName = this.extractWorkspaceName(uri); + return workspaceName ? this.removeWorkspacePrefix(uri, workspaceName) : uri; + }, +}; + +/** Multi-workspace file system router */ +export class MultiWorkspaceFileSystem { + private workspaceMap: Map>; + private defaultWorkspace: Workspace; + + constructor(workspaces: Workspace[]) { + this.workspaceMap = new Map(workspaces.map((w) => [w.name, w])); + this.defaultWorkspace = workspaces[0]; // Use first workspace for root files + } + + private resolveWorkspaceAndPath( + uri?: string + ): [Workspace, string] { + if (!uri) return [this.defaultWorkspace, "/"]; + + if (WorkspaceURI.hasWorkspacePrefix(uri)) { + const workspaceName = WorkspaceURI.extractWorkspaceName(uri); + if (workspaceName) { + const workspace = this.workspaceMap.get(workspaceName); + if (workspace) { + const originalPath = WorkspaceURI.removeWorkspacePrefix( + uri, + workspaceName + ); + return [workspace, originalPath]; + } + } + } + + // Default to first workspace for root-level files + return [this.defaultWorkspace, uri]; + } + + /** Create delegating file system method to the correct workspace */ + private createFSMethod["fs"]>( + methodName: T + ): Workspace["fs"][T] { + return ((uri: string, ...restArgs: unknown[]) => { + const [workspace, actualPath] = this.resolveWorkspaceAndPath(uri); + const method = workspace.fs[methodName]; + if (typeof method === "function") { + return method.call(workspace.fs, actualPath, ...restArgs); + } + return undefined; + }) as Workspace["fs"][T]; + } + + /** Create virtual workspace for multi-workspace setup */ + createVirtualWorkspace(): Workspace { + const virtualWorkspace: Workspace = { + // Static identifier for debugging/logging - not a real workspace + name: "multi-workspace", + + fs: { + // These methods use createFSMethod() to route calls to the correct real workspace + readTextFile: this.createFSMethod("readTextFile"), + readDirectory: this.createFSMethod("readDirectory"), + stat: this.createFSMethod("stat"), + readFile: this.createFSMethod("readFile"), + writeFile: this.createFSMethod("writeFile"), + createDirectory: this.createFSMethod("createDirectory"), + copy: this.createFSMethod("copy"), + delete: this.createFSMethod("delete"), + rename: this.createFSMethod("rename"), + + // Special handling for methods that need custom logic + walk: (): AsyncIterable<[string, FileSystemEntryType]> => { + const [workspace] = this.resolveWorkspaceAndPath(); + if (workspace.fs.walk) { + return workspace.fs.walk(); + } + // Return empty iterator to satisfy TypeScript language service expectations + return { async *[Symbol.asyncIterator]() {} }; + }, + + watch: (( + uri: string, + options?: { recursive: boolean }, + callback?: FileSystemWatchHandle + ) => { + const [workspace, actualPath] = this.resolveWorkspaceAndPath(uri); + + // Handle both 2 and 3 parameter signatures + if (typeof options === "function" && !callback) { + callback = options; + options = undefined; + } + + if (workspace.fs.watch && typeof callback === "function") { + return options + ? workspace.fs.watch(actualPath, options, callback) + : workspace.fs.watch(actualPath, callback); + } + // Return no-op dispose function if watch unavailable + return () => {}; + }) as Workspace["fs"]["watch"], + }, + + // Routes document opening to the correct workspace + _openTextDocument: async ( + uri: string, + editor?: monacoNS.editor.ICodeEditor, + selectionOrPosition?: monacoNS.IRange | monacoNS.IPosition + ) => { + const [workspace, actualPath] = this.resolveWorkspaceAndPath(uri); + return workspace._openTextDocument( + actualPath, + editor, + selectionOrPosition + ); + }, + + // No-op: Monaco is already initialized once in initMultiWorkspace() for all real workspaces + setupMonaco: () => {}, + // This virtual workspace intentionally only implements the subset + // of properties that Monaco/TypeScript worker actually uses (fs methods, _openTextDocument, setupMonaco). + // It's a proxy/router, not a complete workspace, so missing properties like _monaco, _history, etc. is expected. + } as any; + + return virtualWorkspace; + } +} + +/** Create a virtual workspace that can handle multiple workspaces */ +export function createMultiWorkspaceFileSystem( + workspaces: Workspace[] +): Workspace { + const router = new MultiWorkspaceFileSystem(workspaces); + return router.createVirtualWorkspace(); +} diff --git a/src/render.ts b/src/render.ts index 157710a..94067b4 100644 --- a/src/render.ts +++ b/src/render.ts @@ -15,6 +15,7 @@ export interface RenderOptions extends editor.IStandaloneEditorConstructionOptio fontDigitWidth?: number; userAgent?: string; shiki?: ShikiInitOptions; + workspace?: string; } /** Renders a mock monaco editor. */ diff --git a/src/ssr/ssr.ts b/src/ssr/ssr.ts index 3a6160b..7236cc0 100644 --- a/src/ssr/ssr.ts +++ b/src/ssr/ssr.ts @@ -35,8 +35,10 @@ export async function renderToString(input: RenderInput, options?: RenderOptions /** Render a `` component in HTML string. */ export async function renderToWebComponent(input: RenderInput, options?: RenderOptions): Promise { const prerender = await renderToString(input, options); + const workspaceName = options?.workspace ? ` workspace="${options.workspace}"` : ""; + return ( - "" + "" + '" diff --git a/src/workspace.ts b/src/workspace.ts index a5dd639..e4e2157 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -7,8 +7,10 @@ import type { WorkspaceHistory, WorkspaceHistoryState, WorkspaceInit, + WorkspaceInitMultiple, WorkspaceViewState, } from "../types/workspace.d.ts"; +import { WorkspaceURI } from "./multi-workspace.ts"; // ! external modules, don't remove the `.js` extension import { @@ -26,20 +28,22 @@ import { } from "./util.js"; /** class Workspace implements IWorkspace */ -export class Workspace implements IWorkspace { +export class Workspace implements IWorkspace { private _monaco: { promise: Promise; resolve: (value: typeof monacoNS) => void; reject: (reason: any) => void }; private _history: WorkspaceHistory; private _fs: FileSystem; private _viewState: WorkspaceViewState; private _entryFile?: string; + private _name: string; - constructor(options: WorkspaceInit = {}) { + constructor(options: T = {} as T) { const { name = "default", browserHistory, initialFiles, entryFile, customFS } = options; this._monaco = promiseWithResolvers(); this._fs = customFS ?? new FS("modern-monaco-workspace(" + name + ")"); this._viewState = new WorkspaceStateStorage("modern-monaco-state(" + name + ")"); this._entryFile = entryFile; + this._name = name; if (initialFiles) { for (const [name, data] of Object.entries(initialFiles)) { @@ -84,6 +88,10 @@ export class Workspace implements IWorkspace { return this._history; } + get name() { + return this._name; + } + get viewState() { return this._viewState; } @@ -105,8 +113,25 @@ export class Workspace implements IWorkspace { const href = toURL(uri).href; const content = readonlyContent ?? await fs.readTextFile(href); const viewState = await this.viewState.get(href); - const modelUri = monaco.Uri.parse(href); - const model = monaco.editor.getModel(modelUri) ?? monaco.editor.createModel(content, undefined, modelUri); + + // Create workspace-scoped URIs to ensure model isolation + // For non-default workspaces, prefix the path with /workspace/{name} + let modelUri = monaco.Uri.parse(href); + if (this._name !== 'default') { + const transformedPath = WorkspaceURI.addWorkspacePrefix(modelUri.path, this._name); + modelUri = modelUri.with({ path: transformedPath }); + } + + // Always create a new model with the correct content for this workspace + // Check if model already exists and has different content + let model = monaco.editor.getModel(modelUri); + if (!model) { + // Create new model with correct content + model = monaco.editor.createModel(content, undefined, modelUri); + } else if (model.getValue() !== content) { + // Update existing model with correct content for this workspace + model.setValue(content); + } if (!Reflect.has(model, "__OB__") && typeof readonlyContent !== "string") { const persist = createPersistTask(() => fs.writeFile(href, model.getValue(), { isModelContentChange: true })); const disposable = model.onDidChangeContent(persist); diff --git a/types/workspace.d.ts b/types/workspace.d.ts index 546d4bd..a9f7ea9 100644 --- a/types/workspace.d.ts +++ b/types/workspace.d.ts @@ -14,8 +14,13 @@ export interface WorkspaceInit { customFS?: FileSystem; } -export class Workspace { - constructor(options?: WorkspaceInit); +// When hydrating multiple workspaces, setting the name is required +export interface WorkspaceInitMultiple extends WorkspaceInit { + name: string; +} + +export class Workspace { + constructor(options?: WorkspaceInitType); readonly entryFile?: string; readonly fs: FileSystem; readonly history: WorkspaceHistory; From 8cd221ef87eb29c734167bd74dcd4c38f7202b56 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Thu, 18 Sep 2025 11:40:10 +0200 Subject: [PATCH 02/10] Improved types --- types/ssr.d.ts | 1 + types/workspace.d.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/types/ssr.d.ts b/types/ssr.d.ts index 3eb1751..d331275 100644 --- a/types/ssr.d.ts +++ b/types/ssr.d.ts @@ -7,6 +7,7 @@ export interface RenderOptions extends Omit; diff --git a/types/workspace.d.ts b/types/workspace.d.ts index a9f7ea9..e2040be 100644 --- a/types/workspace.d.ts +++ b/types/workspace.d.ts @@ -19,7 +19,11 @@ export interface WorkspaceInitMultiple extends WorkspaceInit { name: string; } -export class Workspace { +export class Workspace< + WorkspaceInitType extends + | WorkspaceInit + | WorkspaceInitMultiple = WorkspaceInit +> { constructor(options?: WorkspaceInitType); readonly entryFile?: string; readonly fs: FileSystem; From 0b5697f0c176cf11ffb266dbb932d301ee007222 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Thu, 18 Sep 2025 12:31:14 +0200 Subject: [PATCH 03/10] Fixed initoptions type --- types/index.d.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 30ae23e..af4cdc1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,7 +1,7 @@ import type * as monacoNS from "./monaco.d.ts"; import type { LSPConfig } from "./lsp.d.ts"; import type { TextmateGrammarName, TextmateThemeName } from "./textmate.d.ts"; -import { ErrorNotFound, FileSystem, Workspace } from "./workspace"; +import { ErrorNotFound, FileSystem, Workspace, WorkspaceInitMultiple } from "./workspace"; type Awaitable = T | Promise; type MaybeGetter = Awaitable> | (() => Awaitable>); @@ -48,17 +48,28 @@ export interface ShikiInitOptions { tmDownloadCDN?: string; } -export interface InitOptions extends ShikiInitOptions { +export interface InitOptionsSingleWorkspace extends ShikiInitOptions { /** - * Virtual file system to be used by the editor. - */ + * Virtual file system to be used by the editor. + */ workspace?: Workspace; /** - * Language server protocol configuration. - */ + * Language server protocol configuration. + */ + lsp?: LSPConfig; +} + +export interface InitOptionsMultipleWorkspaces extends ShikiInitOptions { + workspaces?: Workspace[]; + /** + * Language server protocol configuration. + */ lsp?: LSPConfig; } +export type InitOptions = InitOptionsSingleWorkspace | InitOptionsMultipleWorkspaces; + + export function init(options?: InitOptions): Promise; export function lazy(options?: InitOptions): Promise; export function hydrate(options?: InitOptions): Promise; From daf5e8ec53ab6afe3860cdaf1812377d1a0ebf0f Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Fri, 19 Sep 2025 16:45:55 +0200 Subject: [PATCH 04/10] Export multi workspace name as variable --- src/multi-workspace.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/multi-workspace.ts b/src/multi-workspace.ts index 720a7c5..a26e4b0 100644 --- a/src/multi-workspace.ts +++ b/src/multi-workspace.ts @@ -7,6 +7,8 @@ import type { WorkspaceInitMultiple, } from "../types/workspace"; +export const MULTI_WORKSPACE_NAME = "__modern-monaco-multi-workspace"; + /** Workspace URI utilities */ export const WorkspaceURI = { addWorkspacePrefix(uri: string, workspaceName: string): string { @@ -103,7 +105,7 @@ export class MultiWorkspaceFileSystem { createVirtualWorkspace(): Workspace { const virtualWorkspace: Workspace = { // Static identifier for debugging/logging - not a real workspace - name: "multi-workspace", + name: MULTI_WORKSPACE_NAME, fs: { // These methods use createFSMethod() to route calls to the correct real workspace From 33e04e52e3375e1dce00220be382525561cf7443 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Thu, 25 Sep 2025 11:50:15 +0200 Subject: [PATCH 05/10] fixed type imports --- src/core.ts | 2 +- src/lsp/client.ts | 2 +- src/lsp/css/setup.ts | 2 +- src/lsp/html/setup.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core.ts b/src/core.ts index 9937de9..dd28ce4 100644 --- a/src/core.ts +++ b/src/core.ts @@ -2,7 +2,7 @@ import type monacoNS from "monaco-editor-core"; import type { Highlighter, RenderOptions, ShikiInitOptions } from "./shiki.ts"; import type { LSPConfig, LSPProvider } from "./lsp/index.ts"; import { createMultiWorkspaceFileSystem, WorkspaceURI } from "./multi-workspace.ts"; -import { WorkspaceInit, WorkspaceInitMultiple } from "../types/workspace"; +import type { WorkspaceInit, WorkspaceInitMultiple } from "../types/workspace"; import { parseImportMapFromJson } from "@esm.sh/import-map"; import { version } from "../package.json"; diff --git a/src/lsp/client.ts b/src/lsp/client.ts index a2167a3..d2e0e5d 100644 --- a/src/lsp/client.ts +++ b/src/lsp/client.ts @@ -1,7 +1,7 @@ import type Monaco from "monaco-editor-core"; import type { Workspace } from "~/workspace.ts"; import * as lst from "vscode-languageserver-types"; -import { WorkspaceInit } from "../../types/workspace"; +import type { WorkspaceInit } from "../../types/workspace"; // ! external modules, don't remove the `.js` extension import { cache } from "../cache.js"; diff --git a/src/lsp/css/setup.ts b/src/lsp/css/setup.ts index a63a305..fd61edf 100644 --- a/src/lsp/css/setup.ts +++ b/src/lsp/css/setup.ts @@ -2,7 +2,7 @@ import type monacoNS from "monaco-editor-core"; import type { FormattingOptions } from "vscode-languageserver-types"; import type { Workspace } from "~/workspace.ts"; import type { CreateData, CSSWorker } from "./worker.ts"; -import { WorkspaceInit } from "../../../types/workspace"; +import type { WorkspaceInit } from "../../../types/workspace"; // ! external modules, don't remove the `.js` extension import { walk } from "../../workspace.js"; diff --git a/src/lsp/html/setup.ts b/src/lsp/html/setup.ts index ca966fb..6c70de4 100644 --- a/src/lsp/html/setup.ts +++ b/src/lsp/html/setup.ts @@ -2,7 +2,7 @@ import type monacoNS from "monaco-editor-core"; import type { FormattingOptions } from "vscode-languageserver-types"; import type { Workspace } from "~/workspace.ts"; import type { CreateData, HTMLWorker } from "./worker.ts"; -import { WorkspaceInit } from "../../../types/workspace"; +import type { WorkspaceInit } from "../../../types/workspace"; // ! external modules, don't remove the `.js` extension import { walk } from "../../workspace.js"; From a3b2acb11ee48d75a0b91122169eba83bb89b383 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Thu, 25 Sep 2025 12:26:48 +0200 Subject: [PATCH 06/10] Removed walk from fs --- examples/ssr-multiple-editors.html | 4 +++- src/lsp/index.ts | 2 +- src/multi-workspace.ts | 10 ---------- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/examples/ssr-multiple-editors.html b/examples/ssr-multiple-editors.html index da13661..0762e6c 100644 --- a/examples/ssr-multiple-editors.html +++ b/examples/ssr-multiple-editors.html @@ -30,7 +30,9 @@ diff --git a/src/lsp/index.ts b/src/lsp/index.ts index 9c03ee6..68dbefc 100644 --- a/src/lsp/index.ts +++ b/src/lsp/index.ts @@ -1,7 +1,7 @@ import type monacoNS from "monaco-editor-core"; import type { FormattingOptions } from "vscode-languageserver-types"; import type { Workspace } from "~/workspace.ts"; -import { WorkspaceInit } from "../../types/workspace.js"; +import type { WorkspaceInit } from "../../types/workspace.js"; export interface LSPModule { setup: ( diff --git a/src/multi-workspace.ts b/src/multi-workspace.ts index a26e4b0..a27d2c4 100644 --- a/src/multi-workspace.ts +++ b/src/multi-workspace.ts @@ -119,16 +119,6 @@ export class MultiWorkspaceFileSystem { delete: this.createFSMethod("delete"), rename: this.createFSMethod("rename"), - // Special handling for methods that need custom logic - walk: (): AsyncIterable<[string, FileSystemEntryType]> => { - const [workspace] = this.resolveWorkspaceAndPath(); - if (workspace.fs.walk) { - return workspace.fs.walk(); - } - // Return empty iterator to satisfy TypeScript language service expectations - return { async *[Symbol.asyncIterator]() {} }; - }, - watch: (( uri: string, options?: { recursive: boolean }, From 47e330183de4a5e3bd8906bdf4509881f670e188 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Fri, 26 Sep 2025 10:56:55 +0200 Subject: [PATCH 07/10] Expose monaco via MonacoEnvironment so the instance can be shared and accessed in all workers. --- examples/js/files.mjs | 1 + src/core.ts | 6 +-- src/lsp/client.ts | 84 ++++++++++++++++++++++-------------------- src/multi-workspace.ts | 1 - 4 files changed, 48 insertions(+), 44 deletions(-) diff --git a/examples/js/files.mjs b/examples/js/files.mjs index 13adb4d..9805553 100644 --- a/examples/js/files.mjs +++ b/examples/js/files.mjs @@ -24,6 +24,7 @@ export default function App() { ` export const files = { + "src/greeting.ts": 'export const message = "Hello world!" as const;', "src/App.tsx": appTsx, "style/style.css": [ "h1 {", diff --git a/src/core.ts b/src/core.ts index dd28ce4..082b9b1 100644 --- a/src/core.ts +++ b/src/core.ts @@ -14,7 +14,6 @@ import { render } from "./shiki.js"; import { getWasmInstance } from "./shiki-wasm.js"; import { ErrorNotFound, Workspace } from "./workspace.js"; import { debunce, decode, isDigital } from "./util.js"; -import { init as initLspClient } from "./lsp/client.js"; const editorProps = [ "autoDetectHighContrast", @@ -456,9 +455,6 @@ async function loadMonaco( // bind the monaco namespace to the workspace&lsp workspace?.setupMonaco(monaco); - if (Object.keys(lspProviderMap).length > 0) { - initLspClient(monaco); - } // apply the monaco CSS if (!document.getElementById("monaco-editor-core-css")) { @@ -479,6 +475,8 @@ async function loadMonaco( }, getLanguageIdFromUri: (uri: monacoNS.Uri) => getLanguageIdFromPath(uri.path), getExtnameFromLanguageId: getExtnameFromLanguageId, + // Make Monaco available to all workers and LSP components + monaco: monaco, }); // prevent to open a http link which is a model diff --git a/src/lsp/client.ts b/src/lsp/client.ts index d2e0e5d..5dc59b7 100644 --- a/src/lsp/client.ts +++ b/src/lsp/client.ts @@ -6,12 +6,11 @@ import type { WorkspaceInit } from "../../types/workspace"; // ! external modules, don't remove the `.js` extension import { cache } from "../cache.js"; -let monaco: typeof Monaco; - -/** init the monaco namespace. */ -export function init(monacoNS: typeof Monaco): void { - monaco = monacoNS; -} +const monacoAPI = { + get monaco() { + return (globalThis as any).MonacoEnvironment?.monaco as typeof Monaco; + } +}; /** Strip workspace prefix from URI to get the actual file path */ function stripWorkspacePrefix(uri: string): string { @@ -92,7 +91,8 @@ export function registerBasicFeatures< completionTriggerCharacters: string[], workspace?: Workspace, ) { - const { editor, languages } = monaco; + const { editor, languages } = monacoAPI.monaco; + // remove document cache from worker when the model is disposed const onDispose = async (model: Monaco.editor.ITextModel) => { @@ -110,8 +110,8 @@ export function registerBasicFeatures< } }); - // enable diagnostics - registerDiagnostics(languageId, worker); + // enable diagnostics + registerDiagnostics(languageId, worker); // register language features languages.registerCompletionItemProvider(languageId, new CompletionAdapter(worker, completionTriggerCharacters)); @@ -131,10 +131,10 @@ export function registerBasicFeatures< // refresh diagnostics of the master model if the worker is embedded const embeddedExtname = getEmbeddedExtname(languageId); - monaco.editor.getModels().forEach((model) => { + monacoAPI.monaco.editor.getModels().forEach((model) => { const uri = model.uri.toString(true); if (uri.endsWith(embeddedExtname)) { - const masterModel = monaco.editor.getModel(uri.slice(0, -embeddedExtname.length)); + const masterModel = monacoAPI.monaco.editor.getModel(uri.slice(0, -embeddedExtname.length)); if (masterModel) { Reflect.get(masterModel, "refreshDiagnostics")?.(); } @@ -162,14 +162,14 @@ function registerDiagnostics( languageId: string, worker: Monaco.editor.MonacoWebWorker, ) { - const { editor } = monaco; + const { editor } = monacoAPI.monaco; const modelChangeListeners = new Map(); const doValidate = async (model: Monaco.editor.ITextModel) => { const workerProxy = await worker.withSyncedResources([model.uri]); const diagnostics = await workerProxy.doValidation(model.uri.toString()); if (diagnostics && !model.isDisposed()) { const markers = diagnostics.map(diagnosticToMarker); - monaco.editor.setModelMarkers(model, languageId, markers); + monacoAPI.monaco.editor.setModelMarkers(model, languageId, markers); } }; const validateModel = (model: Monaco.editor.IModel): void => { @@ -232,14 +232,14 @@ function diagnosticToMarker(diag: lst.Diagnostic): Monaco.editor.IMarkerData { function convertSeverity(lsSeverity: number | undefined): Monaco.MarkerSeverity { switch (lsSeverity) { case lst.DiagnosticSeverity.Error: - return monaco.MarkerSeverity.Error; + return monacoAPI.monaco.MarkerSeverity.Error; case lst.DiagnosticSeverity.Warning: - return monaco.MarkerSeverity.Warning; + return monacoAPI.monaco.MarkerSeverity.Warning; case lst.DiagnosticSeverity.Information: - return monaco.MarkerSeverity.Info; + return monacoAPI.monaco.MarkerSeverity.Info; case lst.DiagnosticSeverity.Hint: default: - return monaco.MarkerSeverity.Hint; + return monacoAPI.monaco.MarkerSeverity.Hint; } } @@ -247,7 +247,7 @@ function convertRelatedInformation(info: lst.DiagnosticRelatedInformation): Mona const { location: { uri, range }, message } = info; const { start, end } = range; return { - resource: monaco.Uri.parse(uri), + resource: monacoAPI.monaco.Uri.parse(uri), startLineNumber: start.line + 1, startColumn: start.character + 1, endLineNumber: end.line + 1, @@ -287,6 +287,7 @@ export class CompletionAdapter impleme return; } const wordInfo = model.getWordUntilPosition(position); + const monaco = monacoAPI.monaco; const wordRange = new monaco.Range( position.lineNumber, wordInfo.startColumn, @@ -322,7 +323,7 @@ export class CompletionAdapter impleme item.additionalTextEdits = entry.additionalTextEdits.map(convertTextEdit); } if (entry.insertTextFormat === lst.InsertTextFormat.Snippet) { - item.insertTextRules = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; + item.insertTextRules = monacoAPI.monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; } return item; }); @@ -363,6 +364,7 @@ export function fromRange(range: Monaco.IRange): lst.Range { } export function convertRange(range: lst.Range): Monaco.Range { + const monaco = monacoAPI.monaco; return new monaco.Range( range.start.line + 1, range.start.character + 1, @@ -378,7 +380,7 @@ function isInsertReplaceEdit(edit: lst.TextEdit | lst.InsertReplaceEdit): edit i } function convertCompletionItemKind(kind: lst.CompletionItemKind | undefined): Monaco.languages.CompletionItemKind { - const CompletionItemKind = monaco.languages.CompletionItemKind; + const CompletionItemKind = monacoAPI.monaco.languages.CompletionItemKind; switch (kind) { case lst.CompletionItemKind.Text: return CompletionItemKind.Text; @@ -518,7 +520,7 @@ export function registerSignatureHelp, triggerCharacters: string[], ) { - monaco.languages.registerSignatureHelpProvider( + monacoAPI.monaco.languages.registerSignatureHelpProvider( languageId, new SignatureHelpAdapter(worker, triggerCharacters), ); @@ -574,7 +576,7 @@ export function registerCodeAction( languageId: string, worker: Monaco.editor.MonacoWebWorker, ) { - monaco.languages.registerCodeActionProvider(languageId, new CodeActionAdaptor(worker)); + monacoAPI.monaco.languages.registerCodeActionProvider(languageId, new CodeActionAdaptor(worker)); } export class CodeActionAdaptor implements Monaco.languages.CodeActionProvider { @@ -637,11 +639,11 @@ function fromMarkerToDiagnostic(marker: Monaco.editor.IMarkerData): lst.Diagnost function fromDiagnosticSeverity(severity: Monaco.MarkerSeverity): lst.DiagnosticSeverity { switch (severity) { - case monaco.MarkerSeverity.Error: + case monacoAPI.monaco.MarkerSeverity.Error: return lst.DiagnosticSeverity.Error; - case monaco.MarkerSeverity.Warning: + case monacoAPI.monaco.MarkerSeverity.Warning: return lst.DiagnosticSeverity.Warning; - case monaco.MarkerSeverity.Hint: + case monacoAPI.monaco.MarkerSeverity.Hint: return lst.DiagnosticSeverity.Hint; default: return lst.DiagnosticSeverity.Information; @@ -681,7 +683,7 @@ function convertWorkspaceEdit(edit: lst.WorkspaceEdit): Monaco.languages.Workspa } let resourceEdits: Monaco.languages.IWorkspaceTextEdit[] = []; for (let uri in edit.changes) { - const resource = monaco.Uri.parse(uri); + const resource = monacoAPI.monaco.Uri.parse(uri); for (let change of edit.changes[uri]) { resourceEdits.push({ resource, @@ -763,7 +765,7 @@ export function registerAutoComplete( worker: Monaco.editor.MonacoWebWorker, triggerCharacters: string[], ) { - const { editor } = monaco; + const { editor } = monacoAPI.monaco; const listeners = new Map(); const validateModel = async (model: Monaco.editor.IModel) => { if (model.getLanguageId() !== langaugeId) { @@ -777,6 +779,7 @@ export function registerAutoComplete( const lastCharacter = lastChange.text[lastChange.text.length - 1]; if (triggerCharacters.includes(lastCharacter)) { const lastRange = lastChange.range; + const monaco = monacoAPI.monaco; const position = new monaco.Position(lastRange.endLineNumber, lastRange.endColumn + lastChange.text.length); const workerProxy = await worker.withSyncedResources([model.uri]); const snippet = await workerProxy.doAutoComplete(modelUri, fromPosition(position), lastCharacter); @@ -869,7 +872,7 @@ function convertDocumentSymbol(symbol: lst.DocumentSymbol): Monaco.languages.Doc } function convertSymbolKind(kind: lst.SymbolKind): Monaco.languages.SymbolKind { - const mKind = monaco.languages.SymbolKind; + const mKind = monacoAPI.monaco.languages.SymbolKind; switch (kind) { case lst.SymbolKind.File: return mKind.File; @@ -947,6 +950,7 @@ function isLocationLink(location: lst.Location | lst.LocationLink): location is } function convertLocationLink(location: lst.Location | lst.LocationLink): Monaco.languages.LocationLink { + const monaco = monacoAPI.monaco; let uri: string; let range: lst.Range; let targetSelectionRange: lst.Range | undefined; @@ -972,6 +976,7 @@ function convertLocationLink(location: lst.Location | lst.LocationLink): Monaco. } async function ensureHttpModels(links: Monaco.languages.LocationLink[]): Promise { + const monaco = monacoAPI.monaco; const { editor, Uri } = monaco; const httpUrls = new Set( links @@ -1031,7 +1036,7 @@ export function registerDocumentLinks, ) { - monaco.languages.registerLinkProvider(langaugeId, new DocumentLinkAdapter(worker)); + monacoAPI.monaco.languages.registerLinkProvider(langaugeId, new DocumentLinkAdapter(worker)); } export class DocumentLinkAdapter implements Monaco.languages.LinkProvider { @@ -1068,7 +1073,7 @@ export function registerColorPresentation, ) { - monaco.languages.registerColorProvider(langaugeId, new DocumentColorAdapter(worker)); + monacoAPI.monaco.languages.registerColorProvider(langaugeId, new DocumentColorAdapter(worker)); } export class DocumentColorAdapter implements Monaco.languages.DocumentColorProvider { @@ -1146,13 +1151,13 @@ export class DocumentHighlightAdapter< function convertDocumentHighlightKind(kind: lst.DocumentHighlightKind | undefined): Monaco.languages.DocumentHighlightKind { switch (kind) { case lst.DocumentHighlightKind.Read: - return monaco.languages.DocumentHighlightKind.Read; + return monacoAPI.monaco.languages.DocumentHighlightKind.Read; case lst.DocumentHighlightKind.Write: - return monaco.languages.DocumentHighlightKind.Write; + return monacoAPI.monaco.languages.DocumentHighlightKind.Write; case lst.DocumentHighlightKind.Text: - return monaco.languages.DocumentHighlightKind.Text; + return monacoAPI.monaco.languages.DocumentHighlightKind.Text; } - return monaco.languages.DocumentHighlightKind.Text; + return monacoAPI.monaco.languages.DocumentHighlightKind.Text; } // #endregion @@ -1193,11 +1198,11 @@ export class FoldingRangeAdapter imp function convertFoldingRangeKind(kind: lst.FoldingRangeKind): Monaco.languages.FoldingRangeKind | undefined { switch (kind) { case lst.FoldingRangeKind.Comment: - return monaco.languages.FoldingRangeKind.Comment; + return monacoAPI.monaco.languages.FoldingRangeKind.Comment; case lst.FoldingRangeKind.Imports: - return monaco.languages.FoldingRangeKind.Imports; + return monacoAPI.monaco.languages.FoldingRangeKind.Imports; case lst.FoldingRangeKind.Region: - return monaco.languages.FoldingRangeKind.Region; + return monacoAPI.monaco.languages.FoldingRangeKind.Region; } return undefined; } @@ -1328,6 +1333,7 @@ function convertInlayHintLabelPart(part: lst.InlayHintLabelPart): Monaco.languag } function convertPosition(position: lst.Position): Monaco.Position { + const monaco = monacoAPI.monaco; return new monaco.Position(position.line + 1, position.character + 1); } @@ -1344,7 +1350,7 @@ export function registerEmbedded( mainWorker: Monaco.editor.MonacoWebWorker, languages: string[], ) { - const { editor, Uri } = monaco; + const { editor, Uri } = monacoAPI.monaco; const listeners = new Map(); const validateModel = async (model: Monaco.editor.IModel) => { if (model.getLanguageId() !== languageId) { @@ -1410,7 +1416,7 @@ export function createWorkerWithEmbeddedLanguages worker[method]?.(embeddedUri.toString(), ...args)); } return null; diff --git a/src/multi-workspace.ts b/src/multi-workspace.ts index a27d2c4..200f01b 100644 --- a/src/multi-workspace.ts +++ b/src/multi-workspace.ts @@ -1,7 +1,6 @@ import type monacoNS from "monaco-editor-core"; import type { Workspace } from "./workspace.js"; import type { - FileSystemEntryType, FileSystemWatchHandle, WorkspaceInit, WorkspaceInitMultiple, From 9777ca1c85b320af133019c1179a92589bed61f0 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Fri, 26 Sep 2025 11:36:14 +0200 Subject: [PATCH 08/10] Ensure lazy init passes in correct highlighter --- src/core.ts | 57 ++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/src/core.ts b/src/core.ts index 082b9b1..2b50ce7 100644 --- a/src/core.ts +++ b/src/core.ts @@ -78,36 +78,35 @@ export interface InitOptionsMultipleWorkspaces extends ShikiInitOptions { lsp?: LSPConfig; } -export type InitOptions = InitOptionsSingleWorkspace | InitOptionsMultipleWorkspaces; - - +export type InitOptions = + | InitOptionsSingleWorkspace + | InitOptionsMultipleWorkspaces; /** Initialize Monaco editor with optional multi-workspace support */ -export async function init(options?: InitOptions): Promise { +export async function init( + options?: InitOptions, + highlighter?: Highlighter +): Promise { const langs = (options?.langs ?? []).concat(syntaxes as any[]); - const highlighter = await initShiki({ ...options, langs }); - - // Check if multi-workspace setup is requested - if (options && 'workspaces' in options && options.workspaces) { - return initMultiWorkspace(highlighter, options.workspaces, options.lsp); + const usedHighlighter = + highlighter ?? (await initShiki({ ...options, langs })); + + const workspaces: Workspace[] = []; + if (options && "workspaces" in options && options.workspaces) { + workspaces.push(...options.workspaces); + } else if (options && "workspace" in options && options.workspace) { + workspaces.push(options.workspace); } - // Single workspace setup - const singleOptions = options as InitOptionsSingleWorkspace | undefined; - return loadMonaco(highlighter, singleOptions?.workspace, singleOptions?.lsp); -} - -/** Initialize multi-workspace Monaco setup */ -async function initMultiWorkspace( - highlighter: Highlighter, - workspaces: Workspace[], - lsp?: LSPConfig -): Promise { const multiWorkspaceFS = createMultiWorkspaceFileSystem(workspaces); - const monaco = await loadMonaco(highlighter, multiWorkspaceFS as any, lsp); + const monaco = await loadMonaco( + usedHighlighter, + multiWorkspaceFS, + options?.lsp + ); // Initialize each workspace with the Monaco instance - workspaces.forEach(workspace => workspace.setupMonaco(monaco)); + workspaces.forEach((workspace) => workspace.setupMonaco(monaco)); return monaco; } @@ -116,13 +115,9 @@ async function initMultiWorkspace( async function getMonacoInstance( options: InitOptions | undefined, highlighter: Highlighter, - workspace: Workspace | undefined, sharedPromise: Promise | null ): Promise { - if (options && 'workspaces' in options && options.workspaces) { - return sharedPromise || init(options); - } - return loadMonaco(highlighter, workspace, options?.lsp); + return sharedPromise || init(options, highlighter); } /** Render a mock editor, then load the monaco editor in background. */ @@ -318,8 +313,12 @@ export async function lazy(options?: InitOptions) { // load and render editor { - const monaco = await getMonacoInstance(options, highlighter, workspace, sharedMonacoPromise); - if (options && 'workspaces' in options && options.workspaces && !sharedMonacoPromise) { + const monaco = await getMonacoInstance( + options, + highlighter, + sharedMonacoPromise + ); + if (!sharedMonacoPromise) { sharedMonacoPromise = Promise.resolve(monaco); } const editor = monaco.editor.create(containerEl, renderOptions); From 8269d62ebd1d05ec4638b24ac0df8ecd307b32e0 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Fri, 26 Sep 2025 11:52:41 +0200 Subject: [PATCH 09/10] Whitespace improvements, removed some redundant comments --- examples/js/files.mjs | 2 +- src/core.ts | 2 -- src/lsp/client.ts | 19 ++++++------------- types/index.d.ts | 12 ++++++------ 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/examples/js/files.mjs b/examples/js/files.mjs index 9805553..3a063f1 100644 --- a/examples/js/files.mjs +++ b/examples/js/files.mjs @@ -24,7 +24,7 @@ export default function App() { ` export const files = { - "src/greeting.ts": 'export const message = "Hello world!" as const;', + "src/greeting.ts": "export const message = \"Hello world!\" as const;", "src/App.tsx": appTsx, "style/style.css": [ "h1 {", diff --git a/src/core.ts b/src/core.ts index 2b50ce7..6d239e8 100644 --- a/src/core.ts +++ b/src/core.ts @@ -329,7 +329,6 @@ export async function lazy(options?: InitOptions) { const state = editor.saveViewState(); if (state) { state.viewState.scrollTop ??= editor.getScrollTop(); - // Strip workspace prefix for viewState storage to use original URI const uri = currentModel.uri.toString(); const storageUri = WorkspaceURI.removeWorkspacePrefix(uri, workspace.name); workspace.viewState.save(storageUri, Object.freeze(state)); @@ -493,7 +492,6 @@ async function loadMonaco( openCodeEditor: async (editor, resource, selectionOrPosition) => { if (workspace && resource.scheme === "file") { try { - // Strip workspace prefix to get the original file URI const resourceUri = resource.toString(); const originalUri = WorkspaceURI.removeWorkspacePrefix(resourceUri, workspace.name); await workspace._openTextDocument(originalUri, editor, selectionOrPosition); diff --git a/src/lsp/client.ts b/src/lsp/client.ts index 5dc59b7..653cef1 100644 --- a/src/lsp/client.ts +++ b/src/lsp/client.ts @@ -24,17 +24,14 @@ export function createHost(workspace?: Workspace) { return workspace ? { fs_readDirectory: (uri: string) => { - // Strip workspace prefix to access the actual directory const actualUri = stripWorkspacePrefix(uri); return workspace.fs.readDirectory(actualUri); }, fs_stat: (uri: string) => { - // Strip workspace prefix to access the actual file const actualUri = stripWorkspacePrefix(uri); return workspace.fs.stat(actualUri); }, fs_getContent: (uri: string): Promise => { - // Strip workspace prefix to access the actual file content const actualUri = stripWorkspacePrefix(uri); return workspace.fs.readTextFile(actualUri); }, @@ -110,8 +107,8 @@ export function registerBasicFeatures< } }); - // enable diagnostics - registerDiagnostics(languageId, worker); + // enable diagnostics + registerDiagnostics(languageId, worker); // register language features languages.registerCompletionItemProvider(languageId, new CompletionAdapter(worker, completionTriggerCharacters)); @@ -364,8 +361,7 @@ export function fromRange(range: Monaco.IRange): lst.Range { } export function convertRange(range: lst.Range): Monaco.Range { - const monaco = monacoAPI.monaco; - return new monaco.Range( + return new monacoAPI.monaco.Range( range.start.line + 1, range.start.character + 1, range.end.line + 1, @@ -950,7 +946,6 @@ function isLocationLink(location: lst.Location | lst.LocationLink): location is } function convertLocationLink(location: lst.Location | lst.LocationLink): Monaco.languages.LocationLink { - const monaco = monacoAPI.monaco; let uri: string; let range: lst.Range; let targetSelectionRange: lst.Range | undefined; @@ -968,7 +963,7 @@ function convertLocationLink(location: lst.Location | lst.LocationLink): Monaco. uri = uri.slice(0, uri.lastIndexOf(".(embedded).")); } return { - uri: monaco.Uri.parse(uri), + uri: monacoAPI.monaco.Uri.parse(uri), range: convertRange(range), targetSelectionRange: targetSelectionRange ? convertRange(targetSelectionRange) : undefined, originSelectionRange: originSelectionRange ? convertRange(originSelectionRange) : undefined, @@ -976,8 +971,7 @@ function convertLocationLink(location: lst.Location | lst.LocationLink): Monaco. } async function ensureHttpModels(links: Monaco.languages.LocationLink[]): Promise { - const monaco = monacoAPI.monaco; - const { editor, Uri } = monaco; + const { editor, Uri } = monacoAPI.monaco; const httpUrls = new Set( links .map(link => link.uri) @@ -1333,8 +1327,7 @@ function convertInlayHintLabelPart(part: lst.InlayHintLabelPart): Monaco.languag } function convertPosition(position: lst.Position): Monaco.Position { - const monaco = monacoAPI.monaco; - return new monaco.Position(position.line + 1, position.character + 1); + return new monacoAPI.monaco.Position(position.line + 1, position.character + 1); } // #endregion diff --git a/types/index.d.ts b/types/index.d.ts index af4cdc1..97eb3aa 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -50,20 +50,20 @@ export interface ShikiInitOptions { export interface InitOptionsSingleWorkspace extends ShikiInitOptions { /** - * Virtual file system to be used by the editor. - */ + * Virtual file system to be used by the editor. + */ workspace?: Workspace; /** - * Language server protocol configuration. - */ + * Language server protocol configuration. + */ lsp?: LSPConfig; } export interface InitOptionsMultipleWorkspaces extends ShikiInitOptions { workspaces?: Workspace[]; /** - * Language server protocol configuration. - */ + * Language server protocol configuration. + */ lsp?: LSPConfig; } From dd05e342f0f426ddbfc7723d4bf37580a98fb876 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Fri, 26 Sep 2025 13:25:07 +0200 Subject: [PATCH 10/10] Fixed type import --- src/lsp/json/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lsp/json/setup.ts b/src/lsp/json/setup.ts index 84661df..9c1984e 100644 --- a/src/lsp/json/setup.ts +++ b/src/lsp/json/setup.ts @@ -4,7 +4,7 @@ import type { Workspace } from "~/workspace.ts"; import type { CreateData, JSONWorker } from "./worker.ts"; import { parseImportMapFromHtml, parseImportMapFromJson } from "@esm.sh/import-map"; import { schemas } from "./schemas.ts"; -import { WorkspaceInit } from "../../../types/workspace.js"; +import type { WorkspaceInit } from "../../../types/workspace.js"; // ! external modules, don't remove the `.js` extension import { walk } from "../../workspace.js";