feat(debugger): search the debug console output (#1502)

The Debug console had no Find: Cmd-F did nothing there, in the Debug tool window and in Services' debug output alike. It now searches the whole buffer (Match Case / Words / Regex) with the n/m counter, highlights every match with the current one on its own background, scrolls it into view, and offers the Run console's context menu (Copy, Select All, Copy All, Find…, Clear All).

The find matching and find bar moved from the Run console into src/ui (consoleFind.ts, ConsoleFindBar.tsx) and are shared by both consoles; Enter on one of the bar's buttons activates it instead of jumping to the next match.

Co-authored-by: DomKing <[email protected]>
This commit is contained in:
DomKingandDomKing authored and GitHub committed 2026-11-05 21:03:28 +08:00
1 parent 24cf1a52d5
commit 253fcfb7e0
9 files changed
+494 -54

No files matched your search

@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ContextMenuHost } from "@/ui";
import { usePlatform } from "@/platform/platformInfo";
import { adoptSession, appendConsole, useDebug } from "../state";
import { DebugConsole } from "../ui/DebugConsole";
const clipboard = vi.hoisted(() => ({ writeText: vi.fn(async () => {}) }));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 18,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({ index, start: index * 18, size: 18, key: index })),
scrollToIndex: () => {},
measureElement: () => {},
measure: () => {},
}),
}));
beforeEach(() => {
usePlatform.setState({ os: "macos" });
useDebug.setState({ sessions: {}, console: {}, active: null });
clipboard.writeText.mockClear();
Object.defineProperty(navigator, "clipboard", { value: clipboard, configurable: true });
window.getSelection()?.removeAllRanges();
});
function setup(text = "alpha beta\ngamma Beta\nbeta again\n") {
const session = adoptSession(1);
appendConsole(1, "stdout", text);
const view = render(
<>
<DebugConsole session={session} />
<ContextMenuHost />
<div data-testid="outside">outside text</div>
</>,
);
const log = screen.getByRole("log");
return { session, log, view };
}
function openFind(log: HTMLElement) {
fireEvent.keyDown(log, { key: "f", code: "KeyF", metaKey: true });
return screen.getByRole("search");
}
function input() {
return screen.getByRole("textbox", { name: "Find in console" });
}
function type(query: string) {
fireEvent.change(input(), { target: { value: query } });
}
function markCount(container: HTMLElement) {
return container.querySelectorAll("[data-find-match]").length;
}
describe("Debug console find", () => {
it("opens on ⌘F, searches as you type and highlights every match", () => {
const { log, view } = setup();
expect(openFind(log)).toBeInTheDocument();
expect(input()).toHaveFocus();
type("beta");
expect(screen.getByText("1/3")).toBeInTheDocument();
expect(markCount(view.container)).toBe(3);
expect(view.container.querySelectorAll("[data-find-current]")).toHaveLength(1);
});
it("moves through matches with Enter / Shift+Enter and wraps around", () => {
const { log } = setup();
openFind(log);
type("beta");
const bar = screen.getByRole("search");
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.keyDown(bar, { key: "Enter" });
expect(screen.getByText("2/3")).toBeInTheDocument();
fireEvent.keyDown(bar, { key: "Enter", shiftKey: true });
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.keyDown(bar, { key: "Enter", shiftKey: true });
expect(screen.getByText("3/3")).toBeInTheDocument();
});
it("supports match case and regex", () => {
const { log, view } = setup();
openFind(log);
type("beta");
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Match Case" }));
expect(screen.getByText("1/2")).toBeInTheDocument();
expect(markCount(view.container)).toBe(2);
fireEvent.change(input(), { target: { value: "(" } });
fireEvent.click(screen.getByRole("button", { name: "Regex" }));
expect(screen.getByText("Bad regex")).toBeInTheDocument();
});
it("closes on Escape, clears the highlights and returns focus to the console", () => {
const { log, view } = setup();
openFind(log);
type("beta");
expect(markCount(view.container)).toBe(3);
fireEvent.keyDown(input(), { key: "Escape" });
expect(screen.queryByRole("search")).not.toBeInTheDocument();
expect(markCount(view.container)).toBe(0);
expect(log).toHaveFocus();
});
it("offers the Run console's context menu", () => {
const { log } = setup();
fireEvent.contextMenu(log);
for (const label of ["Copy", "Select All", "Copy All", "Find…", "Clear All"]) {
expect(screen.getByText(label)).toBeInTheDocument();
}
// Nothing is selected, so Copy has nothing to copy.
expect(screen.getByText("Copy").closest("[aria-disabled]")).not.toBeNull();
// Send EOF is the Run console's stdin; the debug console has no such input.
expect(screen.queryByText("Send EOF")).not.toBeInTheDocument();
});
it("opens the find bar from the context menu", () => {
const { log } = setup();
fireEvent.contextMenu(log);
fireEvent.click(screen.getByText("Find…"));
expect(screen.getByRole("search")).toBeInTheDocument();
expect(input()).toHaveFocus();
});
it("copies the whole buffer without doubling the line breaks", async () => {
// Console lines keep their trailing "\n", so joining them with one would add blank lines.
const { log } = setup();
fireEvent.contextMenu(log);
fireEvent.click(screen.getByText("Copy All"));
await waitFor(() => expect(clipboard.writeText).toHaveBeenCalledWith("alpha beta\ngamma Beta\nbeta again\n"));
});
it("leaves Enter over a toggle button to the button, not to the next match", () => {
const { log } = setup();
openFind(log);
type("beta");
expect(screen.getByText("1/3")).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("button", { name: "Match Case" }), { key: "Enter" });
expect(screen.getByText("1/3")).toBeInTheDocument();
});
it("keeps the span's styling on a highlighted match", () => {
const { log, view } = setup("\u001b[3mitalic beta\u001b[0m\n");
openFind(log);
type("beta");
const mark = view.container.querySelector("[data-find-match]") as HTMLElement;
expect(mark).not.toBeNull();
expect(mark.style.fontStyle).toBe("italic");
});
it("keeps Copy disabled when the selection is outside the console", () => {
const { log } = setup();
const outside = screen.getByTestId("outside");
const range = document.createRange();
range.selectNodeContents(outside);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
fireEvent.contextMenu(log);
expect(screen.getByText("Copy").closest("[aria-disabled]")).not.toBeNull();
});
});
+275 -14
View File
@@ -1,17 +1,95 @@
import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { copySelectionOnKeyDown, copyText } from "@/ui/selectableText";
import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui";
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { spanStyle } from "@/ui/ansi";
import { spanStyle, type Span, type SpanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import {
DEFAULT_FIND_OPTIONS,
findInLines,
type ConsoleMatch,
type FindOptions,
type FindResult,
} from "@/ui/consoleFind";
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
import { useExpressionCompletion } from "./ExpressionCompletion";
import styles from "./DebugToolWindow.module.css";
const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false };
const MARK_COLOR = "var(--bg-search-match)";
const CURRENT_MARK_COLOR = "var(--bg-search-match-current)";
interface Segment {
text: string;
style: SpanStyle;
/** The piece is inside a find match. */
mark: boolean;
/** …and inside the current one. */
current: boolean;
}
/** Splits a line's styled spans at find-match boundaries (the Run console's `segmentLine`, without links). */
function findSegments(spans: readonly Span[], marks: readonly ConsoleMatch[], active?: ConsoleMatch): Segment[] {
const out: Segment[] = [];
let pos = 0;
let mi = 0;
for (const span of spans) {
const { text, ...style } = span;
let offset = 0;
while (offset < text.length) {
const abs = pos + offset;
while (mi < marks.length && marks[mi]!.end <= abs) mi++;
const mark = marks[mi];
if (mark && mark.start <= abs) {
const take = Math.min(mark.end - abs, text.length - offset);
out.push({ text: text.slice(offset, offset + take), style, mark: true, current: mark === active });
offset += take;
} else if (mark && mark.start < pos + text.length) {
const take = Math.min(mark.start - abs, text.length - offset);
out.push({ text: text.slice(offset, offset + take), style, mark: false, current: false });
offset += take;
} else {
out.push({ text: text.slice(offset), style, mark: false, current: false });
offset = text.length;
}
}
pos += text.length;
}
return out;
}
/** A match keeps the span's colours but takes the search background (as IDEA does). */
function segmentNode(seg: Segment, key: number) {
if (!seg.mark)
return (
<span key={key} style={spanStyle(seg.style)}>
{seg.text}
</span>
);
return (
<mark
key={key}
className={styles.findMatch}
style={{
...spanStyle(seg.style),
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
color: "var(--fg-search-match)",
}}
data-find-match=""
data-find-current={seg.current ? "" : undefined}
>
{seg.text}
</mark>
);
}
export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
const session = useDebug((s) => s.sessions[sessionId]);
return session ? (
@@ -24,6 +102,7 @@ export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) {
const lines = useDebug((s) => s.console[session.id]);
const scroller = useRef<HTMLDivElement>(null);
const inner = useRef<HTMLDivElement>(null);
// Scroll to End: follows new output; scrolling up turns it off, back to the end turns it on.
const [follow, setFollow] = useState(true);
const stick = useRef(true);
@@ -39,6 +118,15 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const inputRef = useRef<HTMLInputElement>(null);
const completion = useExpressionCompletion(inputRef, input, setInput, session);
const [findOpen, setFindOpen] = useState(false);
const [query, setQuery] = useState("");
const [findOptions, setFindOptions] = useState<FindOptions>(DEFAULT_FIND_OPTIONS);
const [activeMatchIndex, setActiveMatchIndex] = useState(0);
const findInput = useRef<HTMLInputElement>(null);
/** Line the next search starts from (the top of the viewport when the query changed). */
const findAnchor = useRef(0);
const scrolledMatch = useRef<string | null>(null);
const virtualizer = useVirtualizer({
count: lines?.length ?? 0,
getScrollElement: () => scroller.current,
@@ -63,6 +151,99 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
});
}, [lines, virtualizer, follow, softWrap]);
// Find (⌘F): search the whole buffer, not just the rendered rows.
const found = useMemo(
() => (findOpen ? findInLines(lines ?? [], query, findOptions) : EMPTY_FIND),
[findOpen, lines, query, findOptions],
);
const activeIndex = found.matches.length ? Math.min(Math.max(activeMatchIndex, 0), found.matches.length - 1) : -1;
const current = activeIndex >= 0 ? found.matches[activeIndex] : undefined;
const marksByLine = useMemo(() => {
const byLine = new Map<number, ConsoleMatch[]>();
found.matches.forEach((match) => {
const marks = byLine.get(match.line);
if (marks) marks.push(match);
else byLine.set(match.line, [match]);
});
return byLine;
}, [found]);
// The line at the top of the viewport: typing searches from there, as IDEA does.
const topLine = () => {
const offset = scroller.current?.scrollTop ?? 0;
const items = virtualizer.getVirtualItems();
const first = items.find((item) => item.start + item.size > offset) ?? items[0];
return first?.index ?? 0;
};
const openFind = () => {
findAnchor.current = topLine();
// Opening with selected output searches for it (a single line, like the terminal).
if (!findOpen) {
const text = selectedText();
if (text) setQuery(text);
}
scrolledMatch.current = null;
setFindOpen(true);
setFollow(false);
findInput.current?.focus();
findInput.current?.select();
};
const closeFind = () => {
scrolledMatch.current = null;
setFindOpen(false);
scroller.current?.focus();
};
const changeQuery = (value: string) => {
findAnchor.current = topLine();
setQuery(value);
};
const changeOptions = (next: FindOptions) => {
findAnchor.current = topLine();
setFindOptions(next);
};
const goToMatch = (direction: 1 | -1) => {
const total = found.matches.length;
if (!total) return;
const from = activeMatchIndex >= 0 && activeMatchIndex < total ? activeMatchIndex : activeIndex;
setActiveMatchIndex((from + direction + total) % total);
};
const findWasOpen = useRef(false);
useEffect(() => {
if (findOpen) {
findWasOpen.current = true;
findInput.current?.focus();
findInput.current?.select();
} else if (findWasOpen.current) {
// Closed from the toolbar rather than the bar: put the caret back in the console.
findWasOpen.current = false;
scroller.current?.focus();
}
}, [findOpen]);
// The bar owns Escape / Enter / F3 / ⌘G while it is focused; keep the IDE keymap out.
useEffect(() => {
if (!findOpen) return;
return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-console-find]"));
}, [findOpen]);
// A new query (or option) starts from the viewport, not from the top of the output.
useEffect(() => {
if (!findOpen) return;
const next = found.matches.findIndex((match) => match.line >= findAnchor.current);
setActiveMatchIndex(next >= 0 ? next : 0);
// eslint-disable-next-line react-hooks/exhaustive-deps -- new output keeps the current match
}, [findOpen, query, findOptions]);
// Bring the current match into view.
useEffect(() => {
if (!findOpen || !current) return;
const key = `${current.lineId}:${current.start}`;
if (scrolledMatch.current === key) return;
virtualizer.scrollToIndex(current.line, { align: "center" });
scrolledMatch.current = key;
}, [findOpen, current, virtualizer]);
const submit = async () => {
const e = input.trim();
if (!e) return;
@@ -112,8 +293,95 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
}
};
/** Rows are virtualized, so "everything" is a selection of the container, not of the mounted rows. */
const selectAll = () => {
if (inner.current) window.getSelection()?.selectAllChildren(inner.current);
};
const allSelected = () => {
const el = inner.current;
const s = window.getSelection();
return (
!!el &&
!!s &&
!s.isCollapsed &&
s.anchorNode === el &&
s.focusNode === el &&
s.anchorOffset === 0 &&
s.focusOffset === el.childNodes.length
);
};
const bufferText = () => (lines ?? []).map((l) => l.text).join("");
/** The selected text; after Select All the whole buffer, including rows that are not mounted. */
const selectedOutput = () => {
if (allSelected()) return bufferText();
const s = window.getSelection();
const el = scroller.current;
if (!s || !el || !s.anchorNode || !el.contains(s.anchorNode)) return "";
return s.toString();
};
const selectedText = () => {
const el = scroller.current;
const selection = window.getSelection();
if (!el || !selection || !selection.anchorNode || !el.contains(selection.anchorNode)) return "";
const text = selection.toString();
return text && !text.includes("\n") ? text.trim() : "";
};
const onOutputKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing) return;
if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey) {
const key = e.key.toLowerCase();
if (key === "f") {
e.preventDefault();
openFind();
return;
}
if (key === "a") {
e.preventDefault();
selectAll();
return;
}
}
copySelectionOnKeyDown(e, selectedOutput);
};
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
const selection = selectedOutput();
showContextMenu(e, [
{
label: "Copy",
icon: "copy",
shortcut: formatBinding(hostBinding("Meta+C")),
disabled: !selection,
run: () => void copyText(selection),
},
{ label: "Select All", shortcut: formatBinding(hostBinding("Meta+A")), run: selectAll },
{ label: "Copy All", run: () => void copyText(bufferText()) },
{ label: "Find…", icon: "search", shortcut: formatBinding(hostBinding("Meta+F")), run: openFind },
{ kind: "separator" },
{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) },
]);
};
return (
<div className={styles.console}>
{findOpen && (
<ConsoleFindBar
query={query}
onChange={changeQuery}
options={findOptions}
onOptionsChange={changeOptions}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
/>
)}
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
@@ -123,19 +391,16 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
if (atEnd !== stick.current) setFollow(atEnd);
}}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
}}
onContextMenu={onContextMenu}
role="log"
onKeyDown={copySelectionOnKeyDown}
onKeyDown={onOutputKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
>
{!lines?.length && session.launch?.request === "attach" && (
<T>The attached process may not provide standard output to the debugger.</T>
)}
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
<div ref={inner} className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
@@ -147,11 +412,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{l.spans.map((sp, i) => (
<span key={i} style={spanStyle(sp)}>
{sp.text}
</span>
))}
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
</span>
);
})}
@@ -251,6 +251,11 @@
cursor: text;
}
/* Find matches in the console output. */
.findMatch {
border-radius: 2px;
}
/* Soft-Wrap off: long lines scroll horizontally. */
.noWrap {
white-space: pre;