feat(search): Bring Search Everywhere closer to IDEA's popup

- All tab interleaves classes, files, symbols, actions and text by match
  degree, keeping each source's own order; Enter waits for every source
  except a text search the top row already beats.
- Preview pane under the list (remembered toggle), reusing CodePreview.
- "/" lists scope commands; "/files q" switches scope and searches q.
- Per-tab search history: magnifier dropdown and ⌥↑ / ⌥↓.
- Text hits: syntax colouring, IDEA's boxed-T icon, dimmed repeated file
  names, collapsed indentation.
- Flat scope chooser, IDEA header icons, wider popup with a stable list
  height; the list shrinks so the footer stays visible.
This commit is contained in:
l0ng-ai
2026-10-24 14:47:45 +08:00
parent 709e1eb855
commit 0bb730039d
11 changed files with 609 additions and 67 deletions
+35 -14
View File
@@ -81,35 +81,56 @@ export function languageForPath(path: string): string | null {
return LanguageDescription.matchFilename(languages, name)?.name ?? null;
}
/** Replaces `el`'s text with highlighted spans (no-op when the language is unknown). */
export async function highlightInto(el: HTMLElement, text: string, language: string | null): Promise<void> {
export interface CodeToken {
from: number;
to: number;
/** Lezer `classHighlighter` classes (`tok-string` …). */
cls: string;
}
/** Highlighted ranges of `text`; empty when the language is unknown or its grammar fails to load. */
export async function highlightTokens(text: string, language: string | null): Promise<CodeToken[]> {
const desc = language ? findLanguage(language) : null;
if (!desc) return;
if (!desc) return [];
try {
const support = desc.support ?? (await desc.load());
const tree = support.language.parser.parse(text);
const frag = document.createDocumentFragment();
const out: CodeToken[] = [];
let pos = 0;
highlightCode(
text,
tree,
classHighlighter,
(t, cls) => {
if (!cls) frag.appendChild(document.createTextNode(t));
else {
const span = document.createElement("span");
span.className = cls;
span.textContent = t;
frag.appendChild(span);
}
if (cls) out.push({ from: pos, to: pos + t.length, cls });
pos += t.length;
},
() => frag.appendChild(document.createTextNode("\n")),
() => pos++,
);
el.replaceChildren(frag);
return out;
} catch {
// Grammar failed to load: keep the plain text.
return [];
}
}
/** Replaces `el`'s text with highlighted spans (no-op when the language is unknown). */
export async function highlightInto(el: HTMLElement, text: string, language: string | null): Promise<void> {
const tokens = await highlightTokens(text, language);
if (!tokens.length) return;
const frag = document.createDocumentFragment();
let pos = 0;
for (const t of tokens) {
if (t.from > pos) frag.appendChild(document.createTextNode(text.slice(pos, t.from)));
const span = document.createElement("span");
span.className = t.cls;
span.textContent = text.slice(t.from, t.to);
frag.appendChild(span);
pos = t.to;
}
if (pos < text.length) frag.appendChild(document.createTextNode(text.slice(pos)));
el.replaceChildren(frag);
}
function highlightBlocks(root: HTMLElement, fallback: string | null) {
root.querySelectorAll<HTMLElement>("pre > code").forEach((code) => {
const cls = [...code.classList].find((c) => c.startsWith("language-"));
+55 -4
View File
@@ -34,7 +34,8 @@ vi.mock("../find/windowStore", async (orig) => ({
const { allActions } = await import("@/platform/actions");
(globalThis as unknown as { __actions: typeof allActions }).__actions = allActions;
const { SearchEverywhereOverlay } = await import("../everywhere/SearchEverywhere");
const { showSearchEverywhere, hideSearchEverywhere, useSearchEverywhere } = await import("../everywhere/store");
const { showSearchEverywhere, hideSearchEverywhere, useSearchEverywhere, pushSearchHistory, searchHistory } =
await import("../everywhere/store");
const { FindInFilesOverlay } = await import("../find/FindInFiles");
const { showFindPopup, hideFindPopup } = await import("../find/store");
@@ -62,10 +63,10 @@ describe("Search Everywhere dialog", () => {
await tick();
const results = within(document.getElementById("se-results")!);
expect(results.queryByText("Files")).toBeNull();
expect(screen.getByText("src/OrderController.kt")).toBeInTheDocument();
// ↓ moves from the file row to the action row.
// All mixes sources by relevance: the shorter prefix hit (the action) leads.
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByText("src/OrderController.kt")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "ArrowUp" });
fireEvent.keyDown(input, { key: "Enter" });
await tick();
expect(useSearchEverywhere.getState().open).toBe(false);
@@ -73,6 +74,56 @@ describe("Search Everywhere dialog", () => {
dispose();
});
it("the preview toggle is remembered", async () => {
render(<SearchEverywhereOverlay />);
act(() => showSearchEverywhere("all"));
await tick();
const toggle = screen.getByRole("button", { name: "Preview" });
expect(toggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(toggle);
expect(useSearchEverywhere.getState().preview).toBe(true);
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(localStorage.getItem("openidea.search.everywherePreview.v1")).toBe("true");
fireEvent.click(toggle);
expect(useSearchEverywhere.getState().preview).toBe(false);
});
it("/ lists scope commands; picking one or typing '/files q' switches the tab", async () => {
render(<SearchEverywhereOverlay />);
act(() => showSearchEverywhere("all", { query: "" }));
await tick();
const input = screen.getByLabelText("Search query");
fireEvent.change(input, { target: { value: "/fi" } });
await tick();
expect(within(document.getElementById("se-results")!).getByText("/files")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
expect(useSearchEverywhere.getState()).toMatchObject({ tab: "files", query: "", open: true });
fireEvent.change(input, { target: { value: "/text order" } });
expect(useSearchEverywhere.getState()).toMatchObject({ tab: "text", query: "order" });
});
it("⌥↑ / ⌥↓ browse the tab's search history and restore the draft", async () => {
localStorage.removeItem("openidea.search.everywhereHistory.v1");
pushSearchHistory("all", "older");
pushSearchHistory("all", "newer");
pushSearchHistory("files", "other tab");
render(<SearchEverywhereOverlay />);
act(() => showSearchEverywhere("all", { query: "draft" }));
await tick();
const input = screen.getByLabelText("Search query");
const key = (k: string) => fireEvent.keyDown(input, { key: k, altKey: true });
key("ArrowUp");
expect(useSearchEverywhere.getState().query).toBe("newer");
key("ArrowUp");
key("ArrowUp");
expect(useSearchEverywhere.getState().query).toBe("older");
key("ArrowDown");
key("ArrowDown");
expect(useSearchEverywhere.getState().query).toBe("draft");
expect(searchHistory("all")).toEqual(["newer", "older"]);
});
it("⇥ cycles scopes and repeating the scope action toggles non-project items", async () => {
render(<SearchEverywhereOverlay />);
act(() => showSearchEverywhere("all"));
@@ -6,8 +6,10 @@ import {
fileItem,
matchActions,
mergeSymbols,
parseCommand,
sectionsFor,
stableIndex,
symbolItem,
textItems,
topResultSettled,
type SectionId,
@@ -132,10 +134,10 @@ describe("items and rows", () => {
expect(f.line).toBe(41);
});
it("textItems trims indentation and shifts ranges", () => {
const [t] = textItems("/p/a.kt", "a.kt", [{ line: 4, text: " val order = 1", ranges: [[8, 13]], textOffset: 0 }]);
expect(t!.text).toBe("val order = 1");
expect(t!.indices).toEqual([4, 5, 6, 7, 8]);
it("textItems collapses indentation and shifts ranges", () => {
const [t] = textItems("/p/a.kt", "a.kt", [{ line: 4, text: "\t\t val order = 1", ranges: [[8, 13]], textOffset: 0 }]);
expect(t!.text).toBe(" val order = 1");
expect(t!.indices).toEqual([6, 7, 8, 9, 10]);
expect([t!.col, t!.endCol]).toEqual([8, 13]);
});
@@ -150,6 +152,28 @@ describe("items and rows", () => {
expect(buildRows(sections, { headers: false }).map((r) => r.kind)).toEqual(["file", "more", "action"]);
});
it("buildRows interleaves the All tab by relevance, keeping each source's order", () => {
const file = (rel: string) => fileItem({ path: `/p/${rel}`, relPath: rel, indices: [] });
const sections = new Map<SectionId, SectionState>([
["files", { items: [file("src/upload_gray.py"), file("src/upgrade.py")], hasMore: true, loading: false }],
["symbols", { items: [symbolItem(sym("upgrade", "function", "/p/m.py", 3))], hasMore: false, loading: false }],
["text", { items: textItems("/p/a.md", "a.md", [{ line: 0, text: "upgrade", ranges: [[0, 3]], textOffset: 0 }]), hasMore: false, loading: false }],
]);
const rows = buildRows(sections, { headers: false, mix: "upg" });
expect(rows.map((r) => (r.kind === "more" ? `more:${r.section}` : (r as { section: SectionId }).section))).toEqual([
"symbols",
"files",
"files",
"text",
"more:files",
]);
// The index ranked upload_gray first; mixing must not reorder within files.
expect(rows.filter((r) => r.kind === "file").map((r) => (r as { name: string }).name)).toEqual([
"upload_gray.py",
"upgrade.py",
]);
});
it("stableIndex keeps the selected key while rows stream in", () => {
const sections = new Map<SectionId, SectionState>([
["files", { items: [fileItem({ path: "/p/A.kt", relPath: "A.kt", indices: [] }), fileItem({ path: "/p/B.kt", relPath: "B.kt", indices: [] })], hasMore: false, loading: false }],
@@ -184,7 +208,41 @@ describe("topResultSettled", () => {
expect(topResultSettled(new Map([["classes", sec(false)], ["files", sec(false, 1)], ["text", sec(true)]]))).toBe(true);
});
it("with mixing waits for every source but a text search the top row already beats", () => {
const strong = { items: [fileItem({ path: "/p/upgrade.py", relPath: "upgrade.py", indices: [] })], hasMore: false, loading: false };
expect(topResultSettled(new Map([["files", strong], ["symbols", sec(true)]]), "upg")).toBe(false);
expect(topResultSettled(new Map([["files", strong], ["text", sec(true)]]), "upg")).toBe(true);
const weak = { ...strong, items: [fileItem({ path: "/p/xuxpxg.py", relPath: "xuxpxg.py", indices: [] })] };
expect(topResultSettled(new Map([["files", weak], ["text", sec(true)]]), "upg")).toBe(false);
});
it("is settled when everything finished empty", () => {
expect(topResultSettled(new Map([["files", sec(false)]]))).toBe(true);
});
});
describe("parseCommand", () => {
it("lists scope commands matching the typed prefix", () => {
const c = parseCommand("/");
expect(c?.kind === "list" && c.rows.map((r) => (r as { name: string }).name)).toEqual([
"/classes",
"/files",
"/symbols",
"/actions",
"/text",
]);
const fi = parseCommand("/FI");
expect(fi?.kind === "list" && fi.rows.map((r) => r.key)).toEqual(["cmd:files"]);
});
it("runs a complete command followed by a space", () => {
expect(parseCommand("/files upg")).toEqual({ kind: "run", tab: "files", rest: "upg" });
expect(parseCommand("/text ")).toEqual({ kind: "run", tab: "text", rest: "" });
});
it("leaves paths and other queries alone", () => {
expect(parseCommand("/Users/me/a.py")).toBeNull();
expect(parseCommand("/fil x")).toBeNull();
expect(parseCommand("upg")).toBeNull();
});
});
@@ -1,11 +1,17 @@
.dialog {
--se-bg-header: var(--bg-frame, var(--bg-app));
max-height: 72vh;
max-height: 80vh;
margin-top: 9vh;
background: var(--bg-popup, var(--bg-elev));
font-family: var(--font-ui);
font-size: var(--fs-ui);
}
/* The list shrinks to fit (the footer stays visible) instead of the dialog body scrolling. */
.dialog > :global([data-dialog-body]) {
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
flex: none;
display: flex;
@@ -50,6 +56,9 @@
flex: none;
color: var(--fg);
}
.scope {
color: var(--fg);
}
.headerButton {
flex: none;
}
@@ -72,8 +81,23 @@
}
.magnifier {
flex: none;
display: inline-flex;
align-items: center;
gap: 1px;
height: 22px;
padding: 0 2px;
border: none;
border-radius: var(--radius-small);
background: none;
color: var(--fg-subtle);
}
.magnifier:hover {
background: var(--bg-hover);
color: var(--fg);
}
.magnifier:focus-visible {
outline: 2px solid var(--focus-ring, var(--accent));
}
.query {
flex: 1;
min-width: 0;
@@ -105,6 +129,12 @@
flex: 1;
min-height: 0;
}
.preview {
flex: none;
display: flex;
height: 220px;
border-top: 1px solid var(--border-popup);
}
.section {
font-family: var(--font-ui);
}
@@ -142,6 +172,45 @@
}
.code {
font-family: var(--font-ui);
color: var(--fg);
tab-size: 2;
}
/* Lezer classHighlighter tokens of text hits (same colours as the Markdown preview). */
.code :global(.tok-keyword),
.code :global(.tok-modifier),
.code :global(.tok-controlKeyword),
.code :global(.tok-operatorKeyword) {
color: var(--syn-keyword);
}
.code :global(.tok-string),
.code :global(.tok-string2) {
color: var(--syn-string);
}
.code :global(.tok-number),
.code :global(.tok-bool),
.code :global(.tok-atom) {
color: var(--syn-number);
}
.code :global(.tok-typeName),
.code :global(.tok-className),
.code :global(.tok-namespace) {
color: var(--syn-type);
}
.code :global(.tok-comment) {
color: var(--syn-comment);
}
.code :global(.tok-meta),
.code :global(.tok-macroName) {
color: var(--syn-annotation);
}
.code :global(.tok-propertyName) {
color: var(--syn-property);
}
.code :global(.tok-variableName.tok-definition) {
color: var(--syn-function);
}
.repeat {
color: var(--fg-faint);
}
.lineSuffix {
color: var(--fg-muted);
@@ -22,8 +22,10 @@ import {
T,
} from "@/ui";
import { useFileDecoration } from "@/workbench/contributions";
import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown";
import { badgeKind, basename, openLocation, relDir } from "../common/open";
import { parseLocationQuery } from "../common/query";
import { CodePreview, type PreviewHighlight } from "../find/parts";
import { SeController, type SeSnapshot } from "./controller";
import { ipcDeps } from "./deps";
import {
@@ -33,13 +35,21 @@ import {
TABS,
buildRows,
isSectionRow,
parseCommand,
stableIndex,
topResultSettled,
type SeRow,
type SeTab,
type SectionId,
} from "./model";
import { hideSearchEverywhere, pushRecentAction, useSearchEverywhere } from "./store";
import {
hideSearchEverywhere,
pushRecentAction,
pushSearchHistory,
searchHistory,
setSearchEverywherePreview,
useSearchEverywhere,
} from "./store";
import styles from "./SearchEverywhere.module.css";
type SeScope = "allPlaces" | ProjectFileSet;
@@ -52,7 +62,10 @@ const SE_SCOPES: Array<{ value: SeScope; label: string }> = [
];
const ROW = 24;
const MAX_LIST = 528;
const MAX_LIST = 672;
const MIN_LIST = 10 * ROW;
/** List cap while the preview pane takes the lower part of the popup. */
const MAX_LIST_PREVIEW = 312;
const DEBOUNCE_MS = 60;
export function SearchEverywhereOverlay() {
@@ -70,6 +83,7 @@ function SearchEverywhereDialog() {
const fileSet = useSearchEverywhere((s) => s.fileSet);
const session = useSearchEverywhere((s) => s.session);
const ctx = useSearchEverywhere((s) => s.ctx);
const preview = useSearchEverywhere((s) => s.preview);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -78,7 +92,13 @@ function SearchEverywhereDialog() {
const [snap, setSnap] = useState<SeSnapshot | null>(null);
const [controller] = useState(() => new SeController(ipcDeps(ctx), setSnap));
const [sel, setSel] = useState<Selection>({ key: null, index: -1, moved: false });
const [menu, setMenu] = useState<{ anchor: Element; items: MenuItem[] } | null>(null);
const [menu, setMenu] = useState<{
anchor: Element;
items: MenuItem[];
placement: "right-start" | "bottom-start";
} | null>(null);
/** Position in the tab's history while ⌥↑/⌥↓ browse it (-1 = the typed draft). */
const history = useRef({ pos: -1, draft: "" });
const [toggleTick, setToggleTick] = useState(0);
const bumpToggles = () => setToggleTick((t) => t + 1);
@@ -98,28 +118,67 @@ function SearchEverywhereDialog() {
setSel({ key: null, index: -1, moved: false });
}
// "/fi" lists scope commands instead of searching.
const listing = useMemo(() => {
const c = parseCommand(query);
return c?.kind === "list" ? c.rows : null;
}, [query]);
const searchQuery = parseCommand(debounced)?.kind === "list" ? "" : debounced;
useEffect(() => {
controller.run({ query: debounced, tab, includeNonProject, fileSet: tab === "all" ? null : fileSet, extra });
}, [controller, debounced, tab, includeNonProject, fileSet, extra]);
controller.run({ query: searchQuery, tab, includeNonProject, fileSet: tab === "all" ? null : fileSet, extra });
}, [controller, searchQuery, tab, includeNonProject, fileSet, extra]);
useEffect(() => {
inputRef.current?.select();
}, [session]);
const parsed = useMemo(() => parseLocationQuery(debounced), [debounced]);
const parsed = useMemo(() => parseLocationQuery(searchQuery), [searchQuery]);
// The All tab interleaves its sources by relevance once there is a query.
const mix = tab === "all" ? parsed.text : undefined;
const rows = useMemo(
() => (snap ? buildRows(snap.sections, { headers: !parsed.text.trim() }) : []),
[snap, parsed.text],
() => listing ?? (snap ? buildRows(snap.sections, { headers: !parsed.text.trim(), mix }) : []),
[listing, snap, parsed.text, mix],
);
const active = stableIndex(rows, sel.key, sel.index, sel.moved);
const activeRow = active >= 0 ? rows[active] : undefined;
// Rows still answer an older query (debounce / in-flight search): Enter must wait.
const settled =
!!snap && query === debounced && snap.query === debounced && snap.tab === tab && topResultSettled(snap.sections);
!!snap &&
query === debounced &&
snap.query === debounced &&
snap.tab === tab &&
topResultSettled(snap.sections, mix);
const pendingEnter = useRef<{ side: boolean } | null>(null);
const select = (index: number) => setSel({ key: rows[index]?.key ?? null, index, moved: true });
/** Typed or picked query; "/files foo" switches to Files with "foo". */
const setQuery = (value: string) => {
history.current.pos = -1;
const c = parseCommand(value);
useSearchEverywhere.setState(c?.kind === "run" ? { tab: c.tab, query: c.rest } : { query: value });
};
const browseHistory = (dir: 1 | -1) => {
const h = searchHistory(tab);
const cur = history.current;
const pos = Math.max(-1, Math.min(h.length - 1, cur.pos + dir));
if (pos === cur.pos) return;
if (cur.pos < 0) cur.draft = query;
cur.pos = pos;
useSearchEverywhere.setState({ query: pos < 0 ? cur.draft : h[pos]! });
};
const openHistory = (anchor: Element) => {
const h = searchHistory(tab).slice(0, 15);
const items: MenuItem[] = h.length
? [
{ kind: "header", label: "Recent Searches" },
...h.map((q) => ({ label: q, verbatim: true, run: () => setQuery(q) })),
]
: [{ label: "No recent searches", disabled: true, run: () => {} }];
setMenu({ anchor, items, placement: "bottom-start" });
};
const setTab = (t: SeTab) => {
history.current.pos = -1;
useSearchEverywhere.setState({ tab: t });
inputRef.current?.focus();
};
@@ -137,9 +196,15 @@ function SearchEverywhereDialog() {
const activate = (row: SeRow | undefined, side = false) => {
if (!row) return;
const isResult = row.kind !== "section" && row.kind !== "more" && row.kind !== "command";
if (isResult && !(row.kind === "action" && !row.enabled)) pushSearchHistory(tab, query);
switch (row.kind) {
case "section":
return;
case "command":
useSearchEverywhere.setState({ tab: row.tab, query: "" });
inputRef.current?.focus();
return;
case "more": {
const step = tab === "all" ? MORE_STEP_ALL : MORE_STEP_TAB;
setExtra((e) => ({ ...e, [row.section]: (e[row.section] ?? 0) + step }));
@@ -181,7 +246,7 @@ function SearchEverywhereDialog() {
const openIntentions = () => {
const row = activeRow;
if (!row || row.kind === "section" || row.kind === "more") return;
if (!row || row.kind === "section" || row.kind === "more" || row.kind === "command") return;
const anchor = listRef.current?.querySelector('[aria-selected="true"]') ?? inputRef.current;
if (!anchor) return;
const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => {});
@@ -208,7 +273,7 @@ function SearchEverywhereDialog() {
]
: []),
];
setMenu({ anchor, items });
setMenu({ anchor, items, placement: "right-start" });
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
@@ -224,10 +289,16 @@ function SearchEverywhereDialog() {
if (e.altKey) openIntentions();
else if (hasPrimaryModifier(e) && tab === "text")
closeThen(() => void executeAction("find.inPath", { query: parsed.text }));
else if (listing) activate(activeRow);
else if (!settled && !sel.moved) pendingEnter.current = { side: e.shiftKey };
else activate(activeRow, e.shiftKey);
return;
}
if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
e.preventDefault();
browseHistory(e.key === "ArrowUp" ? 1 : -1);
return;
}
if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return;
const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: true, pageSize: 10 });
if (next === null) return;
@@ -235,11 +306,14 @@ function SearchEverywhereDialog() {
select(next);
};
const listHeight = Math.min(MAX_LIST, rows.length * ROW);
const maxList = preview ? MAX_LIST_PREVIEW : MAX_LIST;
// While a query is typed the list keeps a minimum height so the popup doesn't jump with every keystroke.
const minList = parsed.text.trim() && !listing ? MIN_LIST : 0;
const listHeight = Math.min(maxList, Math.max(minList, rows.length * ROW));
const previewTarget = previewOf(activeRow);
const emptyQuery = !parsed.text.trim();
const showEmpty = !rows.length && snap && !snap.loading && (!emptyQuery || tab !== "all");
const canSplit =
!!activeRow && activeRow.kind !== "section" && activeRow.kind !== "more" && activeRow.kind !== "action";
const canSplit = activeRow?.kind === "file" || activeRow?.kind === "symbol" || activeRow?.kind === "text";
const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
@@ -250,7 +324,7 @@ function SearchEverywhereDialog() {
<Dialog
open
onClose={close}
width={670}
width={760}
position="top"
transparentBackdrop
aria-label={tx("Search Everywhere")}
@@ -286,7 +360,8 @@ function SearchEverywhereDialog() {
) : (
// IDEA's scope chooser of the Classes / Files / Symbols / Text tabs.
<Select<SeScope>
className={styles.include}
variant="ghost"
className={clsx(styles.include, styles.scope)}
value={includeNonProject ? "allPlaces" : fileSet}
options={SE_SCOPES}
onChange={(v) => {
@@ -299,7 +374,16 @@ function SearchEverywhereDialog() {
/>
)}
<IconButton
icon="external-link"
icon="list"
size="small"
className={styles.headerButton}
title={tx("Preview")}
pressed={preview}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setSearchEverywherePreview(!preview)}
/>
<IconButton
icon="open-in-tool-window"
size="small"
className={styles.headerButton}
title={tx("Open in Find Tool Window")}
@@ -309,7 +393,17 @@ function SearchEverywhereDialog() {
</div>
<div className={styles.queryRow}>
<div className={styles.field}>
<Icon name="search" size={16} className={styles.magnifier} />
<button
type="button"
className={styles.magnifier}
aria-label={tx("Search history")}
title={tx("Search history (⌥↑ / ⌥↓)")}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => openHistory(e.currentTarget)}
>
<Icon name="search" size={16} />
<Icon name="chevron-down" size={10} />
</button>
<input
id="se-query"
ref={inputRef}
@@ -325,13 +419,15 @@ function SearchEverywhereDialog() {
aria-controls="se-results"
aria-autocomplete="list"
aria-label={tx("Search query")}
onChange={(e) => useSearchEverywhere.setState({ query: e.target.value })}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
{snap?.loading && !emptyQuery && <Spinner size={14} className={styles.spinner} />}
<span className={styles.hint} aria-hidden>
<T>{hintFor(tab)}</T>
</span>
{hintFor(tab) && (
<span className={styles.hint} aria-hidden>
<T>{hintFor(tab)}</T>
</span>
)}
</div>
</div>
{(rows.length > 0 || showEmpty) && (
@@ -358,7 +454,13 @@ function SearchEverywhereDialog() {
className={styles.list}
aria-label={tx("Results")}
renderItem={(r, state) => (
<ResultRow row={r} active={state.active} toggleTick={toggleTick} onToggled={bumpToggles} />
<ResultRow
row={r}
active={state.active}
repeat={sameFile(rows[state.index - 1], r)}
toggleTick={toggleTick}
onToggled={bumpToggles}
/>
)}
/>
)}
@@ -372,6 +474,15 @@ function SearchEverywhereDialog() {
</div>
)}
{!rows.length && !showEmpty && <div id="se-results" ref={listRef} hidden />}
{preview && rows.length > 0 && (
<div className={styles.preview}>
<CodePreview
path={previewTarget?.path ?? null}
line={previewTarget?.line ?? 0}
highlights={previewTarget?.highlights ?? []}
/>
</div>
)}
{rows.length > 0 && (
<footer className={styles.footer}>
<span className={styles.footerInfo}>{describe(activeRow)}</span>
@@ -386,7 +497,7 @@ function SearchEverywhereDialog() {
open={!!menu}
anchor={menu?.anchor ?? null}
items={menu?.items ?? []}
placement="right-start"
placement={menu?.placement ?? "right-start"}
onClose={() => {
setMenu(null);
inputRef.current?.focus();
@@ -401,6 +512,7 @@ function describe(row: SeRow | undefined): string {
switch (row.kind) {
case "section":
case "more":
case "command":
return "";
case "action": {
const a = getAction(row.id);
@@ -414,14 +526,31 @@ function describe(row: SeRow | undefined): string {
}
}
function previewOf(row: SeRow | undefined): { path: string; line: number; highlights: PreviewHighlight[] } | null {
switch (row?.kind) {
case "file":
return { path: row.path, line: row.line ?? 0, highlights: [] };
case "symbol":
return {
path: row.path,
line: row.line,
highlights: [{ line: row.line, ranges: [[row.col, row.col + row.name.length]] }],
};
case "text":
return { path: row.path, line: row.line, highlights: [{ line: row.line, ranges: [[row.col, row.endCol]] }] };
default:
return null;
}
}
function hintFor(tab: SeTab): string {
switch (tab) {
case "all":
return "Type / to see commands";
case "text":
return "⌘↵ to open in Find tool window";
case "files":
return "Type file name, path or file:42";
default:
return "⇥ to switch scope";
return "";
}
}
@@ -430,14 +559,21 @@ function dirOf(path: string) {
return i > 0 ? path.slice(0, i) : path;
}
/** A text hit in the same file as the row above (its file name is dimmed, like IDEA). */
function sameFile(prev: SeRow | undefined, row: SeRow): boolean {
return row.kind === "text" && prev?.kind === "text" && prev.path === row.path;
}
function ResultRow({
row,
active,
repeat,
toggleTick,
onToggled,
}: {
row: SeRow;
active: boolean;
repeat: boolean;
toggleTick: number;
onToggled: () => void;
}) {
@@ -446,6 +582,18 @@ function ResultRow({
switch (row.kind) {
case "section":
return null;
case "command":
return (
<>
<Icon name="search" size={16} className={styles.icon} />
<span className={styles.main}>
<span className={styles.name}>{row.name}</span>
</span>
<span className={styles.right}>
<T>{row.label}</T>
</span>
</>
);
case "more":
return (
<span className={clsx(styles.name, styles.more)}>
@@ -507,24 +655,62 @@ function ResultRow({
return (
<>
<span className={styles.icon}>
<Icon name={fileIconName(basename(row.rel))} size={16} />
<Icon name="text-match" size={16} />
</span>
<span className={styles.main}>
<HighlightedText
className={clsx(styles.name, styles.code)}
text={row.text}
indices={row.indices}
tone={tone}
/>
<CodeText path={row.path} text={row.text} indices={row.indices} />
</span>
<span className={clsx(styles.right, active && styles.rightActive)}>
{basename(row.rel)} {row.line + 1}
<span className={clsx(repeat && !active && styles.repeat)}>{basename(row.rel)}</span> {row.line + 1}
</span>
</>
);
}
}
const tokenCache = new Map<string, CodeToken[]>();
/** A text hit's line, syntax-coloured by the file's language (one line parsed on its own). */
function CodeText({ path, text, indices }: { path: string; text: string; indices: readonly number[] }) {
const language = useMemo(() => languageForPath(path), [path]);
const key = `${language}\u0000${text}`;
const [loaded, setLoaded] = useState<{ key: string; tokens: CodeToken[] } | null>(null);
const tokens = tokenCache.get(key) ?? (loaded?.key === key ? loaded.tokens : null);
useEffect(() => {
if (tokens || !language) return;
let alive = true;
void highlightTokens(text, language).then((t) => {
tokenCache.set(key, t);
if (tokenCache.size > 500) tokenCache.delete(tokenCache.keys().next().value!);
if (alive) setLoaded({ key, tokens: t });
});
return () => {
alive = false;
};
}, [key, language, text, tokens]);
const parts: Array<[number, number, string]> = [];
let pos = 0;
for (const t of tokens ?? []) {
if (t.from > pos) parts.push([pos, t.from, ""]);
parts.push([t.from, t.to, t.cls]);
pos = t.to;
}
if (pos < text.length) parts.push([pos, text.length, ""]);
return (
<span className={clsx(styles.name, styles.code)}>
{parts.map(([s, e, cls]) => (
<HighlightedText
key={s}
className={cls || undefined}
text={text.slice(s, e)}
indices={indices.filter((i) => i >= s && i < e).map((i) => i - s)}
/>
))}
</span>
);
}
function FileRow({
path,
name,
+108 -6
View File
@@ -111,7 +111,7 @@ export interface TextItem {
line: number;
col: number;
endCol: number;
/** Preview with leading whitespace removed. */
/** The line with its indentation collapsed to at most two spaces. */
text: string;
indices: number[];
}
@@ -120,6 +120,7 @@ export type SeItem = FileItem | SymbolItem | ActionItem | TextItem;
export type SeRow =
| { kind: "section"; key: string; section: SectionId; title: string }
| { kind: "command"; key: string; name: string; tab: SeTab; label: string }
| { kind: "more"; key: string; section: SectionId }
| (SeItem & { section: SectionId });
@@ -297,11 +298,14 @@ export function actionItem(a: Action, enabled: boolean): ActionItem {
export function textItems(path: string, rel: string, matches: readonly LineMatch[]): TextItem[] {
const out: TextItem[] = [];
for (const m of matches) {
const lead = m.text.length - m.text.trimStart().length;
const text = m.text.slice(lead).trimEnd();
const ws = m.text.length - m.text.trimStart().length;
// Like IDEA: a hint of indentation shows nesting without wasting the row.
const indent = " ".repeat(Math.min(ws, 2));
const lead = ws - indent.length;
const text = indent + m.text.slice(ws).trimEnd();
const indices: number[] = [];
for (const [s, e] of m.ranges)
for (let i = Math.max(s, lead); i < e; i++) if (i - lead < text.length) indices.push(i - lead);
for (let i = Math.max(s, ws); i < e; i++) if (i - lead < text.length) indices.push(i - lead);
const first = m.ranges[0] ?? [0, 0];
out.push({
kind: "text",
@@ -321,8 +325,71 @@ export function textItems(path: string, rel: string, matches: readonly LineMatch
// ---------------------------------------------------------------------------
// Rows
export function buildRows(sections: ReadonlyMap<SectionId, SectionState>, opts: { headers: boolean }): SeRow[] {
/** Sections the All tab mixes into one relevance-ordered list. */
const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "actions", "text"];
/**
* Weight of a text hit: a literal substring in a line ranks below name
* matches that start a word but above scattered name subsequences.
*/
export const TEXT_WEIGHT = 350;
/** Comparable match degree of an item's name against the query (IDEA's `MatchingDegree`). */
export function itemWeight(item: SeItem, query: string): number {
const name = (text: string) => matchText(text, query)?.score;
switch (item.kind) {
case "file":
return name(item.name) ?? (name(item.rel) ?? 0) - 200;
case "symbol":
return name(item.name) ?? (item.container ? name(`${item.container}.${item.name}`) : undefined) ?? 0;
case "action":
return item.score;
case "text":
return TEXT_WEIGHT;
}
}
interface Mixed {
item: SeItem;
section: SectionId;
weight: number;
}
/**
* The All tab's results interleaved by weight. Within a section the source's
* order wins (the index already ranks with recency etc.), so weights are
* clamped to be non-increasing; ties keep the section order.
*/
export function mixItems(sections: ReadonlyMap<SectionId, SectionState>, query: string): Mixed[] {
const out: Array<Mixed & { rank: number }> = [];
MIXED.forEach((id, rank) => {
let cap = Infinity;
for (const item of sections.get(id)?.items ?? []) {
cap = Math.min(cap, itemWeight(item, query));
out.push({ item, section: id, weight: cap, rank });
}
});
return out
.map((m, i) => ({ m, i }))
.sort((a, b) => b.m.weight - a.m.weight || a.m.rank - b.m.rank || a.i - b.i)
.map(({ m }) => m);
}
/**
* Flat rows the dialog renders. With `mix` (the All tab's query) results are
* interleaved by relevance and "more" rows follow them; otherwise sections
* keep their fixed order.
*/
export function buildRows(
sections: ReadonlyMap<SectionId, SectionState>,
opts: { headers: boolean; mix?: string },
): SeRow[] {
const rows: SeRow[] = [];
if (opts.mix?.trim()) {
for (const m of mixItems(sections, opts.mix.trim())) rows.push({ ...m.item, section: m.section });
for (const id of MIXED) if (sections.get(id)?.hasMore) rows.push({ kind: "more", key: `+${id}`, section: id });
return rows;
}
for (const id of SECTION_ORDER) {
const s = sections.get(id);
if (!s || !s.items.length) continue;
@@ -335,12 +402,47 @@ export function buildRows(sections: ReadonlyMap<SectionId, SectionState>, opts:
export const isSectionRow = (r: SeRow) => r.kind === "section";
// ---------------------------------------------------------------------------
// "/" commands
export const COMMANDS: ReadonlyArray<{ name: string; tab: SeTab; label: string }> = TABS.filter(
(t) => t.id !== "all",
).map((t) => ({ name: `/${t.id}`, tab: t.id, label: t.label }));
export type ParsedCommand = { kind: "list"; rows: SeRow[] } | { kind: "run"; tab: SeTab; rest: string };
/**
* `/` or `/fi` lists the matching scope commands; `/files foo` switches to
* Files and searches `foo`. Anything else (e.g. an absolute path) is a query.
*/
export function parseCommand(query: string): ParsedCommand | null {
const m = /^\/(\S*)(?:\s+([\s\S]*))?$/.exec(query);
if (!m) return null;
const word = m[1]!.toLowerCase();
if (m[2] !== undefined) {
const cmd = COMMANDS.find((c) => c.name === `/${word}`);
return cmd ? { kind: "run", tab: cmd.tab, rest: m[2] } : null;
}
const rows: SeRow[] = COMMANDS.filter((c) => c.name.startsWith(`/${word}`)).map((c) => ({
kind: "command",
key: `cmd:${c.tab}`,
...c,
}));
return rows.length ? { kind: "list", rows } : null;
}
/**
* Whether the top result is final: every section above the first one with
* items has finished. Enter typed before that waits (IDEA opens the first
* result of the new query, never a stale row of the previous one).
* With `mix`, any section can still outrank the top row; only a slow text
* search is not waited for once the top row beats every text hit.
*/
export function topResultSettled(sections: ReadonlyMap<SectionId, SectionState>): boolean {
export function topResultSettled(sections: ReadonlyMap<SectionId, SectionState>, mix?: string): boolean {
if (mix?.trim()) {
const top = mixItems(sections, mix.trim())[0]?.weight ?? -Infinity;
return MIXED.every((id) => !sections.get(id)?.loading || (id === "text" && top > TEXT_WEIGHT));
}
for (const id of SECTION_ORDER) {
const s = sections.get(id);
if (!s) continue;
+39
View File
@@ -15,8 +15,12 @@ interface SeState {
ctx: ContextKeys;
/** Bumped on every (re)open so the field re-selects its text. */
session: number;
/** Code preview of the selected result under the list (remembered). */
preview: boolean;
}
const PREVIEW_KEY = "openidea.search.everywherePreview.v1";
export const useSearchEverywhere = create<SeState>(() => ({
open: false,
tab: "all",
@@ -25,8 +29,14 @@ export const useSearchEverywhere = create<SeState>(() => ({
fileSet: "all",
ctx: {},
session: 0,
preview: loadJson<unknown>(PREVIEW_KEY, false) === true,
}));
export function setSearchEverywherePreview(preview: boolean) {
useSearchEverywhere.setState({ preview });
saveJson(PREVIEW_KEY, preview);
}
/** Opens (or re-targets) the dialog. Re-invoking the same scope toggles "include non-project items", like IDEA. */
export function showSearchEverywhere(tab: SeTab, opts: { query?: string; toggleOnRepeat?: boolean } = {}) {
const s = useSearchEverywhere.getState();
@@ -64,3 +74,32 @@ export function recentActions(): string[] {
export function pushRecentAction(id: string) {
saveJson(RECENT_ACTIONS_KEY, [id, ...recentActions().filter((x) => x !== id)].slice(0, MAX_RECENT_ACTIONS));
}
const HISTORY_KEY = "openidea.search.everywhereHistory.v1";
const MAX_HISTORY = 50;
interface HistoryEntry {
tab: SeTab;
query: string;
}
function loadHistory(): HistoryEntry[] {
const v = loadJson<unknown>(HISTORY_KEY, []);
return Array.isArray(v)
? v.filter((x): x is HistoryEntry => typeof x?.tab === "string" && typeof x?.query === "string")
: [];
}
/** Queries that led somewhere in `tab`, newest first. */
export function searchHistory(tab: SeTab): string[] {
return loadHistory()
.filter((e) => e.tab === tab)
.map((e) => e.query);
}
export function pushSearchHistory(tab: SeTab, query: string) {
const q = query.trim();
if (!q) return;
const rest = loadHistory().filter((e) => e.tab !== tab || e.query !== q);
saveJson(HISTORY_KEY, [{ tab, query: q }, ...rest].slice(0, MAX_HISTORY));
}
+5 -2
View File
@@ -1161,6 +1161,7 @@
"No Maven projects": "没有 Maven 项目",
"No profile for '{name}'": "没有 '{name}' 的分析数据",
"No Python found": "未找到 Python",
"No recent searches": "无最近搜索",
"No TODO items found": "未找到 TODO 项",
"No TODO items in this file": "此文件中没有 TODO 项",
"No active editor": "没有活动编辑器",
@@ -1494,6 +1495,7 @@
"Recent files": "最近的文件",
"Recent locations": "最近的位置",
"Recent projects": "最近的项目",
"Recent Searches": "最近搜索",
"Recently Viewed Files": "最近查看的文件",
"Record": "记录",
"Red": "红色",
@@ -1707,6 +1709,8 @@
"Runs the language server's Organize Imports action.": "运行语言服务器的“整理导入”操作。",
"Runs the language server's formatter before an explicit save (Actions on Save).": "在显式保存前运行语言服务器的格式化程序(保存时操作)。",
"Runs this project": "运行此项目",
"Search history": "搜索历史",
"Search history (⌥↑ / ⌥↓)": "搜索历史(⌥↑ / ⌥↓)",
"Searching for usages of '{symbol}'": "正在搜索 '{symbol}' 的用法",
"SQL Inserts": "SQL Insert 语句",
"SQL Script Template": "SQL 脚本模板",
@@ -2108,8 +2112,8 @@
"Trust and Connect": "信任并连接",
"Tx: Auto": "事务:自动",
"Tx: Manual": "事务:手动",
"Type / to see commands": "输入 / 查看命令",
"Type Declaration": "类型声明",
"Type file name, path or file:42": "输入文件名、路径或 file:42",
"Type Hierarchy": "类型层次结构",
"Type an expression followed by": "输入表达式,后跟",
"Type hierarchy": "类型层次结构",
@@ -2341,7 +2345,6 @@
"· read-only": "· 只读",
"… more": "… 更多",
"↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window": "↵ 打开 · ⇧↵ 在侧边打开 · ⌘↵ 在查找窗口中打开",
"⇥ to switch scope": "⇥ 切换范围",
"⌘↵ to open in Find tool window": "⌘↵ 在“查找”工具窗口中打开",
"⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.": "⌥← / ⌥→ 和 ⌥⌫ 会在 camelCase 名称的驼峰处停止。",
"Specify commit message": "请填写提交信息",
+8 -1
View File
@@ -38,6 +38,8 @@ export type MenuItem =
kind?: "item";
id?: string;
label: string;
/** User text (e.g. a past query): shown as is, never looked up in the catalog. */
verbatim?: boolean;
icon?: IconName | (string & {});
shortcut?: string;
disabled?: boolean;
@@ -86,7 +88,12 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] {
});
return;
}
out.push({ ...item, label: tx(item.label), kind: "item", key: item.id ?? `${i}:${item.label}` });
out.push({
...item,
label: item.verbatim ? item.label : tx(item.label),
kind: "item",
key: item.id ?? `${i}:${item.label}`,
});
});
while (out.length && out[out.length - 1]!.kind === "separator") out.pop();
return out;
+2
View File
@@ -178,6 +178,8 @@ const ICONS = {
regex: "inline/regex",
case: "inline/matchCase",
word: "inline/exactWords",
"text-match": "openidea/nodes/textMatch",
"open-in-tool-window": "general/openInToolWindow",
"preserve-case": "openidea/inline/preserveCase",
filter: "general/filter",
refresh: "general/refresh",
+4
View File
@@ -0,0 +1,4 @@
<!-- OpenIDEA: text occurrence (Search Everywhere › Text) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="2" stroke="#6C707E"/><path d="M5.5 5.5H10.5M8 5.5V10.5" stroke="#6C707E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B