From 74ff468e74b1010ae15a1f493ac6dd33dd87c7f9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 21:30:52 -0400 Subject: [PATCH] feat(panels): outline, code-action picker, hover-doc (Arc 1b phase 2) Pure Lua on the phase-1 substrate (framing Q#P5). Outline: lsp.document-symbols (C-c o) opens *outline* -- the store's FLAT symbol rows indent by their depth field with an LSP SymbolKind tag; RET pushes the jump ring, restores the source buffer, and moves to the symbol (M-, returns to the outline row, the references-panel semantics). Code actions: lsp.code-actions (C-c a) applies a single action directly (previous behavior, now correct instead of lucky) and opens the minibuffer dropdown when several are available -- 'N: title' candidates; a bare typed index also accepts. The apply branch is extracted as apply_code_action, shared by both paths. The m4_14/m4_15 acceptance tests (written against blind-first-apply; the fake LSP returns two actions) now drive the picker: pump until the prompt is live, type '1', RET -- same command-only action as before. Hover doc: new lsp.hover-doc (C-c H) renders the full multi-line hover contents into a non-visitable *lsp-help* panel; lsp.hover (C-c h) keeps its one-line echo-area summary. Co-Authored-By: Claude Fable 5 --- builtin/runtime/lsp.lua | 176 ++++++++++++++++++++++++++++++++-------- tests/m4_acceptance.rs | 36 ++++++++ 2 files changed, 180 insertions(+), 32 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index d2b171b..75be46b 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1234,6 +1234,15 @@ function pmacs.lsp.go_to_definition() end) end +-- LSP SymbolKind (1..=26) -> short outline tag (Arc 1b phase 2). +local SYMBOL_KIND_TAGS = { + "file", "module", "namespace", "package", "class", "method", + "property", "field", "constructor", "enum", "interface", "function", + "variable", "constant", "string", "number", "boolean", "array", + "object", "key", "null", "enum-member", "struct", "event", + "operator", "type-parameter", +} + -- Visit one LSP location (Arc 1b): the SP-4 cross-file template --- -- jump ring, find-or-open, cursor walk. Same-buffer hits skip the -- open. Shared by the references panel (and the outline in phase 2). @@ -1335,14 +1344,38 @@ function pmacs.lsp.document_symbols() pmacs.editor.set_status("LSP: no symbols") return end - -- v1 modeline summary (count + first symbol); a structured - -- outline buffer driven off this store is future UX work, like - -- the references list and hover panel. - local first = syms[1] + -- Arc 1b phase 2: a browsable *outline* panel. Symbols arrive + -- FLAT with a `depth` field --- indent, don't recurse. RET + -- visits (jump ring: M-, returns to the outline row); q restores. + local source_buf = rec.buffer + local rows = {} + for _, sym in ipairs(syms) do + local tag = SYMBOL_KIND_TAGS[sym.kind] or "symbol" + rows[#rows + 1] = { + text = string.format( + "%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag), + item = sym, + } + end + pmacs.listview.open { + name = "*outline*", + header = string.format( + "%d symbol%s RET visit n/p move q quit", + #syms, (#syms == 1 and "" or "s")), + rows = rows, + on_visit = function(sym) + pmacs.editor.push_jump() + local okv = pcall(pmacs.window.switch_buffer, source_buf) + if not okv then + pmacs.editor.jump_back() + pmacs.editor.set_status("LSP: outline source buffer is gone") + return + end + move_active_cursor_to(sym.line, sym.col) + end, + } pmacs.editor.set_status(string.format( - "LSP: %d symbol%s; first '%s' at %d:%d", - #syms, (#syms == 1 and "" or "s"), - first.name, first.line + 1, first.col + 1)) + "LSP: %d symbol%s", #syms, (#syms == 1 and "" or "s"))) end) end @@ -1569,6 +1602,38 @@ end -- the pump installed below). A selection UI over multiple actions is -- future UX work, like the references list and hover panel — v1 -- acts on the first and reports how many were offered. +-- Apply one code action (Arc 1b phase 2: shared by the direct path +-- and the picker). Runs its WorkspaceEdit inline and/or awaits its +-- executeCommand, then reports what happened. Must run inside a +-- `pmacs.async` coroutine. +local function apply_code_action(rec, act) + local bits = {} + if act.has_edit then + local n, files, res = apply_workspace_edit(act.edit) + if not n then + pmacs.editor.set_status("LSP: code action aborted: " .. tostring(files)) + return + end + local b = string.format("%d edit(s) / %d file(s)", n, files) + if res and res > 0 then b = b .. string.format(" / %d file op(s)", res) end + table.insert(bits, b) + end + if act.command then + local ok, cerr = pcall(function() + pmacs.lsp.request_execute_command( + rec.server, act.command.command, act.command.arguments):await() + end) + if not ok then + pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr)) + return + end + table.insert(bits, "ran '" .. act.command.command .. "'") + end + local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or "" + pmacs.editor.set_status(string.format( + "LSP: code action '%s'%s", act.title, detail)) +end + function pmacs.lsp.code_actions() local rec = attached_for_active() if not rec then @@ -1592,33 +1657,35 @@ function pmacs.lsp.code_actions() pmacs.editor.set_status("LSP: no code actions") return end - local first = acts[1] - local bits = {} - if first.has_edit then - local n, files, res = apply_workspace_edit(first.edit) - if not n then - pmacs.editor.set_status("LSP: code action aborted: " .. tostring(files)) - return - end - local b = string.format("%d edit(s) / %d file(s)", n, files) - if res and res > 0 then b = b .. string.format(" / %d file op(s)", res) end - table.insert(bits, b) + -- Arc 1b phase 2: one action applies directly (today's behavior, + -- now correct instead of lucky); several open the minibuffer + -- dropdown so the USER picks — v1 applied acts[1] blind. + if #acts == 1 then + apply_code_action(rec, acts[1]) + return end - if first.command then - local ok2, cerr = pcall(function() - pmacs.lsp.request_execute_command( - rec.server, first.command.command, first.command.arguments):await() - end) - if not ok2 then - pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr)) - return - end - table.insert(bits, "ran '" .. first.command.command .. "'") + local labels = {} + for i, a in ipairs(acts) do + labels[i] = string.format("%d: %s", i, a.title) end - local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or "" - pmacs.editor.set_status(string.format( - "LSP: code action '%s'%s (%d available)", - first.title, detail, #acts)) + pmacs.minibuffer.read { + prompt = string.format("Code action (%d): ", #acts), + source = function() return labels end, + on_accept = function(choice) + if not choice or choice == "" then return end + -- Accept both the completed candidate ("2: Inline fix") + -- and a bare typed index ("2"). + local idx = tonumber(choice:match("^(%d+)")) + local act = idx and acts[idx] + if not act then + pmacs.editor.set_status("LSP: no such code action") + return + end + pmacs.async(function() + apply_code_action(rec, act) + end) + end, + } end) end @@ -1652,6 +1719,44 @@ function pmacs.lsp.hover_at_cursor() end) end +-- Arc 1b phase 2: the full (multi-line) hover body in a *lsp-help* +-- panel --- `lsp.hover` keeps its one-line echo-area summary; this is +-- the "show me everything" companion. Rows are non-visitable +-- (item = nil, so RET is a no-op); q restores the source buffer. +function pmacs.lsp.hover_doc() + local rec = attached_for_active() + if not rec then + pmacs.editor.set_status("LSP: no server for active buffer") + return + end + local line = pmacs.editor.cursor_line() + local col = pmacs.editor.cursor_col() + pmacs.hover.clear(rec.server, rec.uri) + pmacs.async(function() + local ok, err = pcall(function() + pmacs.lsp.request_hover(rec.server, rec.uri, line, col):await() + end) + if not ok then + pmacs.editor.set_status("LSP: " .. lsp_await_error(err)) + return + end + local hover = pmacs.hover.current(rec.server, rec.uri) + if not hover or not hover.contents or hover.contents == "" then + pmacs.editor.set_status("LSP: no hover info") + return + end + local rows = {} + for l in (hover.contents .. "\n"):gmatch("(.-)\n") do + rows[#rows + 1] = { text = l } + end + pmacs.listview.open { + name = "*lsp-help*", + header = "hover documentation q quit", + rows = rows, + } + end) +end + function pmacs.lsp.signature_help_at_cursor() local rec = attached_for_active() if not rec then @@ -1693,6 +1798,12 @@ pmacs.command.define { fn = pmacs.lsp.format_buffer, } +pmacs.command.define { + name = "lsp.hover-doc", + description = "Show the full hover documentation in a *lsp-help* panel.", + fn = pmacs.lsp.hover_doc, +} + pmacs.command.define { name = "lsp.hover", description = "Surface the hover documentation for the symbol under the cursor.", @@ -1767,6 +1878,7 @@ pmacs.keymap.bind { scope = "global", sequence = "C-c a", command = "lsp.code-ac pmacs.keymap.bind { scope = "global", sequence = "C-c i", command = "lsp.inlay-hints" } pmacs.keymap.bind { scope = "global", sequence = "C-c y", command = "lsp.semantic-tokens" } pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" } +pmacs.keymap.bind { scope = "global", sequence = "C-c H", command = "lsp.hover-doc" } pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" } pmacs.keymap.bind { scope = "global", sequence = "C-c f", command = "lsp.format-buffer" } diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index bfa07d4..dd75c4c 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -3850,6 +3850,24 @@ fn m4_14_code_action_command_drives_apply_edit() { .exec() .expect("invoke code actions"); + // Arc 1b phase 2: with two actions available, `code_actions` now + // opens the minibuffer picker instead of blind-applying the + // first. Pump until the prompt is live, then pick action 1 (the + // command-only action, preserving this test's original subject) + // by typed index + RET. + assert!( + pump_lua_flag(&mut state, "#pmacs.minibuffer.candidates() > 0", 5), + "code-action picker never opened" + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + // The applyEdit pump runs on the async tick; it applies the // server's out-of-band edit, turning line 1 "___zzz" -> "ED2zzz". assert!( @@ -3944,6 +3962,24 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() { .exec() .expect("invoke code actions"); + // Arc 1b phase 2: with two actions available, `code_actions` now + // opens the minibuffer picker instead of blind-applying the + // first. Pump until the prompt is live, then pick action 1 (the + // command-only action, preserving this test's original subject) + // by typed index + RET. + assert!( + pump_lua_flag(&mut state, "#pmacs.minibuffer.candidates() > 0", 5), + "code-action picker never opened" + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + // Completion signal: the created file exists on disk. Tick the // full frame order (processes → lsp → async) so the // executeCommand round-trip, the server-initiated applyEdit