mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
+35
-14
@@ -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-"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user