From 4dcf0acc0703c975672883475fffe151aa79cf33 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:09:28 +0800 Subject: [PATCH] feat(debugger): Show a Run to Cursor button when hovering a line while paused (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the debugger is suspended, hovering the start of a line shows a small button left of its text: Run to Cursor runs to that line, and on the execution line it resumes the program. Settings › Debugger › Show inlay Run to Cursor popup turns it off. --- crates/ide-core/src/workflow_settings.rs | 3 + src/bindings.ts | 2 + src/features/debugger/actions.ts | 6 + .../debugger/editor/editor.module.css | 22 ++++ .../debugger/editor/runToCursor.test.ts | 15 +++ src/features/debugger/editor/runToCursor.ts | 121 ++++++++++++++++++ src/features/debugger/index.ts | 9 +- src/features/settings/pages/execution.tsx | 5 + src/platform/locales/text/zh.json | 1 + src/platform/workflowSettings.ts | 3 + 10 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/features/debugger/editor/runToCursor.test.ts create mode 100644 src/features/debugger/editor/runToCursor.ts diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 66440cec..59a6d7e3 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -215,6 +215,8 @@ pub struct DebuggerSettings { pub focus_app_on_breakpoint: bool, pub hide_on_termination: bool, pub breakpoints_any_file: bool, + /// While suspended, hovering the start of a line shows a Run to Cursor button. + pub run_to_cursor_inlay: bool, pub inline_values: bool, pub value_tooltip: bool, pub value_tooltip_delay: u32, @@ -230,6 +232,7 @@ impl Default for DebuggerSettings { focus_app_on_breakpoint: false, hide_on_termination: false, breakpoints_any_file: false, + run_to_cursor_inlay: true, inline_values: true, value_tooltip: true, value_tooltip_delay: 700, diff --git a/src/bindings.ts b/src/bindings.ts index 114ff9f3..e1a8691c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2150,6 +2150,8 @@ export type DebuggerSettings = { focusAppOnBreakpoint?: boolean, hideOnTermination?: boolean, breakpointsAnyFile?: boolean, + /** While suspended, hovering the start of a line shows a Run to Cursor button. */ + runToCursorInlay?: boolean, inlineValues?: boolean, valueTooltip?: boolean, valueTooltipDelay?: number, diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index dd8bf36a..ee0ca88c 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -74,6 +74,12 @@ function pausedTarget(): { s: DebugSession; thread: number } | null { return thread == null ? null : { s, thread }; } +/** Run to Cursor at `line` (0-based) of `path` in the paused session. */ +export function runToLine(path: string, line: number) { + const t = pausedTarget(); + if (t) void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); +} + function notSupported(what: string, s: DebugSession) { notifications().notify({ group: "run", diff --git a/src/features/debugger/editor/editor.module.css b/src/features/debugger/editor/editor.module.css index 66825445..9df44b82 100644 --- a/src/features/debugger/editor/editor.module.css +++ b/src/features/debugger/editor/editor.module.css @@ -185,3 +185,25 @@ background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent); } + +/* Inlay Run to Cursor button, left of a hovered line's text. */ +.runToCursor { + position: absolute; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 1px solid var(--border-popup); + border-radius: var(--radius-small); + background: var(--bg-popup); + box-shadow: var(--shadow-tooltip); + color: var(--fg); + cursor: pointer; +} + +.runToCursor:hover { + background: var(--bg-hover); +} diff --git a/src/features/debugger/editor/runToCursor.test.ts b/src/features/debugger/editor/runToCursor.test.ts new file mode 100644 index 00000000..34be5a29 --- /dev/null +++ b/src/features/debugger/editor/runToCursor.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { hoveredLine } from "./runToCursor"; + +describe("inlay Run to Cursor", () => { + const line = { number: 3, text: " foo()" }; + it("targets a non-blank line when hovering its first characters", () => { + expect(hoveredLine(100, 8, 100, line)).toBe(2); + expect(hoveredLine(100, 8, 132, line)).toBe(2); + }); + it("ignores the pointer further right, left of the text, and blank lines", () => { + expect(hoveredLine(100, 8, 133, line)).toBeNull(); + expect(hoveredLine(100, 8, 99, line)).toBeNull(); + expect(hoveredLine(100, 8, 104, { number: 4, text: " " })).toBeNull(); + }); +}); diff --git a/src/features/debugger/editor/runToCursor.ts b/src/features/debugger/editor/runToCursor.ts new file mode 100644 index 00000000..f1f3ca7d --- /dev/null +++ b/src/features/debugger/editor/runToCursor.ts @@ -0,0 +1,121 @@ +/** + * Inlay Run to Cursor: while the debugger is paused, hovering the start of a + * line shows a small button left of its text that runs to that line (Resume on + * the execution line itself). Settings › Debugger › "Show inlay Run to Cursor popup". + */ +import type { Extension } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { iconNode } from "@/editor/lsp/iconMarkup"; +import { executeAction } from "@/platform/actions"; +import { tx } from "@/platform/i18n"; +import { workflowSettings } from "@/platform/workflowSettings"; +import { runToLine } from "../actions"; +import { activeSession, useExecutionPoint } from "../state"; +import styles from "./editor.module.css"; + +/** Hover area from the left edge of the text, in characters. */ +const HOVER_CHARS = 4; +const BUTTON = 20; + +/** The 0-based line whose start the pointer is over, or null. */ +export function hoveredLine( + textLeft: number, + charWidth: number, + x: number, + line: { number: number; text: string }, +): number | null { + if (x < textLeft || x - textLeft > charWidth * HOVER_CHARS) return null; + return line.text.trim() ? line.number - 1 : null; +} + +export function runToCursorInlay(ctx: EditorDocContext): Extension { + if (ctx.virtual) return []; + const path = ctx.path; + return ViewPlugin.fromClass( + class { + private button: HTMLButtonElement | null = null; + private line = -1; + private unsub: () => void; + constructor(readonly view: EditorView) { + view.scrollDOM.addEventListener("mousemove", this.onMove); + view.scrollDOM.addEventListener("mouseleave", this.onLeave); + view.scrollDOM.addEventListener("scroll", this.hide); + // Resumed / stopped / moved: the button's action may no longer apply. + this.unsub = useExecutionPoint.subscribe(this.hide); + } + + private enabled() { + return workflowSettings().debugger.runToCursorInlay && activeSession()?.status === "paused"; + } + + private onMove = (e: MouseEvent) => { + if (e.target instanceof Node && this.button?.contains(e.target)) return; + if (!this.enabled() || e.buttons) return this.hide(); + const { view } = this; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); + const block = view.lineBlockAtHeight(e.clientY - view.documentTop); + if (pos == null || pos < block.from || pos > block.to) return this.hide(); + const line = view.state.doc.lineAt(block.from); + const textLeft = view.coordsAtPos(line.from, 1)?.left; + if (textLeft == null) return this.hide(); + const target = hoveredLine(textLeft, view.defaultCharacterWidth, e.clientX, line); + if (target == null) return this.hide(); + if (target !== this.line) this.show(target, block.top + view.documentTop, line.from); + }; + + private onLeave = (e: MouseEvent) => { + if (!(e.relatedTarget instanceof Node && this.button?.contains(e.relatedTarget))) this.hide(); + }; + + private show(line: number, top: number, lineFrom: number) { + this.hide(); + const { view } = this; + const exec = useExecutionPoint.getState(); + const atExecution = exec.path === path && exec.line === line; + const label = atExecution ? tx("Resume Program") : tx("Run to Cursor"); + const b = document.createElement("button"); + b.type = "button"; + b.className = styles.runToCursor!; + b.title = label; + b.setAttribute("aria-label", label); + b.append(iconNode(atExecution ? "resume" : "run-to-cursor", 14)); + b.addEventListener("mousedown", (e) => e.preventDefault()); + b.addEventListener("click", (e) => { + e.preventDefault(); + this.hide(); + if (atExecution) executeAction("debug.resume"); + else runToLine(path, line); + }); + b.addEventListener("mouseleave", (e) => { + if (!(e.relatedTarget instanceof Node && view.scrollDOM.contains(e.relatedTarget))) this.hide(); + }); + // Left of the first non-space character; over the gutter edge when the indent is too narrow. + const text = view.state.doc.lineAt(lineFrom).text; + const indentEnd = lineFrom + (text.length - text.trimStart().length); + const textX = view.coordsAtPos(indentEnd)?.left ?? view.contentDOM.getBoundingClientRect().left; + const host = view.dom.getBoundingClientRect(); + const lineHeight = view.lineBlockAt(lineFrom).height; + b.style.left = `${textX - host.left - BUTTON - 2}px`; + b.style.top = `${top - host.top + (lineHeight - BUTTON) / 2}px`; + view.dom.appendChild(b); + this.button = b; + this.line = line; + } + + hide = () => { + this.button?.remove(); + this.button = null; + this.line = -1; + }; + + destroy() { + this.hide(); + this.unsub(); + this.view.scrollDOM.removeEventListener("mousemove", this.onMove); + this.view.scrollDOM.removeEventListener("mouseleave", this.onLeave); + this.view.scrollDOM.removeEventListener("scroll", this.hide); + } + }, + ); +} diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index e7d27b8c..edc93a7b 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -20,6 +20,7 @@ import { loadExceptionBreakpoints } from "./breakpoints/exceptions"; import { loadSpecialBreakpoints } from "./breakpoints/special"; import { breakpointGutter } from "./editor/breakpointGutter"; import { executionPoint } from "./editor/execution"; +import { runToCursorInlay } from "./editor/runToCursor"; import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep"; import { onHotCodeReplace } from "./hotswap"; import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state"; @@ -59,7 +60,13 @@ export function activate(): () => void { registerEditorContribution({ id: "debugger", order: 60, - extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip(), smartStepTargets(ctx)], + extensions: (ctx) => [ + breakpointGutter(ctx), + executionPoint(ctx), + runToCursorInlay(ctx), + valueTooltip(), + smartStepTargets(ctx), + ], }), installSmartStepPicking(), onIdeEvent("debug", (e) => { diff --git a/src/features/settings/pages/execution.tsx b/src/features/settings/pages/execution.tsx index 75c20f7c..c8efadbb 100644 --- a/src/features/settings/pages/execution.tsx +++ b/src/features/settings/pages/execution.tsx @@ -149,6 +149,11 @@ export const EXECUTION_PAGES: PageSpec[] = [ label: "Allow placing breakpoints in any file", hint: "Otherwise line breakpoints can only be set in files of languages that have a debugger.", }, + { + kind: "toggle", + key: "debugger.runToCursorInlay", + label: "Show inlay Run to Cursor popup", + }, ], }, ], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 29610527..f7455c86 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1880,6 +1880,7 @@ "Show hard wrap and visual guides (configured in Code Style options)": "显示硬换行和视觉参考线(在代码样式选项中配置)", "Show in Finder": "在访达中显示", "Show in tool window": "在工具窗口中显示", + "Show inlay Run to Cursor popup": "显示内嵌的“运行到光标处”按钮", "Show indent guides": "显示缩进参考线", "Show inlay hints": "显示嵌入提示", "Show inlay hints for:": "为以下语言显示嵌入提示:", diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 2424ce99..b68e6e99 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -81,6 +81,7 @@ export interface DebuggerSettingsValid { focusAppOnBreakpoint: boolean; hideOnTermination: boolean; breakpointsAnyFile: boolean; + runToCursorInlay: boolean; inlineValues: boolean; valueTooltip: boolean; valueTooltipDelay: number; @@ -239,6 +240,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { focusAppOnBreakpoint: false, hideOnTermination: false, breakpointsAnyFile: false, + runToCursorInlay: true, inlineValues: true, valueTooltip: true, valueTooltipDelay: 700, @@ -372,6 +374,7 @@ export function validateDebugger(raw: unknown): DebuggerSettingsValid { focusAppOnBreakpoint: bool(v.focusAppOnBreakpoint, d.focusAppOnBreakpoint), hideOnTermination: bool(v.hideOnTermination, d.hideOnTermination), breakpointsAnyFile: bool(v.breakpointsAnyFile, d.breakpointsAnyFile), + runToCursorInlay: bool(v.runToCursorInlay, d.runToCursorInlay), inlineValues: bool(v.inlineValues, d.inlineValues), valueTooltip: bool(v.valueTooltip, d.valueTooltip), valueTooltipDelay: num(v.valueTooltipDelay, d.valueTooltipDelay, WORKFLOW_LIMITS.tooltipDelay, true),