дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
24cf1a52d5
коміт
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -24,8 +24,8 @@ import styles from "./Run.module.css";
|
||||
import { useConsoleSelection } from "./useConsoleSelection";
|
||||
import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { ConsoleFindBar } from "./FindBar";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "./find";
|
||||
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "@/ui/consoleFind";
|
||||
|
||||
const ROW = 20;
|
||||
|
||||
@@ -343,7 +343,7 @@ export function ConsoleView({
|
||||
// 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-run-find]"));
|
||||
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.
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
/** ⌘F in the Run console: the IDEA find bar (Match Case / Words / Regex, n/m, previous/next). */
|
||||
import { type KeyboardEvent, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { IconButton, SearchField } from "@/ui";
|
||||
import { formatBinding, hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { FindOptions } from "./find";
|
||||
import styles from "./Run.module.css";
|
||||
|
||||
export interface ConsoleFindBarProps {
|
||||
query: string;
|
||||
onChange: (query: string) => void;
|
||||
options: FindOptions;
|
||||
onOptionsChange: (options: FindOptions) => void;
|
||||
/** Number of matches. */
|
||||
count: number;
|
||||
/** Zero-based current match; -1 when there is none. */
|
||||
active: number;
|
||||
/** More matches exist than were collected. */
|
||||
overflow: boolean;
|
||||
/** The query is an invalid regular expression. */
|
||||
invalid: boolean;
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConsoleFindBar({
|
||||
query,
|
||||
onChange,
|
||||
options,
|
||||
onOptionsChange,
|
||||
count,
|
||||
active,
|
||||
overflow,
|
||||
invalid,
|
||||
inputRef,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onClose,
|
||||
}: ConsoleFindBarProps) {
|
||||
const none = !!query && !invalid && count === 0;
|
||||
const counter = invalid
|
||||
? "Bad regex"
|
||||
: !query
|
||||
? ""
|
||||
: count === 0
|
||||
? "0 results"
|
||||
: `${active + 1}/${count}${overflow ? "+" : ""}`;
|
||||
const toggle = (key: keyof FindOptions) => onOptionsChange({ ...options, [key]: !options[key] });
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrevious();
|
||||
else onNext();
|
||||
} else if (e.key === "F3" || (hasPrimaryModifier(e) && e.key.toLowerCase() === "g")) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrevious();
|
||||
else onNext();
|
||||
} else if (hasPrimaryModifier(e) && e.key.toLowerCase() === "f") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const key =
|
||||
e.code === "KeyC" ? "caseSensitive" : e.code === "KeyW" ? "wholeWord" : e.code === "KeyX" ? "regex" : null;
|
||||
if (key) {
|
||||
e.preventDefault();
|
||||
toggle(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.find}
|
||||
role="search"
|
||||
aria-label={tx("Find in console")}
|
||||
data-run-find=""
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<SearchField
|
||||
ref={inputRef}
|
||||
className={styles.findField}
|
||||
value={query}
|
||||
onChange={onChange}
|
||||
placeholder="Find in console"
|
||||
aria-label="Find in console"
|
||||
invalid={invalid || none}
|
||||
toggles={[
|
||||
{
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: formatBinding("Alt+C"),
|
||||
pressed: options.caseSensitive,
|
||||
onToggle: () => toggle("caseSensitive"),
|
||||
},
|
||||
{
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: formatBinding("Alt+W"),
|
||||
pressed: options.wholeWord,
|
||||
onToggle: () => toggle("wholeWord"),
|
||||
},
|
||||
{
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: formatBinding("Alt+X"),
|
||||
pressed: options.regex,
|
||||
onToggle: () => toggle("regex"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<span className={clsx(styles.findCount, (invalid || none) && styles.findCountNone)} aria-live="polite">
|
||||
{counter}
|
||||
</span>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Occurrence"
|
||||
shortcut={formatBinding("Shift+Enter")}
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onPrevious}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
title="Next Occurrence"
|
||||
shortcut="↵"
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onNext}
|
||||
/>
|
||||
<span className={styles.findSpacer} />
|
||||
<IconButton icon="close" title="Close" shortcut="⎋" size="small" onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -780,35 +780,6 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Find bar (⌘F) */
|
||||
.find {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.findField {
|
||||
flex: 0 1 360px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.findCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
}
|
||||
.findCountNone {
|
||||
color: var(--danger);
|
||||
}
|
||||
.findSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.console:focus .inputCaret {
|
||||
background: var(--fg);
|
||||
animation: caretBlink 1s steps(1) infinite;
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, MAX_MATCHES, type FindOptions } from "./find";
|
||||
|
||||
const lines = (...texts: string[]) => texts.map((text, i) => ({ id: i + 1, text }));
|
||||
const opts = (extra: Partial<FindOptions> = {}): FindOptions => ({ ...DEFAULT_FIND_OPTIONS, ...extra });
|
||||
|
||||
describe("findInLines", () => {
|
||||
it("finds every occurrence on every line, ignoring case by default", () => {
|
||||
expect(findInLines(lines("alpha beta", "gamma Beta", "BETA again"), "beta", opts()).matches).toEqual([
|
||||
{ line: 0, lineId: 1, start: 6, end: 10 },
|
||||
{ line: 1, lineId: 2, start: 6, end: 10 },
|
||||
{ line: 2, lineId: 3, start: 0, end: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches case only when asked", () => {
|
||||
expect(findInLines(lines("beta Beta BETA"), "Beta", opts({ caseSensitive: true })).matches).toEqual([
|
||||
{ line: 0, lineId: 1, start: 5, end: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps whole words only", () => {
|
||||
expect(findInLines(lines("cat concatenate cat."), "cat", opts({ wholeWord: true })).matches).toEqual([
|
||||
{ line: 0, lineId: 1, start: 0, end: 3 },
|
||||
{ line: 0, lineId: 1, start: 16, end: 19 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a literal query literally", () => {
|
||||
const matches = findInLines(lines("a.b axb a.b"), "a.b", opts()).matches;
|
||||
expect(matches.map((m) => m.start)).toEqual([0, 8]);
|
||||
});
|
||||
|
||||
it("uses a regular expression when asked", () => {
|
||||
expect(findInLines(lines("beta betta b3ta"), "b.ta", opts({ regex: true })).matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("applies whole words to regular expression matches", () => {
|
||||
expect(findInLines(lines("cat concatenate cat"), "c.t", opts({ regex: true, wholeWord: true })).matches).toEqual([
|
||||
{ line: 0, lineId: 1, start: 0, end: 3 },
|
||||
{ line: 0, lineId: 1, start: 16, end: 19 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an invalid regular expression instead of throwing", () => {
|
||||
expect(findInLines(lines("anything"), "(", opts({ regex: true }))).toEqual({
|
||||
matches: [],
|
||||
overflow: false,
|
||||
invalid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips empty regular expression matches", () => {
|
||||
expect(findInLines(lines("abc"), "x*", opts({ regex: true })).matches).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the matches and reports the overflow", () => {
|
||||
const result = findInLines(lines("x x x x"), "x", opts(), 2);
|
||||
expect(result.matches).toHaveLength(2);
|
||||
expect(result.overflow).toBe(true);
|
||||
});
|
||||
|
||||
it("has no overflow when the cap is exact", () => {
|
||||
expect(findInLines(lines("x x"), "x", opts(), 2)).toMatchObject({ overflow: false });
|
||||
expect(findInLines(lines("x x"), "x", opts(), MAX_MATCHES).matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty query", () => {
|
||||
expect(findInLines(lines("a b"), " ", opts({ regex: false })).matches).toHaveLength(1);
|
||||
expect(findInLines(lines("text"), "", opts()).matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Find in the Run console output: every occurrence in every line, with IDEA's
|
||||
* Match Case / Words / Regex options. Matches carry the line's stable id so the
|
||||
* bar can keep the current match while output keeps arriving.
|
||||
*/
|
||||
|
||||
export interface FindOptions {
|
||||
caseSensitive: boolean;
|
||||
wholeWord: boolean;
|
||||
regex: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FIND_OPTIONS: FindOptions = {
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
regex: false,
|
||||
};
|
||||
|
||||
export interface ConsoleMatch {
|
||||
/** Index into the model's current lines. */
|
||||
line: number;
|
||||
/** Stable line id (survives trimming of older lines). */
|
||||
lineId: number;
|
||||
/** UTF-16 offsets into the line's text. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface FindResult {
|
||||
matches: ConsoleMatch[];
|
||||
/** More matches exist than were collected. */
|
||||
overflow: boolean;
|
||||
/** The query is an invalid regular expression. */
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
/** Matches collected per search; the counter shows `N+` beyond this. */
|
||||
export const MAX_MATCHES = 2000;
|
||||
|
||||
const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
||||
const WORD_CHAR = /[\p{L}\p{N}_$]/u;
|
||||
|
||||
function wholeWord(text: string, start: number, end: number): boolean {
|
||||
return !WORD_CHAR.test(text[start - 1] ?? "") && !WORD_CHAR.test(text[end] ?? "");
|
||||
}
|
||||
|
||||
/** All matches of `query` in `lines`, in document order. */
|
||||
export function findInLines(
|
||||
lines: readonly { id: number; text: string }[],
|
||||
query: string,
|
||||
options: FindOptions,
|
||||
limit = MAX_MATCHES,
|
||||
): FindResult {
|
||||
if (!query) return { matches: [], overflow: false, invalid: false };
|
||||
const source = options.regex ? query : query.replace(REGEX_SPECIALS, "\\$&");
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(source, options.caseSensitive ? "gu" : "giu");
|
||||
} catch {
|
||||
return { matches: [], overflow: false, invalid: true };
|
||||
}
|
||||
const matches: ConsoleMatch[] = [];
|
||||
for (let line = 0; line < lines.length; line++) {
|
||||
const { id, text } = lines[line]!;
|
||||
re.lastIndex = 0;
|
||||
let found: RegExpExecArray | null;
|
||||
while ((found = re.exec(text))) {
|
||||
const start = found.index;
|
||||
const end = start + found[0].length;
|
||||
// Empty matches (e.g. `x*`) would otherwise loop forever.
|
||||
if (end === start) {
|
||||
re.lastIndex = start + 1;
|
||||
continue;
|
||||
}
|
||||
if (options.wholeWord && !wholeWord(text, start, end)) continue;
|
||||
if (matches.length >= limit) return { matches, overflow: true, invalid: false };
|
||||
matches.push({ line, lineId: id, start, end });
|
||||
}
|
||||
}
|
||||
return { matches, overflow: false, invalid: false };
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача