feat(debugger): Show a Run to Cursor button when hovering a line while paused (#168)

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.
This commit is contained in:
l0ng-ai
2026-10-29 04:09:28 +08:00
committed by GitHub
parent 2ec7f5d630
commit 4dcf0acc07
10 changed files with 186 additions and 1 deletions
+3
View File
@@ -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,
+2
View File
@@ -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,
+6
View File
@@ -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",
@@ -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);
}
@@ -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();
});
});
+121
View File
@@ -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);
}
},
);
}
+8 -1
View File
@@ -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) => {
@@ -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",
},
],
},
],
+1
View File
@@ -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:": "为以下语言显示嵌入提示:",
+3
View File
@@ -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),