From 697a9b0ccb7879f75f3e1b934a849adc1dec6ebb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:27:49 +0800 Subject: [PATCH] fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles Adapters that list every remaining call in the function (debugpy) made Smart Step Into fall back to a chooser that offered calls from later lines. Targets are now narrowed to the calls on the paused line, so they are picked in place. Anonymous functions and callbacks no longer get a 'no usages' hint, and intention titles are no longer cut short when there is room for them. --- src/editor/lsp/Popups.module.css | 8 +++-- src/editor/lsp/codeLens.test.ts | 20 +++++++++++++ src/editor/lsp/codeLens.ts | 5 +++- .../debugger/__tests__/smartStep.test.ts | 9 ++++++ src/features/debugger/actions.ts | 4 +-- src/features/debugger/editor/smartStep.ts | 30 +++++++++++++++++-- 6 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 src/editor/lsp/codeLens.test.ts diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index ac4077ea4..ca8cf7c3a 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -108,11 +108,15 @@ color: var(--fg-faint); } .label { - flex: none; - max-width: 60%; + flex: 0 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.label:has(+ .preview) { + flex: none; + max-width: 60%; +} .preview { min-width: 80px; flex: 0 1 auto; diff --git a/src/editor/lsp/codeLens.test.ts b/src/editor/lsp/codeLens.test.ts new file mode 100644 index 000000000..d9f450437 --- /dev/null +++ b/src/editor/lsp/codeLens.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import type { OutlineItem } from "@/platform/ipc"; +import { lensTargets } from "./codeLens"; + +const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => { + const r = { start: { line, col: 0 }, end: { line, col: 1 } }; + return { name, kind, detail: null, range: r, selection_range: r, children }; +}; + +describe("usages lens targets", () => { + it("skips callbacks and anonymous functions", () => { + const items = [ + item("Cart", "class", 0, [ + item("count", "method", 1, [item("reduce() callback", "function", 2)]), + item("", "function", 3), + ]), + ]; + expect(lensTargets(items).map((t) => t.name)).toEqual(["Cart", "count"]); + }); +}); diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index abcfb84fe..70a33266a 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -40,12 +40,15 @@ export interface LensTarget { col: number; } +/** Servers name callbacks and anonymous functions (``, `map() callback`); they have no usages. */ +const anonymous = (name: string) => /^<.*>$/.test(name) || / callback$/.test(name); + /** Declarations worth a usages lens, in document order. Exported for tests. */ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] { const out: LensTarget[] = []; const walk = (list: readonly OutlineItem[]) => { for (const it of list) { - if (LENS_KINDS.has(it.kind)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col }); + if (LENS_KINDS.has(it.kind) && !anonymous(it.name)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col }); if (it.children.length) walk(it.children); } }; diff --git a/src/features/debugger/__tests__/smartStep.test.ts b/src/features/debugger/__tests__/smartStep.test.ts index dc6c7022e..b2ef44085 100644 --- a/src/features/debugger/__tests__/smartStep.test.ts +++ b/src/features/debugger/__tests__/smartStep.test.ts @@ -10,6 +10,7 @@ import { locateStepTargets, smartStepTargets, startInlineSmartStep, + targetsOnLine, } from "../editor/smartStep"; const ipc = vi.hoisted(() => ({ debugStepInTarget: vi.fn() })); @@ -51,6 +52,14 @@ describe("locating Smart Step Into targets", () => { ]); expect(locateStepTargets(line, 3, [target(1, "missing()")])).toBeNull(); }); + + it("keeps only the paused line's calls when the adapter lists the whole function", () => { + const line = " total = add(total, square(i))"; + const all = [target(0, "square(i)"), target(1, "add(total, square(i))"), target(2, 'print("total", total)')]; + expect(targetsOnLine(line, 11, all).map((t) => t.id)).toEqual([0, 1]); + expect(targetsOnLine(line, 11, [{ ...target(5, "print()"), line: 11 }, { ...target(6, "add()"), line: 12 }]).map((t) => t.id)).toEqual([5]); + expect(targetsOnLine("pass", 0, all)).toBe(all); + }); }); describe("picking in the editor", () => { diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 6f3a0eba9..f70c73c73 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -33,7 +33,7 @@ import { } from "./state"; import { focusedDebugView } from "./ui/focusedView"; import { expressionAt } from "./editor/valueTooltip"; -import { startInlineSmartStep } from "./editor/smartStep"; +import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep"; import { openBreakpointsDialog, openConfigChooser, @@ -92,7 +92,7 @@ async function smartStepInto() { await call(commands.debugStep(t.s.id, t.thread, "into")); return; } - const targets = await call(commands.debugStepInTargets(t.s.id, frame.id)); + const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id))); if (targets.length <= 1) { await call( targets[0] diff --git a/src/features/debugger/editor/smartStep.ts b/src/features/debugger/editor/smartStep.ts index 512aa8147..bd149f125 100644 --- a/src/features/debugger/editor/smartStep.ts +++ b/src/features/debugger/editor/smartStep.ts @@ -23,6 +23,11 @@ export function calleeName(label: string): string | null { return name && [...name].every((c) => IDENT.test(c)) ? name : null; } +/** A call of `name`: `name(` not preceded by an identifier character. */ +function callPattern(name: string, flags: string): RegExp { + return new RegExp(`(? a.from - b.from); } +/** + * Targets that belong to the paused line. Some adapters (debugpy) list every + * call left in the function without positions; those are kept only when their + * callee appears on the line. Unchanged when nothing would remain. + */ +export function targetsOnLine(lineText: string, line: number, targets: StepInTarget[]): StepInTarget[] { + const kept = targets.filter((t) => { + if (t.line != null) return t.line === line; + if (t.column != null) return true; + const name = calleeName(t.label); + return !name || callPattern(name, "u").test(lineText); + }); + return kept.length ? kept : targets; +} + +/** Targets of the paused line, when that line is open in an editor. */ +export function pausedLineTargets(targets: StepInTarget[]): StepInTarget[] { + const { path, line } = useExecutionPoint.getState(); + const doc = path ? editorShowing(path)?.view.state.doc : null; + return doc && line < doc.lines ? targetsOnLine(doc.line(line + 1).text, line, targets) : targets; +} + /** Editor to pick in: the active one when it shows `path`, else any showing it. */ function editorShowing(path: string): TextEditorHandle | null { const active = activeEditor();