From a8847360631f7e6b94d921aa5912d4a4acf85d51 Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 29 Dec 2025 16:07:45 +0100 Subject: [PATCH] Early codeaction support --- package.json | 2 +- src/app/lsp.ts | 38 ++++++++++++++-- src/app/lsp/codeaction.ts | 88 ++++++++++++++++++++++++++++++++++++ src/app/lsp/diagnostics.ts | 91 +++++++++++++++++++++++++++++++++++--- src/main/langserver.ts | 3 ++ 5 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 src/app/lsp/codeaction.ts diff --git a/package.json b/package.json index ca8fcf9..6eb1c77 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "miller", "productName": "miller", - "version": "0.2.3", + "version": "0.2.4", "description": "Column-based code editor", "main": ".vite/build/main.js", "scripts": { diff --git a/src/app/lsp.ts b/src/app/lsp.ts index 4a5316c..56f9a3d 100644 --- a/src/app/lsp.ts +++ b/src/app/lsp.ts @@ -1,6 +1,4 @@ -// Minimal LSP integration helper for the editor. -// Keeps all LSP-specific logic in one place so it's easy to review. - +import type * as lsp from "vscode-languageserver-protocol"; import { Extension, TransactionSpec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -10,6 +8,7 @@ import { Workspace, hoverTooltips, signatureHelp, + WorkspaceMapping } from "@codemirror/lsp-client"; import { serverCompletion } from "./lsp/completion"; @@ -79,6 +78,23 @@ export function inferLanguageFromPath( if (ext === "ts" || ext === "tsx" || ext === "js" || ext === "jsx") return "typescript"; if (ext === "py") return "python"; + if (ext === "c" || ext === "h" || ext === "cpp" || ext === "hpp") + return "cpp"; + if (ext === "java") return "java"; + if (ext === "go") return "go"; + if (ext === "rs") return "rust"; + if (ext === "php") return "php"; + if (ext === "rb") return "ruby"; + if (ext === "cs") return "csharp"; + if (ext === "html" || ext === "htm") return "html"; + if (ext === "css" || ext === "scss" || ext === "less") return "css"; + if (ext === "json") return "json"; + if (ext === "xml") return "xml"; + if (ext === "yaml" || ext === "yml") return "yaml"; + if (ext === "md") return "markdown"; + if (ext === "lua") return "lua"; + if (ext === "sh" || ext === "bash") return "shellscript"; + if (ext === "hs") return "haskell"; // add more mappings as needed return undefined; } @@ -158,6 +174,22 @@ class OpenFileWorkspace extends Workspace { } } +export function applyWorkspaceEdit(mapping: WorkspaceMapping, workspace: Workspace, edit: lsp.WorkspaceEdit, userEvent: string) { + for (const uri in edit.changes) { + const lspChanges = edit.changes[uri]; + const file = workspace.getFile(uri); + if (!lspChanges.length || !file) continue; + workspace.updateFile(uri, { + changes: lspChanges.map(change => ({ + from: mapping.mapPosition(uri, change.range.start), + to: mapping.mapPosition(uri, change.range.end), + insert: change.newText, + })), + userEvent, + }) + } +} + // Public helper: attempt to create an LSP extension for `filePath`. // Returns an empty array (no-op extension) on failure so callers can safely // reconfigure their compartments with the returned value. diff --git a/src/app/lsp/codeaction.ts b/src/app/lsp/codeaction.ts new file mode 100644 index 0000000..e0e6299 --- /dev/null +++ b/src/app/lsp/codeaction.ts @@ -0,0 +1,88 @@ +import * as lsp from "vscode-languageserver-protocol"; +import { StateEffect, StateField } from "@codemirror/state"; +import { GutterMarker, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client"; + +type GutterCodeAction = { + +} + +const codeActionGutterMarker = new class extends GutterMarker { + toDOM() { return document.createTextNode("💡"); } +}; + +const codeActionGutterEffect = StateEffect.define(); + +const codeActionGutterState = StateField.define({ + create() { return []; }, + update(updateCodeActions, tr) { + for (let e of tr.effects) { + if (e.is(codeActionGutterEffect)) { + return e.value; + } + } + return updateCodeActions; + } +}); + +const syncCodeAction = ViewPlugin.fromClass( + class { + pending: any | null = null; + update(update: ViewUpdate) { + if (update.docChanged) { + if (this.pending != null) clearTimeout(this.pending); + this.pending = setTimeout(() => { + this.pending = null; + const plugin = LSPPlugin.get(update.view); + if (plugin) { + plugin.client.sync(); + updateCodeActions(plugin); + } + }, 500); + } + } + destroy() { + if (this.pending != null) clearTimeout(this.pending); + } + }, +); + +function updateCodeActions(plugin: LSPPlugin) { + const hasCap = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["codeActionProvider"] : null; + if (hasCap === false) return Promise.resolve(null); + const params: lsp.CodeActionParams = { + textDocument: { uri: plugin.uri }, + range: { + start: plugin.toPosition(0), + end: plugin.toPosition(plugin.syncedDoc.length), + }, + context: { + diagnostics: [], // TODO fix + triggerKind: lsp.CodeActionTriggerKind.Automatic, + } + } + plugin.client.request< + lsp.CodeActionParams, + (lsp.Command | lsp.CodeAction)[] + >( + "textDocument/codeAction", + params + ).then((actions) => { + const file = plugin.client.workspace.getFile(plugin.uri) as OpenFile; + if (!file) return false; + if () + + }); +} + +export function codeAction(config: {}): LSPClientExtension { + return { + clientCapabilities: { + codeAction: { + dataSupport: true, + resolveSupport: ["edit"], + } + }, + editorExtension: syncCodeAction, + } +} \ No newline at end of file diff --git a/src/app/lsp/diagnostics.ts b/src/app/lsp/diagnostics.ts index 64a4579..6fed573 100644 --- a/src/app/lsp/diagnostics.ts +++ b/src/app/lsp/diagnostics.ts @@ -1,17 +1,64 @@ -import type * as lsp from "vscode-languageserver-protocol"; -import { ViewPlugin, ViewUpdate } from "@codemirror/view"; +import * as lsp from "vscode-languageserver-protocol"; +import { StateField, StateEffect } from "@codemirror/state"; +import { showPanel, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client"; import { OpenFile } from "../filestate"; import { Text } from "@codemirror/state"; +import { applyWorkspaceEdit } from "../lsp"; + +import van from "vanjs-core"; +const v = van.tags; + +type CodeAction = (lsp.CodeAction | lsp.Command); + +const setCodeActions = StateEffect.define(); + +const codeActionState = StateField.define({ + create: () => null, + update(value, tr) { + for (let e of tr.effects) { + if (e.is(setCodeActions)) value = e.value; + } + return value; + }, + provide: f => showPanel.from(f, ca => ca ? createCodeActionPanel : null), +}); + +function createCodeActionPanel(view: EditorView) { + const plugin = LSPPlugin.get(view); + const actions = view.state.field(codeActionState); + const mapping = plugin.client.workspaceMapping(); + // TODO cleanup mapping when done + // TODO use codemirror/view.showDialog instead + const list = v.ul(actions.map(a => { + if (!Object.hasOwn(a, "edit")) return null; + const action = a as lsp.CodeAction; + // TODO action resolving + const onclick = () => { + applyWorkspaceEdit(mapping, plugin.client.workspace, action.edit, "codeaction"); + view.dispatch({effects: setCodeActions.of(null)}); + } + return v.li(v.a({onclick}, action.title)); + })); + const closeBtn = v.button({ + class: "absolute top-0 right-5", + type: "button", + name: "close", + "aria-label": view.state.phrase("close"), + onclick: () => view.dispatch({effects: setCodeActions.of(null)}), + }, "×"); + const dom = v.div(list, closeBtn); + return { top: false, dom }; +} function toSeverity(sev: lsp.DiagnosticSeverity) { return sev == 1 ? "error" : sev == 2 - ? "warning" - : sev == 3 - ? "info" - : "hint"; + ? "warning" + : sev == 3 + ? "info" + : "hint"; } const autoSync = ViewPlugin.fromClass( @@ -38,6 +85,26 @@ function fromPosition(doc: Text, pos: lsp.Position): number { return line.from + pos.character; } +function fetchCodeActionsForDiagnostic(plugin: LSPPlugin, diag: lsp.Diagnostic): Promise { + const hasCap = plugin.client.serverCapabilities ? !!plugin.client.serverCapabilities["codeActionProvider"] : null; + if (hasCap === false) return Promise.resolve(null); + const params: lsp.CodeActionParams = { + textDocument: { uri: plugin.uri }, + range: diag.range, + context: { + diagnostics: [diag], // TODO multiple? + triggerKind: lsp.CodeActionTriggerKind.Invoked, + } + } + return plugin.client.request< + lsp.CodeActionParams, + (lsp.Command | lsp.CodeAction)[] + >( + "textDocument/codeAction", + params + ); +} + export function serverDiagnostics(): LSPClientExtension { return { clientCapabilities: { @@ -55,6 +122,7 @@ export function serverDiagnostics(): LSPClientExtension { if (params.version != null && params.version != file.version) { return false; } + file.setDiagnostics( params.diagnostics.map((item) => ({ from: file.changes.mapPos( @@ -65,11 +133,20 @@ export function serverDiagnostics(): LSPClientExtension { ), severity: toSeverity(item.severity ?? 1), message: item.message, + actions: [{ + name: "Solve", + apply: async (view) => { + const plugin = LSPPlugin.get(view); + const a = await fetchCodeActionsForDiagnostic(plugin, item); + const effects = setCodeActions.of(a); + view.dispatch({ effects }); + }, + }] })), ); return true; }, }, - editorExtension: autoSync, + editorExtension: [autoSync, codeActionState], }; } diff --git a/src/main/langserver.ts b/src/main/langserver.ts index a5bdc39..029ce80 100644 --- a/src/main/langserver.ts +++ b/src/main/langserver.ts @@ -19,6 +19,9 @@ const lspServers = new Map(); const fallbackServerForLanguage: Record = { typescript: "npx typescript-language-server --log-level 4 --stdio", python: "pylsp --check-parent-process", + haskell: "haskell-language-server-wrapper --lsp", + rust: "rust-analyzer", + cpp: "clangd", }; function ensureLspForKey(