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:
l0ng-ai
2026-11-04 04:47:12 +08:00
committed by GitHub
parent e575bf8c24
commit fe0ee338f4
13 changed files with 481 additions and 6 deletions
+54
View File
@@ -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?;
+17
View File
@@ -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();
+25
View File
@@ -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,
+14
View File
@@ -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<()> {
+1
View File
@@ -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,
+13
View File
@@ -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();
});
});
+12 -2
View File
@@ -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
+11 -1
View File
@@ -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,
};
}
+5 -2
View File
@@ -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 {
+7 -1
View File
@@ -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"