diff --git a/src/features/debugger/__tests__/debugFind.test.tsx b/src/features/debugger/__tests__/debugFind.test.tsx new file mode 100644 index 00000000..d933fb13 --- /dev/null +++ b/src/features/debugger/__tests__/debugFind.test.tsx @@ -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( + <> + + +
outside text
+ , + ); + 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(); + }); +}); diff --git a/src/features/debugger/ui/DebugConsole.tsx b/src/features/debugger/ui/DebugConsole.tsx index 3b98217b..9cbc0cb0 100644 --- a/src/features/debugger/ui/DebugConsole.tsx +++ b/src/features/debugger/ui/DebugConsole.tsx @@ -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 ( + + {seg.text} + + ); + return ( + + {seg.text} + + ); +} + 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(null); + const inner = useRef(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(null); const completion = useExpressionCompletion(inputRef, input, setInput, session); + const [findOpen, setFindOpen] = useState(false); + const [query, setQuery] = useState(""); + const [findOptions, setFindOptions] = useState(DEFAULT_FIND_OPTIONS); + const [activeMatchIndex, setActiveMatchIndex] = useState(0); + const findInput = useRef(null); + /** Line the next search starts from (the top of the viewport when the query changed). */ + const findAnchor = useRef(0); + const scrolledMatch = useRef(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(); + 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) => { + 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 (
+ {findOpen && ( + goToMatch(1)} + onPrevious={() => goToMatch(-1)} + onClose={closeFind} + /> + )}
{ - 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" && ( The attached process may not provide standard output to the debugger. )} -
+
{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) => ( - - {sp.text} - - ))} + {findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)} ); })} diff --git a/src/features/debugger/ui/DebugToolWindow.module.css b/src/features/debugger/ui/DebugToolWindow.module.css index ed9bebbb..f428fc6e 100644 --- a/src/features/debugger/ui/DebugToolWindow.module.css +++ b/src/features/debugger/ui/DebugToolWindow.module.css @@ -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; diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 8c5aad24..64cb70cb 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -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. diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css index 99675566..7bf99d8f 100644 --- a/src/features/run/Run.module.css +++ b/src/features/run/Run.module.css @@ -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; diff --git a/src/ui/ConsoleFindBar.module.css b/src/ui/ConsoleFindBar.module.css new file mode 100644 index 00000000..63abc491 --- /dev/null +++ b/src/ui/ConsoleFindBar.module.css @@ -0,0 +1,28 @@ +/* Find bar (⌘F) of the Run and Debug consoles. */ +.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; +} diff --git a/src/features/run/FindBar.tsx b/src/ui/ConsoleFindBar.tsx similarity index 89% rename from src/features/run/FindBar.tsx rename to src/ui/ConsoleFindBar.tsx index a4544704..81bfae19 100644 --- a/src/features/run/FindBar.tsx +++ b/src/ui/ConsoleFindBar.tsx @@ -1,11 +1,12 @@ -/** ⌘F in the Run console: the IDEA find bar (Match Case / Words / Regex, n/m, previous/next). */ +/** ⌘F in a console (Run, Debug): 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 { IconButton } from "./Button"; +import { SearchField } from "./Input"; import { formatBinding, hasPrimaryModifier } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; -import type { FindOptions } from "./find"; -import styles from "./Run.module.css"; +import type { FindOptions } from "./consoleFind"; +import styles from "./ConsoleFindBar.module.css"; export interface ConsoleFindBarProps { query: string; @@ -55,6 +56,8 @@ export function ConsoleFindBar({ e.stopPropagation(); onClose(); } else if (e.key === "Enter") { + // Enter on a button (Match Case, Close, …) must activate it, not jump to the next match. + if ((e.target as Element | null)?.closest?.("button")) return; e.preventDefault(); if (e.shiftKey) onPrevious(); else onNext(); @@ -81,7 +84,7 @@ export function ConsoleFindBar({ className={styles.find} role="search" aria-label={tx("Find in console")} - data-run-find="" + data-console-find="" onKeyDown={onKeyDown} > texts.map((text, i) => ({ id: i + 1, text })); const opts = (extra: Partial = {}): FindOptions => ({ ...DEFAULT_FIND_OPTIONS, ...extra }); diff --git a/src/features/run/find.ts b/src/ui/consoleFind.ts similarity index 94% rename from src/features/run/find.ts rename to src/ui/consoleFind.ts index 57eff195..b12e392c 100644 --- a/src/features/run/find.ts +++ b/src/ui/consoleFind.ts @@ -1,5 +1,5 @@ /** - * Find in the Run console output: every occurrence in every line, with IDEA's + * Find in console output (Run, Debug): 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. */ @@ -17,7 +17,7 @@ export const DEFAULT_FIND_OPTIONS: FindOptions = { }; export interface ConsoleMatch { - /** Index into the model's current lines. */ + /** Index into the console's current lines. */ line: number; /** Stable line id (survives trimming of older lines). */ lineId: number;