From cbdc177bbc34e41b79d23d866a59e8a48d7cdf2e Mon Sep 17 00:00:00 2001 From: itning Date: Sat, 31 Oct 2026 12:38:54 +0800 Subject: [PATCH] feat(run): search the console output (#528) The Run console had no search, so finding a line meant scrolling long output by hand. Cmd/Ctrl+F, a toolbar button and the context menu open a find bar over the whole buffer with match case, words and regex, a match counter, next and previous navigation, and highlighting that keeps output colours. It expands a fold hiding the current match, and works for test output and stack trace consoles too. Co-authored-by: itning <19759891+itning@users.noreply.github.com> --- src/features/run/ConsoleView.tsx | 327 ++++++++++++++++++++++++------ src/features/run/FindBar.tsx | 145 +++++++++++++ src/features/run/Run.module.css | 42 ++++ src/features/run/RunView.tsx | 13 +- src/features/run/find.test.ts | 72 +++++++ src/features/run/find.ts | 81 ++++++++ src/features/run/findBar.test.tsx | 151 ++++++++++++++ src/features/run/segments.test.ts | 36 ++++ src/features/run/segments.ts | 58 +++++- src/platform/locales/text/zh.json | 1 + 10 files changed, 854 insertions(+), 72 deletions(-) create mode 100644 src/features/run/FindBar.tsx create mode 100644 src/features/run/find.test.ts create mode 100644 src/features/run/find.ts create mode 100644 src/features/run/findBar.test.tsx create mode 100644 src/features/run/segments.test.ts diff --git a/src/features/run/ConsoleView.tsx b/src/features/run/ConsoleView.tsx index 752f1ed5..8997d419 100644 --- a/src/features/run/ConsoleView.tsx +++ b/src/features/run/ConsoleView.tsx @@ -19,17 +19,31 @@ import ansi from "@/ui/ansi.module.css"; import type { ConsoleLine, ConsoleModel } from "./console"; import { findOutputLinks, type OutputLink } from "./links"; import { openTarget, openUrl } from "./navigate"; -import { segmentLine } from "./segments"; +import { segmentLine, type SegmentMark } from "./segments"; import styles from "./Run.module.css"; -import { hostBinding, formatBinding } from "@/platform/keymap"; +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"; const ROW = 20; +const MARK_COLOR = "var(--bg-search-match)"; +const CURRENT_MARK_COLOR = "var(--bg-search-match-current)"; + const Line = memo( - function Line({ line, cwd }: { line: ConsoleLine; length: number; cwd: string }) { + function Line({ + line, + marks, + cwd, + }: { + line: ConsoleLine; + marks?: readonly SegmentMark[]; + length: number; + cwd: string; + }) { // Not memoised: the open (last) line mutates in place; `memo` below re-renders on length changes only. - const segments = segmentLine(line.spans, line.kind === "system" ? [] : findOutputLinks(line.text)); + const segments = segmentLine(line.spans, line.kind === "system" ? [] : findOutputLinks(line.text), marks); const activate = (e: MouseEvent, link: OutputLink) => { e.preventDefault(); if (window.getSelection()?.toString()) return; @@ -39,29 +53,43 @@ const Line = memo( if (segments.length === 0) return <>{"​"}; return ( <> - {segments.map((seg, i) => - seg.link ? ( + {segments.map((seg, i) => { + // A match keeps the span's colours but takes the search background (as IDEA does). + const style = seg.mark + ? { + ...spanStyle(seg.style), + backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR, + color: "var(--fg-search-match)", + } + : spanStyle(seg.style); + const marked = seg.mark ? { "data-find-match": "", "data-find-current": seg.current ? "" : undefined } : {}; + return seg.link ? ( activate(e, seg.link!)} role="link" tabIndex={-1} + {...marked} > {seg.text} + ) : seg.mark ? ( + + {seg.text} + ) : ( - + {seg.text} - ), - )} + ); + })} ); }, - (a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd, + (a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd && a.marks === b.marks, ); /** A run of folded lines (Settings › Console › Fold lines that contain). */ @@ -130,6 +158,9 @@ export interface ConsoleViewProps { onClear?: () => void; /** Set while the process accepts input. */ input?: ConsoleInput; + /** Controlled find bar (the Run toolbar button); without it ⌘F opens it locally. */ + findOpen?: boolean; + onFindOpenChange?: (open: boolean) => void; "aria-label"?: string; } @@ -142,12 +173,27 @@ export function ConsoleView({ cwd, onClear, input, + findOpen: controlledFindOpen, + onFindOpenChange, ...aria }: ConsoleViewProps) { const scrollRef = useRef(null); const lines = model.lines; const patterns = useWorkflowSettings((s) => s.console.foldPatterns); const [expanded, setExpanded] = useState>(() => new Set()); + const [internalFindOpen, setInternalFindOpen] = useState(false); + const findOpen = controlledFindOpen ?? internalFindOpen; + const setFindOpen = (open: boolean) => { + onFindOpenChange?.(open); + if (controlledFindOpen === undefined) setInternalFindOpen(open); + }; + const [query, setQuery] = useState(""); + const [findOptions, setFindOptions] = useState(DEFAULT_FIND_OPTIONS); + const [active, setActive] = 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); // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` marks in-place appends to `lines` const folded = useMemo(() => foldRows(lines, patterns, expanded), [lines, version, patterns, expanded]); // Pending input continues the open line (a prompt), or starts a row of its own. @@ -198,6 +244,135 @@ export function ConsoleView({ if (atBottom !== follow) onFollowChange(atBottom); }; + // Find (⌘F): search the whole buffer, not just the rendered rows. + const found = useMemo( + () => (findOpen ? findInLines(lines, query, findOptions) : { matches: [], overflow: false, invalid: false }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` marks in-place appends to `lines` + [findOpen, lines, version, query, findOptions], + ); + const activeIndex = found.matches.length ? Math.min(Math.max(active, 0), found.matches.length - 1) : -1; + const activeMatch = activeIndex >= 0 ? found.matches[activeIndex] : undefined; + const marksByLine = useMemo(() => { + const byLine = new Map(); + found.matches.forEach((match, i) => { + const mark: SegmentMark = { start: match.start, end: match.end, current: i === activeIndex }; + const marks = byLine.get(match.line); + if (marks) marks.push(mark); + else byLine.set(match.line, [mark]); + }); + return byLine; + }, [found, activeIndex]); + const rowOfLine = useMemo(() => { + const rowsByLine = new Map(); + rows.forEach((row, i) => { + if (row.kind === "line") rowsByLine.set(row.index, i); + }); + return rowsByLine; + }, [rows]); + + /** The line at the top of the viewport: typing searches from there, as IDEA does. */ + const topLine = () => { + const offset = scrollRef.current?.scrollTop ?? 0; + const items = virtualizer.getVirtualItems(); + // The virtualizer also renders overscan rows above the viewport. + const first = items.find((item) => item.start + item.size > offset) ?? items[0]; + const row = first ? rows[first.index] : undefined; + if (!row) return 0; + return row.kind === "line" ? row.index : row.fold.start; + }; + const selectedText = () => { + const el = scrollRef.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 openFind = () => { + findAnchor.current = topLine(); + // Opening with selected output searches for it (a single line, like the terminal). + if (!findOpen) { + const selection = selectedText(); + if (selection) setQuery(selection); + } + scrolledMatch.current = null; + setFindOpen(true); + onFollowChange(false); + findInput.current?.focus(); + findInput.current?.select(); + }; + const closeFind = () => { + scrolledMatch.current = null; + setFindOpen(false); + scrollRef.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; + setActive((current) => { + const from = current >= 0 && current < total ? current : activeIndex; + return (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; + scrollRef.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-run-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); + setActive(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; expand a collapsed fold that hides it. + useEffect(() => { + if (!findOpen || !activeMatch) return; + const key = `${activeMatch.lineId}:${activeMatch.start}`; + if (scrolledMatch.current === key) return; + const rowIndex = rowOfLine.get(activeMatch.line); + if (rowIndex === undefined) { + for (const row of rows) { + const fold = row.fold; + if (fold && activeMatch.line >= fold.start && activeMatch.line < fold.start + fold.count) { + setExpanded((s) => { + const next = new Set(s); + next.add(fold.id); + return next; + }); + break; + } + } + return; + } + virtualizer.scrollToIndex(rowIndex, { align: "center" }); + scrolledMatch.current = key; + }, [findOpen, activeMatch, rowOfLine, rows, virtualizer]); + const onContextMenu = (e: MouseEvent) => { e.preventDefault(); const selection = window.getSelection()?.toString() ?? ""; @@ -210,6 +385,12 @@ export function ConsoleView({ run: () => void navigator.clipboard?.writeText(selection).catch(() => undefined), }, { label: "Copy All", run: () => void navigator.clipboard?.writeText(model.text()).catch(() => undefined) }, + { + label: "Find…", + icon: "search", + shortcut: formatBinding(hostBinding("Meta+F")), + run: openFind, + }, { kind: "separator" }, { label: "Clear All", icon: "trash", disabled: !onClear, run: () => onClear?.() }, ...(input @@ -219,8 +400,14 @@ export function ConsoleView({ }; const onKeyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "f") { + e.preventDefault(); + openFind(); + return; + } if (copySelectionOnKeyDown(e)) return; - if (!input || e.nativeEvent.isComposing) return; + if (!input) return; if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); input.onEof(); @@ -278,64 +465,82 @@ export function ConsoleView({ const items = virtualizer.getVirtualItems(); return ( -
-
- {items.map((item) => { - const row = rows[item.index]; - if (!row) { +
+ {findOpen && ( + goToMatch(1)} + onPrevious={() => goToMatch(-1)} + onClose={closeFind} + /> + )} +
+
+ {items.map((item) => { + const row = rows[item.index]; + if (!row) { + return ( +
+ {pendingInput} +
+ ); + } + if (row.kind === "fold") { + return ( +
+ {foldButton(row.fold, false)} +
+ ); + } + const line = lines[row.index]; + if (!line) return null; return (
- {pendingInput} + + {row.fold && foldButton(row.fold, true)} + {inputInline && item.index === rows.length - 1 && pendingInput}
); - } - if (row.kind === "fold") { - return ( -
- {foldButton(row.fold, false)} -
- ); - } - const line = lines[row.index]; - if (!line) return null; - return ( -
- - {row.fold && foldButton(row.fold, true)} - {inputInline && item.index === rows.length - 1 && pendingInput} -
- ); - })} + })} +
); diff --git a/src/features/run/FindBar.tsx b/src/features/run/FindBar.tsx new file mode 100644 index 00000000..81af6d83 --- /dev/null +++ b/src/features/run/FindBar.tsx @@ -0,0 +1,145 @@ +/** ⌘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 { 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; + 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) => { + 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 ( +
+ toggle("caseSensitive"), + }, + { + id: "word", + icon: "word", + title: "Words", + shortcut: "⌥W", + pressed: options.wholeWord, + onToggle: () => toggle("wholeWord"), + }, + { + id: "regex", + icon: "regex", + title: "Regex", + shortcut: "⌥X", + pressed: options.regex, + onToggle: () => toggle("regex"), + }, + ]} + /> + + {counter} + + + + + +
+ ); +} diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css index 3e6a9344..64b8da79 100644 --- a/src/features/run/Run.module.css +++ b/src/features/run/Run.module.css @@ -81,6 +81,14 @@ /* Console */ +.consoleWrap { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + .console { flex: 1; min-height: 0; @@ -107,6 +115,10 @@ min-width: 0; } +.match { + border-radius: 2px; +} + .line { position: absolute; top: 0; @@ -748,6 +760,36 @@ vertical-align: text-bottom; 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/features/run/RunView.tsx b/src/features/run/RunView.tsx index 9170bca9..ca43eb62 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -3,7 +3,7 @@ import type { RunTargetValid } from "@/platform/remoteSettings"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui"; import { executeAction } from "@/platform/actions"; -import { shortcutLabel } from "@/platform/keymap"; +import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap"; import { ConsoleModel } from "./console"; import { ConsoleView } from "./ConsoleView"; import { TerminalConsole } from "./TerminalConsole"; @@ -52,6 +52,7 @@ export function SessionPanel({ session }: { session: RunSession }) { const [wrapOverride, setWrapOverride] = useState(null); const softWrap = wrapOverride ?? defaultWrap; const [selectedTest, setSelectedTest] = useState(null); + const [findOpen, setFindOpen] = useState(false); const running = session.status === "running" || session.status === "starting"; const cwd = runDirectory(session.config.cwd); @@ -94,6 +95,8 @@ export function SessionPanel({ session }: { session: RunSession }) { softWrap={softWrap} follow={testModel ? false : follow} onFollowChange={setFollow} + findOpen={findOpen} + onFindOpenChange={setFindOpen} cwd={cwd} onClear={() => clearConsole(session.id)} input={ @@ -153,6 +156,14 @@ export function SessionPanel({ session }: { session: RunSession }) {
{!rt.terminal && ( <> + setFindOpen((v) => !v)} + /> texts.map((text, i) => ({ id: i + 1, text })); +const opts = (extra: Partial = {}): 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([]); + }); +}); diff --git a/src/features/run/find.ts b/src/features/run/find.ts new file mode 100644 index 00000000..57eff195 --- /dev/null +++ b/src/features/run/find.ts @@ -0,0 +1,81 @@ +/** + * 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 }; +} diff --git a/src/features/run/findBar.test.tsx b/src/features/run/findBar.test.tsx new file mode 100644 index 00000000..9aa44c4d --- /dev/null +++ b/src/features/run/findBar.test.tsx @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { usePlatform } from "@/platform/platformInfo"; +import { ConsoleModel } from "./console"; +import { ConsoleView } from "./ConsoleView"; + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 20, + getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 20, key: index })), + scrollToIndex: () => {}, + measureElement: () => {}, + measure: () => {}, + }), +})); + +beforeEach(() => usePlatform.setState({ os: "macos" })); + +function setup(text = "alpha beta\ngamma Beta\nbeta again\n") { + const model = new ConsoleModel(); + model.append({ kind: "stdout", text }); + const view = render( + {}} cwd="/" />, + ); + const log = screen.getByRole("log"); + return { model, 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("Run console find", () => { + it("opens on ⌘F, searches as you type and highlights every match", () => { + const { log, view } = setup(); + const bar = openFind(log); + expect(bar).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, words 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: "b.ta" } }); + expect(screen.getByText("0 results")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Regex" })); + expect(screen.getByText("1/2")).toBeInTheDocument(); + }); + + it("reports an invalid regular expression", () => { + const { log } = setup(); + openFind(log); + fireEvent.click(screen.getByRole("button", { name: "Regex" })); + type("("); + 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("seeds the field with the selected console text", () => { + const { log } = setup(); + const text = screen.getByText("gamma Beta"); + const range = document.createRange(); + range.setStart(text.firstChild!, 0); + range.setEnd(text.firstChild!, 5); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + + openFind(log); + expect(input()).toHaveValue("gamma"); + }); + + it("shows no matches when nothing matches", () => { + const { log, view } = setup(); + openFind(log); + type("nothing"); + expect(screen.getByText("0 results")).toBeInTheDocument(); + expect(markCount(view.container)).toBe(0); + }); + + it("runs the bar in a controlled open state", () => { + const onOpenChange = vi.fn(); + const model = new ConsoleModel(); + model.append({ kind: "stdout", text: "one two" }); + render( + {}} + cwd="/" + findOpen + onFindOpenChange={onOpenChange} + />, + ); + expect(screen.getByRole("search")).toBeInTheDocument(); + fireEvent.keyDown(input(), { key: "Escape" }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/features/run/segments.test.ts b/src/features/run/segments.test.ts new file mode 100644 index 00000000..1061924c --- /dev/null +++ b/src/features/run/segments.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { segmentLine } from "./segments"; + +describe("segmentLine marks", () => { + it("splits a line around matches", () => { + expect( + segmentLine( + [{ text: "hello world" }], + [], + [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + ], + ), + ).toEqual([ + { text: "hello", style: {}, mark: true, current: false }, + { text: " ", style: {} }, + { text: "world", style: {}, mark: true, current: false }, + ]); + }); + + it("marks the current match and keeps its span style", () => { + expect(segmentLine([{ text: "red", fg: "#f00" }], [], [{ start: 0, end: 3, current: true }])).toEqual([ + { text: "red", style: { fg: "#f00" }, mark: true, current: true }, + ]); + }); + + it("splits matches inside links", () => { + const link = { kind: "url" as const, url: "http://x", text: "http://x", start: 4, end: 12 }; + expect(segmentLine([{ text: "see http://x" }], [link], [{ start: 8, end: 12 }])).toEqual([ + { text: "see ", style: {} }, + { text: "http", style: {}, link }, + { text: "://x", style: {}, link, mark: true, current: false }, + ]); + }); +}); diff --git a/src/features/run/segments.ts b/src/features/run/segments.ts index 2dea2095..f4f59f46 100644 --- a/src/features/run/segments.ts +++ b/src/features/run/segments.ts @@ -1,36 +1,74 @@ -/** Splits a console line's styled spans at link boundaries for rendering. */ +/** Splits a console line's styled spans at link and find-match boundaries for rendering. */ import type { Span, SpanStyle } from "./console"; import type { OutputLink } from "./links"; +/** A find match on one line, in UTF-16 offsets. */ +export interface SegmentMark { + start: number; + end: number; + current?: boolean; +} + export interface Segment { text: string; style: SpanStyle; link?: OutputLink; + mark?: boolean; + current?: boolean; } -export function segmentLine(spans: readonly Span[], links: readonly OutputLink[]): Segment[] { +export function segmentLine( + spans: readonly Span[], + links: readonly OutputLink[], + marks: readonly SegmentMark[] = [], +): Segment[] { const out: Segment[] = []; let pos = 0; let li = 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 (li < links.length && links[li]!.end <= abs) li++; + while (mi < marks.length && marks[mi]!.end <= abs) mi++; const link = links[li]; - let take: number; - let current: OutputLink | undefined; + const mark = marks[mi]; + let take = text.length - offset; + let currentLink: OutputLink | undefined; + let currentMark: SegmentMark | undefined; if (link && link.start <= abs) { - current = link; - take = Math.min(text.length - offset, link.end - abs); - } else { - take = Math.min(text.length - offset, (link ? link.start : Infinity) - abs); + currentLink = link; + take = Math.min(take, link.end - abs); + } else if (link) { + take = Math.min(take, link.start - abs); + } + if (mark && mark.start <= abs) { + currentMark = mark; + take = Math.min(take, mark.end - abs); + } else if (mark) { + take = Math.min(take, mark.start - abs); } const piece = text.slice(offset, offset + take); const prev = out[out.length - 1]; - if (prev && prev.link === current && sameStyle(prev.style, style)) prev.text += piece; - else out.push(current ? { text: piece, style, link: current } : { text: piece, style }); + const current = !!currentMark?.current; + if ( + prev && + prev.link === currentLink && + !!prev.mark === !!currentMark && + !!prev.current === current && + sameStyle(prev.style, style) + ) { + prev.text += piece; + } else { + out.push({ + text: piece, + style, + link: currentLink, + ...(currentMark ? { mark: true, current } : {}), + }); + } offset += take; } pos += text.length; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 09587aef..2d36c078 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -799,6 +799,7 @@ "Find in Files…": "在文件中查找…", "Find in Folder…": "在文件夹中查找…", "Find in Terminal": "在终端中查找", + "Find in console": "在控制台中查找", "Find in file": "在文件中查找", "Find in terminal": "在终端中查找", "Find results": "查找结果",