diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index feb4a5be..57d96067 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -21,6 +21,7 @@ function makeView(doc: string) { } const flush = () => new Promise((r) => setTimeout(r, 0)); +const flushFrame = () => new Promise((r) => requestAnimationFrame(r)); function gutterClasses(view: EditorView): string[] { return [...view.dom.querySelectorAll(".cm-lineNumbers .cm-gutterElement")].map((e) => e.className); @@ -154,11 +155,12 @@ describe("execution point", () => { }); describe("session events", () => { - it("tracks pause / continue / termination and publishes context keys", () => { + it("tracks pause / continue / termination and publishes context keys", async () => { vi.spyOn(console, "warn").mockImplementation(() => {}); adoptSession(77); handleDebugEvent(77, { kind: "output", category: "stdout", output: "hel" }); handleDebugEvent(77, { kind: "output", category: "stdout", output: "lo\n" }); + await flushFrame(); expect(useDebug.getState().console[77]!.map((l) => l.text)).toEqual(["hello\n"]); expect(getContext().debugSessionActive).toBe(true); handleDebugEvent(77, { @@ -181,7 +183,7 @@ describe("session events", () => { expect(getContext().debugSessionActive).toBe(false); }); - it("ignores sessions this window did not start, but adopts children of its own", () => { + it("ignores sessions this window did not start, but adopts children of its own", async () => { vi.spyOn(console, "warn").mockImplementation(() => {}); adoptSession(90, { name: "app", adapter: "js-debug" }); // Another window's session: no session, no console, no tool window. @@ -202,6 +204,7 @@ describe("session events", () => { // A child the adapter of our session started is adopted with its parent. handleDebugEvent(92, { kind: "started", name: "worker", adapter: "js-debug", parent: 90 }); handleDebugEvent(92, { kind: "output", category: "stdout", output: "child\n" }); + await flushFrame(); const child = useDebug.getState().sessions[92]!; expect([child.name, child.parent, child.status]).toEqual(["worker", 90, "running"]); expect(useDebug.getState().console[92]!.map((l) => l.text)).toEqual(["child\n"]); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 5a8c36a6..46a6bdfa 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -95,11 +95,21 @@ interface ExecutionPoint { export const useExecutionPoint = create(() => ({ path: null, line: 0, top: true, values: {} })); -const MAX_CONSOLE_LINES = 5000; +/** Console buffer: at most this many entries (merged output runs) per session. */ +const MAX_CONSOLE_LINES = 50_000; let lineSeq = 0; /** ANSI state per session and output category (a colour can span several output events). */ const ansiStreams = new Map>(); +/** Streamed output buffered until the next animation frame (one setState per frame). */ +interface PendingOutput { + session: DebugSessionId; + category: string; + raw: string; +} +const pendingOutput: PendingOutput[] = []; +let outputFlushHandle: number | null = null; + function ansiStream(session: DebugSessionId, category: string): AnsiStream { let streams = ansiStreams.get(session); if (!streams) ansiStreams.set(session, (streams = new Map())); @@ -252,6 +262,7 @@ export function publishContext() { export function appendConsole(session: DebugSessionId, category: string, raw: string) { if (!raw) return; + flushConsoleOutput(); const spans = ansiStream(session, category).parse(raw); const text = spans.map((sp) => sp.text).join(""); if (!text) return; @@ -265,11 +276,79 @@ export function appendConsole(session: DebugSessionId, category: string, raw: st } else { next.push({ id: ++lineSeq, category, text, spans }); } - if (next.length > MAX_CONSOLE_LINES) next.splice(0, next.length - MAX_CONSOLE_LINES); + trimConsole(next); return { console: { ...s.console, [session]: next } }; }); } +/** + * Queues streamed debuggee output: ANSI parsing happens now, the console store + * is updated once per animation frame with all pending chunks merged per + * (session, category), so a flood of `output` events can't saturate React. + */ +export function appendConsoleQueued(session: DebugSessionId, category: string, raw: string) { + if (!raw) return; + const last = pendingOutput[pendingOutput.length - 1]; + if (last && last.session === session && last.category === category) { + last.raw += raw; + } else { + pendingOutput.push({ session, category, raw }); + } + if (outputFlushHandle === null) { + outputFlushHandle = requestAnimationFrame(() => { + outputFlushHandle = null; + flushConsoleOutput(); + }); + } +} + +function flushConsoleOutput() { + if (pendingOutput.length === 0) return; + const chunks = pendingOutput.splice(0); + useDebug.setState((s) => { + const consoleBySession = { ...s.console }; + for (const { session, category, raw } of chunks) { + if (!s.sessions[session]) continue; + const spans = ansiStream(session, category).parse(raw); + const text = spans.map((sp) => sp.text).join(""); + if (!text) continue; + const prev = consoleBySession[session] ?? []; + const next = [...prev]; + const last = next[next.length - 1]; + // Merge partial lines of the same category. + if (last && last.category === category && !last.text.endsWith("\n")) { + next[next.length - 1] = { ...last, text: last.text + text, spans: appendSpans(last.spans, spans) }; + } else { + next.push({ id: ++lineSeq, category, text, spans }); + } + trimConsole(next); + consoleBySession[session] = next; + } + return { console: consoleBySession }; + }); +} + +/** Drops the oldest lines once past the line or configured character cap. */ +function trimConsole(lines: ConsoleLine[]) { + let drop = 0; + if (lines.length > MAX_CONSOLE_LINES) { + drop = lines.length - MAX_CONSOLE_LINES + Math.ceil(MAX_CONSOLE_LINES / 10); + } + const c = workflowSettings().console; + if (c.overrideBuffer) { + const limit = c.bufferKb * 1024; + let chars = 0; + for (let i = lines.length - 1; i >= 0; i--) { + chars += lines[i]!.text.length + 1; + if (chars > limit) { + drop = Math.max(drop, i + 1); + break; + } + } + } + if (drop > 0) lines.splice(0, drop); +} + export function clearConsole(session: DebugSessionId) { ansiStreams.delete(session); useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } })); @@ -685,7 +764,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { break; } case "output": - appendConsole(id, event.category, event.output); + appendConsoleQueued(id, event.category, event.output); break; case "thread": if (s.status === "paused") void loadThreads(id); @@ -707,6 +786,11 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { export function resetSessions() { const { sessions } = useDebug.getState(); for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id); + pendingOutput.length = 0; + if (outputFlushHandle !== null) { + cancelAnimationFrame(outputFlushHandle); + outputFlushHandle = null; + } ansiStreams.clear(); useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 }); useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); diff --git a/src/features/debugger/ui/DebugToolWindow.module.css b/src/features/debugger/ui/DebugToolWindow.module.css index e9cf9c70..a5e878be 100644 --- a/src/features/debugger/ui/DebugToolWindow.module.css +++ b/src/features/debugger/ui/DebugToolWindow.module.css @@ -230,6 +230,7 @@ flex: 1; min-height: 0; overflow: auto; + position: relative; padding: 6px 14px; font-family: var(--font-mono); font-size: var(--fs-ui); @@ -244,7 +245,16 @@ color: var(--fg); } +.consoleInner { + position: relative; + width: 100%; +} + .out { + position: absolute; + top: 0; + left: 0; + min-width: 100%; color: var(--fg); } .cat-stderr { diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 75c668a4..3746a54b 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -1,7 +1,8 @@ /** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */ import { copySelectionOnKeyDown } from "@/ui/selectableText"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { executeAction, getAction } from "@/platform/actions"; import { evaluateWhen, useContextKeys } from "@/platform/context"; import { basename, useProject } from "@/platform/project"; @@ -550,15 +551,28 @@ function ConsoleView({ session }: { session: DebugSession }) { const lines = useDebug((s) => s.console[session.id]); const scroller = useRef(null); const stick = useRef(true); + const programmatic = useRef(false); const [input, setInput] = useState(""); const history = useRef(readHistory("console")); const historyPos = useRef(-1); const inputRef = useRef(null); - useEffect(() => { + const virtualizer = useVirtualizer({ + count: lines?.length ?? 0, + getScrollElement: () => scroller.current, + estimateSize: () => 18, + overscan: 30, + getItemKey: (i) => lines?.[i]?.id ?? i, + }); + + useLayoutEffect(() => { const el = scroller.current; - if (el && stick.current) el.scrollTop = el.scrollHeight; - }, [lines]); + if (!el || !stick.current || !lines?.length) return; + programmatic.current = true; + virtualizer.scrollToIndex(lines.length - 1, { align: "end" }); + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => (programmatic.current = false)); + }, [lines, virtualizer]); const submit = async () => { const e = input.trim(); @@ -614,6 +628,7 @@ function ConsoleView({ session }: { session: DebugSession }) { ref={scroller} className={clsx(styles.consoleOutput, ansi.palette)} onScroll={(e) => { + if (programmatic.current) return; const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24; }} @@ -626,15 +641,27 @@ function ConsoleView({ session }: { session: DebugSession }) { aria-label={tx("Debug console")} tabIndex={0} > - {(lines ?? []).map((l) => ( - - {l.spans.map((sp, i) => ( - - {sp.text} +
+ {virtualizer.getVirtualItems().map((item) => { + const l = lines?.[item.index]; + if (!l) return null; + return ( + + {l.spans.map((sp, i) => ( + + {sp.text} + + ))} - ))} - - ))} + ); + })} +