fix(debugger): put no breakpoint on blank or comment-only lines (#1678)

A gutter click, Toggle Line Breakpoint or Toggle Temporary Line Breakpoint on a blank or comment-only line added a breakpoint the debugger can never stop at. Those lines now take no new breakpoint; comments come from the syntax tree, so this works for every language with a parser, and only blank lines are skipped otherwise. An existing breakpoint on such a line can still be removed.

Fixes #1676

Co-authored-by: ldg030201 <100822592+ldg030201@users.noreply.github.com>
This commit is contained in:
Lee Dong Geon
2026-11-06 13:13:50 +08:00
committed by GitHub
parent d984695e79
commit ff187a10f6
6 changed files with 105 additions and 6 deletions
@@ -53,7 +53,7 @@ import { executeAction, getAction } from "@/platform/actions";
import { intellijMacos } from "@/platform/keymaps/intellij-macos";
import type { DebugCapabilities } from "@/platform/ipc";
import { registerDebugActions } from "../actions";
import { breakpointsOf, loadBreakpoints } from "../breakpoints/store";
import { breakpointsOf, loadBreakpoints, toggleBreakpoint } from "../breakpoints/store";
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
import { focusDebugView, blurDebugView } from "../ui/focusedView";
@@ -253,6 +253,21 @@ describe("debugger actions (IDEA parity)", () => {
expect(opened.at(-1)).toEqual({ path: "/p/app.py", focus: true });
});
it("puts no new breakpoint on a blank line", async () => {
for (const action of ["debug.toggleBreakpoint", "debug.toggleTemporaryBreakpoint"]) {
for (const line of [1, 2]) {
caret("/p/a.py", "python", "x = 1\n\n \ny = 2", line);
await executeAction(action);
}
expect(breakpointsOf("/p/a.py"), action).toHaveLength(0);
}
// One placed before the line went blank can still be toggled off.
toggleBreakpoint("/p/a.py", 1);
caret("/p/a.py", "python", "x = 1\n\n \ny = 2", 1);
await executeAction("debug.toggleBreakpoint");
expect(breakpointsOf("/p/a.py")).toHaveLength(0);
});
it("toggles temporary, method and field breakpoints at the caret", async () => {
caret("/p/a.py", "python", "x = 1\ndef run():\n pass", 0);
await executeAction("debug.toggleTemporaryBreakpoint");
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { java } from "@codemirror/lang-java";
import { lineHasCode } from "../breakpoints/codeLines";
const lines = (state: EditorState) => Array.from({ length: state.doc.lines }, (_, i) => lineHasCode(state, i));
describe("lines that take a breakpoint", () => {
it("skips blank and comment-only lines", () => {
const doc = [
"class A {",
"",
" \t",
" // note",
" /** doc",
" * more",
" */",
" int f() { return 1; } // trailing",
" /* a */ int x = 1;",
" /* a */ /* b */",
"}",
].join("\n");
const state = EditorState.create({ doc, extensions: [java()] });
expect(lines(state)).toEqual([true, false, false, false, false, false, false, true, true, false, true]);
});
it("only skips blank lines in a file without a parser", () => {
expect(lines(EditorState.create({ doc: "x\n\n \n// y" }))).toEqual([true, false, false, true]);
});
});
@@ -153,6 +153,17 @@ describe("breakpoint gutter clicks", () => {
expect(breakpointsOf(ctx.path)[0]).toMatchObject({ line: 0, temporary: true });
});
it("adds nothing on a blank line but still removes a breakpoint there", () => {
localStorage.clear();
loadBreakpoints("/proj");
const view = makeView("\na");
for (const init of [{}, { altKey: true }, { shiftKey: true }]) click(view, init);
expect(breakpointsOf(ctx.path)).toHaveLength(0);
toggleBreakpoint(ctx.path, 0);
click(view, {});
expect(breakpointsOf(ctx.path)).toHaveLength(0);
});
it("middle-click enables / disables an existing breakpoint", () => {
localStorage.clear();
loadBreakpoints("/proj");
+9 -2
View File
@@ -7,6 +7,7 @@ import { activeEditor } from "@/editor/api";
import type { EditorView } from "@codemirror/view";
import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions";
import { classFileBreakpointReason } from "@/platform/classFiles";
import { lineHasCode } from "./breakpoints/codeLines";
import { canPlaceBreakpoint } from "./breakpoints/languages";
import {
removeAllBreakpoints,
@@ -628,6 +629,9 @@ export function registerDebugActions(): () => void {
return;
}
const ed = activeEditor();
// Like IDEA, no new breakpoint on a blank or comment-only line; an existing one still goes.
const taken = exists || specialAt(c.path).some((b) => b.line === c.line);
if (!taken && ed && !lineHasCode(ed.view.state, c.line)) return;
if (ed) toggleBreakpointSmart(c.path, c.line, language, ed.view.state.doc.toString());
else toggleBreakpoint(c.path, c.line);
},
@@ -640,8 +644,11 @@ export function registerDebugActions(): () => void {
description: "A line breakpoint that is removed after it is hit",
run: () => {
const c = cursor();
if (c && breakpointPathAllowed(c.path) && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "", c.path))
toggleTemporaryBreakpoint(c.path, c.line);
const ed = activeEditor();
if (!c || !breakpointPathAllowed(c.path) || !canPlaceBreakpoint(ed?.ctx.language ?? "", c.path)) return;
const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line);
if (!exists && ed && !lineHasCode(ed.view.state, c.line)) return;
toggleTemporaryBreakpoint(c.path, c.line);
},
},
{
@@ -0,0 +1,33 @@
/**
* IDEA puts a new line breakpoint only where there is code: a gutter click or Toggle Line
* Breakpoint on a blank or comment-only line does nothing. Comments come from the syntax
* tree; in a file without a parser only blank lines are skipped.
*/
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { SyntaxNode, Tree } from "@lezer/common";
const PARSE_BUDGET_MS = 20;
function commentAt(tree: Tree, pos: number): SyntaxNode | null {
for (let n: SyntaxNode | null = tree.resolveInner(pos, 1); n; n = n.parent) if (/comment/i.test(n.name)) return n;
return null;
}
/** Whether `line` (0-based) has anything besides whitespace and comments. */
export function lineHasCode(state: EditorState, line: number): boolean {
if (line < 0 || line >= state.doc.lines) return false;
const { from, to, text } = state.doc.line(line + 1);
if (!/\S/.test(text)) return false;
const tree = ensureSyntaxTree(state, to, PARSE_BUDGET_MS) ?? syntaxTree(state);
for (let pos = from; pos < to;) {
if (/\s/.test(text[pos - from]!)) {
pos++;
continue;
}
const comment = commentAt(tree, pos);
if (!comment) return true;
pos = Math.max(pos + 1, comment.to);
}
return false;
}
@@ -18,6 +18,7 @@ import {
import { classFileBreakpointReason } from "@/platform/classFiles";
import { tx } from "@/platform/i18n";
import { showStatusInfo } from "@/workbench/contributions";
import { lineHasCode } from "../breakpoints/codeLines";
import { canPlaceBreakpoint } from "../breakpoints/languages";
import {
removeSpecial,
@@ -232,9 +233,10 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
}
/**
* IDEA's gutter clicks: click toggles; on an empty line ⇧-click adds a non-suspending
* logging breakpoint and opens its properties, ⌥-click a temporary one. On an existing
* breakpoint only a plain (or ⌘/Ctrl) click removes it; middle-click enables/disables.
* IDEA's gutter clicks: click toggles; on a line without a breakpoint ⇧-click adds a
* non-suspending logging breakpoint and opens its properties, ⌥-click a temporary one.
* Blank and comment-only lines take none. On an existing breakpoint only a plain
* (or ⌘/Ctrl) click removes it; middle-click enables/disables.
*/
onMouseDown = (e: MouseEvent) => {
if ((e.button !== 0 && e.button !== 1) || !inBreakpointArea(e)) return;
@@ -271,6 +273,7 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
else removeBreakpoint(path, bp!.id);
return;
}
if (!lineHasCode(this.view.state, line)) return;
if (e.altKey) {
addBreakpoint(path, line, { temporary: true });
return;