fix(search): Find / Replace in Files popup layout, footer and platform shortcuts (#1227)

The Find / Replace in Files popup gets the popup surface and spacing,
the match count and empty states ("Clear option(s)"), the footer with
"Open results in new tab", a resizable popup with a remembered results /
preview split, file mask suggestions from the project's file types
(new index_file_extensions), mnemonics and inline icons. Shortcuts are
spelled for the platform: Linux showed macOS glyphs in the popup, the
shared SearchField, the find bars and Search Everywhere. Replace keeps
its Preview diff.

Closes #1219

Co-authored-by: fuqiuluo <61898844+fuqiuluo@users.noreply.github.com>
This commit is contained in:
fuqiuluo
2026-11-05 02:45:36 +08:00
committed by GitHub
co-authored by fuqiuluo
parent 5d19baf06b
commit 948e93e1a2
48 changed files with 1067 additions and 361 deletions
+7 -1
View File
@@ -3,7 +3,7 @@ use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
@@ -32,6 +32,11 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
/// The project's file extensions, most common first.
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
Ok(host.project(window)?.index.file_extensions())
}
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
@@ -160,6 +165,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
+1
View File
@@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "project_current"
| "project_roots"
| "index_status"
| "index_file_extensions"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
+30
View File
@@ -8,6 +8,7 @@ use ide_language::{LanguageRegistry, Syntax};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -64,6 +65,14 @@ pub struct IndexStatus {
pub symbols: u32,
}
/// How many indexed files share an extension (lower case, without the dot).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExtensionCount {
pub extension: String,
pub files: u32,
}
pub struct ProjectIndex {
shared: Arc<Shared>,
worker: Mutex<Option<JoinHandle<()>>>,
@@ -159,6 +168,27 @@ impl ProjectIndex {
}
}
/// The extensions of the indexed files, most common first: the project's file
/// types, for suggestions such as Find in Files' file masks. Dotfiles
/// (`.gitignore`) have no extension.
pub fn file_extensions(&self) -> Vec<ExtensionCount> {
let d = self.shared.data.read();
let mut counts: HashMap<String, u32> = HashMap::new();
for file in d.files.iter().flatten() {
let name = &file.rel[file.name_start as usize..];
if let Some((stem, extension)) = name.rsplit_once('.')
&& !stem.is_empty()
&& !extension.is_empty()
{
*counts.entry(extension.to_ascii_lowercase()).or_default() += 1;
}
}
let mut out: Vec<ExtensionCount> =
counts.into_iter().map(|(extension, files)| ExtensionCount { extension, files }).collect();
out.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.extension.cmp(&b.extension)));
out
}
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
/// matches; `/` in the query matches path segments; recently opened files boost.
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
+22
View File
@@ -111,6 +111,28 @@ fn indexes_files_and_symbols_honouring_excludes() {
assert_eq!(m.path, fx.root.join("src/main.rs"));
}
#[test]
fn counts_file_extensions_most_common_first() {
let fx = Fixture::new(&[
("src/a.rs", "fn a() {}\n"),
("src/b.RS", "fn b() {}\n"),
("src/lib.rs", "fn c() {}\n"),
("Cargo.toml", "[package]\n"),
("README.md", "# r\n"),
("docs/guide.md", "# g\n"),
(".gitignore", "target\n"),
("Makefile", "all:\n"),
("target/debug/gen.rs", "fn generated() {}\n"),
]);
let idx = fx.open();
let counts: Vec<(String, u32)> = idx.file_extensions().into_iter().map(|c| (c.extension, c.files)).collect();
assert_eq!(
counts,
[("rs".into(), 3), ("md".into(), 2), ("toml".into(), 1)],
"case-folded, excluded folders and extension-less files left out"
);
}
#[test]
fn file_ranking_is_idea_like() {
let fx = sample();
+1
View File
@@ -186,6 +186,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
caches::caches_schedule_invalidation,
caches::app_restart,
nav::index_status,
nav::index_file_extensions,
nav::spring_route_sources,
nav::find_files,
nav::find_dirs,
+7 -1
View File
@@ -4,7 +4,7 @@ use crate::state::AppState;
use ide_core::{DocId, IdeResult, IpcPath, Pos};
#[allow(unused_imports)]
pub use ide_host::ops::nav::*;
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::FileReference;
use ide_workspace::file_refs::PathCompletion;
use std::path::PathBuf;
@@ -16,6 +16,12 @@ pub fn index_status(window: Window, state: State<'_, AppState>) -> IdeResult<Ind
ide_host::ops::nav::index_status(&state.host, window.label())
}
#[tauri::command]
#[specta::specta]
pub fn index_file_extensions(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ExtensionCount>> {
ide_host::ops::nav::index_file_extensions(&state.host, window.label())
}
#[tauri::command]
#[specta::specta]
pub fn find_files(window: Window, state: State<'_, AppState>, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
+7
View File
@@ -325,6 +325,7 @@ export const commands = {
/** Quits through every window's unsaved-changes check, then starts again. */
appRestart: () => __TAURI_INVOKE<void>("app_restart"),
indexStatus: () => typedError<IndexStatus, IdeError>(__TAURI_INVOKE("index_status")),
indexFileExtensions: () => typedError<ExtensionCount[], IdeError>(__TAURI_INVOKE("index_file_extensions")),
/** Spring Web mapping sources for the request-path index of Search Everywhere. */
springRouteSources: () => typedError<RouteSource[], IdeError>(__TAURI_INVOKE("spring_route_sources")),
findFiles: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_files", { query, limit })),
@@ -3413,6 +3414,12 @@ export type ExpressionAt = {
hasError: boolean,
};
/** How many indexed files share an extension (lower case, without the dot). */
export type ExtensionCount = {
extension: string,
files: number,
};
/** A formatter that runs outside the language servers, with its own settings group. */
export type ExternalFormatter = "prettier" | "oxfmt";
+40 -10
View File
@@ -39,7 +39,14 @@ import { setContext } from "@/platform/context";
import { showFindResults, type FindResultsSource } from "@/workbench/contributions";
import { relativePath, useProject } from "@/platform/project";
import { tx } from "@/platform/i18n";
import { bindingsFor, hasPrimaryModifier, normalizeBinding, registerKeyClaim, shortcutLabel } from "@/platform/keymap";
import {
bindingsFor,
formatBinding,
hasPrimaryModifier,
normalizeBinding,
registerKeyClaim,
shortcutLabel,
} from "@/platform/keymap";
/**
* The last search (IDEA's find-in-file model): a newly opened bar starts from it and ⌘G
@@ -516,7 +523,12 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
: [{ label: kind === "find" ? "No recent searches" : "No recent replacements", disabled: true }];
};
const filterItems: MenuItem[] = [
{ label: "Search In Selection", shortcut: "⌥E", checked: scoped || undefined, run: () => toggleScope(target) },
{
label: "Search In Selection",
shortcut: formatBinding("Alt+E"),
checked: scoped || undefined,
run: () => toggleScope(target),
},
{ kind: "separator" },
{ kind: "header", label: "Search Scope" },
...SEARCH_CONTEXTS.map((c) => ({
@@ -595,7 +607,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
shortcut: formatBinding("Alt+C"),
pressed: opts.caseSensitive,
onToggle: () => toggle("caseSensitive"),
},
@@ -603,7 +615,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
shortcut: formatBinding("Alt+W"),
pressed: opts.wholeWord,
onToggle: () => toggle("wholeWord"),
},
@@ -611,7 +623,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
shortcut: formatBinding("Alt+X"),
pressed: opts.regexp,
onToggle: () => toggle("regexp"),
},
@@ -625,11 +637,17 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
<IconButton
icon="arrow-up"
title="Previous Occurrence"
shortcut="⇧↵"
shortcut={formatBinding("Shift+Enter")}
size="small"
onClick={() => run(findPrevious)}
/>
<IconButton icon="arrow-down" title="Next Occurrence" shortcut="↵" size="small" onClick={() => run(findNext)} />
<IconButton
icon="arrow-down"
title="Next Occurrence"
shortcut={formatBinding("Enter")}
size="small"
onClick={() => run(findNext)}
/>
<IconButton
ref={filterRef}
icon="filter"
@@ -665,15 +683,27 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
/>
</div>
<div className={styles.replaceButtons}>
<Button size="small" title="Replace (⌥P)" onClick={doReplace} disabled={!canReplace}>
<Button
size="small"
title={tx`Replace (${formatBinding("Alt+P")})`}
onClick={doReplace}
disabled={!canReplace}
>
<T>Replace</T>
</Button>
<Button size="small" title="Replace All (⌥A)" onClick={doReplaceAll} disabled={!canReplace}>
<Button
size="small"
title={tx`Replace All (${formatBinding("Alt+A")})`}
onClick={doReplaceAll}
disabled={!canReplace}
>
<T>Replace All</T>
</Button>
<Button
size="small"
title={count?.excluded ? "Include (⌥L)" : "Exclude (⌥L)"}
title={
count?.excluded ? tx`Include (${formatBinding("Alt+L")})` : tx`Exclude (${formatBinding("Alt+L")})`
}
onClick={() => run(excludeCurrent)}
disabled={!canReplace || (!count?.total && !count?.excluded)}
>
+5 -5
View File
@@ -2,7 +2,7 @@
import { type KeyboardEvent, type RefObject } from "react";
import clsx from "clsx";
import { IconButton, SearchField } from "@/ui";
import { hasPrimaryModifier } from "@/platform/keymap";
import { formatBinding, hasPrimaryModifier } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import type { FindOptions } from "./find";
import styles from "./Run.module.css";
@@ -97,7 +97,7 @@ export function ConsoleFindBar({
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
shortcut: formatBinding("Alt+C"),
pressed: options.caseSensitive,
onToggle: () => toggle("caseSensitive"),
},
@@ -105,7 +105,7 @@ export function ConsoleFindBar({
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
shortcut: formatBinding("Alt+W"),
pressed: options.wholeWord,
onToggle: () => toggle("wholeWord"),
},
@@ -113,7 +113,7 @@ export function ConsoleFindBar({
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
shortcut: formatBinding("Alt+X"),
pressed: options.regex,
onToggle: () => toggle("regex"),
},
@@ -125,7 +125,7 @@ export function ConsoleFindBar({
<IconButton
icon="arrow-up"
title="Previous Occurrence"
shortcut="⇧↵"
shortcut={formatBinding("Shift+Enter")}
size="small"
disabled={count === 0}
onClick={onPrevious}
+30 -2
View File
@@ -65,6 +65,7 @@ const { showSearchEverywhere, hideSearchEverywhere, useSearchEverywhere, pushSea
await import("../everywhere/store");
const { FindInFilesOverlay } = await import("../find/FindInFiles");
const { showFindPopup, hideFindPopup } = await import("../find/store");
const { defaultPreset, useKeymap } = await import("@/platform/keymap");
const tick = () => act(() => new Promise((r) => setTimeout(r, 100)));
@@ -476,13 +477,40 @@ describe("Recent Files multi-selection", () => {
});
describe("Find in Files dialog", () => {
it("streams results and previews the replacement", async () => {
it("streams results and highlights the matches while replacing, as IDEA", async () => {
render(<FindInFilesOverlay />);
act(() => showFindPopup(true, { query: "order" }));
await act(() => new Promise((r) => setTimeout(r, 250)));
expect(screen.getByText("1 match in 1 file")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Replace with"), { target: { value: "item" } });
expect(screen.getByText("item", { ignore: "script, style, textarea" })).toBeInTheDocument();
const matches = screen.getByRole("listbox", { name: "Matches" });
expect(matches.querySelector("mark")).not.toBeNull();
expect(matches.querySelector("del, ins")).toBeNull();
expect(screen.getByRole("button", { name: "Replace All" })).toBeEnabled();
});
it.each([
["macos", ["⌥C", "⌥↓", "⇧⌘↵"], ["⌘↵", "⌥⇧↵"]],
["linux", ["Alt+C", "Alt+Down", "Ctrl+Shift+Enter"], ["Ctrl+Enter", "Alt+Shift+Enter"]],
["windows", ["Alt+C", "Alt+Down", "Ctrl+Shift+Enter"], ["Ctrl+Enter", "Alt+Shift+Enter"]],
] as const)("spells its shortcuts the %s way", (os, [matchCase, history, newLine], [openKey, replaceAllKey]) => {
const previous = { os: usePlatform.getState().os, preset: useKeymap.getState().preset };
usePlatform.setState({ os });
useKeymap.setState({ preset: defaultPreset() });
try {
render(<FindInFilesOverlay />);
act(() => showFindPopup(true, { query: "order" }));
const shortcut = (name: string) =>
screen.getAllByRole("button", { name }).map((b) => b.getAttribute("data-tooltip-shortcut"));
expect(shortcut("Match Case")).toEqual([matchCase]);
expect(shortcut("Recent Searches")).toEqual([history, history]);
expect(shortcut("New Line")).toEqual([newLine, newLine]);
expect(document.querySelector("footer")!.textContent).toContain(`${openKey}Open in Find Window`);
expect(screen.getByRole("button", { name: "Replace All" })).toHaveAttribute("title", replaceAllKey);
if (os !== "macos") expect(document.querySelector("[role=dialog]")!.innerHTML).not.toMatch(/[⌘⌥⇧↵]/);
} finally {
usePlatform.setState({ os: previous.os });
useKeymap.setState({ preset: previous.preset });
}
});
});
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const extensions = [
{ extension: "rs", files: 40 },
{ extension: "md", files: 12 },
{ extension: "lock", files: 9 },
{ extension: "toml", files: 6 },
{ extension: "png", files: 5 },
{ extension: "json", files: 3 },
{ extension: "yml", files: 2 },
{ extension: "sh", files: 1 },
];
const languages = [
{ id: "rust", extensions: ["rs"] },
{ id: "markdown", extensions: ["md", "markdown"] },
{ id: "toml", extensions: ["toml"] },
{ id: "json", extensions: ["json"] },
{ id: "yaml", extensions: ["yml", "yaml"] },
{ id: "shell", extensions: ["sh"] },
];
vi.mock("@/platform/ipc", async (orig) => ({
...(await orig<typeof import("@/platform/ipc")>()),
call: async (p: unknown) => p,
commands: {
indexFileExtensions: () => Promise.resolve(extensions),
languages: () => Promise.resolve(languages),
},
}));
const { recentMasks, refreshProjectMasks, rememberMask, useProjectMasks } = await import("../find/store");
beforeEach(() => {
localStorage.clear();
useProjectMasks.setState({ masks: [] });
});
describe("file mask suggestions", () => {
it("are the project's most common language file types, recent masks first", async () => {
await refreshProjectMasks();
expect(useProjectMasks.getState().masks).toEqual(["*.rs", "*.md", "*.toml", "*.json", "*.yml"]);
rememberMask("*.toml");
rememberMask("!*Test*");
expect(recentMasks()).toEqual(["!*Test*", "*.toml", "*.rs", "*.md", "*.json", "*.yml"]);
});
});
@@ -63,8 +63,18 @@ vi.mock("@/platform/ipc", async (orig) => ({
}));
const { FindInFilesOverlay } = await import("../find/FindInFiles");
const { findHistory, hideFindPopup, rememberFindString, runFindPopupCommand, showFindPopup, useFindPopup } =
await import("../find/store");
const {
findHistory,
hideFindPopup,
rememberFindString,
runFindPopupCommand,
showFindPopup,
useFindPopup,
useProjectMasks,
} = await import("../find/store");
const { formatBinding } = await import("@/platform/keymap");
const { usePlatform } = await import("@/platform/platformInfo");
const settle = () => act(() => new Promise((r) => setTimeout(r, 250)));
const selectedRows = () =>
@@ -92,6 +102,7 @@ beforeEach(() => {
preserveCase: false,
context: "any",
replacement: "",
newTab: false,
});
});
@@ -206,7 +217,8 @@ describe("Find in Files popup", () => {
});
describe("outdated popup results", () => {
const results = () => screen.getByRole("listbox", { name: "Matches" }).parentElement!;
// The results box, whether it holds the list or the empty text.
const results = () => document.querySelector<HTMLElement>("[aria-busy]")!;
const rowText = () =>
within(screen.getByRole("listbox", { name: "Matches" }))
.getAllByRole("option")
@@ -255,13 +267,14 @@ describe("outdated popup results", () => {
expect(results()).not.toHaveAttribute("data-stale");
expect(rowText()).toHaveLength(1);
expect(screen.getByText("1 match in 1 file")).toBeInTheDocument();
expect(results().querySelector(replace ? "ins" : "mark")).not.toBeNull();
expect(results().querySelector("mark")).not.toBeNull();
expect(results().querySelector("del, ins")).toBeNull();
if (replace) expect(screen.getByRole("button", { name: "Replace" })).toBeEnabled();
await act(async () => finish(null));
},
);
it.each(["Replace", "Replace All in This File", "Replace All", "Enter", "All shortcut", "All mnemonic"])(
it.each(["Replace", "Replace All", "Enter", "All shortcut", "All mnemonic"])(
"blocks %s during debounce and validation",
async (action) => {
render(<FindInFilesOverlay />);
@@ -318,12 +331,12 @@ describe("outdated popup results", () => {
await settle();
expect(results()).toHaveAttribute("data-stale", "true");
expect(rowText()).toHaveLength(3);
const box = results();
await act(async () => finish(null));
expect(box).not.toHaveAttribute("data-stale");
expect(screen.getByText("No matches")).toBeInTheDocument();
expect(results()).not.toHaveAttribute("data-stale");
// IDEA's header shows a count only while something matches.
expect(screen.queryByText(/\bmatch(es)? in\b/)).not.toBeInTheDocument();
expect(screen.getByText("Nothing found")).toBeInTheDocument();
expect(within(box).queryAllByRole("option")).toHaveLength(0);
expect(within(results()).queryAllByRole("option")).toHaveLength(0);
expect(screen.getByRole("button", { name: "Replace" })).toBeDisabled();
});
@@ -354,3 +367,145 @@ describe("outdated popup results", () => {
expect(replaced).toHaveLength(0);
});
});
describe("footer, as IDEA", () => {
it("remembers Open results in new tab and passes it on; ⌥B toggles it", () => {
render(<FindInFilesOverlay />);
act(() => showFindPopup(false, { query: "order" }));
const box = screen.getByRole("checkbox", { name: "Open results in new tab" });
expect(box).not.toBeChecked();
fireEvent.keyDown(field(), { key: "b", code: "KeyB", altKey: true });
expect(box).toBeChecked();
expect(localStorage.getItem("openidea.search.findNewTab.v1")).toBe("true");
fireEvent.click(box);
expect(useFindPopup.getState().newTab).toBe(false);
});
it("keeps OpenIDEA's Replace preview: a diff in place of the code preview", async () => {
render(<FindInFilesOverlay />);
act(() => showFindPopup(false, { query: "order" }));
expect(screen.queryByRole("checkbox", { name: "Preview" })).not.toBeInTheDocument();
act(() => showFindPopup(true, { query: "order" }));
await settle();
expect(screen.getByRole("region", { name: "Preview" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("checkbox", { name: "Preview" }));
expect(screen.queryByRole("region", { name: "Preview" })).not.toBeInTheDocument();
});
it("names the result keys only for a multi-line query", () => {
render(<FindInFilesOverlay />);
act(() => showFindPopup(false, { query: "order" }));
expect(screen.queryByText(/to select usages$/)).not.toBeInTheDocument();
fireEvent.change(field(), { target: { value: "order\nitem" } });
expect(screen.getByText(/^Use .+ and .+ to select usages$/)).toBeInTheDocument();
});
});
describe("mnemonics, as IDEA", () => {
it("⌥P / ⌥M / ⌥D / ⌥S pick the scope tab, whose letter is underlined off macOS", () => {
const os = usePlatform.getState().os;
usePlatform.setState({ os: "linux" });
try {
render(<FindInFilesOverlay />);
act(() => showFindPopup(false, { query: "order" }));
expect(screen.getByRole("tab", { name: "In Project" }).querySelector("u")?.textContent).toBe("P");
for (const [code, tab] of [
["KeyD", "directory"],
["KeyS", "scope"],
["KeyM", "module"],
["KeyP", "project"],
] as const) {
fireEvent.keyDown(field(), { key: code.slice(3).toLowerCase(), code, altKey: true });
expect(useFindPopup.getState().scopeTab).toBe(tab);
}
} finally {
usePlatform.setState({ os });
}
});
});
describe("file mask, as IDEA", () => {
it("turned on with nothing typed takes the first suggestion", () => {
useProjectMasks.setState({ masks: ["*.rs", "*.toml"] });
render(<FindInFilesOverlay />);
act(() => showFindPopup(false));
expect(screen.getByLabelText("File mask")).toHaveAttribute("placeholder", "*.rs");
fireEvent.click(screen.getByRole("checkbox", { name: "File mask:" }));
expect([useFindPopup.getState().maskEnabled, useFindPopup.getState().mask]).toEqual([true, "*.rs"]);
});
});
describe("layout, as IDEA", () => {
it("shows the preview only once something matches", async () => {
render(<FindInFilesOverlay />);
act(() => showFindPopup(false));
expect(screen.queryByRole("region", { name: "Preview" })).not.toBeInTheDocument();
fireEvent.change(field(), { target: { value: "order" } });
await settle();
expect(screen.getByRole("region", { name: "Preview" })).toBeInTheDocument();
});
it("is resizable and opens at the size it was left at", () => {
localStorage.setItem("openidea.search.findPopupSize.v1", JSON.stringify({ w: 700, h: 520 }));
render(<FindInFilesOverlay />);
act(() => showFindPopup(false));
const dialog = screen.getByRole("dialog");
expect([dialog.style.width, dialog.style.height]).toEqual(["700px", "520px"]);
expect(within(dialog).getByRole("separator", { name: "Resize" })).toBeInTheDocument();
});
});
describe("empty results, as IDEA", () => {
const findNothing = () => {
search = async () => null;
};
it("asks for a query while there is none", () => {
render(<FindInFilesOverlay />);
act(() => showFindPopup(false));
expect(screen.getByText("Type search query to find in files")).toBeInTheDocument();
});
it("names the one option in use and clears it", () => {
useFindPopup.setState({ maskEnabled: true, mask: "*.rs" });
render(<FindInFilesOverlay />);
act(() => showFindPopup(false));
expect(screen.getByText("No search text entered.")).toBeInTheDocument();
expect(screen.getByText("Search option used: File mask")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear option" }));
expect(useFindPopup.getState().maskEnabled).toBe(false);
expect(screen.getByText("Type search query to find in files")).toBeInTheDocument();
});
it("lists every option in use with its key, and ⇧⌥⌫ clears them all", async () => {
findNothing();
useFindPopup.setState({ caseSensitive: true, wholeWord: true, context: "inComments" });
render(<FindInFilesOverlay />);
act(() => showFindPopup(false, { query: "absent" }));
await settle();
expect(screen.getByText("Nothing found.")).toBeInTheDocument();
expect(screen.getByText("Search options used:")).toBeInTheDocument();
expect(
screen.getByText(`Match Case ${formatBinding("Alt+C")} Words ${formatBinding("Alt+W")} Filter`),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear all options" })).toBeInTheDocument();
fireEvent.keyDown(field(), { key: "Backspace", altKey: true, shiftKey: true });
const s = useFindPopup.getState();
expect([s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.context]).toEqual([
false,
false,
false,
false,
"any",
]);
});
it("offers Search with Regex for a query that reads as one", async () => {
findNothing();
render(<FindInFilesOverlay />);
act(() => showFindPopup(false, { query: "fo+.*bar" }));
await settle();
fireEvent.click(screen.getByRole("button", { name: "Search with Regex" }));
expect(useFindPopup.getState().isRegex).toBe(true);
});
});
@@ -39,22 +39,29 @@ afterEach(disposeFindWindow);
describe("Find tool window tabs", () => {
it("reuses the selected tab unless it is pinned", () => {
const a = openInFindWindow(q("a"), "a", null, deps);
const b = openInFindWindow(q("b"), "b", null, deps);
const a = openInFindWindow(q("a"), "a", { deps });
const b = openInFindWindow(q("b"), "b", { deps });
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b"]);
expect(useFindWindow.getState().active).toBe(b);
expect(b).not.toBe(a);
pinFindTab(b, true);
const c = openInFindWindow(q("c"), "c", null, deps);
const c = openInFindWindow(q("c"), "c", { deps });
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c"]);
expect(useFindWindow.getState().active).toBe(c);
// Selecting the pinned tab again opens the next search beside it.
useFindWindow.setState({ active: b });
openInFindWindow(q("d"), "d", null, deps);
openInFindWindow(q("d"), "d", { deps });
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c", "d"]);
});
it("keeps the selected tab with IDEA's Open results in new tab", () => {
openInFindWindow(q("a"), "a", { deps });
const b = openInFindWindow(q("b"), "b", { deps, newTab: true });
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["a", "b"]);
expect(useFindWindow.getState().active).toBe(b);
});
});
describe("buildFindTree", () => {
@@ -5,7 +5,7 @@
*/
import { useEffect, useMemo, useState } from "react";
import { Button, Checkbox, Input, Popup, T, type PopupAnchor } from "@/ui";
import { recentMasks } from "../find/store";
import { recentMasks, refreshProjectMasks, useProjectMasks } from "../find/store";
import { KIND_LABELS, kindsFor, type FilterTab, type SeFilters } from "./filters";
import { loadLanguages } from "./deps";
import { setSearchEverywhereFilter } from "./store";
@@ -135,6 +135,8 @@ export function FilterPopup({
function MaskFilter({ mask, onClose }: { mask: string; onClose: () => void }) {
const [value, setValue] = useState(mask);
const projectMasks = useProjectMasks((p) => p.masks);
useEffect(() => void refreshProjectMasks(), []);
const apply = (m: string) => setSearchEverywhereFilter("text", { mask: m.trim() });
return (
<div className={styles.filterGroup}>
@@ -145,7 +147,7 @@ function MaskFilter({ mask, onClose }: { mask: string; onClose: () => void }) {
id="se-file-mask"
data-autofocus
value={value}
placeholder="*.ts, !*.test.*"
placeholder={projectMasks[0] ?? ""}
spellCheck={false}
onChange={(e) => {
setValue(e.target.value);
@@ -4,7 +4,7 @@ import clsx from "clsx";
import { actionToggled, executeAction, executeActionFrom, getAction } from "@/platform/actions";
import type { ProjectFileSet } from "@/platform/ipc";
import { tx as translate, useTx } from "@/platform/i18n";
import { hasPrimaryModifier, registerKeyClaim, shortcutLabel } from "@/platform/keymap";
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim, shortcutLabel } from "@/platform/keymap";
import {
Checkbox,
Dialog,
@@ -98,6 +98,7 @@ const INITIAL_SELECTION: Selection = { key: null, anchorKey: null, lead: -1, anc
function SearchEverywhereDialog() {
const tx = useTx();
const tab = useSearchEverywhere((s) => s.tab);
const hint = hintFor(tab, tx);
const query = useSearchEverywhere((s) => s.query);
const includeNonProject = useSearchEverywhere((s) => s.includeNonProject);
const fileSet = useSearchEverywhere((s) => s.fileSet);
@@ -339,7 +340,7 @@ function SearchEverywhereDialog() {
? [{ label: "Open Settings Page", run: () => activate(row) }]
: [
{ label: "Open", run: () => activate(row) },
{ label: "Open to the Side", shortcut: "⇧↵", run: () => activate(row, true) },
{ label: "Open to the Side", shortcut: formatBinding("Shift+Enter"), run: () => activate(row, true) },
{ kind: "separator" },
{ label: "Copy Path", run: () => copy(row.path) },
{ label: "Copy Path from Project Root", run: () => copy(row.rel) },
@@ -503,7 +504,7 @@ function SearchEverywhereDialog() {
size="small"
className={styles.headerButton}
title={tx("Open in Find Tool Window")}
shortcut={tab === "text" ? "⌘↵" : undefined}
shortcut={tab === "text" ? formatBinding(hostBinding("Meta+Enter")) : undefined}
onClick={() => closeThen(() => void executeAction("find.inPath", { query: parsed.text }))}
/>
</div>
@@ -513,7 +514,7 @@ function SearchEverywhereDialog() {
type="button"
className={styles.magnifier}
aria-label={tx("Search history")}
title={tx("Search history (⌥↑ / ⌥↓)")}
title={tx`Search history (${formatBinding("Alt+Up")} / ${formatBinding("Alt+Down")})`}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => openHistory(e.currentTarget)}
>
@@ -539,9 +540,9 @@ function SearchEverywhereDialog() {
onKeyDown={onKeyDown}
/>
{snap?.loading && !emptyQuery && <Spinner size={14} className={styles.spinner} />}
{hintFor(tab) && (
{hint && (
<span className={styles.hint} aria-hidden>
<T>{hintFor(tab)}</T>
{hint}
</span>
)}
</div>
@@ -645,7 +646,7 @@ function describe(row: SeRow | undefined): string {
case "action": {
const a = getAction(row.id);
const text = a?.description ?? (row.category ? `${row.category} | ${row.title}` : row.title);
return `${text} · ${translate("⌥↵ to assign a shortcut")}`;
return `${text} · ${translate`${formatBinding("Alt+Enter")} to assign a shortcut`}`;
}
case "setting":
return [...row.path, row.title].map((p) => translate(p)).join(" | ");
@@ -674,12 +675,12 @@ function previewOf(row: SeRow | undefined): { path: string; line: number; highli
}
}
function hintFor(tab: SeTab): string {
function hintFor(tab: SeTab, tx: ReturnType<typeof useTx>): string {
switch (tab) {
case "all":
return "Type / to see commands";
return tx("Type / to see commands");
case "text":
return "⌘↵ to open in Find tool window";
return tx`${formatBinding(hostBinding("Meta+Enter"))} to open in Find tool window`;
default:
return "";
}
+77 -27
View File
@@ -1,5 +1,8 @@
/* IDEA's Find in Files is a popup: Popup.background, not the dialog surface. */
.dialog {
max-height: min(86vh, calc(90vh - 24px));
max-height: calc(90vh - 12px);
background: var(--bg-popup, var(--bg-elev));
font-family: var(--font-ui);
}
.dialog > :global([data-dialog-body]) {
display: flex;
@@ -17,13 +20,14 @@
display: flex;
align-items: center;
gap: 10px;
height: 34px;
padding: 0 8px 0 12px;
height: 42px;
padding: 0 14px 0 20px;
border-bottom: 1px solid var(--border-popup);
color: var(--fg-subtle);
}
.title {
color: var(--fg-emphasis);
font-weight: 500;
color: var(--fg-strong);
font-weight: 600;
}
.status {
display: inline-flex;
@@ -39,7 +43,8 @@
.mask {
display: flex;
align-items: center;
height: var(--h-control-small);
height: var(--h-control);
background: var(--bg-field);
border: 1px solid var(--border-strong);
border-radius: var(--radius-small);
padding-left: 6px;
@@ -53,18 +58,18 @@
border: 0;
outline: none;
color: var(--fg);
font-family: var(--font-mono);
font-size: var(--fs-small);
}
.fields {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 12px 8px;
gap: 8px;
padding: 8px 8px 0;
}
.field {
/* The query fields sit on the popup surface, framed only by their border. */
.fields .field {
width: 100%;
background: transparent;
}
.field input {
font-family: var(--font-mono);
@@ -75,13 +80,15 @@
font-size: var(--fs-small);
padding-left: 2px;
}
/* Scope tabs line up with the title; the selected one is a neutral pressed pill, as in IDEA. */
.scopeRow {
--bg-tab-selected: var(--bg-toolbar-pressed);
--border-tab-selected: transparent;
flex: none;
display: flex;
align-items: center;
gap: 12px;
padding: 0 12px 0 6px;
border-bottom: 1px solid var(--border-popup);
padding: 8px 14px 4px 20px;
}
.scopeDetail {
flex: 1;
@@ -107,15 +114,24 @@
.dirInput:focus {
border-color: var(--accent);
}
/* Results alone (empty state) or results over the preview; grows with the popup. */
.split {
flex: 1 1 auto;
height: 500px;
min-height: 0;
display: flex;
flex-direction: column;
}
.split > * {
flex: 1;
min-height: 0;
}
.results {
flex: 0 1 242px;
flex: 1;
min-height: 0;
height: 242px;
display: flex;
flex-direction: column;
padding: 2px 0;
font-family: var(--font-mono);
font-weight: var(--fw-code);
}
.results > * {
flex: 1;
@@ -131,6 +147,8 @@
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-mono);
font-weight: var(--fw-code);
}
.rowFile {
flex: none;
@@ -138,7 +156,6 @@
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-subtle);
font-family: var(--font-ui);
}
.rowFileActive {
color: var(--fg-muted);
@@ -153,22 +170,24 @@
align-items: center;
justify-content: space-between;
gap: 10px;
height: 24px;
padding: 0 12px;
height: 36px;
padding: 0 20px;
border-top: 1px solid var(--border-popup);
border-bottom: 1px solid var(--border-popup);
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.previewPath {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.previewName {
color: var(--fg-strong);
margin-right: 8px;
}
.previewBox {
flex: 0 1 220px;
flex: 1;
min-height: 0;
height: 220px;
display: flex;
flex-direction: column;
}
@@ -179,9 +198,8 @@
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 38px;
padding: 6px 12px;
border-top: 1px solid var(--border-popup);
min-height: 46px;
padding: 8px 20px;
}
.footer > button,
.footer > label {
@@ -204,3 +222,35 @@
opacity: 0.55;
text-decoration: line-through;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 20px;
text-align: center;
line-height: 1.6;
}
.emptyOptions {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 1.2em;
}
.link {
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.link:hover {
color: var(--link-hover);
}
/* IDEA's disabled "Ctrl+Enter" label before Open in Find Window. */
.okHint {
flex: none;
color: var(--fg-faint);
white-space: nowrap;
}
+278 -158
View File
@@ -3,7 +3,8 @@ import clsx from "clsx";
import { openEditorPaths, useEditorRegistry } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useTxTemplate, tx } from "@/platform/i18n";
import { t, useTxTemplate, tx } from "@/platform/i18n";
import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap";
import { call, commands, isTauri, type SearchScope } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useProjectModules } from "@/platform/projectRoots";
@@ -15,9 +16,13 @@ import {
IconButton,
List,
Menu,
Mnemonic,
SearchField,
Select,
Spinner,
SplitGroup,
SplitHandle,
SplitPanel,
Tabs,
listNavigate,
type MenuItem,
@@ -59,12 +64,19 @@ import {
hideFindPopup,
recentMasks,
rememberFindString,
refreshProjectMasks,
rememberMask,
resolveDirectory,
resolveFindScope,
setFindPopupCommandHandler,
loadFindPopupSize,
loadFindPopupSplit,
saveFindPopupSize,
saveFindPopupSplit,
setFindPopupNewTab,
setFindPopupPinned,
useFindPopup,
useProjectMasks,
type FindOptions,
type BuiltinScope,
type FindPopupCommand,
@@ -89,18 +101,19 @@ async function chooseDirectory(current: string): Promise<string | null> {
const POPUP_MAX_MATCHES = 2000;
const SEARCH_DEBOUNCE_MS = 150;
const ROW = 22;
const ROW = 24;
export function FindInFilesOverlay() {
const open = useFindPopup((s) => s.open);
return open ? <FindInFilesDialog /> : null;
}
const SCOPE_TABS: Array<{ id: ScopeTab; label: string }> = [
{ id: "project", label: "In Project" },
{ id: "module", label: "Module" },
{ id: "directory", label: "Directory" },
{ id: "scope", label: "Scope" },
/** IDEA's scope tabs and their Alt mnemonics. */
const SCOPE_TABS: Array<{ id: ScopeTab; label: string; mnemonic: string }> = [
{ id: "project", label: "In Project", mnemonic: "P" },
{ id: "module", label: "Module", mnemonic: "M" },
{ id: "directory", label: "Directory", mnemonic: "D" },
{ id: "scope", label: "Scope", mnemonic: "S" },
];
const NAMED_SCOPES: Array<{ value: BuiltinScope; label: string }> = [
@@ -134,11 +147,18 @@ function FindInFilesDialog() {
const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null);
const [contextMenu, setContextMenu] = useState(false);
const [previewVersion, setPreviewVersion] = useState(0);
// OpenIDEA's Replace › Preview: the preview shows the replacement as a diff.
const [previewMode, setPreviewMode] = useState(false);
const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null);
const [initialSize] = useState(loadFindPopupSize);
const [split, setSplit] = useState(loadFindPopupSplit);
const keepSplit = (layout: Record<string, number>) => {
setSplit(layout);
saveFindPopupSplit(layout);
};
const [marks, setMarks] = useState<ReplaceMarks>(NO_MARKS);
const [running, setRunning] = useState<ReplaceRun | null>(null);
const [progress, setProgress] = useState<ReplaceProgress | null>(null);
const [previewMode, setPreviewMode] = useState(false);
const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null);
const busy = !!running;
const patternRef = useRef<HTMLInputElement>(null);
const patternBoxRef = useRef<HTMLDivElement>(null);
@@ -149,6 +169,11 @@ function FindInFilesDialog() {
const set = (patch: Partial<FindOptions>) => useFindPopup.setState(patch);
useEffect(() => () => session.dispose(), [session]);
const projectMasks = useProjectMasks((p) => p.masks);
useEffect(() => void refreshProjectMasks(), []);
/** Turning the mask on with nothing typed takes the first suggestion, as IDEA's combo box shows one. */
const enableMask = (on: boolean) =>
set(on && !s.mask.trim() ? { maskEnabled: true, mask: recentMasks()[0] ?? "" } : { maskEnabled: on });
useEffect(() => {
if (modules.length && !modules.some((m) => m.path === s.module))
useFindPopup.setState({ module: modules[0]!.path });
@@ -258,7 +283,11 @@ function FindInFilesDialog() {
if (s.maskEnabled) rememberMask(s.mask);
const pinned = buildQuery(options, scope, 0);
close();
openInFindWindow(pinned, describeQuery(pinned, root), s.replace ? s.replacement : null, undefined, s.preserveCase);
openInFindWindow(pinned, describeQuery(pinned, root), {
replacement: s.replace ? s.replacement : null,
preserveCase: s.preserveCase,
newTab: s.newTab,
});
};
// Check again after a confirmation: its captured rows may have been superseded.
@@ -350,23 +379,6 @@ function FindInFilesDialog() {
if (i >= 0) setIndex(i);
}
};
const skip = () => {
const i = nextPending(rows, rows.indexOf(chosen[chosen.length - 1]!), marks);
if (i >= 0) setIndex(i);
};
const replaceInFile = async () => {
if (!selected) return;
const next = await replaceUsages(
pendingUsages(
rows.filter((r) => r.path === selected.path),
marks,
),
);
if (next) {
const i = nextPending(rows, active, next);
if (i >= 0) setIndex(i);
}
};
const replaceAll = async (usages: Usage[]) => {
const done = await replaceUsages(usages);
if (done) close();
@@ -413,19 +425,41 @@ function FindInFilesDialog() {
| undefined;
if (toggle) set({ [toggle]: !s[toggle] });
else if (code === "KeyK") {
set({ maskEnabled: !s.maskEnabled });
enableMask(!s.maskEnabled);
if (!s.maskEnabled) setTimeout(() => maskInputRef.current?.select(), 0);
} else if (code === "KeyE" && s.replace) set({ preserveCase: !s.preserveCase });
else if (code === "KeyB") setFindPopupNewTab(!s.newTab);
else if (SCOPE_TABS.some((t) => code === `Key${t.mnemonic}`))
selectScope(SCOPE_TABS.find((t) => code === `Key${t.mnemonic}`)!.id);
else if (code === "KeyA" && s.replace) {
if (rows.length && !busy) void confirmReplaceAll();
} else return false;
return true;
};
const selectScope = (id: ScopeTab) => {
// IDEA starts the Directory scope at the current file's folder.
if (id === "directory" && !s.directory.trim()) {
const active = maybeEditorService()?.activePath();
const folders = project?.workspace?.projects ?? (root ? [root] : []);
const dir = active && folders.some((r) => active.startsWith(r + "/")) ? dirname(active) : root;
set({ scopeTab: "directory", directory: dir ?? "" });
} else set({ scopeTab: id });
};
/** IDEA's "Clear all options" (⇧⌥⌫): back to a plain search everywhere. */
const clearOptions = () =>
set({ caseSensitive: false, wholeWord: false, isRegex: false, maskEnabled: false, context: "any" });
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const inField =
(target.tagName === "INPUT" || target.tagName === "TEXTAREA") && target.closest("[data-find-field]");
if (e.key === "Backspace" && e.altKey && e.shiftKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
clearOptions();
return;
}
if (e.key === "Enter" && e.altKey && e.shiftKey && !e.metaKey && !e.ctrlKey) {
// ⇧⌥↵: Replace All.
e.preventDefault();
@@ -499,12 +533,75 @@ function FindInFilesDialog() {
run: () => set({ context: c.value }),
}));
const pendingSelected = s.replace && !stale ? pendingUsages(chosen, marks).length : 0;
// A multi-line query takes the arrow keys, so IDEA names the keys that still move through results.
const nextKey = shortcutLabel("find.popup.nextResult");
const previousKey = shortcutLabel("find.popup.previousResult");
const navigationHint =
s.pattern.includes("\n") && nextKey && previousKey ? tx`Use ${nextKey} and ${previousKey} to select usages` : "";
const resultsBox = (
<div
className={clsx(styles.results, stale && styles.outdated)}
data-stale={stale || undefined}
aria-busy={stale || state.status === "searching"}
>
<List<MatchRow>
items={rows}
getKey={(r, i) => `${r.key}:${i}`}
activeIndex={active}
isSelected={(r) => isRowSelected(sel, r.key)}
onActivate={(r, i, e) => {
if (e.type === "click" && (e as MouseEvent).detail < 2) return onRowClick(i, e as MouseEvent);
openRow(r, e.shiftKey);
}}
focusable={false}
rowHeight={ROW}
aria-label="Matches"
empty={
<EmptyResults state={state} options={options} onClear={clearOptions} onRegex={() => set({ isRegex: true })} />
}
renderItem={(r, st) => {
const state = s.replace ? groupState(r, marks) : "pending";
return (
<>
<span
className={clsx(
styles.rowText,
state === "done" && styles.rowDone,
state === "excluded" && styles.rowExcluded,
)}
>
<MatchText
text={r.text}
ranges={stale ? [] : r.ranges}
source={{ path: r.path, line: r.line, col: r.textCol }}
/>
</span>
<span className={clsx(styles.rowFile, st.active && styles.rowFileActive)}>
{state === "done" && (
<span className={styles.rowLine}>
<T>replaced ·</T>{" "}
</span>
)}
{workspace ? r.relPath : basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
</span>
</>
);
}}
/>
</div>
);
return (
<Dialog
open
onClose={close}
width={820}
resizable="both"
minWidth={560}
minHeight={400}
initialSize={initialSize}
onResized={saveFindPopupSize}
position="top"
transparentBackdrop
// IDEA's Find in Files is a modeless dialog: it survives switching to another app.
@@ -521,13 +618,17 @@ function FindInFilesDialog() {
{summary}
</span>
<span className={styles.spacer} />
<Checkbox checked={s.maskEnabled} onChange={(v) => set({ maskEnabled: v })} label="File mask:" />
<Checkbox
checked={s.maskEnabled}
onChange={enableMask}
label={<Mnemonic text={tx("File mask:")} char="K" />}
/>
<div ref={maskRef} className={clsx(styles.mask, !s.maskEnabled && styles.maskOff)}>
<input
ref={maskInputRef}
className={styles.maskInput}
value={s.mask}
placeholder="*.kt, !*Test*"
placeholder={projectMasks[0] ?? ""}
aria-label={tx("File mask")}
spellCheck={false}
onChange={(e) => set({ mask: e.target.value, maskEnabled: true })}
@@ -557,7 +658,7 @@ function FindInFilesDialog() {
data-autofocus
value={s.pattern}
onChange={(v) => set({ pattern: v })}
placeholder={s.isRegex ? "Regular expression" : "Text to find"}
placeholder="Search"
aria-label="Text to find"
invalid={state.status === "invalid"}
className={styles.field}
@@ -568,7 +669,7 @@ function FindInFilesDialog() {
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
shortcut: formatBinding("Alt+C"),
pressed: s.caseSensitive,
onToggle: (v) => set({ caseSensitive: v }),
},
@@ -576,7 +677,7 @@ function FindInFilesDialog() {
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
shortcut: formatBinding("Alt+W"),
pressed: s.wholeWord,
onToggle: (v) => set({ wholeWord: v }),
},
@@ -584,7 +685,7 @@ function FindInFilesDialog() {
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
shortcut: formatBinding("Alt+X"),
pressed: s.isRegex,
onToggle: (v) => set({ isRegex: v }),
},
@@ -598,7 +699,7 @@ function FindInFilesDialog() {
value={s.replacement}
onChange={(v) => set({ replacement: v })}
leadingIcon="replace"
placeholder="Replace with"
placeholder="Replace"
aria-label="Replace with"
className={styles.field}
multiline
@@ -608,7 +709,7 @@ function FindInFilesDialog() {
id: "preserveCase",
icon: "preserve-case",
title: "Preserve Case",
shortcut: "⌥E",
shortcut: formatBinding("Alt+E"),
pressed: s.preserveCase,
onToggle: (v) => set({ preserveCase: v }),
},
@@ -621,17 +722,13 @@ function FindInFilesDialog() {
<Tabs
variant="compact"
aria-label="Search scope"
items={SCOPE_TABS.map((t) => ({ id: t.id, label: t.label }))}
items={SCOPE_TABS.map((t) => ({
id: t.id,
label: <Mnemonic text={tx(t.label)} char={t.mnemonic} />,
text: t.label,
}))}
activeId={s.scopeTab}
onSelect={(id) => {
// IDEA starts the Directory scope at the current file's folder.
if (id === "directory" && !s.directory.trim()) {
const active = maybeEditorService()?.activePath();
const folders = project?.workspace?.projects ?? (root ? [root] : []);
const dir = active && folders.some((r) => active.startsWith(r + "/")) ? dirname(active) : root;
set({ scopeTab: "directory", directory: dir ?? "" });
} else set({ scopeTab: id as ScopeTab });
}}
onSelect={(id) => selectScope(id as ScopeTab)}
/>
<div className={styles.scopeDetail}>
{s.scopeTab === "directory" && (
@@ -706,128 +803,82 @@ function FindInFilesDialog() {
)}
</div>
</div>
<div
className={clsx(styles.results, stale && styles.outdated)}
data-stale={stale || undefined}
aria-busy={stale || state.status === "searching"}
>
<List<MatchRow>
items={rows}
getKey={(r, i) => `${r.key}:${i}`}
activeIndex={active}
isSelected={(r) => isRowSelected(sel, r.key)}
onActivate={(r, i, e) => {
if (e.type === "click" && (e as MouseEvent).detail < 2) return onRowClick(i, e as MouseEvent);
openRow(r, e.shiftKey);
}}
focusable={false}
rowHeight={ROW}
aria-label="Matches"
empty={<span>{emptyText(state, s.pattern)}</span>}
renderItem={(r, st) => {
const state = s.replace ? groupState(r, marks) : "pending";
return (
<>
<span
className={clsx(
styles.rowText,
state === "done" && styles.rowDone,
state === "excluded" && styles.rowExcluded,
)}
>
<MatchText
text={r.text}
ranges={stale ? [] : r.ranges}
source={{ path: r.path, line: r.line, col: r.textCol }}
active={st.active}
replacement={!stale && s.replace && state !== "done" ? s.replacement : null}
query={query}
preserveCase={s.preserveCase}
<div className={styles.split}>
{rows.length ? (
<SplitGroup orientation="vertical" id="find-popup-split" defaultLayout={split} onLayoutChanged={keepSplit}>
<SplitPanel id="results" minSize={ROW * 3} defaultSize="48%">
{resultsBox}
</SplitPanel>
<SplitHandle hairline={false} />
<SplitPanel id="preview" minSize={96}>
<div className={styles.previewHeader}>
{selected ? (
<span className={styles.previewPath}>
<span className={styles.previewName}>{basename(selected.relPath)}</span>
{selected.relPath.includes("/") && dirname(selected.relPath)}
</span>
) : (
<span />
)}
</div>
<div className={styles.previewBox}>
{!stale && previewMode && s.replace && selected ? (
<ReplaceDiffPreview
lines={diffPreview?.key === diffPreviewKey ? diffPreview.lines : null}
line={selected.line}
/>
</span>
<span className={clsx(styles.rowFile, st.active && styles.rowFileActive)}>
{state === "done" && (
<span className={styles.rowLine}>
<T>replaced ·</T>{" "}
</span>
)}
{workspace ? r.relPath : basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
</span>
</>
);
}}
/>
</div>
<div className={styles.previewHeader}>
{selected ? (
<>
<span className={styles.previewPath}>{selected.relPath}</span>
<span className={styles.rowLine}>
{selected.line + 1}:{selected.col + 1}
</span>
</>
) : (
<CodePreview
path={selected?.path ?? null}
line={selected?.line ?? 0}
highlights={fileHighlights}
version={previewVersion}
/>
)}
</div>
</SplitPanel>
</SplitGroup>
) : (
<span />
)}
</div>
<div className={styles.previewBox}>
{!stale && previewMode && s.replace && selected ? (
<ReplaceDiffPreview
lines={diffPreview?.key === diffPreviewKey ? diffPreview.lines : null}
line={selected.line}
/>
) : (
<CodePreview
path={selected?.path ?? null}
line={selected?.line ?? 0}
highlights={fileHighlights}
version={previewVersion}
/>
resultsBox
)}
</div>
<footer className={styles.footer}>
<span className={styles.hint}>
{s.replace ? (
<T>↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window</T>
) : (
<T>↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window</T>
)}
</span>
<Checkbox
checked={s.newTab}
onChange={setFindPopupNewTab}
label={<Mnemonic text={tx("Open results in new tab")} char="B" />}
/>
{s.replace && <Checkbox checked={previewMode} onChange={setPreviewMode} label="Preview" />}
{navigationHint && <span className={styles.hint}>{navigationHint}</span>}
<span className={styles.spacer} />
{s.replace && progress && (
<span className={styles.hint} aria-live="polite">
{tx`Replacing… ${progress.done}/${progress.total} files`}
</span>
)}
{s.replace && running && (
<Button size="small" onClick={() => running.cancel()}>
<Button onClick={() => running.cancel()}>
<T>Stop</T>
</Button>
)}
<span className={styles.okHint}>{formatBinding(hostBinding("Meta+Enter"))}</span>
<Button disabled={!s.pattern} onClick={pin}>
<T>Open in Find Window</T>
</Button>
{s.replace && (
<>
<Checkbox checked={previewMode} onChange={setPreviewMode} label="Preview" />
<Button size="small" variant="secondary" disabled={stale || !selected || busy} onClick={skip}>
<T>Skip</T>
</Button>
<Button size="small" disabled={stale || !selected || busy} onClick={() => void replaceSelected()}>
{pendingSelected > 1 ? txt(`Replace ${pendingSelected} Occurrences`) : <T>Replace</T>}
</Button>
<Button size="small" disabled={stale || !selected || busy} onClick={() => void replaceInFile()}>
<T>Replace All in This File</T>
</Button>
<Button
size="small"
title="Replace All (⇧⌥↵, ⌥A)"
title={formatBinding("Shift+Alt+Enter")}
disabled={stale || !rows.length || busy || state.status === "searching"}
onClick={() => void confirmReplaceAll()}
>
<T>Replace All</T>
<Mnemonic text={tx("Replace All")} char="A" />
</Button>
<Button variant="primary" disabled={stale || !selected || busy} onClick={() => void replaceSelected()}>
{pendingSelected > 1 ? txt(`Replace ${pendingSelected} Occurrences`) : <T>Replace</T>}
</Button>
</>
)}
<Button size="small" variant={s.replace ? "secondary" : "primary"} disabled={!s.pattern} onClick={pin}>
<T>Open in Find Window</T>
</Button>
</footer>
</div>
<Menu
@@ -857,20 +908,89 @@ function FindInFilesDialog() {
);
}
/** IDEA's header count: shown once something matched. */
function statusText(s: FindState): string {
if (s.status === "invalid") return "Invalid pattern";
if (s.status === "error") return s.error ?? "Search failed";
if (s.status === "idle") return "";
const files = s.files.length;
const more = s.summary?.limitHit ? "+" : "";
if (!s.matchCount) return s.status === "done" ? "No matches" : "Searching…";
return `${s.matchCount}${more} match${s.matchCount === 1 ? "" : "es"} in ${files}${more} file${files === 1 ? "" : "s"}`;
if (!s.matchCount) return "";
const key = s.summary?.limitHit ? "search.find.matchesIncomplete" : "search.find.matches";
return t(key, { matches: s.matchCount, files: s.files.length });
}
function emptyText(s: FindState, pattern: string): string {
if (!pattern) return "Type text to search the project";
if (s.status === "invalid") return s.error ?? "Invalid pattern";
if (s.status === "searching" || s.status === "validating") return "Searching…";
if (s.status === "error") return s.error ?? "Search failed";
return "Nothing found";
const REGEX_CHARS = /[.$|()[\]{}^?*+\\]/;
/** Whether text with regex syntax in it is also a valid pattern: IDEA then offers "Search with Regex". */
function couldBeRegex(text: string): boolean {
if (!REGEX_CHARS.test(text)) return false;
try {
new RegExp(text);
return true;
} catch {
return false;
}
}
/** IDEA's empty results text: why nothing is listed, the search options in use and how to clear them. */
function EmptyResults({
state,
options: o,
onClear,
onRegex,
}: {
state: FindState;
options: FindOptions;
onClear: () => void;
onRegex: () => void;
}) {
const masked = o.maskEnabled && !!o.mask.trim();
if (!o.pattern && !masked) return <div className={styles.empty}>{tx("Type search query to find in files")}</div>;
if (state.status === "invalid") return <div className={styles.empty}>{state.error ?? tx("Invalid pattern")}</div>;
if (state.status === "error") return <div className={styles.empty}>{state.error ?? tx("Search failed")}</div>;
if (o.pattern && state.status !== "done") return <div className={styles.empty}>{tx("Searching…")}</div>;
const used = [
o.caseSensitive && { label: "Match Case", key: "Alt+C" },
o.wholeWord && { label: "Words", key: "Alt+W" },
o.isRegex && { label: "Regex", key: "Alt+X" },
masked && { label: "File mask", key: "Alt+K" },
o.context !== "any" && { label: "Filter", key: "" },
].filter((u) => !!u);
const regex = !!o.pattern && !o.isRegex && couldBeRegex(o.pattern);
const clearKey = formatBinding("Shift+Alt+Backspace");
return (
<div className={styles.empty}>
<div>
{o.pattern ? tx("Nothing found") : tx("No search text entered")}
{used.length || regex ? "." : ""}
</div>
{regex ? (
<div className={styles.emptyOptions}>
<div>
<button type="button" className={styles.link} onClick={onRegex}>
<T>Search with Regex</T>
</button>{" "}
{formatBinding("Alt+X")}
</div>
</div>
) : used.length ? (
<div className={styles.emptyOptions}>
{used.length === 1 ? (
<div>{tx`Search option used: ${tx(used[0]!.label)}`}</div>
) : (
<>
<div>
<T>Search options used:</T>
</div>
<div>
{used.map((u) => [tx(u.label), u.key && formatBinding(u.key)].filter(Boolean).join(" ")).join(" ")}
</div>
</>
)}
<div>
<button type="button" className={styles.link} onClick={onClear}>
{used.length === 1 ? <T>Clear option</T> : <T>Clear all options</T>}
</button>{" "}
{clearKey}
</div>
</div>
) : null}
</div>
);
}
+7 -6
View File
@@ -3,7 +3,7 @@ import clsx from "clsx";
import { create } from "zustand";
import { executeAction } from "@/platform/actions";
import type { FileMatches } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap";
import {
Button,
EmptyState,
@@ -53,7 +53,7 @@ import {
type FindTab,
} from "./windowStore";
import styles from "./FindToolWindow.module.css";
import { tx } from "@/platform/i18n";
import { t, tx } from "@/platform/i18n";
type Node =
| { kind: "dir"; key: string; label: string; count: number; children: Node[] }
@@ -205,7 +205,7 @@ export function FindToolWindow() {
if (!tab) {
return (
<EmptyState title="No search results" icon="search">
{tx`Use Find in Files (${shortcutLabel("find.inPath") ?? "⇧⌘F"}) and choose “Open in Find Window”.`}
{tx`Use Find in Files (${shortcutLabel("find.inPath") ?? formatBinding(hostBinding("Meta+Shift+F"))}) and choose “Open in Find Window”.`}
</EmptyState>
);
}
@@ -376,8 +376,10 @@ function FindTabView({ tab }: { tab: FindTab }) {
? (st.error ?? tx("Search failed"))
: progress
? tx`Replacing… ${progress.done}/${progress.total} files`
: tx`${`${st.matchCount}${st.summary?.limitHit ? "+" : ""}`} matches in ${st.files.length} files` +
(marks.excluded.size ? ` · ${tx`${marks.excluded.size} excluded`}` : "")}
: t(st.summary?.limitHit ? "search.find.matchesIncomplete" : "search.find.matches", {
matches: st.matchCount,
files: st.files.length,
}) + (marks.excluded.size ? ` · ${tx`${marks.excluded.size} excluded`}` : "")}
</span>
<span className={styles.spacer} />
<Button size="small" variant="ghost" disabled={!selection.size} onClick={exclude} title="Exclude / include (⌫)">
@@ -543,7 +545,6 @@ function FindNodeContent({ node, state, tab }: { node: Node; state: TreeRowState
text={node.text}
ranges={node.ranges}
source={{ path: node.path, line: node.line, col: node.textCol }}
active={state.selected}
replacement={tab.replacement}
query={tab.query}
preserveCase={!!tab.preserveCase}
+5 -4
View File
@@ -2,12 +2,13 @@
white-space: pre;
}
.match {
background: var(--accent-soft);
color: var(--fg-strong);
background: var(--bg-search-match);
color: var(--fg-search-match, inherit);
border-radius: 2px;
}
.matchActive {
background: color-mix(in srgb, var(--accent) 30%, transparent);
/* IDEA paints a hit in one colour over the syntax colours, selected row or not. */
.match :global([class*="tok-"]) {
color: inherit;
}
.removed {
color: var(--fg-subtle);
+1 -3
View File
@@ -19,7 +19,6 @@ import { tx } from "@/platform/i18n";
export function MatchText({
text,
ranges,
active,
replacement,
query,
preserveCase = false,
@@ -27,7 +26,6 @@ export function MatchText({
}: {
text: string;
ranges: ReadonlyArray<readonly [number, number]>;
active?: boolean;
replacement?: string | null;
query?: Pick<SearchQuery, "pattern" | "isRegex" | "caseSensitive"> | null;
preserveCase?: boolean;
@@ -51,7 +49,7 @@ export function MatchText({
);
} else {
parts.push(
<mark key={i} className={clsx(styles.match, active && styles.matchActive)}>
<mark key={i} className={styles.match}>
{tokenSpans(text, s, e, tokens, "")}
</mark>,
);
+65 -3
View File
@@ -1,6 +1,13 @@
import { create } from "zustand";
import type { ContextKeys } from "@/platform/context";
import type { ProjectFileSet, SearchContext, SearchQuery, SearchScope } from "@/platform/ipc";
import {
call,
commands,
type ProjectFileSet,
type SearchContext,
type SearchQuery,
type SearchScope,
} from "@/platform/ipc";
import { SEARCH_CONTEXTS, customScopes, findCustomScope, rememberSearch, searchHistory } from "@/platform/search";
import { loadJson, saveJson } from "../common/open";
@@ -52,9 +59,12 @@ interface FindPopupState extends FindOptions {
session: number;
/** Title-bar pin: the popup stays open when a result is opened or the user clicks outside. */
pinned: boolean;
/** IDEA's "Open results in new tab": Open in Find Window keeps the active Find tab. */
newTab: boolean;
}
const PINNED_KEY = "openidea.search.findPinned.v1";
const NEW_TAB_KEY = "openidea.search.findNewTab.v1";
export const useFindPopup = create<FindPopupState>(() => ({
open: false,
@@ -74,6 +84,7 @@ export const useFindPopup = create<FindPopupState>(() => ({
namedScope: "projectFiles",
context: "any",
pinned: loadJson<unknown>(PINNED_KEY, false) === true,
newTab: loadJson<unknown>(NEW_TAB_KEY, false) === true,
}));
export function setFindPopupPinned(pinned: boolean) {
@@ -81,6 +92,35 @@ export function setFindPopupPinned(pinned: boolean) {
saveJson(PINNED_KEY, pinned);
}
export function setFindPopupNewTab(newTab: boolean) {
useFindPopup.setState({ newTab });
saveJson(NEW_TAB_KEY, newTab);
}
const SIZE_KEY = "openidea.search.findPopupSize.v1";
const SPLIT_KEY = "openidea.search.findPopupSplit.v1";
/** The popup's size and its results / preview split, remembered like IDEA's. */
export function loadFindPopupSize(): { w: number; h: number } | null {
const v = loadJson<{ w?: unknown; h?: unknown } | null>(SIZE_KEY, null);
return typeof v?.w === "number" && typeof v.h === "number" ? { w: v.w, h: v.h } : null;
}
export function saveFindPopupSize(size: { w: number; h: number }) {
saveJson(SIZE_KEY, size);
}
export function loadFindPopupSplit(): Record<string, number> | undefined {
const v = loadJson<unknown>(SPLIT_KEY, null);
return v && typeof v === "object" && Object.values(v).every((n) => typeof n === "number")
? (v as Record<string, number>)
: undefined;
}
export function saveFindPopupSplit(layout: Record<string, number>) {
saveJson(SPLIT_KEY, layout);
}
/**
* Keys the popup handles that are also global shortcuts (⌘G / ⇧⌘G, F4 / ⌘↓, ⌥↓):
* hidden actions bound later in the keymap win while the popup is open and
@@ -138,12 +178,34 @@ export function hideFindPopup() {
}
const MASKS_KEY = "openidea.search.fileMasks.v1";
const DEFAULT_MASKS = ["*.ts,*.tsx", "*.rs", "*.kt", "*.java", "*.py", "*.go", "*.md", "*.json", "!*.test.*"];
const PROJECT_MASKS = 5;
/**
* `*.ext` masks of the project's most common language file types. IDEA seeds its
* mask list with the IDE's languages; a multi-language IDE takes the project's.
*/
export const useProjectMasks = create<{ masks: string[] }>(() => ({ masks: [] }));
export async function refreshProjectMasks() {
try {
const [counts, languages] = await Promise.all([call(commands.indexFileExtensions()), commands.languages()]);
const known = new Set(languages.flatMap((l) => l.extensions.map((e) => e.toLowerCase())));
const masks = counts
.filter((c) => known.has(c.extension))
.slice(0, PROJECT_MASKS)
.map((c) => `*.${c.extension}`);
useProjectMasks.setState({ masks });
} catch {
/* no project index (welcome screen, tests) */
}
}
/** Masks to offer: the ones used recently, then the project's file types. */
export function recentMasks(): string[] {
const v = loadJson<unknown>(MASKS_KEY, []);
const recent = Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
return [...recent, ...DEFAULT_MASKS.filter((m) => !recent.includes(m))];
const project = useProjectMasks.getState().masks;
return [...recent, ...project.filter((m) => !recent.includes(m))];
}
export function rememberMask(mask: string) {
+21 -13
View File
@@ -49,19 +49,25 @@ function patchTab(id: number, patch: Partial<FindTab>) {
useFindWindow.setState((s) => ({ tabs: s.tabs.map((t) => (t.id === id ? { ...t, ...patch } : t)) }));
}
export interface OpenInFindWindowOptions {
replacement?: string | null;
preserveCase?: boolean;
/** IDEA's "Open results in new tab": keep the active tab instead of replacing it. */
newTab?: boolean;
deps?: FindDeps;
}
export function openInFindWindow(
query: SearchQuery,
title: string,
replacement: string | null = null,
deps = ipcFindDeps(),
preserveCase = false,
{ replacement = null, preserveCase = false, newTab = false, deps = ipcFindDeps() }: OpenInFindWindowOptions = {},
) {
const id = nextTab++;
const session = new FindSession(deps, (state) => patchTab(id, { state }));
sessions.set(id, session);
useFindWindow.setState((s) => {
const tab: FindTab = { id, title, query, replacement, preserveCase, state: IDLE };
const reused = s.tabs.find((t) => t.id === s.active && !t.pinned);
const reused = newTab ? undefined : s.tabs.find((t) => t.id === s.active && !t.pinned);
if (reused) {
sessions.get(reused.id)?.dispose();
sessions.delete(reused.id);
@@ -82,15 +88,17 @@ export function openInFindWindow(
/** Use the normal Find session/tab lifecycle for live editor results. */
export function openEditorResults(source: FindResultsSource) {
const id = openInFindWindow(source.query, source.title, null, {
validate: async () => {},
nextRequestId,
cancel: () => {},
start: async (_request, _query, onFile) => {
const file = source.read();
const matches = countFileMatches(file);
if (matches) onFile(file);
return { files: matches ? 1 : 0, matches, limitHit: false, cancelled: false, elapsedMs: 0 };
const id = openInFindWindow(source.query, source.title, {
deps: {
validate: async () => {},
nextRequestId,
cancel: () => {},
start: async (_request, _query, onFile) => {
const file = source.read();
const matches = countFileMatches(file);
if (matches) onFile(file);
return { files: matches ? 1 : 0, matches, limitHit: false, cancelled: false, elapsedMs: 0 };
},
},
});
patchTab(id, { editorResults: true });
+5 -5
View File
@@ -6,7 +6,7 @@ import { IconButton, SearchField } from "@/ui";
import type { TerminalSearchOptions, TerminalSearchResult } from "./session";
import { activeSession } from "./store";
import styles from "./Terminal.module.css";
import { hasPrimaryModifier } from "@/platform/keymap";
import { formatBinding, hasPrimaryModifier } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
export const useTerminalFind = create<{ open: boolean; seq: number; query: string; options: TerminalSearchOptions }>(
@@ -125,7 +125,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
shortcut: formatBinding("Alt+C"),
pressed: options.caseSensitive,
onToggle: () => toggle("caseSensitive"),
},
@@ -133,7 +133,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
shortcut: formatBinding("Alt+W"),
pressed: options.wholeWord,
onToggle: () => toggle("wholeWord"),
},
@@ -141,7 +141,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
shortcut: formatBinding("Alt+X"),
pressed: options.regex,
onToggle: () => toggle("regex"),
},
@@ -153,7 +153,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
<IconButton
icon="arrow-up"
title="Previous Occurrence"
shortcut="⇧↵"
shortcut={formatBinding("Shift+Enter")}
size="small"
disabled={!query}
onClick={() => find("previous")}
+2
View File
@@ -450,5 +450,7 @@
"menu.editor.contextInfo": "Context Info",
"run.services.configurationCount": "{count} configurations",
"search.bookmarks.count": "{count, plural, one {# bookmark} other {# bookmarks}}",
"search.find.matches": "{matches, plural, one {# match} other {# matches}} in {files, plural, one {# file} other {# files}}",
"search.find.matchesIncomplete": "{matches, plural, one {#+ match} other {#+ matches}} in {files, plural, one {#+ file} other {#+ files}}",
"settings.zoom.hint": "Change with {zoomIn} or {zoomOut}. Set to 100% with {reset}"
}
+9 -14
View File
@@ -1669,7 +1669,6 @@
"Reopen projects on startup": "Rouvrir les projets au démarrage",
"Replace": "Remplacer",
"Replace All": "Tout remplacer",
"Replace All in This File": "Tout remplacer dans ce fichier",
"Replace Preview": "Aperçu du remplacement",
"Replace Selected": "Remplacer la sélection",
"Replace Structurally": "Remplacement structurel",
@@ -1679,7 +1678,6 @@
"Replace in Files": "Remplacer dans les fichiers",
"Replace in Files…": "Remplacer dans les fichiers…",
"Replace in file": "Remplacer dans le fichier",
"Replace preview": "Aperçu du remplacement",
"Replace template": "Modèle de remplacement",
"Replace this occurrence only": "Remplacer cette occurrence uniquement",
"Replace with": "Remplacer par",
@@ -1816,7 +1814,7 @@
"Runs the language server's Organize Imports action.": "Exécute l'action Organiser les imports du serveur de langage.",
"Runs the language server's formatter before an explicit save (Actions on Save).": "Exécute le formateur du serveur de langage avant un enregistrement explicite (Actions à l'enregistrement).",
"Search history": "Historique de recherche",
"Search history (⌥↑ / ⌥↓)": "Historique de recherche (⌥↑ / ⌥↓)",
"Search history ({previous} / {next})": "Historique de recherche ({previous} / {next})",
"Searching for usages of '{symbol}'": "Recherche des utilisations de '{symbol}'",
"SQL completion": "Complétion SQL",
"SQL Inserts": "Insertions SQL",
@@ -2437,8 +2435,7 @@
"{name} has unsaved changes": "{name} contient des modifications non enregistrées",
"{name} installed": "{name} installé",
"{name} is not installed": "{name} n'est pas installé",
"↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window": "↵ Ouvrir · ⇧↵ Ouvrir sur le côté · ⌘↵ Ouvrir dans la fenêtre de recherche",
"⌘↵ to open in Find tool window": "⌘↵ pour ouvrir dans la fenêtre d'outil Recherche",
"{key} to open in Find tool window": "{key} pour ouvrir dans la fenêtre d'outil Recherche",
"⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.": "⌥← / ⌥→ et ⌥⌫ s'arrêtent aux bosses des noms en camelCase.",
"Specify commit message": "Saisir le message de commit",
"Author:": "Auteur :",
@@ -2536,7 +2533,6 @@
"Except Comments and String Literals": "Sauf les commentaires et les chaînes littérales",
"Recent Replacements": "Remplacements récents",
"No recent replacements": "Aucun remplacement récent",
"↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window": "↵ Remplacer la sélection · ⇧⌥↵ Tout remplacer · ⌘↵ Ouvrir dans la fenêtre de recherche",
"Replace {count} Occurrences": "Remplacer {count} occurrences",
"Add Mnemonic Bookmark": "Ajouter un signet mnémonique",
"Change Mnemonic": "Modifier le mnémonique",
@@ -2595,7 +2591,7 @@
"No remotes": "Aucun dépôt distant",
"Include disabled actions": "Inclure les actions désactivées",
"Symbol Kinds": "Types de symboles",
"⌥↵ to assign a shortcut": "⌥↵ pour attribuer un raccourci",
"{key} to assign a shortcut": "{key} pour attribuer un raccourci",
"Open Settings Page": "Ouvrir la page de paramètres",
"Assign Shortcut…": "Attribuer un raccourci…",
"New List…": "Nouvelle liste…",
@@ -2648,10 +2644,10 @@
"Search Scope": "Portée de la recherche",
"Filter Search Results": "Filtrer les résultats de recherche",
"Filter search results": "Filtrer les résultats de recherche",
"Replace (⌥P)": "Remplacer (⌥P)",
"Replace All (⌥A)": "Tout remplacer (⌥A)",
"Include (⌥L)": "Inclure (⌥L)",
"Exclude (⌥L)": "Exclure (⌥L)",
"Replace ({key})": "Remplacer ({key})",
"Replace All ({key})": "Tout remplacer ({key})",
"Include ({key})": "Inclure ({key})",
"Exclude ({key})": "Exclure ({key})",
"Recent searches": "Recherches récentes",
"New Line": "Nouvelle ligne",
"New Line in Search Field": "Nouvelle ligne dans le champ de recherche",
@@ -3656,7 +3652,6 @@
"Godot command line options, e.g. --debug-collisions": "Options de ligne de commande Godot, p. ex. --debug-collisions",
"Bookmark description": "Description du signet",
"Cannot open {path}": "Impossible d'ouvrir {path}",
"Replace All (⇧⌥↵, ⌥A)": "Tout remplacer (⇧⌥↵, ⌥A)",
"Search context": "Contexte de recherche",
"not": "non",
"and": "et",
@@ -4513,7 +4508,6 @@
"Rebase commits of {head} not on:": "Rebaser les commits de {head} absents de :",
"{head} has no upstream: commits up to {revision} will be pushed as a new branch:": "{head} n'a pas de branche amont : les commits jusqu'à {revision} seront poussés comme nouvelle branche :",
"{head} has no upstream: it will be pushed as a new branch and tracked:": "{head} n'a pas de branche amont : elle sera poussée comme nouvelle branche et suivie :",
"{count} matches in {files} files": "{count} correspondances dans {files} fichiers",
"{count} excluded": "{count} exclus",
"{count} file with conflicts": "{count} fichier en conflit",
"{count} files with conflicts": "{count} fichiers en conflit",
@@ -5799,5 +5793,6 @@
"Make abstract": "Rendre abstrait",
"Keep abstract": "Garder abstrait",
"Methods pulled up to an interface are declared abstract there.": "Les méthodes remontées dans une interface y sont déclarées abstraites.",
"The file changed; open the dialog again": "Le fichier a changé ; rouvrez la boîte de dialogue"
"The file changed; open the dialog again": "Le fichier a changé ; rouvrez la boîte de dialogue",
"Replace preview": "Aperçu du remplacement"
}
+9 -14
View File
@@ -1744,7 +1744,6 @@
"Reopen projects on startup": "시작 시 프로젝트 다시 열기",
"Replace": "바꾸기",
"Replace All": "모두 바꾸기",
"Replace All in This File": "이 파일에서 모두 바꾸기",
"Replace Preview": "바꾸기 미리 보기",
"Replace Selected": "선택 항목 바꾸기",
"Replace Structurally": "구조적 바꾸기",
@@ -1754,7 +1753,6 @@
"Replace in Files": "파일에서 바꾸기",
"Replace in Files…": "파일에서 바꾸기…",
"Replace in file": "파일에서 바꾸기",
"Replace preview": "바꾸기 미리 보기",
"Replace template": "바꾸기 템플릿",
"Replace this occurrence only": "이 항목만 바꾸기",
"Replace with": "바꿀 내용",
@@ -1894,7 +1892,7 @@
"Runs the language server's Organize Imports action.": "언어 서버의 import 정리 액션을 실행합니다.",
"Runs the language server's formatter before an explicit save (Actions on Save).": "명시적으로 저장하기 전에 언어 서버의 포매터를 실행합니다(저장 시 액션).",
"Search history": "검색 기록",
"Search history (⌥↑ / ⌥↓)": "검색 기록(⌥↑ / ⌥↓)",
"Search history ({previous} / {next})": "검색 기록({previous} / {next})",
"Searching for usages of '{symbol}'": "'{symbol}'의 사용 위치 검색 중",
"SQL completion": "SQL 완성",
"SQL Inserts": "SQL Insert 문",
@@ -2523,8 +2521,7 @@
"{name} has unsaved changes": "{name}에 저장되지 않은 변경 사항이 있습니다",
"{name} installed": "{name} 설치됨",
"{name} is not installed": "{name}이(가) 설치되어 있지 않습니다",
"↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window": "↵ 열기 · ⇧↵ 옆에 열기 · ⌘↵ 찾기 창에서 열기",
"⌘↵ to open in Find tool window": "⌘↵ 찾기 도구 창에서 열기",
"{key} to open in Find tool window": "{key} 찾기 도구 창에서 열기",
"⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.": "⌥← / ⌥→ 및 ⌥⌫가 camelCase 이름의 대문자 경계에서 멈춥니다.",
"Specify commit message": "커밋 메시지를 입력하세요",
"Author:": "작성자:",
@@ -2622,7 +2619,6 @@
"Except Comments and String Literals": "주석 및 문자열 리터럴 제외",
"Recent Replacements": "최근 바꾸기",
"No recent replacements": "최근 바꾸기 없음",
"↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window": "↵ 선택 항목 바꾸기 · ⇧⌥↵ 모두 바꾸기 · ⌘↵ 찾기 창에서 열기",
"Replace {count} Occurrences": "{count}개 항목 바꾸기",
"Add Mnemonic Bookmark": "니모닉 북마크 추가",
"Change Mnemonic": "니모닉 변경",
@@ -2681,7 +2677,7 @@
"No remotes": "원격 없음",
"Include disabled actions": "비활성화된 액션 포함",
"Symbol Kinds": "심볼 종류",
"⌥↵ to assign a shortcut": "⌥↵ 단축키 할당",
"{key} to assign a shortcut": "{key} 단축키 할당",
"Open Settings Page": "설정 페이지 열기",
"Assign Shortcut…": "단축키 할당…",
"New List…": "새 목록…",
@@ -2734,10 +2730,10 @@
"Search Scope": "검색 범위",
"Filter Search Results": "검색 결과 필터링",
"Filter search results": "검색 결과 필터링",
"Replace (⌥P)": "바꾸기(⌥P)",
"Replace All (⌥A)": "모두 바꾸기(⌥A)",
"Include (⌥L)": "포함(⌥L)",
"Exclude (⌥L)": "제외(⌥L)",
"Replace ({key})": "바꾸기({key})",
"Replace All ({key})": "모두 바꾸기({key})",
"Include ({key})": "포함({key})",
"Exclude ({key})": "제외({key})",
"Recent searches": "최근 검색",
"New Line": "새 줄",
"New Line in Search Field": "검색 필드에 새 줄",
@@ -3769,7 +3765,6 @@
"Godot command line options, e.g. --debug-collisions": "Godot 명령줄 옵션, 예: --debug-collisions",
"Bookmark description": "북마크 설명",
"Cannot open {path}": "{path}을(를) 열 수 없습니다",
"Replace All (⇧⌥↵, ⌥A)": "모두 바꾸기(⇧⌥↵, ⌥A)",
"Search context": "검색 컨텍스트",
"not": "아님",
"and": "그리고",
@@ -4630,7 +4625,6 @@
"Rebase commits of {head} not on:": "다음에 없는 {head}의 커밋 리베이스:",
"{head} has no upstream: commits up to {revision} will be pushed as a new branch:": "{head}에 업스트림이 없습니다. {revision}까지의 커밋이 새 브랜치로 푸시됩니다:",
"{head} has no upstream: it will be pushed as a new branch and tracked:": "{head}에 업스트림이 없습니다. 새 브랜치로 푸시되고 추적됩니다:",
"{count} matches in {files} files": "파일 {files}개에서 일치 항목 {count}개",
"{count} excluded": "{count}개 제외됨",
"{count} file with conflicts": "충돌이 있는 파일 {count}개",
"{count} files with conflicts": "충돌이 있는 파일 {count}개",
@@ -5851,5 +5845,6 @@
"Make abstract": "추상으로 만들기",
"Keep abstract": "추상 선언 유지",
"Methods pulled up to an interface are declared abstract there.": "인터페이스로 끌어올린 메서드는 그곳에 추상 메서드로 선언됩니다.",
"The file changed; open the dialog again": "파일이 변경되었습니다. 대화상자를 다시 여세요"
"The file changed; open the dialog again": "파일이 변경되었습니다. 대화상자를 다시 여세요",
"Replace preview": "바꾸기 미리 보기"
}
-12
View File
@@ -1747,7 +1747,6 @@
"Reopen projects on startup": "Reabrir projetos ao iniciar",
"Replace": "Substituir",
"Replace All": "Substituir tudo",
"Replace All in This File": "Substituir tudo neste arquivo",
"Replace Preview": "Pré-visualização da substituição",
"Replace Selected": "Substituir selecionados",
"Replace Structurally": "Substituição estrutural",
@@ -1897,7 +1896,6 @@
"Runs the language server's Organize Imports action.": "Executa a ação Organizar importações do servidor de linguagem.",
"Runs the language server's formatter before an explicit save (Actions on Save).": "Executa o formatador do servidor de linguagem antes de um salvamento explícito (Ações ao salvar).",
"Search history": "Histórico de pesquisa",
"Search history (⌥↑ / ⌥↓)": "Histórico de pesquisa (⌥↑ / ⌥↓)",
"Searching for usages of '{symbol}'": "Pesquisando usos de '{symbol}'",
"SQL completion": "Preenchimento SQL",
"SQL Inserts": "Inserções SQL",
@@ -2522,8 +2520,6 @@
"{name} has unsaved changes": "{name} tem alterações não salvas",
"{name} installed": "{name} instalado",
"{name} is not installed": "{name} não está instalado",
"↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window": "↵ Abrir · ⇧↵ Abrir ao lado · ⌘↵ Abrir na Janela Localizar",
"⌘↵ to open in Find tool window": "⌘↵ para abrir na janela de ferramentas Localizar",
"⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.": "⌥← / ⌥→ e ⌥⌫ param nas maiúsculas de nomes em camelCase.",
"Specify commit message": "Especificar mensagem de commit",
"Author:": "Autor:",
@@ -2621,7 +2617,6 @@
"Except Comments and String Literals": "Exceto comentários e literais de string",
"Recent Replacements": "Substituições recentes",
"No recent replacements": "Nenhuma substituição recente",
"↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window": "↵ Substituir selecionado · ⇧⌥↵ Substituir tudo · ⌘↵ Abrir na Janela Localizar",
"Replace {count} Occurrences": "Substituir {count} ocorrências",
"Add Mnemonic Bookmark": "Adicionar marcador mnemônico",
"Change Mnemonic": "Alterar mnemônico",
@@ -2680,7 +2675,6 @@
"No remotes": "Nenhum remoto",
"Include disabled actions": "Incluir ações desativadas",
"Symbol Kinds": "Tipos de símbolo",
"⌥↵ to assign a shortcut": "⌥↵ para atribuir um atalho",
"Open Settings Page": "Abrir página de configurações",
"Assign Shortcut…": "Atribuir atalho…",
"New List…": "Nova lista…",
@@ -2733,10 +2727,6 @@
"Search Scope": "Escopo de pesquisa",
"Filter Search Results": "Filtrar resultados da pesquisa",
"Filter search results": "Filtrar resultados da pesquisa",
"Replace (⌥P)": "Substituir (⌥P)",
"Replace All (⌥A)": "Substituir tudo (⌥A)",
"Include (⌥L)": "Incluir (⌥L)",
"Exclude (⌥L)": "Excluir (⌥L)",
"Recent searches": "Pesquisas recentes",
"New Line": "Nova linha",
"New Line in Search Field": "Nova linha no campo de pesquisa",
@@ -3768,7 +3758,6 @@
"Godot command line options, e.g. --debug-collisions": "Opções de linha de comando do Godot, p. ex. --debug-collisions",
"Bookmark description": "Descrição do favorito",
"Cannot open {path}": "Não é possível abrir {path}",
"Replace All (⇧⌥↵, ⌥A)": "Substituir tudo (⇧⌥↵, ⌥A)",
"Search context": "Contexto de pesquisa",
"not": "não",
"and": "e",
@@ -4636,7 +4625,6 @@
"Rebase commits of {head} not on:": "Fazer rebase dos commits de {head} ausentes em:",
"{head} has no upstream: commits up to {revision} will be pushed as a new branch:": "{head} não tem upstream: commits até {revision} serão enviados com push como uma nova branch:",
"{head} has no upstream: it will be pushed as a new branch and tracked:": "{head} não tem upstream: ela será enviada com push como uma nova branch e rastreada:",
"{count} matches in {files} files": "{count} correspondências em {files} arquivos",
"{count} excluded": "{count} excluídos",
"{count} file with conflicts": "{count} arquivo com conflitos",
"{count} files with conflicts": "{count} arquivos com conflitos",
+21 -15
View File
@@ -1033,7 +1033,7 @@
"Implement Members": "实现成员",
"Implementation(s)": "实现",
"Imports": "导入",
"In Project": "在项目中",
"In Project": "在项目",
"Include": "包括",
"Include non-project items": "包括非项目项",
"Include untracked files": "包括未跟踪的文件",
@@ -1753,7 +1753,6 @@
"Reopen projects on startup": "启动时重新打开项目",
"Replace": "替换",
"Replace All": "全部替换",
"Replace All in This File": "在此文件中全部替换",
"Replace Preview": "替换预览",
"Replace Selected": "替换所选内容",
"Replace Structurally": "结构化替换",
@@ -1763,7 +1762,6 @@
"Replace in Files": "在文件中替换",
"Replace in Files…": "在文件中替换…",
"Replace in file": "在文件中替换",
"Replace preview": "替换预览",
"Replace template": "替换模板",
"Replace this occurrence only": "仅替换此匹配项",
"Replace with": "替换为",
@@ -1903,7 +1901,7 @@
"Runs the language server's Organize Imports action.": "运行语言服务器的“整理导入”操作。",
"Runs the language server's formatter before an explicit save (Actions on Save).": "在显式保存前运行语言服务器的格式化程序(保存时操作)。",
"Search history": "搜索历史",
"Search history (⌥↑ / ⌥↓)": "搜索历史(⌥↑ / ⌥↓)",
"Search history ({previous} / {next})": "搜索历史({previous} / {next})",
"Searching for usages of '{symbol}'": "正在搜索 '{symbol}' 的用法",
"SQL completion": "SQL 补全",
"SQL Inserts": "SQL Insert 语句",
@@ -1926,7 +1924,7 @@
"Save to file:": "保存到文件:",
"Scanning for TODO items…": "正在扫描 TODO 项…",
"Scheme": "方案",
"Scope": "范围",
"Scope": "作用域",
"Scratches": "临时文件",
"Script": "脚本",
"Scroll to End": "滚动到末尾",
@@ -2539,8 +2537,7 @@
"{name} has unsaved changes": "{name} 有未保存的更改",
"{name} installed": "{name} 已安装",
"{name} is not installed": "{name} 未安装",
"↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window": "↵ 打开 · ⇧↵ 在侧边打开 · ⌘↵ 在查找窗口中打开",
"⌘↵ to open in Find tool window": "⌘↵ 在“查找”工具窗口中打开",
"{key} to open in Find tool window": "{key} 在“查找”工具窗口中打开",
"⌥← / ⌥→ and ⌥⌫ stop at humps of camelCase names.": "⌥← / ⌥→ 和 ⌥⌫ 会在 camelCase 名称的驼峰处停止。",
"Specify commit message": "请填写提交信息",
"Author:": "作者:",
@@ -2638,7 +2635,6 @@
"Except Comments and String Literals": "注释和字符串字面量除外",
"Recent Replacements": "最近的替换",
"No recent replacements": "没有最近的替换",
"↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window": "↵ 替换所选 · ⇧⌥↵ 全部替换 · ⌘↵ 在查找窗口中打开",
"Replace {count} Occurrences": "替换 {count} 处",
"Add Mnemonic Bookmark": "添加助记符书签",
"Change Mnemonic": "更改助记符",
@@ -2697,7 +2693,7 @@
"No remotes": "无远程",
"Include disabled actions": "包括禁用的操作",
"Symbol Kinds": "符号类型",
"⌥↵ to assign a shortcut": "⌥↵ 分配快捷键",
"{key} to assign a shortcut": "{key} 分配快捷键",
"Open Settings Page": "打开设置页面",
"Assign Shortcut…": "分配快捷键…",
"New List…": "新建列表…",
@@ -2750,10 +2746,10 @@
"Search Scope": "搜索范围",
"Filter Search Results": "过滤搜索结果",
"Filter search results": "过滤搜索结果",
"Replace (⌥P)": "替换 (⌥P)",
"Replace All (⌥A)": "全部替换 (⌥A)",
"Include (⌥L)": "包括 (⌥L)",
"Exclude (⌥L)": "排除 (⌥L)",
"Replace ({key})": "替换 ({key})",
"Replace All ({key})": "全部替换 ({key})",
"Include ({key})": "包括 ({key})",
"Exclude ({key})": "排除 ({key})",
"Recent searches": "最近的搜索",
"New Line": "新行",
"New Line in Search Field": "在搜索字段中插入新行",
@@ -3786,7 +3782,6 @@
"Godot command line options, e.g. --debug-collisions": "Godot 命令行选项,例如 --debug-collisions",
"Bookmark description": "书签描述",
"Cannot open {path}": "无法打开 {path}",
"Replace All (⇧⌥↵, ⌥A)": "全部替换 (⇧⌥↵, ⌥A)",
"Search context": "搜索上下文",
"not": "非",
"and": "与",
@@ -4661,7 +4656,6 @@
"Rebase commits of {head} not on:": "变基 {head} 中不在以下分支上的提交:",
"{head} has no upstream: commits up to {revision} will be pushed as a new branch:": "{head} 没有上游分支:截至 {revision} 的提交将作为新分支推送:",
"{head} has no upstream: it will be pushed as a new branch and tracked:": "{head} 没有上游分支:它将作为新分支推送并被跟踪:",
"{count} matches in {files} files": "{files} 个文件中有 {count} 个匹配项",
"{count} excluded": "已排除 {count} 项",
"{count} file with conflicts": "{count} 个文件存在冲突",
"{count} files with conflicts": "{count} 个文件存在冲突",
@@ -6078,6 +6072,18 @@
"Keep abstract": "保留抽象声明",
"Methods pulled up to an interface are declared abstract there.": "上拉到接口的方法将在接口中声明为抽象方法。",
"The file changed; open the dialog again": "文件已更改,请重新打开对话框",
"Type search query to find in files": "输入搜索内容以在文件中查找",
"Invalid pattern": "模式无效",
"Searching…": "正在搜索…",
"No search text entered": "没有输入搜索内容",
"Search with Regex": "使用正则表达式搜索",
"Search option used: {label}": "搜索已用选项:{label}",
"Search options used:": "搜索已用选项:",
"Clear option": "清除选项",
"Clear all options": "清除所有选项",
"Open results in new tab": "在新标签打开",
"Use {next} and {previous} to select usages": "使用 {next} 和 {previous} 选择用法",
"Replace preview": "替换预览",
"Bookmarked Commits": "已添加书签的提交",
"Open Another Log Tab": "打开另一个日志标签页",
"In Tool Window": "在工具窗口中",
+2
View File
@@ -450,5 +450,7 @@
"menu.editor.contextInfo": "上下文信息",
"run.services.configurationCount": "{count} 个配置",
"search.bookmarks.count": "{count} 个书签",
"search.find.matches": "在 {files} 个文件中有 {matches} 个匹配项",
"search.find.matchesIncomplete": "在 {files}+ 个文件中有 {matches}+ 个匹配项",
"settings.zoom.hint": "使用 {zoomIn} 或 {zoomOut} 更改,使用 {reset} 重置为 100%"
}
+43
View File
@@ -125,6 +125,49 @@ describe("Dialog chrome", () => {
expect(dialog.style.translate).toBe("190px 30px");
});
it("resizes a top popup by its corner, keeping its top edge, and reports the size", () => {
const onResized = vi.fn();
render(
<Dialog
open
onClose={() => {}}
position="top"
aria-label="Find in Files"
width={400}
resizable="both"
minWidth={200}
minHeight={100}
onResized={onResized}
>
<div>rows</div>
</Dialog>,
);
const dialog = screen.getByRole("dialog");
fireEvent.pointerDown(screen.getByRole("separator", { name: "Resize" }), { button: 0, clientX: 0, clientY: 0 });
fireEvent.pointerMove(window, { buttons: 1, clientX: 500, clientY: 300 });
fireEvent.pointerUp(window);
expect([dialog.style.width, dialog.style.height]).toEqual(["500px", "300px"]);
expect(dialog.style.translate).toBe("250px 0px");
expect(onResized).toHaveBeenCalledExactlyOnceWith({ w: 500, h: 300 });
});
it("opens a resizable popup at a remembered size", () => {
render(
<Dialog
open
onClose={() => {}}
position="top"
aria-label="Find in Files"
resizable="both"
initialSize={{ w: 600, h: 400 }}
>
<div>rows</div>
</Dialog>,
);
const dialog = screen.getByRole("dialog");
expect([dialog.style.width, dialog.style.height]).toEqual(["600px", "400px"]);
});
it("still drags a window dialog by its title bar", () => {
render(
<Dialog open onClose={() => {}} title="Usages Detected" buttons={safeDelete()} platform="other">
+34 -10
View File
@@ -110,10 +110,17 @@ export interface DialogProps {
footer?: ReactNode;
width?: number | string;
/**
* `both` shows a corner grip resizing the window. The content lays out again
* against the new size; a numeric `width` is also the minimum width.
* `both` shows a corner grip resizing the window or popup. The content lays out
* again against the new size; a numeric `width` is also the minimum width
* unless `minWidth` says otherwise.
*/
resizable?: "both";
minWidth?: number;
minHeight?: number;
/** Size to open at, such as one remembered from `onResized`; kept inside the window. */
initialSize?: { w: number; h: number } | null;
/** The size a resize ended at. */
onResized?: (size: { w: number; h: number }) => void;
/**
* `top` places a lightweight modal popup (choosers, Search Everywhere) near
* the top. Popups (also any dialog without a title) get no window title bar
@@ -201,6 +208,10 @@ function DialogInner({
footer,
width = 480,
resizable,
minWidth,
minHeight,
initialSize,
onResized,
position = "center",
className,
dismissOnBackdrop,
@@ -224,7 +235,11 @@ function DialogInner({
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [active, setActive] = useState(() => typeof document === "undefined" || document.hasFocus());
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
const [size, setSize] = useState<{ w: number; h: number } | null>(() =>
resizable && initialSize && typeof window !== "undefined"
? { w: Math.min(initialSize.w, window.innerWidth - 24), h: Math.min(initialSize.h, window.innerHeight - 24) }
: null,
);
useEffect(() => {
const onFocus = () => setActive(true);
@@ -318,7 +333,7 @@ function DialogInner({
const label = aria["aria-label"] ?? (typeof title === "string" ? title : undefined);
const hasFooter = !!(buttons?.length || footerStart || onHelp || footer);
const canResize = resizable === "both" && !popup;
const canResize = resizable === "both";
const resize = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
@@ -327,21 +342,30 @@ function DialogInner({
e.preventDefault();
const start = { x: e.clientX, y: e.clientY };
const rect = el.getBoundingClientRect();
const minW = typeof width === "number" ? width : 320;
const minW = minWidth ?? (typeof width === "number" ? width : 320);
const chrome = el.querySelector<HTMLElement>("[data-dialog-titlebar]")?.offsetHeight ?? 0;
const minH = chrome + 120;
const minH = minHeight ?? chrome + 120;
const origin = offset;
let last: { w: number; h: number } | null = null;
const move = (ev: globalThis.PointerEvent) => {
if (!(ev.buttons & 1)) return up();
const w = Math.min(Math.max(rect.width + ev.clientX - start.x, minW), window.innerWidth - 24);
const h = Math.min(Math.max(rect.height + ev.clientY - start.y, minH), window.innerHeight - 24);
setSize({ w, h });
// The overlay centres the dialog: shift by half the growth so its top-left stays put.
setOffset({ x: origin.x + (w - rect.width) / 2, y: origin.y + (h - rect.height) / 2 });
// A `top` popup grows downwards from where it sits.
const maxH = position === "top" ? window.innerHeight - rect.top - 12 : window.innerHeight - 24;
const h = Math.min(Math.max(rect.height + ev.clientY - start.y, minH), maxH);
last = { w, h };
setSize(last);
// The overlay centres the dialog (a `top` popup only horizontally): shift by half
// the growth so its top-left stays put.
setOffset({
x: origin.x + (w - rect.width) / 2,
y: position === "top" ? origin.y : origin.y + (h - rect.height) / 2,
});
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (last) onResized?.(last);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
+3 -2
View File
@@ -15,6 +15,7 @@ import { IconButton } from "./Button";
import styles from "./Input.module.css";
import { setContext } from "@/platform/context";
import { useTx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
invalid?: boolean;
@@ -199,7 +200,7 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
<IconButton
icon="search-history"
title="Recent Searches"
shortcut="⌥↓"
shortcut={shortcutLabel("find.field.recentSearches")}
size="small"
iconSize={iconSize}
tabIndex={-1}
@@ -253,7 +254,7 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
<IconButton
icon="new-line"
title="New Line"
shortcut="⇧⌘↵"
shortcut={shortcutLabel("find.field.newLine")}
size="small"
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { usePlatform } from "@/platform/platformInfo";
import { Mnemonic } from "./Mnemonic";
const os = usePlatform.getState().os;
afterEach(() => usePlatform.setState({ os }));
const html = (text: string, char: string) =>
render(<Mnemonic text={text} char={char} />).container.querySelector("span")!.innerHTML;
describe("Mnemonic", () => {
it.each([
["In Project", "P", "In <u>P</u>roject"],
["Replace All", "A", "Replace <u>A</u>ll"],
["File mask:", "K", "File mas<u>k</u>:"],
["Open results in new tab", "B", "Open results in new ta<u>b</u>"],
["在项目", "P", "在项目 (<u>P</u>)"],
["文件掩码:", "K", "文件掩码 (<u>K</u>):"],
])("underlines %s's %s on Windows and Linux", (text, char, expected) => {
usePlatform.setState({ os: "linux" });
expect(html(text, char)).toBe(expected);
});
it("is plain text on macOS", () => {
usePlatform.setState({ os: "macos" });
expect(html("在项目", "P")).toBe("在项目");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { usePlatform } from "@/platform/platformInfo";
/** Where the mnemonic letter sits in `text`: an upper-case match first ("Replace &All"), else any. */
export function mnemonicIndex(text: string, char: string): number {
const upper = text.indexOf(char.toUpperCase());
return upper >= 0 ? upper : text.toLowerCase().indexOf(char.toLowerCase());
}
/**
* A control label with its Alt+letter mnemonic as IDEA draws it on Windows and Linux:
* the letter underlined, or `(X)` added to text without it (CJK translations).
* macOS has no mnemonics. `text` is already translated. One span, so a flex
* container (a button) doesn't space its pieces apart.
*/
export function Mnemonic({ text, char }: { text: string; char: string }) {
const mac = usePlatform((p) => p.os === "macos");
if (mac) return <span>{text}</span>;
const at = mnemonicIndex(text, char);
if (at >= 0)
return (
<span>
{text.slice(0, at)}
<u>{text[at]}</u>
{text.slice(at + 1)}
</span>
);
// "文件掩码:" → "文件掩码 (K):"
const colon = /[::]$/.exec(text)?.[0] ?? "";
return (
<span>
{text.slice(0, text.length - colon.length)} (<u>{char.toUpperCase()}</u>){colon}
</span>
);
}
+5
View File
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 5C12.4328 5 13.9999 6.56719 14 8.5C13.9999 10.4328 12.4328 12 10.5 12H3.70703L5.85352 14.1465C6.04862 14.3417 6.04858 14.6583 5.85352 14.8535C5.65828 15.0487 5.34172 15.0486 5.14648 14.8535L2.14648 11.8535C1.95141 11.6583 1.95135 11.3417 2.14648 11.1465L5.14648 8.14648C5.34169 7.95147 5.6583 7.9515 5.85352 8.14648C6.04871 8.34169 6.0486 8.65824 5.85352 8.85352L3.70703 11H10.5C11.8805 11 12.9999 9.88052 13 8.5C12.9999 7.11948 11.8805 6 10.5 6H2.5C2.22396 5.99994 2.00008 5.77604 2 5.5C2.0002 5.22406 2.22403 5.00006 2.5 5H10.5Z" fill="#6C707E"/>
<path d="M13.5 2C13.7761 2 14 2.22386 14 2.5C14 2.77614 13.7761 3 13.5 3H2.5C2.22386 3 2 2.77614 2 2.5C2 2.22386 2.22386 2 2.5 2H13.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 947 B

+5
View File
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 5C12.4328 5 13.9999 6.56719 14 8.5C13.9999 10.4328 12.4328 12 10.5 12H3.70703L5.85352 14.1465C6.04862 14.3417 6.04858 14.6583 5.85352 14.8535C5.65828 15.0487 5.34172 15.0486 5.14648 14.8535L2.14648 11.8535C1.95141 11.6583 1.95135 11.3417 2.14648 11.1465L5.14648 8.14648C5.34169 7.95147 5.6583 7.9515 5.85352 8.14648C6.04871 8.34169 6.0486 8.65824 5.85352 8.85352L3.70703 11H10.5C11.8805 11 12.9999 9.88052 13 8.5C12.9999 7.11948 11.8805 6 10.5 6H2.5C2.22396 5.99994 2.00008 5.77604 2 5.5C2.0002 5.22406 2.22403 5.00006 2.5 5H10.5Z" fill="#CED0D6"/>
<path d="M13.5 2C13.7761 2 14 2.22386 14 2.5C14 2.77614 13.7761 3 13.5 3H2.5C2.22386 3 2 2.77614 2 2.5C2 2.22386 2.22386 2 2.5 2H13.5Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 947 B

+6
View File
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.3695 14H0.1815L3.0085 6.3H4.213L7.139 14H5.984L5.28 12.042H2.013L1.3695 14ZM3.476 7.741L2.321 11.1235H4.95L3.729 7.741L3.6025 7.268L3.476 7.741Z" fill="#6C707E"/>
<path d="M9.8695 14H8.6815L11.5085 6.3H12.713L15.639 14H14.484L13.78 12.042H10.513L9.8695 14ZM11.976 7.741L10.821 11.1235H13.45L12.229 7.741L12.1025 7.268L11.976 7.741Z" fill="#6C707E"/>
<path d="M7 5.8125L9.40625 3.40625L7 1" stroke="#6C707E" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 674 B

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.2035 14L3.0305 6.3H4.235L7.1555 14H6.0005L5.302 12.042H2.035L1.3805 14H0.2035ZM2.343 11.1235H4.972L3.751 7.763L3.6245 7.29L3.498 7.763L2.343 11.1235Z" fill="#CED0D6"/>
<path d="M8.7035 14L11.5305 6.3H12.735L15.6555 14H14.5005L13.802 12.042H10.535L9.8805 14H8.7035ZM10.843 11.1235H13.472L12.251 7.763L12.1245 7.29L11.998 7.763L10.843 11.1235Z" fill="#CED0D6"/>
<path d="M7 5.8125L9.40625 3.40625L7 1" stroke="#CED0D6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 684 B

+5
View File
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.85355 10.1432C8.65829 9.94789 8.34171 9.94789 8.14645 10.1432C7.95118 10.3384 7.95118 10.655 8.14645 10.8503L8.85355 10.1432ZM11.1457 13.8495C11.3409 14.0447 11.6575 14.0447 11.8528 13.8495C12.048 13.6542 12.048 13.3376 11.8528 13.1424L11.1457 13.8495ZM9 7C9 9.20914 7.20914 11 5 11V12C7.76142 12 10 9.76142 10 7H9ZM5 11C2.79086 11 1 9.20914 1 7H0C0 9.76142 2.23858 12 5 12V11ZM1 7C1 4.79086 2.79086 3 5 3V2C2.23858 2 0 4.23858 0 7H1ZM5 3C7.20914 3 9 4.79086 9 7H10C10 4.23858 7.76142 2 5 2V3ZM8.14645 10.8503L11.1457 13.8495L11.8528 13.1424L8.85355 10.1432L8.14645 10.8503Z" fill="#6C707E"/>
<path d="M11 7L13.5 9.5L16 7H11Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 886 B

@@ -0,0 +1,5 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.85355 10.1432C8.65829 9.94789 8.34171 9.94789 8.14645 10.1432C7.95118 10.3384 7.95118 10.655 8.14645 10.8503L8.85355 10.1432ZM11.1457 13.8495C11.3409 14.0447 11.6575 14.0447 11.8528 13.8495C12.048 13.6542 12.048 13.3376 11.8528 13.1424L11.1457 13.8495ZM9 7C9 9.20914 7.20914 11 5 11V12C7.76142 12 10 9.76142 10 7H9ZM5 11C2.79086 11 1 9.20914 1 7H0C0 9.76142 2.23858 12 5 12V11ZM1 7C1 4.79086 2.79086 3 5 3V2C2.23858 2 0 4.23858 0 7H1ZM5 3C7.20914 3 9 4.79086 9 7H10C10 4.23858 7.76142 2 5 2V3ZM8.14645 10.8503L11.1457 13.8495L11.8528 13.1424L8.85355 10.1432L8.14645 10.8503Z" fill="#CED0D6"/>
<path d="M11 7L13.5 9.5L16 7H11Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 886 B

+3 -3
View File
@@ -218,9 +218,9 @@ const ICONS = {
word: "inline/exactWords",
"text-match": "openidea/nodes/textMatch",
"open-in-tool-window": "general/openInToolWindow",
"preserve-case": "openidea/inline/preserveCase",
"new-line": "openidea/inline/newLine",
"search-history": "openidea/inline/searchHistory",
"preserve-case": "inline/preserveCase",
"new-line": "inline/newLine",
"search-history": "inline/searchHistory",
filter: "general/filter",
refresh: "general/refresh",
settings: "general/settings",
+1
View File
@@ -2,6 +2,7 @@ export * from "./icons";
export * from "./Button";
export * from "./FloatingToolbar";
export * from "./Kbd";
export * from "./Mnemonic";
export * from "./Tooltip";
export * from "./Input";
export * from "./Checkbox";
-4
View File
@@ -1,4 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 3V8.5C12.5 9.32843 11.8284 10 11 10H3" stroke="#6C707E" stroke-linecap="round"/>
<path d="M5.5 7.5L3 10L5.5 12.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 298 B

-4
View File
@@ -1,4 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 3V8.5C12.5 9.32843 11.8284 10 11 10H3" stroke="#CED0D6" stroke-linecap="round"/>
<path d="M5.5 7.5L3 10L5.5 12.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 298 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 12.5L4.25 3.5L7.5 12.5M2.25 9.25H6.25" stroke="#6C707E" stroke-width="1.2"/>
<path d="M9.5 3.5V12.5H12.6C13.9 12.5 14.9 11.6 14.9 10.35C14.9 9.1 13.9 8.2 12.6 8.2H9.5M9.5 8.2H12.2C13.3 8.2 14.2 7.4 14.2 5.85C14.2 4.3 13.3 3.5 12.2 3.5H9.5" stroke="#6C707E" stroke-width="1.2"/>
<path d="M1 14.5H15" stroke="#6C707E" stroke-width="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 450 B

@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 12.5L4.25 3.5L7.5 12.5M2.25 9.25H6.25" stroke="#CED0D6" stroke-width="1.2"/>
<path d="M9.5 3.5V12.5H12.6C13.9 12.5 14.9 11.6 14.9 10.35C14.9 9.1 13.9 8.2 12.6 8.2H9.5M9.5 8.2H12.2C13.3 8.2 14.2 7.4 14.2 5.85C14.2 4.3 13.3 3.5 12.2 3.5H9.5" stroke="#CED0D6" stroke-width="1.2"/>
<path d="M1 14.5H15" stroke="#CED0D6" stroke-width="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 450 B

-5
View File
@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="6" r="3.5" stroke="#6C707E"/>
<path d="M8.5 8.5L11.5 11.5" stroke="#6C707E" stroke-linecap="round"/>
<path d="M11 5H15L13 7.5L11 5Z" fill="#6C707E"/>
</svg>

Before

Width:  |  Height:  |  Size: 272 B

@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="6" r="3.5" stroke="#CED0D6"/>
<path d="M8.5 8.5L11.5 11.5" stroke="#CED0D6" stroke-linecap="round"/>
<path d="M11 5H15L13 7.5L11 5Z" fill="#CED0D6"/>
</svg>

Before

Width:  |  Height:  |  Size: 272 B