Merge pull request #95 from levineuwirth/session-lsp-panels-p2
feat(panels): outline, code-action picker, hover-doc — Arc 1b phase 2
This commit is contained in:
commit
4199a1c272
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -6063,3 +6099,200 @@ fn m4_5_symbols_and_highlight_round_trip() {
|
|||
assert_eq!(g("_dh1k"), "2", "explicit DocumentHighlightKind (Read)");
|
||||
assert_eq!(g("_dh2k"), "1", "absent kind defaults to Text(1)");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Arc 1b phase 2 --- LSP panels (outline, hover-doc) end-to-end
|
||||
// ===========================================================================
|
||||
|
||||
/// Open `path` against the fake server and wait for initialization
|
||||
/// (shared bootstrap for the panel tests).
|
||||
fn open_against_fake(path: &std::path::Path) -> pmacs::editor::EditorState {
|
||||
let mut state = pmacs::editor::EditorState::new();
|
||||
let fake = fake_lsp_path();
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(format!("pmacs.lsp.config.rust = {{ command = '{fake}' }}"))
|
||||
.exec()
|
||||
.expect("override rust config");
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(format!("pmacs.buffer.find_or_open('{}')", path.display()))
|
||||
.exec()
|
||||
.expect("open file against fake");
|
||||
assert!(
|
||||
pump_lua_flag(
|
||||
&mut state,
|
||||
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
|
||||
if r.state and r.state.kind=='initialized' then return true end \
|
||||
end return false end)()",
|
||||
5,
|
||||
),
|
||||
"fake never initialized"
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// The *outline* panel end-to-end against the fake server's
|
||||
/// hierarchical documentSymbol response ("Outer" class > "inner"
|
||||
/// method): open, depth-indented rows, RET jump-ring visit to the
|
||||
/// symbol's selectionRange, M-, back to the outline row, q restore.
|
||||
#[test]
|
||||
fn outline_panel_opens_visits_and_restores() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let a_path = dir.path().join("a.rs");
|
||||
std::fs::write(
|
||||
&a_path,
|
||||
b"l0\nl1\nl2\nl3 inner here\nl4\nl5\nl6\nl7\nl8\nl9\n",
|
||||
)
|
||||
.expect("write a");
|
||||
|
||||
let mut state = open_against_fake(&a_path);
|
||||
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.lsp.document_symbols()")
|
||||
.exec()
|
||||
.expect("invoke document symbols");
|
||||
assert!(
|
||||
pump_lua_flag(
|
||||
&mut state,
|
||||
"pmacs.describe.buffer(pmacs.window.buffer()).name == '*outline*'",
|
||||
5,
|
||||
),
|
||||
"the outline panel never opened"
|
||||
);
|
||||
|
||||
let text: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("local b = pmacs.window.buffer() return b:slice(0, b:len())")
|
||||
.eval()
|
||||
.expect("outline text");
|
||||
assert!(
|
||||
text.contains("Outer [class]"),
|
||||
"top-level symbol row with kind tag; got {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("\n inner [method]"),
|
||||
"depth-1 symbol indents two spaces; got {text:?}"
|
||||
);
|
||||
|
||||
// n moves to the second row (inner); RET visits its
|
||||
// selectionRange (line 3, col 7 in the fake's response).
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
|
||||
);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
let (name, line, col): (String, i64, i64) = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r"
|
||||
local d = pmacs.describe.buffer(pmacs.window.buffer())
|
||||
return d.name, pmacs.editor.cursor_line(), pmacs.editor.cursor_col()
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("post-visit probe");
|
||||
assert!(name.ends_with("a.rs"), "RET returns to the source buffer");
|
||||
assert_eq!(
|
||||
(line, col),
|
||||
(3, 7),
|
||||
"cursor lands on inner's selectionRange"
|
||||
);
|
||||
|
||||
// M-, returns to the outline row (the visit pushed the jump ring
|
||||
// from the panel).
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.editor.jump_back()")
|
||||
.exec()
|
||||
.expect("jump back");
|
||||
let name: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return pmacs.describe.buffer(pmacs.window.buffer()).name")
|
||||
.eval()
|
||||
.expect("post-jump-back probe");
|
||||
assert_eq!(name, "*outline*", "M-, returns to the outline panel");
|
||||
|
||||
// q restores the source buffer.
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
);
|
||||
let name: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return pmacs.describe.buffer(pmacs.window.buffer()).name")
|
||||
.eval()
|
||||
.expect("post-q probe");
|
||||
assert!(name.ends_with("a.rs"), "q restores the source buffer");
|
||||
}
|
||||
|
||||
/// The *lsp-help* panel end-to-end, driven through the real `C-c H`
|
||||
/// keybinding (shifted-letter chord --- this test is also the
|
||||
/// binding's parse check): full multi-line hover contents render,
|
||||
/// q restores.
|
||||
#[test]
|
||||
fn hover_doc_panel_shows_full_contents_via_binding() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let a_path = dir.path().join("h.rs");
|
||||
std::fs::write(&a_path, b"fn main() {}\n").expect("write h");
|
||||
|
||||
let mut state = open_against_fake(&a_path);
|
||||
|
||||
// The real chord: C-c, then Shift+h (terminals deliver uppercase
|
||||
// Char('H') with the SHIFT modifier set).
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT),
|
||||
);
|
||||
assert!(
|
||||
pump_lua_flag(
|
||||
&mut state,
|
||||
"pmacs.describe.buffer(pmacs.window.buffer()).name == '*lsp-help*'",
|
||||
5,
|
||||
),
|
||||
"C-c H never opened the hover panel (chord parse or binding gap)"
|
||||
);
|
||||
|
||||
let text: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("local b = pmacs.window.buffer() return b:slice(0, b:len())")
|
||||
.eval()
|
||||
.expect("hover panel text");
|
||||
assert!(
|
||||
text.contains("Synthetic hover content"),
|
||||
"the full hover body renders; got {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# pmacs-fake-lsp"),
|
||||
"multi-line contents keep their first line; got {text:?}"
|
||||
);
|
||||
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
);
|
||||
let name: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return pmacs.describe.buffer(pmacs.window.buffer()).name")
|
||||
.eval()
|
||||
.expect("post-q probe");
|
||||
assert!(name.ends_with("h.rs"), "q restores the source buffer");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue