дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(debugger): search the debug console output (#1502)
The Debug console had no Find: Cmd-F did nothing there, in the Debug tool window and in Services' debug output alike. It now searches the whole buffer (Match Case / Words / Regex) with the n/m counter, highlights every match with the current one on its own background, scrolls it into view, and offers the Run console's context menu (Copy, Select All, Copy All, Find…, Clear All). The find matching and find bar moved from the Run console into src/ui (consoleFind.ts, ConsoleFindBar.tsx) and are shared by both consoles; Enter on one of the bar's buttons activates it instead of jumping to the next match. Co-authored-by: DomKing <[email protected]>
Цей коміт міститься в:
1 джерело
24cf1a52d5
коміт
253fcfb7e0
9 files changed
+494
-54
No files matched your search
@@ -0,0 +1,28 @@
|
||||
/* Find bar (⌘F) of the Run and Debug consoles. */
|
||||
.find {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.findField {
|
||||
flex: 0 1 360px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.findCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
}
|
||||
.findCountNone {
|
||||
color: var(--danger);
|
||||
}
|
||||
.findSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/** ⌘F in a console (Run, Debug): the IDEA find bar (Match Case / Words / Regex, n/m, previous/next). */
|
||||
import { type KeyboardEvent, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { IconButton } from "./Button";
|
||||
import { SearchField } from "./Input";
|
||||
import { formatBinding, hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { FindOptions } from "./consoleFind";
|
||||
import styles from "./ConsoleFindBar.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") {
|
||||
// Enter on a button (Match Case, Close, …) must activate it, not jump to the next match.
|
||||
if ((e.target as Element | null)?.closest?.("button")) return;
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrevious();
|
||||
else onNext();
|
||||
} 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-console-find=""
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<SearchField
|
||||
ref={inputRef}
|
||||
className={styles.findField}
|
||||
value={query}
|
||||
onChange={onChange}
|
||||
placeholder="Find in console"
|
||||
aria-label="Find in console"
|
||||
invalid={invalid || none}
|
||||
toggles={[
|
||||
{
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: formatBinding("Alt+C"),
|
||||
pressed: options.caseSensitive,
|
||||
onToggle: () => toggle("caseSensitive"),
|
||||
},
|
||||
{
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: formatBinding("Alt+W"),
|
||||
pressed: options.wholeWord,
|
||||
onToggle: () => toggle("wholeWord"),
|
||||
},
|
||||
{
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: formatBinding("Alt+X"),
|
||||
pressed: options.regex,
|
||||
onToggle: () => toggle("regex"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<span className={clsx(styles.findCount, (invalid || none) && styles.findCountNone)} aria-live="polite">
|
||||
{counter}
|
||||
</span>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Occurrence"
|
||||
shortcut={formatBinding("Shift+Enter")}
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onPrevious}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
title="Next Occurrence"
|
||||
shortcut="↵"
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onNext}
|
||||
/>
|
||||
<span className={styles.findSpacer} />
|
||||
<IconButton icon="close" title="Close" shortcut="⎋" size="small" onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, MAX_MATCHES, type FindOptions } from "./consoleFind";
|
||||
|
||||
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 console output (Run, Debug): every occurrence in every line, with IDEA's
|
||||
* Match Case / Words / Regex options. Matches carry the line's stable id so the
|
||||
* bar can keep the current match while output keeps arriving.
|
||||
*/
|
||||
|
||||
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 console's current lines. */
|
||||
line: number;
|
||||
/** Stable line id (survives trimming of older lines). */
|
||||
lineId: number;
|
||||
/** UTF-16 offsets into the line's text. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface FindResult {
|
||||
matches: ConsoleMatch[];
|
||||
/** More matches exist than were collected. */
|
||||
overflow: boolean;
|
||||
/** The query is an invalid regular expression. */
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
/** Matches collected per search; the counter shows `N+` beyond this. */
|
||||
export const MAX_MATCHES = 2000;
|
||||
|
||||
const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
||||
const WORD_CHAR = /[\p{L}\p{N}_$]/u;
|
||||
|
||||
function wholeWord(text: string, start: number, end: number): boolean {
|
||||
return !WORD_CHAR.test(text[start - 1] ?? "") && !WORD_CHAR.test(text[end] ?? "");
|
||||
}
|
||||
|
||||
/** All matches of `query` in `lines`, in document order. */
|
||||
export function findInLines(
|
||||
lines: readonly { id: number; text: string }[],
|
||||
query: string,
|
||||
options: FindOptions,
|
||||
limit = MAX_MATCHES,
|
||||
): FindResult {
|
||||
if (!query) return { matches: [], overflow: false, invalid: false };
|
||||
const source = options.regex ? query : query.replace(REGEX_SPECIALS, "\\$&");
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(source, options.caseSensitive ? "gu" : "giu");
|
||||
} catch {
|
||||
return { matches: [], overflow: false, invalid: true };
|
||||
}
|
||||
const matches: ConsoleMatch[] = [];
|
||||
for (let line = 0; line < lines.length; line++) {
|
||||
const { id, text } = lines[line]!;
|
||||
re.lastIndex = 0;
|
||||
let found: RegExpExecArray | null;
|
||||
while ((found = re.exec(text))) {
|
||||
const start = found.index;
|
||||
const end = start + found[0].length;
|
||||
// Empty matches (e.g. `x*`) would otherwise loop forever.
|
||||
if (end === start) {
|
||||
re.lastIndex = start + 1;
|
||||
continue;
|
||||
}
|
||||
if (options.wholeWord && !wholeWord(text, start, end)) continue;
|
||||
if (matches.length >= limit) return { matches, overflow: true, invalid: false };
|
||||
matches.push({ line, lineId: id, start, end });
|
||||
}
|
||||
}
|
||||
return { matches, overflow: false, invalid: false };
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача