From fe0ee338f404c79263fc673ce4cdbd0bfc3227b7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:47:12 +0800 Subject: [PATCH] feat(debugger): completion in Evaluate, the Variables evaluate field and the Debug console (#1102) While suspended, the debugger's expression fields complete from the debug adapter (DAP completions) in the selected frame: Evaluate, the Variables evaluate field and the Debug console share one hook. The list opens while typing an identifier or after '.', or on Ctrl+Space; Up/Down, Enter/Tab, Escape. The Evaluate dialog is resizable and its result tree grows with it. Backend: ide-dap completions (skipped without supportsCompletionsRequest), debug_completions on the host and the desktop app. --- crates/ide-dap/src/lib.rs | 54 +++++ crates/ide-dap/src/tests.rs | 17 ++ crates/ide-host/src/ops/debug.rs | 25 +++ src-tauri/src/commands/debug.rs | 14 ++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 13 ++ .../__tests__/expressionCompletion.test.tsx | 96 +++++++++ src/features/debugger/ui/DebugConsole.tsx | 14 +- src/features/debugger/ui/DebugToolWindow.tsx | 12 +- .../ui/ExpressionCompletion.module.css | 38 ++++ .../debugger/ui/ExpressionCompletion.tsx | 188 ++++++++++++++++++ src/features/debugger/ui/Overlays.module.css | 7 +- src/features/debugger/ui/Overlays.tsx | 8 +- 13 files changed, 481 insertions(+), 6 deletions(-) create mode 100644 src/features/debugger/__tests__/expressionCompletion.test.tsx create mode 100644 src/features/debugger/ui/ExpressionCompletion.module.css create mode 100644 src/features/debugger/ui/ExpressionCompletion.tsx diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 5b8786a1..1be66239 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -212,6 +212,19 @@ pub struct Variable { pub evaluate_name: Option, } +/// A completion the adapter offers for an expression (DAP `completions`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DebugCompletion { + pub label: String, + /// What to insert, when it differs from `label`. + pub text: Option, + /// DAP item type: `method`, `field`, `variable`, `class`, `keyword`, … + pub kind: Option, + pub detail: Option, + pub sort_text: Option, +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum StepKind { @@ -1157,6 +1170,47 @@ impl DebugManager { }) } + /// Completions for `text` at `line` / `column` (0-based, UTF-16) in the frame's scope; none + /// when the adapter doesn't offer them. + pub async fn completions( + &self, + s: DebugSessionId, + frame_id: Option, + text: String, + line: u32, + column: u32, + ) -> IdeResult> { + let session = self.session(s)?; + if !session.supports("supportsCompletionsRequest") { + return Ok(Vec::new()); + } + let mut args = json!({ "text": text, "line": line + 1, "column": column + 1 }); + if let Some(f) = frame_id { + args["frameId"] = f.into(); + } + let body = session.conn.request_timeout("completions", args, REQUEST_TIMEOUT).await?; + let text = + |v: &Value, key: &str| v.get(key).and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned); + Ok(body + .get("targets") + .and_then(Value::as_array) + .map(|targets| { + targets + .iter() + .filter_map(|t| { + Some(DebugCompletion { + label: text(t, "label")?, + text: text(t, "text"), + kind: text(t, "type"), + detail: text(t, "detail"), + sort_text: text(t, "sortText"), + }) + }) + .collect() + }) + .unwrap_or_default()) + } + pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> { let session = self.session(s)?; let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?; diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index f108c0fb..7f5930b3 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -73,6 +73,7 @@ async fn fake_adapter(stream msg, json!({ "supportsConfigurationDoneRequest": true, + "supportsCompletionsRequest": true, "supportsTerminateRequest": true, "supportsSetVariable": true, "supportsFunctionBreakpoints": true, @@ -172,6 +173,14 @@ async fn fake_adapter(stream ]}) ), "evaluate" => ok!(msg, json!({"result": "42", "type": "int", "variablesReference": 0})), + "completions" => ok!( + msg, + json!({"targets": [ + {"label": "getName()", "text": "getName", "type": "method", "detail": "String"}, + {"label": "name", "type": "field"}, + {"label": ""}, + ]}) + ), "continue" => { ok!(msg, json!({"allThreadsContinued": true})); send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}})); @@ -368,6 +377,14 @@ async fn full_protocol_flow() { let eval = h.request("evaluate"); assert_eq!(eval["arguments"]["context"], "watch"); assert_eq!(eval["arguments"]["frameId"], 10); + let items = h.manager.completions(id, Some(10), "req.na".into(), 0, 6).await.unwrap(); + assert_eq!( + items.iter().map(|c| (c.label.as_str(), c.text.as_deref(), c.kind.as_deref())).collect::>(), + [("getName()", Some("getName"), Some("method")), ("name", None, Some("field"))] + ); + let asked = h.request("completions"); + assert_eq!((asked["arguments"]["line"].clone(), asked["arguments"]["column"].clone()), (json!(1), json!(7))); + assert_eq!(asked["arguments"]["frameId"], 10); // Live breakpoint updates reach the session. let states = h.manager.set_breakpoints(path.clone(), vec![bp(0)]).await.unwrap(); diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 2af1eedd..0d17a593 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -188,6 +188,19 @@ pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) -> host.debugger.stop(session).await } +/// The adapter's completions for an expression being typed in the frame `frame_id`. +pub async fn debug_completions( + host: &Host, + _window: &str, + session: DebugSessionId, + frame_id: Option, + text: String, + line: u32, + column: u32, +) -> IdeResult> { + host.debugger.completions(session, frame_id, text, line, column).await +} + /// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`. pub async fn debug_evaluate_in( host: &Host, @@ -376,6 +389,18 @@ pub(crate) fn register(d: &mut Dispatcher) { .await }); d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await }); + d.add("debug_completions", |c| async move { + debug_completions( + &c.host, + &c.window, + c.arg("session")?, + c.arg("frameId")?, + c.arg("text")?, + c.arg("line")?, + c.arg("column")?, + ) + .await + }); d.add("debug_evaluate_in", |c| async move { debug_evaluate_in( &c.host, diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index eb213460..e2a23a2f 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -72,6 +72,20 @@ pub async fn debug_evaluate( ide_host::ops::debug::debug_evaluate(&state.host, "", session, frame_id, expression).await } +/// Completions for an expression typed in Evaluate (`line` / `column` 0-based, UTF-16). +#[tauri::command] +#[specta::specta] +pub async fn debug_completions( + state: State<'_, AppState>, + session: DebugSessionId, + frame_id: Option, + text: String, + line: u32, + column: u32, +) -> IdeResult> { + ide_host::ops::debug::debug_completions(&state.host, "", session, frame_id, text, line, column).await +} + #[tauri::command] #[specta::specta] pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2a7ec4c3..a5d1e271 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -349,6 +349,7 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_scopes, debug::debug_variables, debug::debug_evaluate, + debug::debug_completions, debug::debug_resume, debug::debug_pause, debug::debug_step, diff --git a/src/bindings.ts b/src/bindings.ts index cc6f2b8d..7c597233 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -671,6 +671,8 @@ export const commands = { debugScopes: (session: DebugSessionId, frameId: number) => typedError(__TAURI_INVOKE("debug_scopes", { session, frameId })), debugVariables: (session: DebugSessionId, reference: number) => typedError(__TAURI_INVOKE("debug_variables", { session, reference })), debugEvaluate: (session: DebugSessionId, frameId: number | null, expression: string) => typedError(__TAURI_INVOKE("debug_evaluate", { session, frameId, expression })), + /** Completions for an expression typed in Evaluate (`line` / `column` 0-based, UTF-16). */ + debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })), debugResume: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_resume", { session, threadId })), debugPause: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_pause", { session, threadId })), debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError(__TAURI_INVOKE("debug_step", { session, threadId, kind })), @@ -2584,6 +2586,17 @@ export type DebugCapabilities = { hotCodeReplace: boolean, }; +/** A completion the adapter offers for an expression (DAP `completions`). */ +export type DebugCompletion = { + label: string, + /** What to insert, when it differs from `label`. */ + text: string | null, + /** DAP item type: `method`, `field`, `variable`, `class`, `keyword`, … */ + kind: string | null, + detail: string | null, + sortText: string | null, +}; + export type DebugEvent = /** * First event of every session (only build output of a root session's diff --git a/src/features/debugger/__tests__/expressionCompletion.test.tsx b/src/features/debugger/__tests__/expressionCompletion.test.tsx new file mode 100644 index 00000000..f76dba5c --- /dev/null +++ b/src/features/debugger/__tests__/expressionCompletion.test.tsx @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +const { completions } = vi.hoisted(() => ({ completions: vi.fn() })); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, commands: { ...real.commands, debugCompletions: completions } }; +}); + +import { useDebug, type DebugSession } from "../state"; +import { DebuggerOverlays } from "../ui/Overlays"; +import { applyCompletion, completionPrefix, matchingCompletions } from "../ui/ExpressionCompletion"; +import { openEvaluateDialog, useOverlays } from "../ui/popoverStore"; + +const session: DebugSession = { + id: 1, + name: "main", + adapter: "java", + parent: null, + launch: null, + process: null, + status: "paused", + stoppedThread: 1, + stopReason: "breakpoint", + stopDescription: null, + threads: [{ id: 1, name: "main" }], + selectedThread: 1, + frames: [{ id: 7, name: "run", path: "/p/App.java", line: 11, column: 0, presentationHint: null }], + selectedFrame: 7, + hits: 1, + exitCode: null, + caps: null, +}; + +afterEach(() => { + act(() => useOverlays.setState({ evaluate: null })); + completions.mockReset(); + vi.useRealTimers(); +}); + +describe("expression completion", () => { + it("replaces the identifier before the caret and keeps matching as more is typed", () => { + expect(completionPrefix("request.getHe", 13)).toBe("getHe"); + expect(completionPrefix("request.", 8)).toBe(""); + const items = [ + { label: "getHeader(String)", text: "getHeader(", kind: "method", detail: null, sortText: null }, + { label: "getMethod()", text: null, kind: "method", detail: null, sortText: null }, + ]; + expect(matchingCompletions(items, "getHe").map((i) => i.label)).toEqual(["getHeader(String)"]); + expect(applyCompletion("request.getHe + 1", 13, items[0]!)).toEqual({ text: "request.getHeader( + 1", caret: 18 }); + const members = ["__init__", "name", "_cache", "age", "name"].map((label) => ({ + label, + text: null, + kind: null, + detail: null, + sortText: null, + })); + expect(matchingCompletions(members, "").map((i) => i.label)).toEqual(["age", "name", "__init__", "_cache"]); + expect(matchingCompletions(members, "_").map((i) => i.label)).toEqual(["__init__", "_cache"]); + const named = ["Request", "request"].map((label) => ({ + label, + text: null, + kind: null, + detail: null, + sortText: null, + })); + expect(matchingCompletions(named, "requ").map((i) => i.label)).toEqual(["request", "Request"]); + }); + + it("offers the adapter's completions in Evaluate for the selected frame and inserts the chosen one", async () => { + vi.useFakeTimers(); + completions.mockResolvedValue({ + status: "ok", + data: [ + { label: "clientId", text: null, kind: "variable", detail: "String", sortText: null }, + { label: "clone()", text: "clone()", kind: "method", detail: null, sortText: null }, + ], + }); + useDebug.setState({ sessions: { 1: session }, active: 1 }); + render(); + act(() => openEvaluateDialog()); + const field = screen.getByRole("textbox", { name: "Expression" }) as HTMLTextAreaElement; + fireEvent.change(field, { target: { value: "cl", selectionStart: 2, selectionEnd: 2 } }); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(completions).toHaveBeenCalledWith(1, 7, "cl", 0, 2); + const options = screen.getAllByRole("option"); + expect(options.map((o) => o.textContent)).toEqual(["clientIdString", "clone()"]); + fireEvent.keyDown(field, { key: "ArrowDown" }); + fireEvent.keyDown(field, { key: "Enter" }); + expect(field.value).toBe("clone()"); + expect(screen.queryByRole("option")).toBeNull(); + // The dialog stays open: Enter went to the completion, not to Evaluate. + expect(useOverlays.getState().evaluate).not.toBeNull(); + }); +}); diff --git a/src/features/debugger/ui/DebugConsole.tsx b/src/features/debugger/ui/DebugConsole.tsx index 18a32b9f..083735fa 100644 --- a/src/features/debugger/ui/DebugConsole.tsx +++ b/src/features/debugger/ui/DebugConsole.tsx @@ -8,6 +8,7 @@ import { spanStyle } from "@/ui/ansi"; import ansi from "@/ui/ansi.module.css"; import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state"; import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory"; +import { useExpressionCompletion } from "./ExpressionCompletion"; import styles from "./DebugToolWindow.module.css"; export function DebugServiceConsole({ sessionId }: { sessionId: number }) { @@ -28,6 +29,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe const history = useRef(readHistory("console")); const historyPos = useRef(-1); const inputRef = useRef(null); + const completion = useExpressionCompletion(inputRef, input, setInput, session); const virtualizer = useVirtualizer({ count: lines?.length ?? 0, @@ -81,6 +83,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe }; const onKeyDown = (e: KeyboardEvent) => { + if (completion.onKeyDown(e)) return; if (e.key === "Enter") { e.preventDefault(); void submit(); @@ -148,9 +151,15 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe ref={inputRef} className={styles.evalInput} onFocus={() => setConsoleHistoryBrowser(browseHistory)} - onBlur={() => setConsoleHistoryBrowser(null)} + onBlur={() => { + setConsoleHistoryBrowser(null); + completion.close(); + }} value={input} - onChange={(e) => setInput(e.target.value)} + onChange={(e) => { + setInput(e.target.value); + completion.onInput(); + }} onKeyDown={onKeyDown} placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"} disabled={!isLive(session)} @@ -158,6 +167,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe spellCheck={false} autoComplete="off" /> + {completion.popup} )} s.watches); const stopSeq = useDebug((s) => s.stopSeq); const [expression, setExpression] = useState(""); + const evalField = useRef(null); + const completion = useExpressionCompletion(evalField, expression, setExpression, session); const [lastEval, setLastEval] = useState(null); const [scopeData, setScopeData] = useState<{ frame: number | null; scopes: Scope[]; locals: Variable[] }>({ frame: null, @@ -502,10 +505,16 @@ function VariablesPane({ session }: { session: DebugSession }) {
setExpression(e.target.value)} + onChange={(e) => { + setExpression(e.target.value); + completion.onInput(); + }} + onBlur={completion.close} onKeyDown={(e) => { + if (completion.onKeyDown(e)) return; if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && e.shiftKey) { e.preventDefault(); addWatch(expression); @@ -521,6 +530,7 @@ function VariablesPane({ session }: { session: DebugSession }) { autoComplete="off" disabled={frame == null} /> + {completion.popup}