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>
This commit is contained in:
itning
2026-10-31 12:38:54 +08:00
committed by GitHub
co-authored by itning
parent c0f8557f05
commit cbdc177bbc
10 changed files with 854 additions and 72 deletions
+266 -61
View File
@@ -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 ? (
<a
key={i}
href={seg.link.kind === "url" ? seg.link.url : undefined}
className={styles.link}
style={spanStyle(seg.style)}
style={style}
onClick={(e) => activate(e, seg.link!)}
role="link"
tabIndex={-1}
{...marked}
>
{seg.text}
</a>
) : seg.mark ? (
<mark key={i} className={styles.match} style={style} {...marked}>
{seg.text}
</mark>
) : (
<span key={i} style={spanStyle(seg.style)}>
<span key={i} style={style}>
{seg.text}
</span>
),
)}
);
})}
</>
);
},
(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<HTMLDivElement>(null);
const lines = model.lines;
const patterns = useWorkflowSettings((s) => s.console.foldPatterns);
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => 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<FindOptions>(DEFAULT_FIND_OPTIONS);
const [active, setActive] = 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);
// 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<number, SegmentMark[]>();
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<number, number>();
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<HTMLDivElement>) => {
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 (
<div
ref={scrollRef}
className={clsx(styles.console, ansi.palette, softWrap && styles.wrap)}
role="log"
aria-live="off"
aria-label={aria["aria-label"] ?? "Console"}
tabIndex={0}
onScroll={onScroll}
onContextMenu={onContextMenu}
onKeyDown={onKeyDown}
onPaste={onPaste}
data-autofocus=""
>
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{items.map((item) => {
const row = rows[item.index];
if (!row) {
<div className={styles.consoleWrap}>
{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={scrollRef}
className={clsx(styles.console, ansi.palette, softWrap && styles.wrap)}
role="log"
aria-live="off"
aria-label={aria["aria-label"] ?? "Console"}
tabIndex={0}
onScroll={onScroll}
onContextMenu={onContextMenu}
onKeyDown={onKeyDown}
onPaste={onPaste}
data-autofocus=""
>
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{items.map((item) => {
const row = rows[item.index];
if (!row) {
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{pendingInput}
</div>
);
}
if (row.kind === "fold") {
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{foldButton(row.fold, false)}
</div>
);
}
const line = lines[row.index];
if (!line) return null;
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
className={clsx(styles.line, styles[line.kind])}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{pendingInput}
<Line line={line} marks={marksByLine.get(row.index)} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
{inputInline && item.index === rows.length - 1 && pendingInput}
</div>
);
}
if (row.kind === "fold") {
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{foldButton(row.fold, false)}
</div>
);
}
const line = lines[row.index];
if (!line) return null;
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={clsx(styles.line, styles[line.kind])}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
<Line line={line} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
{inputInline && item.index === rows.length - 1 && pendingInput}
</div>
);
})}
})}
</div>
</div>
</div>
);
+145
View File
@@ -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<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: "⌥C",
pressed: options.caseSensitive,
onToggle: () => 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"),
},
]}
/>
<span className={clsx(styles.findCount, (invalid || none) && styles.findCountNone)} aria-live="polite">
{counter}
</span>
<IconButton
icon="arrow-up"
title="Previous Occurrence"
shortcut="⇧↵"
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>
);
}
+42
View File
@@ -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;
+12 -1
View File
@@ -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<boolean | null>(null);
const softWrap = wrapOverride ?? defaultWrap;
const [selectedTest, setSelectedTest] = useState<string | null>(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 }) {
<div className={styles.toolbarSep} />
{!rt.terminal && (
<>
<IconButton
icon="search"
iconSize={14}
title="Find…"
shortcut={formatBinding(hostBinding("Meta+F"))}
pressed={findOpen}
onClick={() => setFindOpen((v) => !v)}
/>
<IconButton
icon="arrow-down"
iconSize={14}
+72
View File
@@ -0,0 +1,72 @@
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([]);
});
});
+81
View File
@@ -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 };
}
+151
View File
@@ -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(
<ConsoleView model={model} version={1} softWrap={false} follow={false} onFollowChange={() => {}} 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(
<ConsoleView
model={model}
version={1}
softWrap={false}
follow={false}
onFollowChange={() => {}}
cwd="/"
findOpen
onFindOpenChange={onOpenChange}
/>,
);
expect(screen.getByRole("search")).toBeInTheDocument();
fireEvent.keyDown(input(), { key: "Escape" });
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
+36
View File
@@ -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 },
]);
});
});
+48 -10
View File
@@ -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;
+1
View File
@@ -799,6 +799,7 @@
"Find in Files…": "在文件中查找…",
"Find in Folder…": "在文件夹中查找…",
"Find in Terminal": "在终端中查找",
"Find in console": "在控制台中查找",
"Find in file": "在文件中查找",
"Find in terminal": "在终端中查找",
"Find results": "查找结果",