mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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.
This commit is contained in:
@@ -212,6 +212,19 @@ pub struct Variable {
|
||||
pub evaluate_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// DAP item type: `method`, `field`, `variable`, `class`, `keyword`, …
|
||||
pub kind: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
pub sort_text: Option<String>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
text: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
) -> IdeResult<Vec<DebugCompletion>> {
|
||||
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?;
|
||||
|
||||
@@ -73,6 +73,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": true,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
@@ -172,6 +173,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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::<Vec<_>>(),
|
||||
[("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();
|
||||
|
||||
@@ -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<i64>,
|
||||
text: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
) -> IdeResult<Vec<ide_dap::DebugCompletion>> {
|
||||
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,
|
||||
|
||||
@@ -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<i64>,
|
||||
text: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
) -> IdeResult<Vec<ide_dap::DebugCompletion>> {
|
||||
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<()> {
|
||||
|
||||
@@ -349,6 +349,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
debug::debug_scopes,
|
||||
debug::debug_variables,
|
||||
debug::debug_evaluate,
|
||||
debug::debug_completions,
|
||||
debug::debug_resume,
|
||||
debug::debug_pause,
|
||||
debug::debug_step,
|
||||
|
||||
@@ -671,6 +671,8 @@ export const commands = {
|
||||
debugScopes: (session: DebugSessionId, frameId: number) => typedError<Scope[], IdeError>(__TAURI_INVOKE("debug_scopes", { session, frameId })),
|
||||
debugVariables: (session: DebugSessionId, reference: number) => typedError<Variable[], IdeError>(__TAURI_INVOKE("debug_variables", { session, reference })),
|
||||
debugEvaluate: (session: DebugSessionId, frameId: number | null, expression: string) => typedError<Variable, IdeError>(__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<DebugCompletion[], IdeError>(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })),
|
||||
debugResume: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_resume", { session, threadId })),
|
||||
debugPause: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_pause", { session, threadId })),
|
||||
debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError<null, IdeError>(__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
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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(<DebuggerOverlays />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string[]>(readHistory("console"));
|
||||
const historyPos = useRef(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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}
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../state";
|
||||
import { DebugConsole } from "./DebugConsole";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
import { useExpressionCompletion } from "./ExpressionCompletion";
|
||||
import { sortVariables } from "../values";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
@@ -378,6 +379,8 @@ function VariablesPane({ session }: { session: DebugSession }) {
|
||||
const watches = useDebug((s) => s.watches);
|
||||
const stopSeq = useDebug((s) => s.stopSeq);
|
||||
const [expression, setExpression] = useState("");
|
||||
const evalField = useRef<HTMLInputElement>(null);
|
||||
const completion = useExpressionCompletion(evalField, expression, setExpression, session);
|
||||
const [lastEval, setLastEval] = useState<EvalRow | null>(null);
|
||||
const [scopeData, setScopeData] = useState<{ frame: number | null; scopes: Scope[]; locals: Variable[] }>({
|
||||
frame: null,
|
||||
@@ -502,10 +505,16 @@ function VariablesPane({ session }: { session: DebugSession }) {
|
||||
<div className={styles.variables}>
|
||||
<div className={styles.evalField}>
|
||||
<input
|
||||
ref={evalField}
|
||||
className={styles.evalInput}
|
||||
value={expression}
|
||||
onChange={(e) => 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}
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add to Watches"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Completion popup under an expression field: menu metrics, scrolling past ~12 rows. */
|
||||
.popup {
|
||||
padding: 6px 0;
|
||||
min-width: 240px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.list {
|
||||
max-height: calc(12 * var(--h-list-row));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--h-list-row);
|
||||
margin: 0 6px;
|
||||
padding: 0 8px 0 6px;
|
||||
border-radius: var(--radius-selection, 5px);
|
||||
color: var(--fg-strong);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.active {
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.detail {
|
||||
margin-left: 16px;
|
||||
color: var(--fg-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Completion in the debugger's expression fields (Evaluate, Variables, Debug console): the
|
||||
* adapter's DAP `completions` for the selected frame, as you type an identifier or after `.`,
|
||||
* or on ⌃Space. ↑/↓ move, Enter or Tab inserts, Escape closes.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { DebugCompletion } from "@/bindings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { badgeKind, Popup, SymbolBadge } from "@/ui";
|
||||
import { isLive, type DebugSession } from "../state";
|
||||
import styles from "./ExpressionCompletion.module.css";
|
||||
|
||||
type Field = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
const WORD = /[\p{L}\p{N}_$]+$/u;
|
||||
const DELAY = 120;
|
||||
const MAX_ITEMS = 200;
|
||||
|
||||
/** The identifier being typed before `caret`. */
|
||||
export function completionPrefix(text: string, caret: number): string {
|
||||
return WORD.exec(text.slice(0, caret))?.[0] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter's items that still match `prefix` (they narrow as more is typed), by the adapter's
|
||||
* sort text, without repeats; exact-case matches first, private `_names` last unless the prefix asks for them.
|
||||
*/
|
||||
export function matchingCompletions(items: readonly DebugCompletion[], prefix: string): DebugCompletion[] {
|
||||
const p = prefix.toLowerCase();
|
||||
const hidden = (i: DebugCompletion) =>
|
||||
(!p.startsWith("_") && i.label.startsWith("_") ? 2 : 0) + ((i.text ?? i.label).startsWith(prefix) ? 0 : 1);
|
||||
const key = (i: DebugCompletion) => i.sortText ?? i.label;
|
||||
const seen = new Set<string>();
|
||||
return items
|
||||
.filter((i) => !p || (i.text ?? i.label).toLowerCase().startsWith(p) || i.label.toLowerCase().startsWith(p))
|
||||
.filter((i) => {
|
||||
const id = `${i.label}\u0000${i.text ?? ""}`;
|
||||
return !seen.has(id) && !!seen.add(id);
|
||||
})
|
||||
.map((item, order) => ({ item, order }))
|
||||
.sort((a, b) => hidden(a.item) - hidden(b.item) || compare(key(a.item), key(b.item)) || a.order - b.order)
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((x) => x.item);
|
||||
}
|
||||
|
||||
const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
/** `text` with `item` replacing the identifier before `caret`, and the caret after it. */
|
||||
export function applyCompletion(text: string, caret: number, item: DebugCompletion): { text: string; caret: number } {
|
||||
const start = caret - completionPrefix(text, caret).length;
|
||||
const insert = item.text ?? item.label;
|
||||
return { text: text.slice(0, start) + insert + text.slice(caret), caret: start + insert.length };
|
||||
}
|
||||
|
||||
/** 0-based line and UTF-16 column of `caret`. */
|
||||
function position(text: string, caret: number): { line: number; column: number } {
|
||||
const before = text.slice(0, caret);
|
||||
const lineStart = before.lastIndexOf("\n") + 1;
|
||||
return { line: before.split("\n").length - 1, column: caret - lineStart };
|
||||
}
|
||||
|
||||
export function useExpressionCompletion(
|
||||
field: RefObject<Field | null>,
|
||||
value: string,
|
||||
setValue: (text: string) => void,
|
||||
session: DebugSession | null | undefined,
|
||||
) {
|
||||
const tx = useTx();
|
||||
const [items, setItems] = useState<DebugCompletion[] | null>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const seq = useRef(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const list = useRef<HTMLDivElement | null>(null);
|
||||
const caret = field.current?.selectionStart ?? value.length;
|
||||
const shown = items ? matchingCompletions(items, completionPrefix(value, caret)) : [];
|
||||
const open = shown.length > 0;
|
||||
|
||||
const close = () => {
|
||||
seq.current++;
|
||||
clearTimeout(timer.current);
|
||||
setItems(null);
|
||||
};
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
|
||||
const request = (explicit: boolean) => {
|
||||
const el = field.current;
|
||||
if (!el || !session || !isLive(session) || session.status !== "paused") return;
|
||||
const text = el.value;
|
||||
const at = el.selectionStart ?? text.length;
|
||||
const prefix = completionPrefix(text, at);
|
||||
if (!explicit && !prefix && text[at - 1] !== ".") {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const mine = ++seq.current;
|
||||
const { line, column } = position(text, at);
|
||||
call(commands.debugCompletions(session.id, session.selectedFrame, text, line, column)).then(
|
||||
(result) => {
|
||||
if (mine !== seq.current) return;
|
||||
setItems(result.length ? result : null);
|
||||
setIndex(0);
|
||||
},
|
||||
() => mine === seq.current && setItems(null),
|
||||
);
|
||||
};
|
||||
|
||||
const accept = (item: DebugCompletion) => {
|
||||
const el = field.current;
|
||||
if (!el) return;
|
||||
const next = applyCompletion(el.value, el.selectionStart ?? el.value.length, item);
|
||||
close();
|
||||
setValue(next.text);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(next.caret, next.caret);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
list.current?.querySelector(`[data-index="${index}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [index, open]);
|
||||
|
||||
return {
|
||||
/** Call after the field's value changed by typing. */
|
||||
onInput: () => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => request(false), DELAY);
|
||||
},
|
||||
/** Handles the popup's keys first; true when the key was consumed. */
|
||||
onKeyDown: (e: KeyboardEvent<Field>): boolean => {
|
||||
if (isImeKey(e.nativeEvent)) return false;
|
||||
if (e.key === " " && e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
request(true);
|
||||
return true;
|
||||
}
|
||||
if (!open) return false;
|
||||
const plain = !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey;
|
||||
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && plain) {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setIndex((i) => (i + step + shown.length) % shown.length);
|
||||
return true;
|
||||
}
|
||||
if ((e.key === "Enter" || e.key === "Tab") && plain) {
|
||||
e.preventDefault();
|
||||
accept(shown[Math.min(index, shown.length - 1)]!);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "Home" || e.key === "End") close();
|
||||
return false;
|
||||
},
|
||||
close,
|
||||
popup: open ? (
|
||||
<Popup
|
||||
open
|
||||
anchor={field}
|
||||
placement="bottom-start"
|
||||
autoFocus={false}
|
||||
restoreFocus={false}
|
||||
onClose={close}
|
||||
className={styles.popup}
|
||||
>
|
||||
<div ref={list} className={styles.list} role="listbox" aria-label={tx("Completions")}>
|
||||
{shown.map((item, i) => (
|
||||
<div
|
||||
key={`${item.label}\u0000${i}`}
|
||||
data-index={i}
|
||||
role="option"
|
||||
aria-selected={i === index}
|
||||
className={clsx(styles.item, i === Math.min(index, shown.length - 1) && styles.active)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseMove={() => i !== index && setIndex(i)}
|
||||
onClick={() => accept(item)}
|
||||
>
|
||||
<SymbolBadge kind={badgeKind(item.kind ?? "variable")} />
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
{item.detail && <span className={styles.detail}>{item.detail}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
@@ -173,20 +173,23 @@
|
||||
|
||||
/* evaluate dialog */
|
||||
.evalBody {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Grows with the dialog when it is resized. */
|
||||
.evalResult {
|
||||
height: 220px;
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-popup);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 4px 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chooser {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "../breakpoints/exceptions";
|
||||
import { closeBreakpointPopover, useOverlays } from "./popoverStore";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
import { useExpressionCompletion } from "./ExpressionCompletion";
|
||||
import { pushHistory, readHistory } from "./expressionHistory";
|
||||
import styles from "./Overlays.module.css";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
@@ -697,6 +698,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
const paused = session?.status === "paused";
|
||||
const close = () => useOverlays.setState({ evaluate: null });
|
||||
const lines = Math.min(10, expression.split("\n").length);
|
||||
const completion = useExpressionCompletion(field, expression, setExpression, session);
|
||||
const run = async () => {
|
||||
const e = expression.trim();
|
||||
if (!e) return;
|
||||
@@ -720,7 +722,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
});
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isImeKey(e.nativeEvent)) return;
|
||||
if (isImeKey(e.nativeEvent) || completion.onKeyDown(e)) return;
|
||||
const el = e.currentTarget;
|
||||
if (e.key === "Enter" && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
@@ -746,6 +748,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
onClose={close}
|
||||
title="Evaluate"
|
||||
width={620}
|
||||
resizable="both"
|
||||
aria-label="Evaluate expression"
|
||||
footerStart={
|
||||
<Button
|
||||
@@ -778,8 +781,10 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
onChange={(e) => {
|
||||
setExpression(e.target.value);
|
||||
historyPos.current = -1;
|
||||
completion.onInput();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={completion.close}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
@@ -787,6 +792,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
aria-label={tx("Expression")}
|
||||
placeholder={paused ? "Expression to evaluate in the selected frame" : "Pause the program to evaluate"}
|
||||
/>
|
||||
{completion.popup}
|
||||
<span ref={historyButton}>
|
||||
<IconButton
|
||||
icon="history"
|
||||
|
||||
Reference in New Issue
Block a user