mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -799,6 +799,7 @@
|
||||
"Find in Files…": "在文件中查找…",
|
||||
"Find in Folder…": "在文件夹中查找…",
|
||||
"Find in Terminal": "在终端中查找",
|
||||
"Find in console": "在控制台中查找",
|
||||
"Find in file": "在文件中查找",
|
||||
"Find in terminal": "在终端中查找",
|
||||
"Find results": "查找结果",
|
||||
|
||||
Reference in New Issue
Block a user