diff --git a/src/cm/baseExtensions.js b/src/cm/baseExtensions.js index 61b5dbfb3..a64f6f34c 100644 --- a/src/cm/baseExtensions.js +++ b/src/cm/baseExtensions.js @@ -1,4 +1,4 @@ -import { closeBrackets } from "@codemirror/autocomplete"; +import { closeBrackets, completionKeymap } from "@codemirror/autocomplete"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { bracketMatching, @@ -14,6 +14,7 @@ import { drawSelection, dropCursor, highlightActiveLine, + highlightActiveLineGutter, highlightSpecialChars, keymap, rectangularSelection, @@ -25,8 +26,10 @@ import { */ export default function createBaseExtensions() { return [ + highlightActiveLineGutter(), highlightSpecialChars(), history(), + foldGutter(), drawSelection(), dropCursor(), EditorState.allowMultipleSelections.of(true), @@ -38,7 +41,6 @@ export default function createBaseExtensions() { crosshairCursor(), highlightActiveLine(), highlightSelectionMatches(), - foldGutter(), - keymap.of([...defaultKeymap, ...historyKeymap]), + keymap.of([...completionKeymap, ...defaultKeymap, ...historyKeymap]), ]; } diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index 96c96c74c..e90e7a6f5 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -54,6 +54,26 @@ import { undo, } from "@codemirror/commands"; import { indentUnit as indentUnitFacet } from "@codemirror/language"; +import { + closeLintPanel, + nextDiagnostic, + openLintPanel, + previousDiagnostic, +} from "@codemirror/lint"; +import { + LSPPlugin, + closeReferencePanel as lspCloseReferencePanel, + findReferences as lspFindReferences, + formatDocument as lspFormatDocument, + jumpToDeclaration as lspJumpToDeclaration, + jumpToDefinition as lspJumpToDefinition, + jumpToImplementation as lspJumpToImplementation, + jumpToTypeDefinition as lspJumpToTypeDefinition, + nextSignature as lspNextSignature, + prevSignature as lspPrevSignature, + renameSymbol as lspRenameSymbol, + showSignatureHelp as lspShowSignatureHelp, +} from "@codemirror/lsp-client"; import { Compartment, EditorSelection } from "@codemirror/state"; import { keymap } from "@codemirror/view"; import prompt from "dialogs/prompt"; @@ -132,6 +152,8 @@ const CODEMIRROR_COMMAND_MAP = new Map( ); registerCoreCommands(); +registerLspCommands(); +registerLintCommands(); registerCommandsFromKeyBindings(); rebuildKeymap(); @@ -834,6 +856,137 @@ function registerCoreCommands() { }); } +function registerLspCommands() { + addCommand({ + name: "formatDocument", + description: "Format document (Language Server)", + readOnly: false, + requiresView: true, + run: runLspCommand(lspFormatDocument), + }); + addCommand({ + name: "renameSymbol", + description: "Rename symbol (Language Server)", + readOnly: false, + requiresView: true, + run: runLspCommand(lspRenameSymbol), + }); + addCommand({ + name: "showSignatureHelp", + description: "Show signature help", + readOnly: true, + requiresView: true, + run: runLspCommand(lspShowSignatureHelp), + }); + addCommand({ + name: "nextSignature", + description: "Next signature", + readOnly: true, + requiresView: true, + run: runLspCommand(lspNextSignature, { silentOnMissing: true }), + }); + addCommand({ + name: "prevSignature", + description: "Previous signature", + readOnly: true, + requiresView: true, + run: runLspCommand(lspPrevSignature, { silentOnMissing: true }), + }); + addCommand({ + name: "jumpToDefinition", + description: "Go to definition (Language Server)", + readOnly: true, + requiresView: true, + run: runLspCommand(lspJumpToDefinition), + }); + addCommand({ + name: "jumpToDeclaration", + description: "Go to declaration (Language Server)", + readOnly: true, + requiresView: true, + run: runLspCommand(lspJumpToDeclaration), + }); + addCommand({ + name: "jumpToTypeDefinition", + description: "Go to type definition (Language Server)", + readOnly: true, + requiresView: true, + run: runLspCommand(lspJumpToTypeDefinition), + }); + addCommand({ + name: "jumpToImplementation", + description: "Go to implementation (Language Server)", + readOnly: true, + requiresView: true, + run: runLspCommand(lspJumpToImplementation), + }); + addCommand({ + name: "findReferences", + description: "Find references (Language Server)", + readOnly: true, + requiresView: true, + run: runLspCommand(lspFindReferences), + }); + addCommand({ + name: "closeReferencePanel", + description: "Close references panel", + readOnly: true, + requiresView: true, + run(view) { + const resolvedView = resolveView(view); + if (!resolvedView) return false; + return lspCloseReferencePanel(resolvedView); + }, + }); +} + +function registerLintCommands() { + addCommand({ + name: "openLintPanel", + description: "Open lint panel", + readOnly: true, + requiresView: true, + run(view) { + const resolvedView = resolveView(view); + if (!resolvedView) return false; + return openLintPanel(resolvedView); + }, + }); + addCommand({ + name: "closeLintPanel", + description: "Close lint panel", + readOnly: true, + requiresView: true, + run(view) { + const resolvedView = resolveView(view); + if (!resolvedView) return false; + return closeLintPanel(resolvedView); + }, + }); + addCommand({ + name: "nextDiagnostic", + description: "Go to next diagnostic", + readOnly: true, + requiresView: true, + run(view) { + const resolvedView = resolveView(view); + if (!resolvedView) return false; + return nextDiagnostic(resolvedView); + }, + }); + addCommand({ + name: "previousDiagnostic", + description: "Go to previous diagnostic", + readOnly: true, + requiresView: true, + run(view) { + const resolvedView = resolveView(view); + if (!resolvedView) return false; + return previousDiagnostic(resolvedView); + }, + }); +} + function registerCommandsFromKeyBindings() { Object.entries(keyBindings).forEach(([name, binding]) => { if (commandMap.has(name)) return; @@ -894,6 +1047,26 @@ function resolveView(view) { return view || editorManager?.editor || null; } +function notifyLspUnavailable() { + toast?.("Language server not available"); +} + +function runLspCommand(commandFn, options = {}) { + return (view) => { + const resolvedView = resolveView(view); + if (!resolvedView) return false; + const plugin = LSPPlugin.get(resolvedView); + if (!plugin) { + if (!options?.silentOnMissing) { + notifyLspUnavailable(); + } + return false; + } + const result = commandFn(resolvedView); + return result !== false; + }; +} + function humanizeCommandName(name) { return name .replace(/([a-z0-9])([A-Z])/g, "$1 $2") diff --git a/src/cm/lsp/clientManager.js b/src/cm/lsp/clientManager.js new file mode 100644 index 000000000..f09a11e3b --- /dev/null +++ b/src/cm/lsp/clientManager.js @@ -0,0 +1,606 @@ +import { getIndentUnit, indentUnit } from "@codemirror/language"; +import { + findReferencesKeymap, + formatKeymap, + hoverTooltips, + jumpToDefinitionKeymap, + LSPClient, + LSPPlugin, + renameKeymap, + serverCompletion, + serverDiagnostics, + signatureHelp, +} from "@codemirror/lsp-client"; +import { MapMode } from "@codemirror/state"; +import { keymap } from "@codemirror/view"; +import Uri from "utils/Uri"; +import { ensureServerRunning } from "./serverLauncher"; +import serverRegistry from "./serverRegistry"; +import { createTransport } from "./transport"; +import AcodeWorkspace from "./workspace"; + +function asArray(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function pluginKey(serverId, rootUri) { + return `${serverId}::${rootUri || "__global__"}`; +} + +function safeString(value) { + return value != null ? String(value) : ""; +} + +const defaultKeymaps = keymap.of([ + ...formatKeymap, + ...renameKeymap, + ...jumpToDefinitionKeymap, + ...findReferencesKeymap, +]); + +function buildBuiltinExtensions({ + includeHover = true, + includeCompletion = true, + includeSignature = true, + includeKeymaps = true, + includeDiagnostics = true, +} = {}) { + const extensions = []; + let diagnosticsExtension = null; + + if (includeCompletion) extensions.push(serverCompletion()); + if (includeHover) extensions.push(hoverTooltips()); + if (includeKeymaps) extensions.push(defaultKeymaps); + if (includeSignature) extensions.push(signatureHelp()); + if (includeDiagnostics) { + diagnosticsExtension = serverDiagnostics(); + extensions.push(diagnosticsExtension); + } + + return { extensions, diagnosticsExtension }; +} + +export class LspClientManager { + constructor(options = {}) { + this.options = { ...options }; + this.#clients = new Map(); + } + + #clients; + + setOptions(next) { + this.options = { ...this.options, ...next }; + } + + getActiveClients() { + return Array.from(this.#clients.values()); + } + + async getExtensionsForFile(metadata) { + const { uri, languageId, languageName, view, file, rootUri } = metadata; + + const effectiveLang = safeString(languageId || languageName).toLowerCase(); + if (!effectiveLang) return []; + + const servers = serverRegistry.getServersForLanguage(effectiveLang); + if (!servers.length) return []; + + const lspExtensions = []; + const diagnosticsUiExtension = this.options.diagnosticsUiExtension; + + for (const server of servers) { + let targetLanguageId = effectiveLang; + if (server.resolveLanguageId) { + try { + const resolved = server.resolveLanguageId({ + languageId: effectiveLang, + languageName, + uri, + file, + }); + if (resolved) targetLanguageId = safeString(resolved); + } catch (error) { + console.warn( + `LSP server ${server.id} failed to resolve language id for ${uri}`, + error, + ); + } + } + + try { + const clientState = await this.#ensureClient(server, { + uri, + file, + view, + languageId: targetLanguageId, + rootUri, + }); + const plugin = clientState.client.plugin(uri, targetLanguageId); + clientState.attach(uri, view); + lspExtensions.push(plugin); + } catch (error) { + if (error?.code === "LSP_SERVER_UNAVAILABLE") { + console.info( + `Skipping LSP client for ${server.id}: ${error.message}`, + ); + continue; + } + console.error( + `Failed to initialize LSP client for ${server.id}`, + error, + ); + } + } + + if (diagnosticsUiExtension && lspExtensions.length) { + lspExtensions.push(...asArray(diagnosticsUiExtension)); + } + + return lspExtensions; + } + + async formatDocument(metadata, options = {}) { + const { uri, languageId, languageName, view, file } = metadata; + const effectiveLang = safeString(languageId || languageName).toLowerCase(); + if (!effectiveLang || !view) return false; + const servers = serverRegistry.getServersForLanguage(effectiveLang); + if (!servers.length) return false; + + for (const server of servers) { + try { + const context = { + uri, + languageId: effectiveLang, + languageName, + view, + file, + rootUri: metadata.rootUri, + }; + const state = await this.#ensureClient(server, context); + const capabilities = state.client.serverCapabilities; + if (!capabilities?.documentFormattingProvider) continue; + state.attach(uri, view); + const plugin = LSPPlugin.get(view); + if (!plugin) continue; + plugin.client.sync(); + const edits = await state.client.request("textDocument/formatting", { + textDocument: { uri }, + options: buildFormattingOptions(view, options), + }); + if (!edits || !edits.length) { + plugin.client.sync(); + return true; + } + const applied = applyTextEdits(plugin, view, edits); + if (applied) { + plugin.client.sync(); + return true; + } + } catch (error) { + console.error(`LSP formatting failed for ${server.id}`, error); + } + } + return false; + } + + detach(uri, view) { + for (const state of this.#clients.values()) { + state.detach(uri, view); + } + } + + async dispose() { + const disposeOps = []; + for (const [key, state] of this.#clients.entries()) { + disposeOps.push(state.dispose?.()); + this.#clients.delete(key); + } + await Promise.allSettled(disposeOps); + } + + async #ensureClient(server, context) { + const resolvedRoot = await this.#resolveRootUri(server, context); + const { normalizedRootUri, originalRootUri } = normalizeRootUriForServer( + server, + resolvedRoot, + ); + const key = pluginKey(server.id, normalizedRootUri); + if (this.#clients.has(key)) { + return this.#clients.get(key); + } + + const workspaceOptions = { + displayFile: this.options.displayFile, + }; + + const clientConfig = { ...(server.clientConfig || {}) }; + const builtinConfig = clientConfig.builtinExtensions || {}; + const useDefaultExtensions = clientConfig.useDefaultExtensions !== false; + const { extensions: defaultExtensions, diagnosticsExtension } = + useDefaultExtensions + ? buildBuiltinExtensions({ + includeHover: builtinConfig.hover !== false, + includeCompletion: builtinConfig.completion !== false, + includeSignature: builtinConfig.signature !== false, + includeKeymaps: builtinConfig.keymaps !== false, + includeDiagnostics: builtinConfig.diagnostics !== false, + }) + : { extensions: [], diagnosticsExtension: null }; + + const extraExtensions = asArray(this.options.clientExtensions); + const serverExtensions = asArray(clientConfig.extensions); + const wantsCustomDiagnostics = [ + ...extraExtensions, + ...serverExtensions, + ].some( + (ext) => !!ext?.clientCapabilities?.textDocument?.publishDiagnostics, + ); + + const filteredBuiltins = + wantsCustomDiagnostics && diagnosticsExtension + ? defaultExtensions.filter((ext) => ext !== diagnosticsExtension) + : defaultExtensions; + + const mergedExtensions = [ + ...filteredBuiltins, + ...extraExtensions, + ...serverExtensions, + ]; + clientConfig.extensions = mergedExtensions; + + const existingHandlers = clientConfig.notificationHandlers || {}; + clientConfig.notificationHandlers = { + ...existingHandlers, + "window/logMessage": (_client, params) => { + if (!params?.message) return false; + const { type, message } = params; + let level = "info"; + switch (type) { + case 1: + level = "error"; + break; + case 2: + level = "warn"; + break; + case 4: + level = "log"; + break; + default: + level = "info"; + } + (console[level] || console.info)(`[LSP:${server.id}] ${message}`); + return true; + }, + "window/showMessage": (_client, params) => { + if (!params?.message) return false; + console.info(`[LSP:${server.id}] ${params.message}`); + return true; + }, + }; + + if (!clientConfig.workspace) { + clientConfig.workspace = (client) => + new AcodeWorkspace(client, workspaceOptions); + } + + if (normalizedRootUri && !clientConfig.rootUri) { + clientConfig.rootUri = normalizedRootUri; + } + + if (!normalizedRootUri && clientConfig.rootUri) { + delete clientConfig.rootUri; + } + + if (server.startupTimeout && !clientConfig.timeout) { + clientConfig.timeout = server.startupTimeout; + } + + let transportHandle; + let client; + + try { + await ensureServerRunning(server); + transportHandle = createTransport(server, { + ...context, + rootUri: normalizedRootUri ?? null, + originalRootUri, + }); + await transportHandle.ready; + client = new LSPClient(clientConfig); + client.connect(transportHandle.transport); + await client.initializing; + if (!client.__acodeLoggedInfo) { + const info = client.serverInfo; + if (info) { + console.info(`[LSP:${server.id}] server info`, info); + } + if (normalizedRootUri) { + if (originalRootUri && originalRootUri !== normalizedRootUri) { + console.info( + `[LSP:${server.id}] root ${normalizedRootUri} (from ${originalRootUri})`, + ); + } else { + console.info(`[LSP:${server.id}] root`, normalizedRootUri); + } + } else if (originalRootUri) { + console.info(`[LSP:${server.id}] root ignored`, originalRootUri); + } + client.__acodeLoggedInfo = true; + } + } catch (error) { + transportHandle?.dispose?.(); + throw error; + } + + const state = this.#createClientState({ + key, + server, + client, + transportHandle, + normalizedRootUri, + originalRootUri, + }); + + this.#clients.set(key, state); + return state; + } + + #createClientState({ + key, + server, + client, + transportHandle, + normalizedRootUri, + originalRootUri, + }) { + const fileRefs = new Map(); + const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; + + const attach = (uri, view) => { + const existing = fileRefs.get(uri) || new Set(); + existing.add(view); + fileRefs.set(uri, existing); + const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; + console.info(`[LSP:${server.id}] attached to ${uri}${suffix}`); + }; + + const detach = (uri, view) => { + const existing = fileRefs.get(uri); + if (!existing) return; + if (view) existing.delete(view); + if (!view || !existing.size) { + fileRefs.delete(uri); + try { + client.workspace?.closeFile?.(uri, view); + } catch (error) { + console.warn(`Failed to close LSP file ${uri}`, error); + } + } + + if (!fileRefs.size) { + this.options.onClientIdle?.({ + server, + client, + rootUri: effectiveRoot, + }); + } + }; + + const dispose = async () => { + try { + client.disconnect(); + } catch (error) { + console.warn(`Error disconnecting LSP client ${server.id}`, error); + } + try { + await transportHandle.dispose?.(); + } catch (error) { + console.warn(`Error disposing LSP transport ${server.id}`, error); + } + this.#clients.delete(key); + }; + + return { + server, + client, + transport: transportHandle, + rootUri: effectiveRoot, + attach, + detach, + dispose, + }; + } + + async #resolveRootUri(server, context) { + if (context?.rootUri) return context.rootUri; + + if (typeof server.rootUri === "function") { + try { + const value = await server.rootUri(context?.uri, context); + if (value) return safeString(value); + } catch (error) { + console.warn(`Server root resolver failed for ${server.id}`, error); + } + } + + if (typeof this.options.resolveRoot === "function") { + try { + const value = await this.options.resolveRoot(context); + if (value) return safeString(value); + } catch (error) { + console.warn("Global LSP root resolver failed", error); + } + } + + return null; + } +} + +function applyTextEdits(plugin, view, edits) { + const changes = []; + for (const edit of edits) { + if (!edit?.range) continue; + let fromBase; + let toBase; + try { + fromBase = plugin.fromPosition(edit.range.start, plugin.syncedDoc); + toBase = plugin.fromPosition(edit.range.end, plugin.syncedDoc); + } catch (_) { + continue; + } + const from = plugin.unsyncedChanges.mapPos(fromBase, 1, MapMode.TrackDel); + const to = plugin.unsyncedChanges.mapPos(toBase, -1, MapMode.TrackDel); + if (from == null || to == null) continue; + const insert = + typeof edit.newText === "string" + ? edit.newText.replace(/\r\n/g, "\n") + : ""; + changes.push({ from, to, insert }); + } + if (!changes.length) return false; + changes.sort((a, b) => a.from - b.from || a.to - b.to); + view.dispatch({ changes }); + return true; +} + +function buildFormattingOptions(view, overrides = {}) { + const state = view?.state; + if (!state) return { ...overrides }; + + const unitValue = state.facet(indentUnit); + const unit = + typeof unitValue === "string" && unitValue.length + ? unitValue + : String(unitValue || "\t"); + let tabSize = getIndentUnit(state); + if ( + typeof tabSize !== "number" || + !Number.isFinite(tabSize) || + tabSize <= 0 + ) { + tabSize = resolveIndentWidth(unit); + } + const insertSpaces = !unit.includes("\t"); + + return { + tabSize, + insertSpaces, + ...overrides, + }; +} + +function resolveIndentWidth(unit) { + if (typeof unit !== "string" || !unit.length) return 4; + let width = 0; + for (const ch of unit) { + if (ch === "\t") return 4; + width += 1; + } + return width || 4; +} + +const defaultManager = new LspClientManager(); + +export default defaultManager; + +const FILE_SCHEME_REQUIRED_SERVERS = new Set(["typescript"]); + +function normalizeRootUriForServer(server, rootUri) { + if (!rootUri || typeof rootUri !== "string") { + return { normalizedRootUri: null, originalRootUri: null }; + } + const schemeMatch = /^([a-zA-Z][\w+\-.]*):/.exec(rootUri); + const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null; + if (scheme === "file") { + return { normalizedRootUri: rootUri, originalRootUri: rootUri }; + } + + if (scheme === "content") { + const fileUri = contentUriToFileUri(rootUri); + if (fileUri) { + return { normalizedRootUri: fileUri, originalRootUri: rootUri }; + } + if (FILE_SCHEME_REQUIRED_SERVERS.has(server.id)) { + return { normalizedRootUri: null, originalRootUri: rootUri }; + } + } + + return { normalizedRootUri: rootUri, originalRootUri: rootUri }; +} + +function contentUriToFileUri(uri) { + try { + const parsed = Uri.parse(uri); + if (!parsed || typeof parsed !== "object") return null; + const { docId, rootUri, isFileUri } = parsed; + if (!docId) return null; + + if (isFileUri && rootUri) { + return rootUri; + } + + const providerMatch = + /^content:\/\/com\.((?![:<>"/\\|?*]).*)\.documents\//.exec(rootUri); + const providerId = providerMatch ? providerMatch[1] : null; + + let normalized = docId.trim(); + if (!normalized) return null; + + switch (providerId) { + case "foxdebug.acode": + normalized = normalized.replace(/:+$/, ""); + if (!normalized) return null; + if (normalized.startsWith("raw:/")) { + normalized = normalized.slice(4); + } else if (normalized.startsWith("raw:")) { + normalized = normalized.slice(4); + } + if (!normalized.startsWith("/")) return null; + return buildFileUri(normalized); + case "android.externalstorage": + normalized = normalized.replace(/:+$/, ""); + if (!normalized) return null; + + if (normalized.startsWith("/")) { + return buildFileUri(normalized); + } + + if (normalized.startsWith("raw:/")) { + return buildFileUri(normalized.slice(4)); + } + + if (normalized.startsWith("raw:")) { + return buildFileUri(normalized.slice(4)); + } + + const separator = normalized.indexOf(":"); + if (separator === -1) return null; + + const root = normalized.slice(0, separator); + const remainder = normalized.slice(separator + 1); + if (!remainder) return null; + + switch (root) { + case "primary": + return buildFileUri(`/storage/emulated/0/${remainder}`); + default: + if (/^[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$/.test(root)) { + return buildFileUri(`/storage/${root}/${remainder}`); + } + } + return null; + default: + return null; + } + } catch (_) { + return null; + } +} + +function buildFileUri(pathname) { + if (!pathname) return null; + const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; + const encoded = encodeURI(normalized).replace(/#/g, "%23"); + return `file://${encoded}`; +} diff --git a/src/cm/lsp/diagnostics.js b/src/cm/lsp/diagnostics.js new file mode 100644 index 000000000..0076eb931 --- /dev/null +++ b/src/cm/lsp/diagnostics.js @@ -0,0 +1,195 @@ +import { forceLinting, linter, lintGutter } from "@codemirror/lint"; +import { LSPPlugin } from "@codemirror/lsp-client"; +import { MapMode, StateEffect, StateField } from "@codemirror/state"; + +const setPublishedDiagnostics = StateEffect.define(); + +export const LSP_DIAGNOSTICS_EVENT = "acode:lsp-diagnostics-updated"; + +function emitDiagnosticsUpdated() { + if ( + typeof document === "undefined" || + typeof document.dispatchEvent !== "function" + ) { + return; + } + + let event; + try { + event = new CustomEvent(LSP_DIAGNOSTICS_EVENT); + } catch (_) { + try { + event = document.createEvent("CustomEvent"); + event.initCustomEvent(LSP_DIAGNOSTICS_EVENT, false, false, undefined); + } catch (_) { + return; + } + } + + document.dispatchEvent(event); +} + +const lspPublishedDiagnostics = StateField.define({ + create() { + return []; + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setPublishedDiagnostics)) { + value = effect.value; + } + } + return value; + }, +}); + +const severities = ["hint", "error", "warning", "info", "hint"]; + +function storeLspDiagnostics(plugin, diagnostics) { + const items = []; + const { syncedDoc } = plugin; + + for (const diagnostic of diagnostics) { + let from; + let to; + try { + const mappedFrom = plugin.fromPosition( + diagnostic.range.start, + plugin.syncedDoc, + ); + const mappedTo = plugin.fromPosition( + diagnostic.range.end, + plugin.syncedDoc, + ); + from = plugin.unsyncedChanges.mapPos(mappedFrom); + to = plugin.unsyncedChanges.mapPos(mappedTo); + } catch (_) { + continue; + } + if (to > syncedDoc.length) continue; + + const severity = severities[diagnostic.severity ?? 0] || "info"; + const source = diagnostic.code + ? `${diagnostic.source ? `${diagnostic.source}-` : ""}${diagnostic.code}` + : undefined; + + items.push({ + from, + to, + severity, + message: diagnostic.message, + source, + }); + } + + return setPublishedDiagnostics.of(items); +} + +function mapDiagnostics(plugin, state) { + plugin.client.sync(); + const stored = state.field(lspPublishedDiagnostics); + const changes = plugin.unsyncedChanges; + const mapped = []; + + for (const diagnostic of stored) { + let from; + let to; + try { + from = changes.mapPos(diagnostic.from, 1, MapMode.TrackDel); + to = changes.mapPos(diagnostic.to, -1, MapMode.TrackDel); + } catch (_) { + continue; + } + if (from != null && to != null) { + mapped.push({ ...diagnostic, from, to }); + } + } + + return mapped; +} + +function lspLinterSource(view) { + const plugin = LSPPlugin.get(view); + if (!plugin) return []; + return mapDiagnostics(plugin, view.state); +} + +export function lspDiagnosticsClientExtension() { + return { + clientCapabilities: { + textDocument: { + publishDiagnostics: { + relatedInformation: true, + codeDescriptionSupport: true, + dataSupport: true, + versionSupport: true, + }, + }, + }, + notificationHandlers: { + "textDocument/publishDiagnostics": (client, params) => { + const file = client.workspace.getFile(params.uri); + if ( + !file || + (params.version != null && params.version !== file.version) + ) { + return false; + } + const view = file.getView(); + if (!view) return false; + const plugin = LSPPlugin.get(view); + if (!plugin) return false; + + view.dispatch({ + effects: storeLspDiagnostics(plugin, params.diagnostics), + }); + forceLinting(view); + emitDiagnosticsUpdated(); + return true; + }, + }, + }; +} + +export function lspDiagnosticsUiExtension(includeGutter = true) { + const extensions = [ + lspPublishedDiagnostics, + linter(lspLinterSource, { + needsRefresh(update) { + return update.transactions.some((tr) => + tr.effects.some((effect) => effect.is(setPublishedDiagnostics)), + ); + }, + // keep panel closed by default + autoPanel: false, + }), + ]; + if (includeGutter) { + extensions.splice(1, 0, lintGutter()); + } + return extensions; +} + +export function lspDiagnosticsExtension(includeGutter = true) { + return { + ...lspDiagnosticsClientExtension(), + editorExtension: lspDiagnosticsUiExtension(includeGutter), + }; +} + +export default lspDiagnosticsExtension; + +export function clearDiagnosticsEffect() { + return setPublishedDiagnostics.of([]); +} + +export function getLspDiagnostics(state) { + if (!state || typeof state.field !== "function") return []; + try { + const stored = state.field(lspPublishedDiagnostics, false); + if (!stored || !Array.isArray(stored)) return []; + return stored.map((diagnostic) => ({ ...diagnostic })); + } catch (_) { + return []; + } +} diff --git a/src/cm/lsp/formatter.js b/src/cm/lsp/formatter.js new file mode 100644 index 000000000..3cbb7d972 --- /dev/null +++ b/src/cm/lsp/formatter.js @@ -0,0 +1,93 @@ +import { getModes } from "cm/modelist"; +import toast from "components/toast"; +import lspClientManager from "./clientManager"; +import serverRegistry from "./serverRegistry"; + +function getActiveMetadata(manager, file) { + if (!manager?.getLspMetadata || !file) return null; + const metadata = manager.getLspMetadata(file); + if (!metadata) return null; + metadata.view = manager.editor; + return metadata; +} + +export function registerLspFormatter(acode) { + const languages = new Set(); + serverRegistry.listServers().forEach((server) => { + (server.languages || []).forEach((lang) => { + if (lang) languages.add(String(lang)); + }); + }); + const extensions = languages.size + ? collectFormatterExtensions(languages) + : ["*"]; + + acode.registerFormatter( + "lsp", + extensions, + async () => { + const manager = window.editorManager; + const file = manager?.activeFile; + const metadata = getActiveMetadata(manager, file); + if (!metadata) { + toast("LSP formatter unavailable"); + return false; + } + const languageId = metadata.languageId; + if (!languageId) { + toast("Unknown language for LSP formatting"); + return false; + } + const servers = serverRegistry.getServersForLanguage(languageId); + if (!servers.length) { + toast("No LSP formatter available"); + return false; + } + metadata.languageName = metadata.languageName || languageId; + const success = await lspClientManager.formatDocument(metadata); + if (!success) { + toast("LSP formatter failed"); + } + return success; + }, + "Language Server", + ); +} + +function collectFormatterExtensions(languages) { + const extensions = new Set(); + const modeMap = new Map(); + + try { + getModes().forEach((mode) => { + const key = String(mode?.name || "") + .trim() + .toLowerCase(); + if (key) modeMap.set(key, mode); + }); + } catch (_) {} + + languages.forEach((language) => { + const key = String(language || "") + .trim() + .toLowerCase(); + if (!key) return; + extensions.add(key); + const mode = modeMap.get(key); + if (!mode?.extensions) return; + String(mode.extensions) + .split("|") + .forEach((part) => { + const ext = part.trim(); + if (ext && !ext.startsWith("^")) { + extensions.add(ext); + } + }); + }); + + if (!extensions.size) { + return ["*"]; + } + + return Array.from(extensions); +} diff --git a/src/cm/lsp/index.js b/src/cm/lsp/index.js new file mode 100644 index 000000000..90225a191 --- /dev/null +++ b/src/cm/lsp/index.js @@ -0,0 +1,9 @@ +export { default as clientManager, LspClientManager } from "./clientManager"; +export { + ensureServerRunning, + resetManagedServers, + stopManagedServer, +} from "./serverLauncher"; +export { default as serverRegistry } from "./serverRegistry"; +export { createTransport } from "./transport"; +export { default as AcodeWorkspace } from "./workspace"; diff --git a/src/cm/lsp/serverLauncher.js b/src/cm/lsp/serverLauncher.js new file mode 100644 index 000000000..63fd21772 --- /dev/null +++ b/src/cm/lsp/serverLauncher.js @@ -0,0 +1,310 @@ +import toast from "components/toast"; +import confirm from "dialogs/confirm"; +import loader from "dialogs/loader"; + +const managedServers = new Map(); +const checkedCommands = new Map(); +const announcedServers = new Set(); + +const STATUS_PRESENT = "present"; +const STATUS_DECLINED = "declined"; +const STATUS_FAILED = "failed"; + +const AXS_BINARY = "$PREFIX/axs"; + +function getExecutor() { + const executor = globalThis.Executor; + if (!executor) { + throw new Error("Executor plugin is not available"); + } + return executor; +} + +function joinCommand(command, args = []) { + if (!Array.isArray(args)) return command; + return [command, ...args].join(" "); +} + +function wrapShellCommand(command) { + const script = command.trim(); + const escaped = script.replace(/"/g, '\\"'); + return `sh -lc "set -e; ${escaped}"`; +} + +async function runCommand(command) { + const wrapped = wrapShellCommand(command); + return getExecutor().execute(wrapped, true); +} + +function quoteArg(value) { + const str = String(value ?? ""); + if (!str.length) return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(str)) return str; + return `'${str.replace(/'/g, "'\\''")}'`; +} + +function buildAxsBridgeCommand(bridge) { + if (!bridge || bridge.kind !== "axs") return null; + const port = Number(bridge.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error( + `Bridge requires a valid TCP port (received ${bridge.port})`, + ); + } + const binary = bridge.command + ? String(bridge.command) + : (() => { + throw new Error("Bridge requires a command to execute"); + })(); + const args = Array.isArray(bridge.args) + ? bridge.args.map((arg) => String(arg)) + : []; + + const parts = [AXS_BINARY, "--port", String(port), "lsp", quoteArg(binary)]; + if (args.length) { + parts.push("--"); + args.forEach((arg) => parts.push(quoteArg(arg))); + } + return parts.join(" "); +} + +function resolveStartCommand(server) { + const launcher = server.launcher || {}; + if (launcher.startCommand) { + return Array.isArray(launcher.startCommand) + ? launcher.startCommand.join(" ") + : String(launcher.startCommand); + } + if (launcher.command) { + return joinCommand(launcher.command, launcher.args); + } + if (launcher.bridge) { + return buildAxsBridgeCommand(launcher.bridge); + } + return null; +} + +async function ensureInstalled(server) { + const launcher = server.launcher; + if (!launcher?.checkCommand) return true; + + const cacheKey = `${server.id}:${launcher.checkCommand}`; + if (checkedCommands.has(cacheKey)) { + return checkedCommands.get(cacheKey) === STATUS_PRESENT; + } + + try { + await runCommand(launcher.checkCommand); + checkedCommands.set(cacheKey, STATUS_PRESENT); + return true; + } catch (error) { + if (!launcher.install) { + checkedCommands.set(cacheKey, STATUS_FAILED); + console.warn( + `LSP server ${server.id} is missing check command result and has no installer.`, + error, + ); + throw error; + } + + const install = launcher.install; + const displayLabel = ( + server.label || + server.id || + "Language server" + ).trim(); + const promptMessage = `Install ${displayLabel} language server?`; + const shouldInstall = await confirm( + server.label || displayLabel, + promptMessage, + ); + + if (!shouldInstall) { + checkedCommands.set(cacheKey, STATUS_DECLINED); + return false; + } + + let loadingDialog; + try { + loadingDialog = loader.create( + server.label, + `Installing ${server.label}...`, + ); + loadingDialog.show(); + await runCommand(install.command); + toast(`${server.label} installed`); + checkedCommands.set(cacheKey, STATUS_PRESENT); + return true; + } catch (installError) { + console.error(`Failed to install ${server.id}`, installError); + toast(strings?.error || "Error"); + checkedCommands.set(cacheKey, STATUS_FAILED); + throw installError; + } finally { + loadingDialog?.destroy?.(); + } + } +} + +async function startInteractiveServer(command, serverId) { + const executor = getExecutor(); + const uuid = await executor.start( + command, + (type, data) => { + if (type === "stderr") { + if (/proot warning/i.test(data)) return; + console.warn(`[${serverId}] ${data}`); + } else if (type === "stdout" && data && data.trim()) { + console.info(`[${serverId}] ${data}`); + } + }, + true, + ); + managedServers.set(serverId, { + uuid, + command, + startedAt: Date.now(), + }); + return uuid; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForWebSocket( + url, + { attempts = 20, delay = 200, probeTimeout = 2000 } = {}, +) { + let lastError = null; + for (let i = 0; i < attempts; i++) { + try { + await new Promise((resolve, reject) => { + let socket; + let timer; + try { + socket = new WebSocket(url); + } catch (error) { + reject(error); + return; + } + + const cleanup = (cb) => { + if (timer) clearTimeout(timer); + if (socket) { + socket.onopen = socket.onerror = null; + try { + socket.close(); + } catch (_) {} + } + cb && cb(); + }; + + socket.onopen = () => cleanup(resolve); + socket.onerror = (event) => + cleanup(() => + reject( + event instanceof Error ? event : new Error("websocket error"), + ), + ); + timer = setTimeout( + () => cleanup(() => reject(new Error("timeout"))), + probeTimeout, + ); + }); + return; + } catch (error) { + lastError = error; + await sleep(delay); + } + } + const reason = lastError + ? lastError.message || lastError.type || String(lastError) + : "unknown"; + throw new Error(`WebSocket ${url} did not become ready (${reason})`); +} + +export async function ensureServerRunning(server) { + const launcher = server.launcher; + if (!launcher) return; + + const installed = await ensureInstalled(server); + if (!installed) { + const unavailable = new Error( + `Language server ${server.id} is not available.`, + ); + unavailable.code = "LSP_SERVER_UNAVAILABLE"; + throw unavailable; + } + + const key = server.id; + if (managedServers.has(key)) { + const existing = managedServers.get(key); + return existing?.uuid || null; + } + + const command = await resolveStartCommand(server); + if (!command) { + return null; + } + + try { + const uuid = await startInteractiveServer(command, key); + if ( + server.transport?.url && + (server.transport.kind === "websocket" || + server.transport.kind === "stdio") + ) { + await waitForWebSocket(server.transport.url); + } + if (!announcedServers.has(key)) { + toast( + strings?.lsp_connected?.replace("{{label}}", server.label) || + `${server.label} connected`, + ); + announcedServers.add(key); + } + return uuid; + } catch (error) { + console.error(`Failed to start language server ${server.id}`, error); + toast( + `${server.label} failed to connect${error?.message ? `: ${error.message}` : ""}`, + ); + const entry = managedServers.get(key); + if (entry) { + getExecutor() + .stop(entry.uuid) + .catch((err) => { + console.warn( + `Failed to stop language server shell ${server.id}`, + err, + ); + }); + managedServers.delete(key); + } + const unavailable = new Error( + `Language server ${server.id} failed to start (${error?.message || error})`, + ); + unavailable.code = "LSP_SERVER_UNAVAILABLE"; + throw unavailable; + } +} + +export function stopManagedServer(serverId) { + const entry = managedServers.get(serverId); + if (!entry) return; + getExecutor() + .stop(entry.uuid) + .catch((error) => { + console.warn(`Failed to stop language server ${serverId}`, error); + }); + managedServers.delete(serverId); + announcedServers.delete(serverId); +} + +export function resetManagedServers() { + for (const id of Array.from(managedServers.keys())) { + stopManagedServer(id); + } + managedServers.clear(); +} diff --git a/src/cm/lsp/serverRegistry.js b/src/cm/lsp/serverRegistry.js new file mode 100644 index 000000000..d2f5b74dd --- /dev/null +++ b/src/cm/lsp/serverRegistry.js @@ -0,0 +1,517 @@ +/** + * @typedef {Object} LspTransportDescriptor + * @property {"stdio"|"websocket"|"external"} kind + * @property {string} [command] + * @property {string[]} [args] + * @property {Record} [options] + * @property {string} [url] + */ + +/** + * @typedef {Object} LspServerDefinition + * @property {string} id + * @property {string} label + * @property {boolean} [enabled] + * @property {string[]} languages + * @property {LspTransportDescriptor} transport + * @property {Record} [initializationOptions] + * @property {Record} [clientConfig] + * @property {number} [startupTimeout] + * @property {Record} [capabilityOverrides] + * @property {(uri: string, context: any) => string | null} [rootUri] + * @property {(metadata: any) => string | null} [resolveLanguageId] + */ + +const registry = new Map(); +const listeners = new Set(); + +function toKey(id) { + return String(id || "") + .trim() + .toLowerCase(); +} + +function clone(value) { + if (!value || typeof value !== "object") return undefined; + try { + return JSON.parse(JSON.stringify(value)); + } catch (_) { + return value; + } +} + +function sanitizeLanguages(languages = []) { + if (!Array.isArray(languages)) return []; + return languages + .map((lang) => + String(lang || "") + .trim() + .toLowerCase(), + ) + .filter(Boolean); +} + +function parsePort(value) { + const num = Number(value); + if (!Number.isFinite(num)) return null; + const int = Math.floor(num); + if (int !== num || int <= 0 || int > 65535) return null; + return int; +} + +function sanitizeBridge(serverId, bridge) { + if (!bridge || typeof bridge !== "object") return undefined; + const kind = bridge.kind || "axs"; + if (kind !== "axs") { + throw new Error( + `LSP server ${serverId} declares unsupported bridge kind ${kind}`, + ); + } + const port = parsePort(bridge.port); + if (!port) { + throw new Error(`LSP server ${serverId} bridge requires a valid port`); + } + const command = bridge.command ? String(bridge.command) : null; + if (!command) { + throw new Error(`LSP server ${serverId} bridge must supply a command`); + } + const args = Array.isArray(bridge.args) + ? bridge.args.map((arg) => String(arg)) + : undefined; + return { + kind, + port, + command, + args, + }; +} + +function sanitizeDefinition(definition) { + if (!definition || typeof definition !== "object") { + throw new TypeError("LSP server definition must be an object"); + } + + const id = toKey(definition.id); + if (!id) throw new Error("LSP server definition requires a non-empty id"); + + const transport = definition.transport || {}; + const kind = transport.kind || "stdio"; + + if (!transport || typeof transport !== "object") { + throw new Error(`LSP server ${id} is missing a transport descriptor`); + } + + if ( + !("languages" in definition) || + !sanitizeLanguages(definition.languages).length + ) { + throw new Error(`LSP server ${id} must declare supported languages`); + } + + if (kind === "stdio" && !transport.command) { + throw new Error(`LSP server ${id} (stdio) requires a command`); + } + + if (kind === "websocket" && !transport.url) { + throw new Error(`LSP server ${id} (websocket) requires a url`); + } + + const transportOptions = + transport.options && typeof transport.options === "object" + ? { ...transport.options } + : {}; + + const sanitized = { + id, + label: definition.label || id, + enabled: definition.enabled !== false, + languages: sanitizeLanguages(definition.languages), + transport: { + kind, + command: transport.command, + args: Array.isArray(transport.args) + ? transport.args.map((arg) => String(arg)) + : undefined, + options: transportOptions, + url: transport.url, + protocols: undefined, + }, + initializationOptions: clone(definition.initializationOptions), + clientConfig: clone(definition.clientConfig), + startupTimeout: + typeof definition.startupTimeout === "number" + ? definition.startupTimeout + : undefined, + capabilityOverrides: clone(definition.capabilityOverrides), + rootUri: + typeof definition.rootUri === "function" ? definition.rootUri : null, + resolveLanguageId: + typeof definition.resolveLanguageId === "function" + ? definition.resolveLanguageId + : null, + launcher: + definition.launcher && typeof definition.launcher === "object" + ? { + command: definition.launcher.command, + args: Array.isArray(definition.launcher.args) + ? definition.launcher.args.map((arg) => String(arg)) + : undefined, + startCommand: Array.isArray(definition.launcher.startCommand) + ? definition.launcher.startCommand.map((arg) => String(arg)) + : definition.launcher.startCommand, + checkCommand: definition.launcher.checkCommand, + install: + definition.launcher.install && + typeof definition.launcher.install === "object" + ? { + command: definition.launcher.install.command, + } + : undefined, + bridge: sanitizeBridge(id, definition.launcher.bridge), + } + : undefined, + }; + + if (!Object.keys(transportOptions).length) { + sanitized.transport.options = undefined; + } + + return sanitized; +} + +function resolveJsTsLanguageId(languageId, languageName) { + const lang = toKey(languageId || languageName); + switch (lang) { + case "tsx": + case "typescriptreact": + return "typescriptreact"; + case "jsx": + case "javascriptreact": + return "javascriptreact"; + case "ts": + return "typescript"; + case "js": + return "javascript"; + default: + return lang || null; + } +} + +function notify(event, payload) { + listeners.forEach((fn) => { + try { + fn(event, payload); + } catch (error) { + console.error("LSP server registry listener failed", error); + } + }); +} + +export function registerServer(definition, { replace = false } = {}) { + const normalized = sanitizeDefinition(definition); + const exists = registry.has(normalized.id); + if (exists && !replace) return registry.get(normalized.id); + + registry.set(normalized.id, normalized); + notify("register", normalized); + return normalized; +} + +export function unregisterServer(id) { + const key = toKey(id); + if (!key || !registry.has(key)) return false; + const existing = registry.get(key); + registry.delete(key); + notify("unregister", existing); + return true; +} + +export function updateServer(id, updater) { + const key = toKey(id); + if (!key || !registry.has(key)) return null; + const current = registry.get(key); + const next = updater({ ...current }); + if (!next) return current; + const normalized = sanitizeDefinition({ + ...current, + ...next, + id: current.id, + }); + registry.set(key, normalized); + notify("update", normalized); + return normalized; +} + +export function getServer(id) { + return registry.get(toKey(id)) || null; +} + +export function listServers() { + return Array.from(registry.values()); +} + +export function getServersForLanguage( + languageId, + { includeDisabled = false } = {}, +) { + const langKey = toKey(languageId); + if (!langKey) return []; + + return listServers().filter((server) => { + if (!includeDisabled && !server.enabled) return false; + return server.languages.includes(langKey); + }); +} + +export function onRegistryChange(listener) { + if (typeof listener !== "function") return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); +} + +function registerBuiltinServers() { + const defaults = [ + { + id: "typescript", + label: "TypeScript / JavaScript", + languages: [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "tsx", + "jsx", + ], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2090", + }, + launcher: { + bridge: { + kind: "axs", + port: 2090, + command: "typescript-language-server", + args: ["--stdio"], + }, + checkCommand: "which typescript-language-server", + install: { + command: + "apk add --no-cache nodejs npm && npm install -g typescript-language-server typescript", + }, + }, + enabled: false, + resolveLanguageId: ({ languageId, languageName }) => + resolveJsTsLanguageId(languageId, languageName), + }, + { + id: "vtsls", + label: "TypeScript / JavaScript (vtsls)", + languages: [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "tsx", + "jsx", + ], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2095", + }, + launcher: { + bridge: { + kind: "axs", + port: 2095, + command: "vtsls", + args: ["--stdio"], + }, + checkCommand: "which vtsls", + install: { + command: + "apk add --no-cache nodejs npm && npm install -g @vtsls/language-server", + }, + }, + enabled: false, + resolveLanguageId: ({ languageId, languageName }) => + resolveJsTsLanguageId(languageId, languageName), + }, + { + id: "python", + label: "Python (pylsp)", + languages: ["python"], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2087", + }, + launcher: { + command: "pylsp", + args: ["--ws", "--host", "127.0.0.1", "--port", "2087"], + checkCommand: "which pylsp", + install: { + command: + "apk update && apk upgrade && apk add python3 py3-pip && PIP_BREAK_SYSTEM_PACKAGES=1 pip install 'python-lsp-server[websockets,all]'", + }, + }, + initializationOptions: { + pylsp: { + plugins: { + pyflakes: { enabled: true }, + pycodestyle: { enabled: true }, + mccabe: { enabled: true }, + }, + }, + }, + enabled: false, + }, + { + id: "eslint", + label: "ESLint", + languages: [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "tsx", + "jsx", + ], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2096", + }, + launcher: { + bridge: { + kind: "axs", + port: 2096, + command: "vscode-eslint-language-server", + args: ["--stdio"], + }, + checkCommand: "which vscode-eslint-language-server", + install: { + command: + "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", + }, + }, + enabled: false, + clientConfig: { + builtinExtensions: { + hover: false, + completion: false, + signature: false, + keymaps: false, + }, + }, + resolveLanguageId: ({ languageId, languageName }) => + resolveJsTsLanguageId(languageId, languageName), + }, + { + id: "clangd", + label: "C / C++ (clangd)", + languages: ["c", "cpp"], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2094", + }, + launcher: { + bridge: { + kind: "axs", + port: 2094, + command: "clangd", + }, + checkCommand: "which clangd", + install: { + command: "apk add --no-cache clang-extra-tools", + }, + }, + enabled: false, + }, + { + id: "html", + label: "HTML", + languages: ["html", "vue", "svelte"], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2091", + }, + launcher: { + bridge: { + kind: "axs", + port: 2091, + command: "vscode-html-language-server", + args: ["--stdio"], + }, + checkCommand: "which vscode-html-language-server", + install: { + command: + "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", + }, + }, + enabled: false, + }, + { + id: "css", + label: "CSS", + languages: ["css", "scss", "less"], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2092", + }, + launcher: { + bridge: { + kind: "axs", + port: 2092, + command: "vscode-css-language-server", + args: ["--stdio"], + }, + checkCommand: "which vscode-css-language-server", + install: { + command: + "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", + }, + }, + enabled: false, + }, + { + id: "json", + label: "JSON", + languages: ["json", "jsonc"], + transport: { + kind: "websocket", + url: "ws://127.0.0.1:2093", + }, + launcher: { + bridge: { + kind: "axs", + port: 2093, + command: "vscode-json-language-server", + args: ["--stdio"], + }, + checkCommand: "which vscode-json-language-server", + install: { + command: + "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", + }, + }, + enabled: false, + }, + ]; + + defaults.forEach((def) => { + try { + registerServer(def, { replace: false }); + } catch (error) { + console.error("Failed to register builtin LSP server", def.id, error); + } + }); +} + +registerBuiltinServers(); + +export default { + registerServer, + unregisterServer, + updateServer, + getServer, + getServersForLanguage, + listServers, + onRegistryChange, +}; diff --git a/src/cm/lsp/transport.js b/src/cm/lsp/transport.js new file mode 100644 index 000000000..bc6e0c2c7 --- /dev/null +++ b/src/cm/lsp/transport.js @@ -0,0 +1,151 @@ +/* + Language servers that expose stdio are proxied through a lightweight + WebSocket bridge so the CodeMirror client can continue to speak WebSocket. +*/ + +/** + * @typedef {Object} TransportHandle + * @property {{ send(message: string): void, subscribe(handler: (value: string) => void): void, unsubscribe(handler: (value: string) => void): void }} transport + * @property {() => Promise | void} dispose + * @property {Promise} ready + */ + +function createWebSocketTransport(server, context) { + const { url, options = {} } = server.transport; + if (!url) { + throw new Error(`WebSocket transport for ${server.id} is missing a url`); + } + + let socket; + try { + // pylsp's websocket endpoint does not require subprotocol negotiation. + // Avoid passing protocols to keep the handshake simple. + socket = new WebSocket(url); + } catch (error) { + throw new Error( + `Failed to construct WebSocket for ${server.id} (${url}): ${error?.message || error}`, + ); + } + const listeners = new Set(); + const binaryMode = !!options.binary; + if (binaryMode) { + socket.binaryType = "arraybuffer"; + } + + const ready = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.onopen = socket.onerror = null; + try { + socket.close(); + } catch (_) {} + reject(new Error(`Timed out opening WebSocket for ${server.id}`)); + }, 5000); + socket.onopen = () => { + clearTimeout(timeout); + socket.onopen = socket.onerror = null; + resolve(); + }; + socket.onerror = (event) => { + clearTimeout(timeout); + socket.onopen = socket.onerror = null; + const reason = event?.message || event?.type || "connection error"; + reject(new Error(`WebSocket error for ${server.id}: ${reason}`)); + }; + }); + + socket.onmessage = async (event) => { + let data; + if (typeof event.data === "string") { + data = event.data; + } else if (event.data instanceof Blob) { + data = await event.data.text(); + } else if (event.data instanceof ArrayBuffer) { + data = new TextDecoder().decode(event.data); + } else { + console.warn( + "Unknown WebSocket message type", + typeof event.data, + event.data, + ); + data = String(event.data); + } + // Debugging aid while stabilising websocket transport + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] <=`, data); + } + listeners.forEach((listener) => { + try { + listener(data); + } catch (error) { + console.error("LSP transport listener failed", error); + } + }); + }; + + const encoder = binaryMode ? new TextEncoder() : null; + const transport = { + send(message) { + if (socket.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket transport is not open"); + } + if (binaryMode) { + socket.send(encoder.encode(message)); + } else { + socket.send(message); + } + }, + subscribe(handler) { + listeners.add(handler); + }, + unsubscribe(handler) { + listeners.delete(handler); + }, + }; + + const dispose = () => { + listeners.clear(); + if ( + socket.readyState === WebSocket.CLOSED || + socket.readyState === WebSocket.CLOSING + ) { + return; + } + socket.close(1000, "Client disposed"); + }; + + return { transport, dispose, ready }; +} + +function createStdioTransport(server, context) { + if (!server.transport?.url) { + throw new Error( + `STDIO transport for ${server.id} is missing a websocket bridge url`, + ); + } + if (!server.transport?.options?.binary) { + console.info( + `LSP server ${server.id} is using stdio bridge without binary mode. Falling back to text frames.`, + ); + } + return createWebSocketTransport(server, context); +} + +export function createTransport(server, context = {}) { + switch (server.transport.kind) { + case "websocket": + return createWebSocketTransport(server, context); + case "stdio": + return createStdioTransport(server, context); + case "external": + if (typeof server.transport.create === "function") { + return server.transport.create(server, context); + } + throw new Error( + `LSP server ${server.id} declares an external transport without a create() factory`, + ); + default: + throw new Error(`Unsupported transport kind: ${server.transport.kind}`); + } +} + +export default { createTransport }; diff --git a/src/cm/lsp/workspace.js b/src/cm/lsp/workspace.js new file mode 100644 index 000000000..b09969d53 --- /dev/null +++ b/src/cm/lsp/workspace.js @@ -0,0 +1,118 @@ +import { LSPPlugin, Workspace } from "@codemirror/lsp-client"; + +class AcodeWorkspaceFile { + constructor(uri, languageId, version, doc, view) { + this.uri = uri; + this.languageId = languageId; + this.version = version; + this.doc = doc; + this.views = new Set(); + if (view) this.views.add(view); + } + + getView(preferred) { + if (preferred && this.views.has(preferred)) return preferred; + const iterator = this.views.values(); + const next = iterator.next(); + return next.done ? null : next.value; + } +} + +export default class AcodeWorkspace extends Workspace { + constructor(client, options = {}) { + super(client); + this.files = []; + this.#fileMap = new Map(); + this.#versions = Object.create(null); + this.options = options; + } + + #fileMap; + #versions; + + #getOrCreateFile(uri, languageId, view) { + let file = this.#fileMap.get(uri); + if (!file) { + file = new AcodeWorkspaceFile( + uri, + languageId, + this.#nextFileVersion(uri), + view.state?.doc, + view, + ); + this.#fileMap.set(uri, file); + this.files.push(file); + this.client.didOpen(file); + } + file.views.add(view); + return file; + } + + #getFileEntry(uri) { + return this.#fileMap.get(uri) || null; + } + + #removeFileEntry(file) { + this.#fileMap.delete(file.uri); + this.files = this.files.filter((candidate) => candidate !== file); + } + + #nextFileVersion(uri) { + const current = this.#versions[uri] ?? -1; + const next = current + 1; + this.#versions[uri] = next; + return next; + } + + syncFiles() { + const updates = []; + for (const file of this.files) { + const view = file.getView(); + if (!view) continue; + const plugin = LSPPlugin.get(view); + if (!plugin) continue; + const { unsyncedChanges } = plugin; + if (unsyncedChanges.empty) continue; + + updates.push({ file, prevDoc: file.doc, changes: unsyncedChanges }); + file.doc = view.state.doc; + file.version = this.#nextFileVersion(file.uri); + plugin.clear(); + } + return updates; + } + + openFile(uri, languageId, view) { + if (!view) return; + this.#getOrCreateFile(uri, languageId, view); + } + + closeFile(uri, view) { + const file = this.#getFileEntry(uri); + if (!file) return; + + if (view && file.views.has(view)) { + file.views.delete(view); + } + + if (!file.views.size) { + this.client.didClose(uri); + this.#removeFileEntry(file); + } + } + + getFile(uri) { + return this.#getFileEntry(uri); + } + + async displayFile(uri) { + if (typeof this.options.displayFile === "function") { + try { + return await this.options.displayFile(uri); + } catch (error) { + console.error("Failed to display file via workspace", error); + } + } + return null; + } +} diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 9c86026a3..42b2899b3 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -33,6 +33,15 @@ import { wrapWithAbbreviation, } from "@emmetio/codemirror6-plugin"; import createBaseExtensions from "cm/baseExtensions"; +import lspClientManager from "cm/lsp/clientManager"; +import { + getLspDiagnostics, + LSP_DIAGNOSTICS_EVENT, + lspDiagnosticsClientExtension, + lspDiagnosticsUiExtension, +} from "cm/lsp/diagnostics"; +import { stopManagedServer } from "cm/lsp/serverLauncher"; +import serverRegistry from "cm/lsp/serverRegistry"; // CodeMirror mode management import { getModeForPath, @@ -59,6 +68,8 @@ import keyboardHandler, { keydownState } from "handlers/keyboard"; import actions from "handlers/quickTools"; // TODO: Update EditorFile for CodeMirror compatibility import EditorFile from "./editorFile"; +import openFile from "./openFile"; +import { addedFolder } from "./openFolder"; import appSettings from "./settings"; import { getSystemConfiguration, @@ -130,6 +141,24 @@ async function EditorManager($header, $body) { ".cm-scroller": { height: "100%", overflow: "auto" }, }); + const pointerCursorVisibilityExtension = EditorView.updateListener.of( + (update) => { + if (!update.selectionSet) return; + const pointerTriggered = update.transactions.some( + (tr) => + tr.isUserEvent("pointer") || + tr.isUserEvent("select.pointer") || + tr.isUserEvent("touch") || + tr.isUserEvent("select.touch"), + ); + if (!pointerTriggered) return; + if (isCursorVisible()) return; + requestAnimationFrame(() => { + if (!isCursorVisible()) scrollCursorIntoView({ behavior: "instant" }); + }); + }, + ); + // Compartment to swap editor theme dynamically const themeCompartment = new Compartment(); // Compartments to control indentation, tab width, and font styling dynamically @@ -154,6 +183,14 @@ async function EditorManager($header, $body) { const readOnlyCompartment = new Compartment(); // Compartment for language mode (allows async loading/reconfigure) const languageCompartment = new Compartment(); + // Compartment for LSP extensions so we can swap per file + const lspCompartment = new Compartment(); + const diagnosticsClientExt = lspDiagnosticsClientExtension(); + const buildDiagnosticsUiExt = () => + lspDiagnosticsUiExtension(appSettings?.value?.lintGutter !== false); + let lspRequestToken = 0; + let lastLspUri = null; + const UNTITLED_URI_PREFIX = "untitled://acode/"; function getEditorFontFamily() { const font = appSettings?.value?.editorFont || "Roboto Mono"; @@ -163,10 +200,12 @@ async function EditorManager($header, $body) { function makeFontTheme() { const fontSize = appSettings?.value?.fontSize || "12px"; const lineHeight = appSettings?.value?.lineHeight || 1.6; + const fontFamily = getEditorFontFamily(); return EditorView.theme({ "&": { fontSize, lineHeight: String(lineHeight) }, - ".cm-content": { fontFamily: getEditorFontFamily() }, - ".cm-tooltip": { fontFamily: getEditorFontFamily() }, + ".cm-content": { fontFamily }, + ".cm-gutter": { fontFamily }, + ".cm-tooltip, .cm-tooltip *": { fontFamily }, }); } @@ -187,8 +226,8 @@ async function EditorManager($header, $body) { }, }); if (!relativeLineNumbers) - return [lineNumbers(), highlightActiveLineGutter()]; - return [ + return Prec.highest([lineNumbers(), highlightActiveLineGutter()]); + return Prec.highest([ lineNumbers({ formatNumber: (lineNo, state) => { try { @@ -201,7 +240,7 @@ async function EditorManager($header, $body) { }, }), highlightActiveLineGutter(), - ]; + ]); } function makeIndentExtensions() { @@ -216,6 +255,13 @@ async function EditorManager($header, $body) { // Centralised CodeMirror options registry for organized configuration // Each spec declares related settings keys, its compartment(s), and a builder returning extension(s) const cmOptionSpecs = [ + { + keys: ["linenumbers", "relativeLineNumbers"], + compartments: [lineNumberCompartment], + build() { + return makeLineNumberExtension(); + }, + }, { keys: ["rainbowBrackets"], compartments: [rainbowCompartment], @@ -247,13 +293,6 @@ async function EditorManager($header, $body) { return [indentExt, tabSizeExt]; }, }, - { - keys: ["linenumbers", "relativeLineNumbers"], - compartments: [lineNumberCompartment], - build() { - return makeLineNumberExtension(); - }, - }, { keys: ["rtlText"], compartments: [rtlCompartment], @@ -324,18 +363,21 @@ async function EditorManager($header, $body) { } function createEmmetExtensionSet({ - syntax = EmmetKnownSyntax.html, + syntax, tracker = {}, config: emmetOverrides = {}, } = {}) { + const resolvedSyntax = + syntax === undefined ? EmmetKnownSyntax.html : syntax; + if (!resolvedSyntax) return []; const trackerExtension = abbreviationTracker({ - syntax, + syntax: resolvedSyntax, ...tracker, }); const { autocompleteTab = ["markup", "stylesheet"], ...restOverrides } = emmetOverrides || {}; const emmetConfigExtension = emmetConfig.of({ - syntax, + syntax: resolvedSyntax, autocompleteTab, ...restOverrides, }); @@ -367,6 +409,68 @@ async function EditorManager($header, $body) { } } + function buildLspMetadata(file) { + if (!file || file.type !== "editor") return null; + const uri = getFileLspUri(file); + if (!uri) return null; + const languageId = getFileLanguageId(file); + return { + uri, + languageId, + languageName: file.currentMode || file.mode || languageId, + view: editor, + file, + rootUri: resolveRootUriForContext({ uri, file }), + }; + } + + async function configureLspForFile(file) { + const metadata = buildLspMetadata(file); + const token = ++lspRequestToken; + if (!metadata) { + detachActiveLsp(); + editor.dispatch({ effects: lspCompartment.reconfigure([]) }); + return; + } + if (metadata.uri !== lastLspUri) { + detachActiveLsp(); + } + try { + const extensions = + (await lspClientManager.getExtensionsForFile(metadata)) || []; + if (token !== lspRequestToken) return; + if (!extensions.length) { + lastLspUri = null; + editor.dispatch({ effects: lspCompartment.reconfigure([]) }); + return; + } + lastLspUri = metadata.uri; + editor.dispatch({ + effects: lspCompartment.reconfigure(extensions), + }); + } catch (error) { + if (token !== lspRequestToken) return; + console.error("Failed to configure LSP", error); + lastLspUri = null; + editor.dispatch({ effects: lspCompartment.reconfigure([]) }); + } + } + + function detachLspForFile(file) { + if (!file || file.type !== "editor") return; + const uri = getFileLspUri(file); + if (!uri) return; + try { + lspClientManager.detach(uri); + } catch (error) { + console.warn(`Failed to detach LSP client for ${uri}`, error); + } + if (uri === lastLspUri && manager.activeFile?.id === file.id) { + lastLspUri = null; + editor.dispatch({ effects: lspCompartment.reconfigure([]) }); + } + } + // Plugin already wires CSS completions; attach extras for related syntaxes. const emmetCompletionSyntaxes = new Set([ EmmetKnownSyntax.scss, @@ -387,6 +491,134 @@ async function EditorManager($header, $body) { } } + function getFileLspUri(file) { + if (!file) return null; + if (file.uri) return file.uri; + return `${UNTITLED_URI_PREFIX}${file.id}`; + } + + function getFileLanguageId(file) { + if (!file) return "plaintext"; + const mode = file.currentMode || file.mode; + if (mode) return String(mode).toLowerCase(); + try { + const guess = getModeForPath(file.filename || file.name || ""); + if (guess?.name) return String(guess.name).toLowerCase(); + } catch (_) {} + return "plaintext"; + } + + function resolveRootUriForContext(context = {}) { + const uri = context.uri || context.file?.uri; + if (!uri) return null; + for (const folder of addedFolder) { + try { + const base = folder?.url; + if (!base) continue; + if (uri.startsWith(base)) return base; + } catch (_) {} + } + return uri; + } + + function detachActiveLsp() { + if (!lastLspUri) return; + try { + lspClientManager.detach(lastLspUri, editor); + } catch (error) { + console.warn(`Failed to detach LSP session for ${lastLspUri}`, error); + } + lastLspUri = null; + } + + function applyLspSettings() { + const { lsp } = appSettings.value || {}; + if (!lsp) return; + const overrides = lsp.servers || {}; + for (const [id, config] of Object.entries(overrides)) { + if (!config || typeof config !== "object") continue; + const key = String(id || "") + .trim() + .toLowerCase(); + if (!key) continue; + const existing = serverRegistry.getServer(key); + if (existing) { + serverRegistry.updateServer(key, (current) => { + const next = { ...current }; + if (Array.isArray(config.languages) && config.languages.length) { + next.languages = config.languages.map((lang) => + String(lang).toLowerCase(), + ); + } + if (config.transport && typeof config.transport === "object") { + next.transport = { ...current.transport, ...config.transport }; + delete next.transport.protocols; + } + if (config.clientConfig && typeof config.clientConfig === "object") { + next.clientConfig = { + ...current.clientConfig, + ...config.clientConfig, + }; + } + if ( + config.initializationOptions && + typeof config.initializationOptions === "object" + ) { + next.initializationOptions = { + ...current.initializationOptions, + ...config.initializationOptions, + }; + } + if (config.launcher && typeof config.launcher === "object") { + next.launcher = { ...current.launcher, ...config.launcher }; + } + if (Object.prototype.hasOwnProperty.call(config, "enabled")) { + next.enabled = !!config.enabled; + } + return next; + }); + if (config.enabled === false) { + stopManagedServer(key); + } + } else if ( + Array.isArray(config.languages) && + config.languages.length && + config.transport && + typeof config.transport === "object" + ) { + try { + serverRegistry.registerServer({ + id: key, + label: config.label || key, + languages: config.languages, + transport: config.transport, + clientConfig: config.clientConfig, + initializationOptions: config.initializationOptions, + launcher: config.launcher, + enabled: config.enabled !== false, + }); + serverRegistry.updateServer(key, (current) => { + if (current.transport?.protocols) { + const updated = { ...current }; + updated.transport = { ...current.transport }; + delete updated.transport.protocols; + return updated; + } + return current; + }); + if (config.enabled === false) { + stopManagedServer(key); + } + } catch (error) { + console.warn( + `Failed to register LSP server override for ${key}`, + error, + ); + } + } + } + } + // Create minimal CodeMirror editor const editorState = EditorState.create({ doc: "", @@ -398,6 +630,7 @@ async function EditorManager($header, $body) { // Default theme themeCompartment.of(oneDark), fixedHeightTheme, + pointerCursorVisibilityExtension, search(), // Ensure read-only can be toggled later via compartment readOnlyCompartment.of(EditorState.readOnly.of(false)), @@ -682,6 +915,7 @@ async function EditorManager($header, $body) { // keep compartment in the state to allow dynamic theme changes later themeCompartment.of(oneDark), fixedHeightTheme, + pointerCursorVisibilityExtension, search(), // Keep dynamic compartments across state swaps ...getBaseExtensionsFromOptions(), @@ -737,6 +971,7 @@ async function EditorManager($header, $body) { // Keep file.session in sync and handle caching/autosave exts.push(getDocSyncListener()); + exts.push(lspCompartment.of([])); // Preserve previous state for restoring selection/folds after swap const prevState = file.session || null; @@ -777,6 +1012,8 @@ async function EditorManager($header, $body) { ) { setScrollPosition(editor, file.lastScrollTop, file.lastScrollLeft); } + + void configureLspForFile(file); } function getEmmetSyntaxForFile(file) { @@ -810,10 +1047,14 @@ async function EditorManager($header, $body) { if (ext === "slim" || mode.includes("slim")) return EmmetKnownSyntax.slim; if (ext === "vue" || mode.includes("vue")) return EmmetKnownSyntax.vue; if (ext === "php" || mode.includes("php")) return EmmetKnownSyntax.html; - if (ext === "html" || ext === "xhtml" || mode.includes("html")) + if ( + ext === "htm" || + ext === "html" || + ext === "xhtml" || + mode.includes("html") + ) return EmmetKnownSyntax.html; - // Defaults to html per Emmet docs - return EmmetKnownSyntax.html; + return null; } const $vScrollbar = ScrollBar({ @@ -843,6 +1084,7 @@ async function EditorManager($header, $body) { getEditorWidth, header: $header, container: $container, + getLspMetadata: buildLspMetadata, get isScrolling() { return isScrolling; }, @@ -883,6 +1125,58 @@ async function EditorManager($header, $body) { }, }; + if (typeof document !== "undefined") { + const globalTarget = + typeof globalThis !== "undefined" ? globalThis : document; + const diagnosticsListenerKey = "__acodeDiagnosticsListener"; + const existing = globalTarget?.[diagnosticsListenerKey]; + if (typeof existing === "function") { + document.removeEventListener(LSP_DIAGNOSTICS_EVENT, existing); + } + const listener = () => { + const active = manager.activeFile; + if (active?.type === "editor") { + try { + active.session = editor.state; + } catch (_) {} + } + toggleProblemButton(); + }; + document.addEventListener(LSP_DIAGNOSTICS_EVENT, listener); + if (globalTarget) { + globalTarget[diagnosticsListenerKey] = listener; + } + } + + lspClientManager.setOptions({ + resolveRoot: resolveRootUriForContext, + onClientIdle: ({ server }) => { + if (server?.id) stopManagedServer(server.id); + }, + displayFile: async (targetUri) => { + if (!targetUri) return null; + const existing = manager.getFile(targetUri, "uri"); + if (existing?.type === "editor") { + existing.makeActive(); + return editor; + } + try { + await openFile(targetUri, { render: true }); + const opened = manager.getFile(targetUri, "uri"); + if (opened?.type === "editor") { + opened.makeActive(); + return editor; + } + } catch (error) { + console.error("Failed to open file for LSP navigation", error); + } + return null; + }, + clientExtensions: [diagnosticsClientExt], + diagnosticsUiExtension: buildDiagnosticsUiExt(), + }); + applyLspSettings(); + // TODO: Implement mode/language support for CodeMirror // editor.setSession(ace.createEditSession("", "ace/mode/text")); $body.append($container); @@ -951,6 +1245,18 @@ async function EditorManager($header, $body) { updateEditorStyleFromSettings(); }); + appSettings.on("update:lsp", async function () { + applyLspSettings(); + const active = manager.activeFile; + if (active?.type === "editor") { + void configureLspForFile(active); + } else { + detachActiveLsp(); + editor.dispatch({ effects: lspCompartment.reconfigure([]) }); + await lspClientManager.dispose(); + } + }); + appSettings.on("update:openFileListPos", function (value) { initFileTabContainer(); $vScrollbar.resize(); @@ -984,6 +1290,16 @@ async function EditorManager($header, $body) { updateEditorLineNumbersFromSettings(); }); + appSettings.on("update:lintGutter", function (value) { + lspClientManager.setOptions({ + diagnosticsUiExtension: lspDiagnosticsUiExtension(value !== false), + }); + const active = manager.activeFile; + if (active?.type === "editor") { + void configureLspForFile(active); + } + }); + // appSettings.on("update:elasticTabstops", function (_value) { // // Not applicable in CodeMirror (Ace-era). No-op for now. // }); @@ -1008,6 +1324,7 @@ async function EditorManager($header, $body) { appSettings.on("update:showSideButtons", function () { updateMargin(); updateSideButtonContainer(); + toggleProblemButton(); }); appSettings.on("update:showAnnotations", function () { @@ -1051,6 +1368,7 @@ async function EditorManager($header, $body) { events.emit("file-content-changed", file); manager.onupdate("file-changed"); manager.emit("update", "file-changed"); + toggleProblemButton(); const { autosave } = appSettings.value; if (file.uri && changed && autosave) { @@ -1086,6 +1404,18 @@ async function EditorManager($header, $body) { } }); + manager.on(["remove-file"], (file) => { + detachLspForFile(file); + toggleProblemButton(); + }); + + manager.on(["rename-file"], (file) => { + if (file?.type !== "editor") return; + if (manager.activeFile?.id === file.id) { + void configureLspForFile(file); + } + }); + // Attach doc-sync listener to the current editor instance try { editor.dispatch({ @@ -1104,6 +1434,7 @@ async function EditorManager($header, $body) { manager.files.push(file); manager.openFileList.append(file.tab); $header.text = file.name; + toggleProblemButton(); } /** @@ -1140,15 +1471,12 @@ async function EditorManager($header, $body) { scroller?.addEventListener("scroll", handleEditorScroll, { passive: true }); handleEditorScroll(); - // TODO: Implement focus event for CodeMirror - // editor.on("focus", async () => { - // const { activeFile } = manager; - // activeFile.focused = true; - // keyboardHandler.on("keyboardShow", scrollCursorIntoView); - // if (isScrolling) return; - // $hScrollbar.hide(); - // $vScrollbar.hide(); - // }); + keyboardHandler.on("keyboardShowStart", () => { + requestAnimationFrame(() => { + scrollCursorIntoView({ behavior: "instant" }); + }); + }); + keyboardHandler.on("keyboardShow", scrollCursorIntoView); // TODO: Implement blur event for CodeMirror // editor.on("blur", async () => { @@ -1241,6 +1569,7 @@ async function EditorManager($header, $body) { updateMargin(true); updateSideButtonContainer(); + toggleProblemButton(); // TODO: Implement scroll margin for CodeMirror // editor.renderer.setScrollMargin( // scrollMarginTop, @@ -1253,34 +1582,51 @@ async function EditorManager($header, $body) { /** * Scrolls the cursor into view if it is not currently visible. */ - // TODO: Implement cursor scrolling for CodeMirror - function scrollCursorIntoView() { - // keyboardHandler.off("keyboardShow", scrollCursorIntoView); - // if (isCursorVisible()) return; - // const { teardropSize } = appSettings.value; - // editor.renderer.scrollCursorIntoView(); - // editor.renderer.scrollBy(0, teardropSize + 10); - // editor._emit("scroll-intoview"); + function scrollCursorIntoView(options = {}) { + const view = editor; + const scroller = view?.scrollDOM; + if (!view || !scroller) return; + + const { behavior = "smooth" } = options; + const { head } = view.state.selection.main; + const caret = view.coordsAtPos(head); + if (!caret) return; + + const scrollerRect = scroller.getBoundingClientRect(); + const relativeTop = caret.top - scrollerRect.top + scroller.scrollTop; + const relativeBottom = caret.bottom - scrollerRect.top + scroller.scrollTop; + const topMargin = 16; + const bottomMargin = (appSettings.value?.teardropSize || 24) + 12; + + const scrollTop = scroller.scrollTop; + const visibleTop = scrollTop + topMargin; + const visibleBottom = scrollTop + scroller.clientHeight - bottomMargin; + + if (relativeTop < visibleTop) { + const nextTop = Math.max(relativeTop - topMargin, 0); + scroller.scrollTo({ top: nextTop, behavior }); + } else if (relativeBottom > visibleBottom) { + const delta = relativeBottom - visibleBottom; + scroller.scrollTo({ top: scrollTop + delta, behavior }); + } } /** - * Checks if the cursor is visible within the Ace editor. + * Checks if the cursor is visible within the CodeMirror viewport. * @returns {boolean} - True if the cursor is visible, false otherwise. */ // TODO: Implement cursor visibility check for CodeMirror function isCursorVisible() { - // const { editor, container } = manager; - // const { teardropSize } = appSettings.value; - // const cursorPos = editor.getCursorPosition(); - // const contentTop = container.getBoundingClientRect().top; - // const contentBottom = contentTop + container.clientHeight; - // const cursorTop = editor.renderer.textToScreenCoordinates( - // cursorPos.row, - // cursorPos.column, - // ).pageY; - // const cursorBottom = cursorTop + teardropSize + 10; - // return cursorTop >= contentTop && cursorBottom <= contentBottom; - return true; // Placeholder + const view = editor; + const scroller = view?.scrollDOM; + if (!view || !scroller) return true; + + const { head } = view.state.selection.main; + const caret = view.coordsAtPos(head); + if (!caret) return true; + + const scrollerRect = scroller.getBoundingClientRect(); + return caret.top >= scrollerRect.top && caret.bottom <= scrollerRect.bottom; } /** @@ -1467,18 +1813,48 @@ async function EditorManager($header, $body) { /** * Toggles the visibility of the problem button based on the presence of annotations in the files. */ - // TODO: Implement problem button toggle for CodeMirror + function fileHasProblems(file) { + const state = getDiagnosticStateForFile(file); + if (!state) return false; + + const session = file.session; + if (session && typeof session.getAnnotations === "function") { + try { + const annotations = session.getAnnotations() || []; + if (annotations.length) return true; + } catch (_) {} + } + + if (typeof state.field !== "function") return false; + try { + const diagnostics = getLspDiagnostics(state); + return diagnostics.length > 0; + } catch (_) {} + + return false; + } + function toggleProblemButton() { - // const fileWithProblems = manager.files.find((file) => { - // if (file.type !== "editor") return false; - // const annotations = file?.session?.getAnnotations(); - // return !!annotations.length; - // }); - // if (fileWithProblems) { - // problemButton.show(); - // } else { - // problemButton.hide(); - // } + const { showSideButtons } = appSettings.value; + if (!showSideButtons) { + problemButton.hide(); + return; + } + + const hasProblems = manager.files.some((file) => fileHasProblems(file)); + if (hasProblems) { + problemButton.show(); + } else { + problemButton.hide(); + } + } + + function getDiagnosticStateForFile(file) { + if (!file || file.type !== "editor") return null; + if (manager.activeFile?.id === file.id && editor?.state) { + return editor.state; + } + return file.session || null; } /** @@ -1594,6 +1970,8 @@ async function EditorManager($header, $body) { $header.subText = file.headerSubtitle || ""; manager.onupdate("switch-file"); events.emit("switch-file", file); + + toggleProblemButton(); } /** diff --git a/src/lib/keyBindings.js b/src/lib/keyBindings.js index c9c02f325..0d2b8b94d 100644 --- a/src/lib/keyBindings.js +++ b/src/lib/keyBindings.js @@ -202,7 +202,7 @@ const APP_BINDING_CONFIG = [ { name: "problems", description: "Show problems", - key: "Ctrl-Shift-K", + key: null, readOnly: true, editorOnly: true, }, diff --git a/src/lib/settings.js b/src/lib/settings.js index 15ece7008..cd201b2e5 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -177,8 +177,12 @@ class Settings { showRetryToast: false, showSideButtons: true, showAnnotations: false, + lintGutter: true, rainbowBrackets: true, pluginsDisabled: {}, // pluginId: true/false + lsp: { + servers: {}, + }, }; this.value = structuredClone(this.#defaultSettings); } diff --git a/src/main.scss b/src/main.scss index f1f7b8cd5..b281dc1f7 100644 --- a/src/main.scss +++ b/src/main.scss @@ -4,6 +4,7 @@ @use "./styles/keyframes.scss"; @use "./styles/fileInfo.scss"; @use "./styles/markdown.scss"; +@use "./styles/codemirror.scss"; :root { --scrollbar-width: 4px; @@ -843,4 +844,4 @@ input[type="search"]::-webkit-search-results-decoration { transform: translateX(0); opacity: 1; } -} \ No newline at end of file +} diff --git a/src/pages/problems/problems.js b/src/pages/problems/problems.js index b3d9d5a3c..e79e6289d 100644 --- a/src/pages/problems/problems.js +++ b/src/pages/problems/problems.js @@ -1,4 +1,5 @@ import "./style.scss"; +import { getLspDiagnostics } from "cm/lsp/diagnostics"; import Page from "components/page"; import actionStack from "lib/actionStack"; import EditorFile from "lib/editorFile"; @@ -12,29 +13,17 @@ export default function Problems() { files.forEach((file) => { if (file.type !== "editor") return; - /**@type {[]} */ - const annotations = file.session?.getAnnotations(); + const annotations = collectAnnotations(file); if (!annotations.length) return; + const title = `${file.name} (${annotations.length})`; $content.append(
- {`${file.name} (${annotations.length})`} + {title}
{annotations.map((annotation) => { - let icon = "info"; - - switch (annotation.type) { - case "error": - icon = "cancel"; - break; - - case "warning": - icon = "warningreport_problem"; - break; - - default: - break; - } + const { type, text, row, column } = annotation; + const icon = getIconForType(type); return (
- - {annotation.text} + + {text} - {annotation.row + 1}:{annotation.column + 1} + {row + 1}:{column + 1}
); @@ -78,15 +67,19 @@ export default function Problems() { * @param {MouseEvent} e */ function clickHandler(e) { - const $target = e.target; + const $target = e.target.closest("[data-action='goto']"); + if (!$target) return; const { action } = $target.dataset; if (action === "goto") { const { fileId } = $target.dataset; const annotation = $target.annotation; + if (!annotation) return; + const row = normalizeIndex(annotation.row); + const column = normalizeIndex(annotation.column); editorManager.switchFile(fileId); - editorManager.editor.gotoLine(annotation.row + 1, annotation.column); + editorManager.editor.gotoLine(row + 1, column); $page.hide(); setTimeout(() => { @@ -94,4 +87,110 @@ export default function Problems() { }, 100); } } + + function collectAnnotations(file) { + const annotations = []; + const { session } = file; + const isActiveFile = editorManager.activeFile?.id === file.id; + const state = + isActiveFile && editorManager.editor + ? editorManager.editor.state + : session; + + if (session && typeof session.getAnnotations === "function") { + const aceAnnotations = session.getAnnotations() || []; + for (const item of aceAnnotations) { + if (!item) continue; + const row = normalizeIndex(item.row); + const column = normalizeIndex(item.column); + annotations.push({ + row, + column, + text: item.text || "", + type: normalizeSeverity(item.type), + }); + } + } + + if (state && typeof state.field === "function") { + annotations.push(...readLspAnnotations(state)); + } + + return annotations; + } + + function readLspAnnotations(state) { + const diagnostics = getLspDiagnostics(state); + if (!diagnostics.length) return []; + + const doc = state.doc; + if (!doc || typeof doc.lineAt !== "function") return []; + + return diagnostics + .map((diagnostic) => { + const start = clampPosition(diagnostic.from, doc.length); + let row = 0; + let column = 0; + try { + const line = doc.lineAt(start); + row = Math.max(0, line.number - 1); + column = Math.max(0, start - line.from); + } catch (_) {} + + let message = diagnostic.message || ""; + if (diagnostic.source) { + message = message + ? `${message} (${diagnostic.source})` + : diagnostic.source; + } + + return { + row: normalizeIndex(row), + column: normalizeIndex(column), + text: message, + type: normalizeSeverity(diagnostic.severity), + }; + }) + .filter((annotation) => annotation.text); + } + + function clampPosition(pos, length) { + if (typeof pos !== "number" || Number.isNaN(pos)) return 0; + return Math.max(0, Math.min(pos, Math.max(0, length))); + } + + function normalizeIndex(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, value); + } + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.max(0, parsed); + } + return 0; + } + + function normalizeSeverity(severity) { + switch (severity) { + case "error": + case "fatal": + return "error"; + case "warn": + case "warning": + return "warning"; + default: + return "info"; + } + } + + function getIconForType(type) { + switch (type) { + case "error": + return "cancel"; + case "warning": + return "warningreport_problem"; + default: + return "info"; + } + } } diff --git a/src/settings/lspSettings.js b/src/settings/lspSettings.js new file mode 100644 index 000000000..071ecd0d7 --- /dev/null +++ b/src/settings/lspSettings.js @@ -0,0 +1,59 @@ +import serverRegistry from "cm/lsp/serverRegistry"; +import settingsPage from "components/settingsPage"; +import appSettings from "lib/settings"; + +function getServerOverride(id) { + return appSettings.value?.lsp?.servers?.[id] || {}; +} + +export default function lspSettings() { + const title = strings?.lsp_settings || "Language Servers"; + const servers = serverRegistry.listServers(); + + const items = []; + + for (const server of servers) { + const override = getServerOverride(server.id); + const serverEnabled = override.enabled ?? server.enabled; + const infoParts = []; + if (Array.isArray(server.languages) && server.languages.length) { + infoParts.push(server.languages.join(", ")); + } + items.push({ + key: `server:${server.id}`, + text: server.label, + checkbox: serverEnabled, + info: infoParts.join(" ยท ") || undefined, + }); + } + + return settingsPage(title, items, callback); + + async function callback(key, value) { + if (key.startsWith("server:")) { + const id = key.split(":")[1]; + const override = { + ...(appSettings.value.lsp?.servers?.[id] || {}), + enabled: !!value, + }; + await updateConfig({ servers: { [id]: override } }); + } + } + + async function updateConfig(partial) { + const current = JSON.parse(JSON.stringify(appSettings.value.lsp || {})); + if (partial.servers) { + current.servers = { + ...(current.servers || {}), + ...partial.servers, + }; + } + + await appSettings.update( + { + lsp: current, + }, + false, + ); + } +} diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js index 3df4c3dfc..247969444 100644 --- a/src/settings/mainSettings.js +++ b/src/settings/mainSettings.js @@ -17,6 +17,7 @@ import backupRestore from "./backupRestore"; import editorSettings from "./editorSettings"; import filesSettings from "./filesSettings"; import formatterSettings from "./formatterSettings"; +import lspSettings from "./lspSettings"; import previewSettings from "./previewSettings"; import scrollSettings from "./scrollSettings"; import searchSettings from "./searchSettings"; @@ -93,6 +94,12 @@ export default function mainSettings() { icon: "licons terminal", index: 5, }, + { + key: "lsp-settings", + text: strings?.lsp_settings || "Language servers", + icon: "psychology", + index: 7, + }, { key: "editSettings", text: `${strings["edit"]} settings.json`, @@ -125,6 +132,7 @@ export default function mainSettings() { case "editor-settings": case "preview-settings": case "terminal-settings": + case "lsp-settings": appSettings.uiSettings[key].show(); break; @@ -199,4 +207,5 @@ export default function mainSettings() { appSettings.uiSettings["search-settings"] = searchSettings(); appSettings.uiSettings["preview-settings"] = previewSettings(); appSettings.uiSettings["terminal-settings"] = terminalSettings(); + appSettings.uiSettings["lsp-settings"] = lspSettings(); } diff --git a/src/styles/codemirror.scss b/src/styles/codemirror.scss new file mode 100644 index 000000000..ac9876aad --- /dev/null +++ b/src/styles/codemirror.scss @@ -0,0 +1,111 @@ +.cm-tooltip { + box-sizing: border-box; + max-width: min(32rem, calc(100vw - 1.25rem)); + width: max-content; + padding: 0.4rem 0.45rem; + border-radius: 0; + overscroll-behavior: contain; + overflow-y: auto; + max-height: min(70vh, 22rem); + + .cm-tooltip-section + .cm-tooltip-section { + margin-top: 0.5rem; + } +} + +.cm-tooltip.cm-tooltip-hover { + font-size: 0.9rem; + line-height: 1.45; + word-break: break-word; + max-height: min(65vh, 20rem); +} + +.cm-tooltip.cm-tooltip-autocomplete { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: 0.4rem; + width: auto; + min-width: min(15rem, calc(100vw - 1.75rem)); + max-width: min(32rem, calc(100vw - 1.25rem)); + max-height: min(60vh, 20rem); + padding: 0.25rem; + overflow: visible; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul { + flex: 1 1 auto; + max-height: inherit; + overflow: auto; + padding: 0.25rem; + margin: 0; + scrollbar-gutter: stable; +} + +.cm-tooltip.cm-tooltip-autocomplete > ul > li { + display: flex; + align-items: center; + gap: 0.12rem; + padding: 0.3rem 0.36rem; + border-radius: 0.2rem; +} + +.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { + flex: 0 0 auto; + min-width: 1rem; + text-align: center; + line-height: 1; +} + +.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { + flex: 1 1 auto; + font-size: 0.95em; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { + font-weight: 600; +} + +.cm-tooltip.cm-tooltip-autocomplete .cm-completionDetail { + margin-left: auto; + font-size: 0.85em; +} + +.cm-tooltip.cm-tooltip-autocomplete .cm-completionInfo { + flex: 1 1 45%; + min-width: min(12rem, calc(100vw - 3rem)); + max-width: min(18rem, calc(100vw - 2.5rem)); + max-height: inherit; + padding: 0.3rem 0.35rem; + font-size: 0.85rem; + line-height: 1.35; + overflow: auto; +} + +@media (max-width: 480px) { + .cm-tooltip { + font-size: 0.9rem; + max-width: calc(100vw - 1.25rem); + max-height: min(70vh, 20rem); + } + + .cm-tooltip.cm-tooltip-autocomplete { + flex-direction: column; + min-width: min(13.5rem, calc(100vw - 1.5rem)); + max-width: calc(100vw - 1.35rem); + max-height: min(65vh, 18rem); + } + + .cm-tooltip.cm-tooltip-autocomplete > ul > li { + padding: 0.32rem 0.4rem; + } + + .cm-tooltip.cm-tooltip-autocomplete .cm-completionInfo { + min-width: auto; + max-width: 100%; + max-height: 12rem; + padding: 0.35rem 0.4rem 0.2rem; + } +} diff --git a/webpack.config.js b/webpack.config.js index f936408eb..207607e85 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -39,7 +39,7 @@ module.exports = (env, options) => { // if (mode === 'production') { rules.push({ test: /\.m?js$/, - exclude: /node_modules\/(@codemirror|codemirror)/, // Exclude CodeMirror files from html-tag-js loader + exclude: /node_modules\/(@codemirror|codemirror|marked)/, // Exclude CodeMirror and marked files from html-tag-js loader use: [ 'html-tag-js/jsx/tag-loader.js', { @@ -51,6 +51,20 @@ module.exports = (env, options) => { ], }); + // Separate rule for CodeMirror files - only babel-loader, no html-tag-js + rules.push({ + test: /\.m?js$/, + include: /node_modules\/(@codemirror|codemirror)/, + use: [ + { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-env'], + }, + }, + ], + }); + // Separate rule for CodeMirror files - only babel-loader, no html-tag-js rules.push({ test: /\.m?js$/, @@ -99,4 +113,4 @@ module.exports = (env, options) => { }; return [main]; -}; \ No newline at end of file +};