дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
At session start and around Force Step Over / Force Run to Cursor, setBreakpoints went out one file at a time, each waiting for the previous answer, so every file with breakpoints added a round trip before the program ran or the stop was handled. The requests now go out together, and files whose breakpoints are all disabled are skipped (a fresh adapter has none). The breakpoint gutter now rebuilds only when its own file's breakpoint states change or a session starts or ends, not on every step. Fixes #1677 Co-authored-by: ldg030201 <[email protected]>
322 рядки
13 KiB
TypeScript
322 рядки
13 KiB
TypeScript
/**
|
|
* Breakpoints in the editor: markers on the line-number gutter (classes only,
|
|
* so the number keeps rendering; the dot is a pseudo-element), click to
|
|
* toggle, right-click for the properties popover, and line tracking through edits.
|
|
*/
|
|
import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
|
|
import { Decoration, EditorView, GutterMarker, ViewPlugin, lineNumberMarkers, type ViewUpdate } from "@codemirror/view";
|
|
import { editorsFor, mirroredChange, remoteChange, useEditorRegistry, type EditorDocContext } from "@/editor/api";
|
|
import { isConditional, mapLines, suspends, type BreakpointEntry, type BreakpointStatus } from "../breakpoints/model";
|
|
import {
|
|
addBreakpoint,
|
|
breakpointsOf,
|
|
removeBreakpoint,
|
|
setFileBreakpoints,
|
|
updateBreakpoint,
|
|
useBreakpoints,
|
|
} from "../breakpoints/store";
|
|
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,
|
|
setSpecialLines,
|
|
specialAt,
|
|
updateSpecial,
|
|
useSpecialBreakpoints,
|
|
type SpecialBreakpoint,
|
|
} from "../breakpoints/special";
|
|
import { toggleBreakpointSmart } from "../breakpoints/toggle";
|
|
import { sessionsRunning, useDebug } from "../state";
|
|
import { openBreakpointPopover } from "../ui/popoverStore";
|
|
import styles from "./editor.module.css";
|
|
|
|
export type BreakpointLook =
|
|
| "normal"
|
|
| "disabled"
|
|
| "muted"
|
|
| "unverified"
|
|
| "verified"
|
|
| "conditional"
|
|
| "log"
|
|
| "method"
|
|
| "methodDisabled"
|
|
| "methodVerified"
|
|
| "field"
|
|
| "fieldDisabled"
|
|
| "fieldVerified";
|
|
|
|
export function lookOf(b: BreakpointEntry, status: BreakpointStatus | undefined, muted: boolean, running: boolean) {
|
|
if (!b.enabled) return "disabled";
|
|
// Mute Breakpoints: IDEA's grey dot, so it isn't taken for a disabled breakpoint.
|
|
if (muted) return "muted";
|
|
if (running && status && !status.verified) return "unverified";
|
|
if (!suspends(b)) return "log";
|
|
if (isConditional(b)) return "conditional";
|
|
return running && status?.verified ? "verified" : "normal";
|
|
}
|
|
|
|
/** Method breakpoints / field watchpoints use IDEA's expui icons. */
|
|
export function specialLook(
|
|
b: SpecialBreakpoint,
|
|
status: BreakpointStatus | undefined,
|
|
muted: boolean,
|
|
running: boolean,
|
|
): BreakpointLook {
|
|
if (!b.enabled) return b.kind === "method" ? "methodDisabled" : "fieldDisabled";
|
|
if (muted) return "muted";
|
|
if (running && status && !status.verified) return "unverified";
|
|
if (running && status?.verified) return b.kind === "method" ? "methodVerified" : "fieldVerified";
|
|
return b.kind;
|
|
}
|
|
|
|
class BreakpointMarker extends GutterMarker {
|
|
constructor(
|
|
readonly id: string,
|
|
readonly look: BreakpointLook,
|
|
) {
|
|
super();
|
|
this.elementClass = `${styles.bp} ${styles[look] ?? ""}`;
|
|
}
|
|
eq(other: BreakpointMarker) {
|
|
return other.id === this.id && other.look === this.look;
|
|
}
|
|
}
|
|
|
|
const bpLine = Decoration.line({ class: `${styles.bpLine} cm-openidea-lineBg` });
|
|
const DISABLED_LOOKS = new Set<BreakpointLook>(["disabled", "muted", "methodDisabled", "fieldDisabled"]);
|
|
|
|
const setMarkers = StateEffect.define<RangeSet<BreakpointMarker>>();
|
|
|
|
function buildMarkers(state: EditorState, path: string): RangeSet<BreakpointMarker> {
|
|
const { status, muted } = useBreakpoints.getState();
|
|
const running = sessionsRunning();
|
|
const doc = state.doc;
|
|
const special = specialAt(path).filter((b) => b.line! < doc.lines);
|
|
const specialStatus = useSpecialBreakpoints.getState().status;
|
|
const taken = new Set(special.map((b) => b.line));
|
|
const ranges = [
|
|
...special.map((b) =>
|
|
new BreakpointMarker(b.id, specialLook(b, specialStatus[b.id], muted, running)).range(doc.line(b.line! + 1).from),
|
|
),
|
|
...breakpointsOf(path)
|
|
.filter((b) => b.line < doc.lines && !taken.has(b.line))
|
|
.map((b) => new BreakpointMarker(b.id, lookOf(b, status[b.id], muted, running)).range(doc.line(b.line + 1).from)),
|
|
];
|
|
return RangeSet.of(ranges, true);
|
|
}
|
|
|
|
/** For backend patches applied to every pane: the one editor of `path` that moves the stored lines. */
|
|
function isWriter(view: EditorView, path: string): boolean {
|
|
const editors = editorsFor(path);
|
|
if (editors.length <= 1) return true;
|
|
const active = useEditorRegistry.getState().active;
|
|
const writer = editors.find((e) => e === active) ?? editors[0];
|
|
return writer?.view === view;
|
|
}
|
|
|
|
function lineAtEvent(view: EditorView, e: MouseEvent): number | null {
|
|
const block = view.lineBlockAtHeight(e.clientY - view.documentTop);
|
|
if (!block) return null;
|
|
return view.state.doc.lineAt(block.from).number - 1;
|
|
}
|
|
|
|
/**
|
|
* Like IDEA, the whole gutter left of the fold column toggles breakpoints:
|
|
* line numbers plus the empty space of the other gutters (their own markers,
|
|
* e.g. run icons or change bars, keep their clicks).
|
|
*/
|
|
function inBreakpointArea(e: MouseEvent): boolean {
|
|
const t = e.target as HTMLElement | null;
|
|
if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter, .cm-vcs-blame")) return false;
|
|
if (t.closest(".cm-lineNumbers")) return true;
|
|
if (t.classList.contains("cm-gutter")) return true;
|
|
return t.classList.contains("cm-gutterElement") && t.childElementCount === 0;
|
|
}
|
|
|
|
export function breakpointGutter(ctx: EditorDocContext): Extension {
|
|
const reason = classFileBreakpointReason(ctx.path);
|
|
if (reason) {
|
|
const message = tx(reason);
|
|
return ViewPlugin.fromClass(
|
|
class {
|
|
constructor(readonly view: EditorView) {
|
|
view.dom.addEventListener("mouseover", this.hover);
|
|
view.dom.addEventListener("mousedown", this.down, true);
|
|
}
|
|
hover = (e: MouseEvent) => {
|
|
if (inBreakpointArea(e)) (e.target as HTMLElement).closest(".cm-gutters")?.setAttribute("title", message);
|
|
};
|
|
down = (e: MouseEvent) => {
|
|
if (!inBreakpointArea(e)) return;
|
|
showStatusInfo(message);
|
|
};
|
|
destroy() {
|
|
this.view.dom.removeEventListener("mouseover", this.hover);
|
|
this.view.dom.removeEventListener("mousedown", this.down, true);
|
|
const gutters = this.view.dom.querySelector(".cm-gutters");
|
|
if (gutters?.getAttribute("title") === message) gutters.removeAttribute("title");
|
|
}
|
|
},
|
|
);
|
|
}
|
|
const path = ctx.path;
|
|
const field = StateField.define<RangeSet<BreakpointMarker>>({
|
|
create: (state) => buildMarkers(state, path),
|
|
update(value, tr) {
|
|
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
|
|
return tr.docChanged ? value.map(tr.changes) : value;
|
|
},
|
|
provide: (f) => [
|
|
lineNumberMarkers.from(f),
|
|
// IDEA tints the whole line of an active breakpoint.
|
|
EditorView.decorations.from(f, (markers) => {
|
|
const lines: Array<ReturnType<Decoration["range"]>> = [];
|
|
for (const it = markers.iter(); it.value; it.next())
|
|
if (!DISABLED_LOOKS.has(it.value.look)) lines.push(bpLine.range(it.from));
|
|
return Decoration.set(lines);
|
|
}),
|
|
],
|
|
});
|
|
|
|
const plugin = ViewPlugin.fromClass(
|
|
class {
|
|
private unsubs: Array<() => void>;
|
|
private pending = false;
|
|
private destroyed = false;
|
|
private running = sessionsRunning();
|
|
constructor(readonly view: EditorView) {
|
|
this.unsubs = [
|
|
useBreakpoints.subscribe((s, prev) => {
|
|
// States arrive per file: another file's don't change this one's markers.
|
|
const states = (s.byFile[path] ?? []).some((b) => s.status[b.id] !== prev.status[b.id]);
|
|
if (s.byFile[path] !== prev.byFile[path] || states || s.muted !== prev.muted) this.scheduleRebuild();
|
|
}),
|
|
// The markers only show whether a session runs, while each step updates `sessions` several times.
|
|
useDebug.subscribe((s, prev) => {
|
|
if (s.sessions === prev.sessions || sessionsRunning() === this.running) return;
|
|
this.running = !this.running;
|
|
this.scheduleRebuild();
|
|
}),
|
|
useSpecialBreakpoints.subscribe((s, prev) => {
|
|
if (s.methods !== prev.methods || s.fields !== prev.fields || s.status !== prev.status)
|
|
this.scheduleRebuild();
|
|
}),
|
|
];
|
|
view.dom.addEventListener("mousedown", this.onMouseDown);
|
|
// Inside view.dom, so this runs before the editor's gutter menu listener there.
|
|
view.scrollDOM.addEventListener("contextmenu", this.onContextMenu);
|
|
}
|
|
|
|
scheduleRebuild() {
|
|
if (this.pending) return;
|
|
this.pending = true;
|
|
queueMicrotask(() => {
|
|
this.pending = false;
|
|
if (this.destroyed) return;
|
|
const next = buildMarkers(this.view.state, path);
|
|
const cur = this.view.state.field(field);
|
|
if (!RangeSet.eq([cur], [next])) this.view.dispatch({ effects: setMarkers.of(next) });
|
|
});
|
|
}
|
|
|
|
update(u: ViewUpdate) {
|
|
if (!u.docChanged) return;
|
|
// Split panes mirror one document: only the originating view moves the stored lines.
|
|
const mirrored = u.transactions.some((tr) => tr.annotation(mirroredChange));
|
|
const remote = u.transactions.some((tr) => tr.annotation(remoteChange));
|
|
if (!mirrored && (!remote || isWriter(u.view, path))) {
|
|
const list = breakpointsOf(path);
|
|
if (list.length) setFileBreakpoints(path, mapLines(list, u.startState.doc, u.changes, u.state.doc));
|
|
const special = specialAt(path).map((b) => ({ id: b.id, line: b.line! }));
|
|
if (special.length) setSpecialLines(path, mapLines(special, u.startState.doc, u.changes, u.state.doc));
|
|
} else {
|
|
this.scheduleRebuild();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
const line = lineAtEvent(this.view, e);
|
|
if (line == null) return;
|
|
const special = specialAt(path).find((b) => b.line === line);
|
|
const bp = breakpointsOf(path).find((b) => b.line === line);
|
|
if (e.button === 1) {
|
|
if (!special && !bp) return;
|
|
e.preventDefault();
|
|
if (special) updateSpecial(special.id, { enabled: !special.enabled });
|
|
else if (bp) updateBreakpoint(path, bp.id, { enabled: !bp.enabled });
|
|
return;
|
|
}
|
|
if (!canPlaceBreakpoint(ctx.language) && !bp) return;
|
|
e.preventDefault();
|
|
// Acts on release over the same line: a drag across the gutter selects lines instead.
|
|
const up = (u: MouseEvent) => {
|
|
window.removeEventListener("mouseup", up, true);
|
|
if (u.button === 0 && lineAtEvent(this.view, u) === line) this.onClick(e, line, special, bp);
|
|
};
|
|
window.addEventListener("mouseup", up, true);
|
|
};
|
|
|
|
onClick = (
|
|
e: MouseEvent,
|
|
line: number,
|
|
special: ReturnType<typeof specialAt>[number] | undefined,
|
|
bp: ReturnType<typeof breakpointsOf>[number] | undefined,
|
|
) => {
|
|
if (special || bp) {
|
|
if (e.shiftKey || e.altKey) return;
|
|
if (special) removeSpecial(special.id);
|
|
else removeBreakpoint(path, bp!.id);
|
|
return;
|
|
}
|
|
if (!lineHasCode(this.view.state, line)) return;
|
|
if (e.altKey) {
|
|
addBreakpoint(path, line, { temporary: true });
|
|
return;
|
|
}
|
|
if (e.shiftKey) {
|
|
const created = addBreakpoint(path, line, { suspend: false, logHit: true });
|
|
openBreakpointPopover(path, created.id, { x: e.clientX, y: e.clientY });
|
|
return;
|
|
}
|
|
toggleBreakpointSmart(path, line, ctx.language, this.view.state.doc.toString());
|
|
};
|
|
|
|
onContextMenu = (e: MouseEvent) => {
|
|
if (!inBreakpointArea(e)) return;
|
|
const line = lineAtEvent(this.view, e);
|
|
if (line == null) return;
|
|
// Other lines get the editor's gutter menu, like IDEA.
|
|
const bp = specialAt(path).find((b) => b.line === line) ?? breakpointsOf(path).find((b) => b.line === line);
|
|
if (!bp) return;
|
|
e.preventDefault();
|
|
openBreakpointPopover(path, bp.id, { x: e.clientX, y: e.clientY });
|
|
};
|
|
|
|
destroy() {
|
|
this.destroyed = true;
|
|
this.unsubs.forEach((u) => u());
|
|
this.view.dom.removeEventListener("mousedown", this.onMouseDown);
|
|
this.view.scrollDOM.removeEventListener("contextmenu", this.onContextMenu);
|
|
}
|
|
},
|
|
);
|
|
|
|
return [
|
|
field,
|
|
plugin,
|
|
EditorView.theme({
|
|
".cm-lineNumbers .cm-gutterElement": { cursor: "default" },
|
|
}),
|
|
];
|
|
}
|