Early codeaction support

This commit is contained in:
Quinten Kock 2025-12-29 16:07:45 +01:00
parent c2ebaa17b8
commit a884736063
5 changed files with 211 additions and 11 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "miller", "name": "miller",
"productName": "miller", "productName": "miller",
"version": "0.2.3", "version": "0.2.4",
"description": "Column-based code editor", "description": "Column-based code editor",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"scripts": { "scripts": {

View File

@ -1,6 +1,4 @@
// Minimal LSP integration helper for the editor. import type * as lsp from "vscode-languageserver-protocol";
// Keeps all LSP-specific logic in one place so it's easy to review.
import { Extension, TransactionSpec } from "@codemirror/state"; import { Extension, TransactionSpec } from "@codemirror/state";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
@ -10,6 +8,7 @@ import {
Workspace, Workspace,
hoverTooltips, hoverTooltips,
signatureHelp, signatureHelp,
WorkspaceMapping
} from "@codemirror/lsp-client"; } from "@codemirror/lsp-client";
import { serverCompletion } from "./lsp/completion"; import { serverCompletion } from "./lsp/completion";
@ -79,6 +78,23 @@ export function inferLanguageFromPath(
if (ext === "ts" || ext === "tsx" || ext === "js" || ext === "jsx") if (ext === "ts" || ext === "tsx" || ext === "js" || ext === "jsx")
return "typescript"; return "typescript";
if (ext === "py") return "python"; 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 // add more mappings as needed
return undefined; 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`. // Public helper: attempt to create an LSP extension for `filePath`.
// Returns an empty array (no-op extension) on failure so callers can safely // Returns an empty array (no-op extension) on failure so callers can safely
// reconfigure their compartments with the returned value. // reconfigure their compartments with the returned value.

88
src/app/lsp/codeaction.ts Normal file
View File

@ -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<lsp.CodeAction[]>();
const codeActionGutterState = StateField.define<lsp.CodeAction[]>({
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,
}
}

View File

@ -1,17 +1,64 @@
import type * as lsp from "vscode-languageserver-protocol"; import * as lsp from "vscode-languageserver-protocol";
import { ViewPlugin, ViewUpdate } from "@codemirror/view"; import { StateField, StateEffect } from "@codemirror/state";
import { showPanel, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client"; import { LSPPlugin, LSPClientExtension } from "@codemirror/lsp-client";
import { OpenFile } from "../filestate"; import { OpenFile } from "../filestate";
import { Text } from "@codemirror/state"; 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<CodeAction[]>();
const codeActionState = StateField.define<CodeAction[]>({
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) { function toSeverity(sev: lsp.DiagnosticSeverity) {
return sev == 1 return sev == 1
? "error" ? "error"
: sev == 2 : sev == 2
? "warning" ? "warning"
: sev == 3 : sev == 3
? "info" ? "info"
: "hint"; : "hint";
} }
const autoSync = ViewPlugin.fromClass( const autoSync = ViewPlugin.fromClass(
@ -38,6 +85,26 @@ function fromPosition(doc: Text, pos: lsp.Position): number {
return line.from + pos.character; return line.from + pos.character;
} }
function fetchCodeActionsForDiagnostic(plugin: LSPPlugin, diag: lsp.Diagnostic): Promise<CodeAction[]> {
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 { export function serverDiagnostics(): LSPClientExtension {
return { return {
clientCapabilities: { clientCapabilities: {
@ -55,6 +122,7 @@ export function serverDiagnostics(): LSPClientExtension {
if (params.version != null && params.version != file.version) { if (params.version != null && params.version != file.version) {
return false; return false;
} }
file.setDiagnostics( file.setDiagnostics(
params.diagnostics.map((item) => ({ params.diagnostics.map((item) => ({
from: file.changes.mapPos( from: file.changes.mapPos(
@ -65,11 +133,20 @@ export function serverDiagnostics(): LSPClientExtension {
), ),
severity: toSeverity(item.severity ?? 1), severity: toSeverity(item.severity ?? 1),
message: item.message, 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; return true;
}, },
}, },
editorExtension: autoSync, editorExtension: [autoSync, codeActionState],
}; };
} }

View File

@ -19,6 +19,9 @@ const lspServers = new Map<string, LspEntry>();
const fallbackServerForLanguage: Record<string, string | undefined> = { const fallbackServerForLanguage: Record<string, string | undefined> = {
typescript: "npx typescript-language-server --log-level 4 --stdio", typescript: "npx typescript-language-server --log-level 4 --stdio",
python: "pylsp --check-parent-process", python: "pylsp --check-parent-process",
haskell: "haskell-language-server-wrapper --lsp",
rust: "rust-analyzer",
cpp: "clangd",
}; };
function ensureLspForKey( function ensureLspForKey(