diff --git a/crates/ide-host/src/ops/nav.rs b/crates/ide-host/src/ops/nav.rs index 40266639..646e3ab8 100644 --- a/crates/ide-host/src/ops/nav.rs +++ b/crates/ide-host/src/ops/nav.rs @@ -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 { Ok(host.project(window)?.index.status()) } +/// The project's file extensions, most common first. +pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult> { + Ok(host.project(window)?.index.file_extensions()) +} + pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult> { 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 { diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 5e2a0945..2ddd6859 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -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" diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index cf0256ac..711373fe 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -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, worker: Mutex>>, @@ -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 { + let d = self.shared.data.read(); + let mut counts: HashMap = 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 = + 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 { diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index ba610dc7..d8dadee2 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -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(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 03265689..d4832c86 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -186,6 +186,7 @@ pub fn builder() -> tauri_specta::Builder { caches::caches_schedule_invalidation, caches::app_restart, nav::index_status, + nav::index_file_extensions, nav::spring_route_sources, nav::find_files, nav::find_dirs, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index 605e7b27..d5cd946d 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -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) -> IdeResult> { + 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> { diff --git a/src/bindings.ts b/src/bindings.ts index 6bb04f59..2f7cf0bd 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -325,6 +325,7 @@ export const commands = { /** Quits through every window's unsaved-changes check, then starts again. */ appRestart: () => __TAURI_INVOKE("app_restart"), indexStatus: () => typedError(__TAURI_INVOKE("index_status")), + indexFileExtensions: () => typedError(__TAURI_INVOKE("index_file_extensions")), /** Spring Web mapping sources for the request-path index of Search Everywhere. */ springRouteSources: () => typedError(__TAURI_INVOKE("spring_route_sources")), findFiles: (query: string, limit: number) => typedError(__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"; diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index fd926415..e7139b43 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -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 run(findPrevious)} /> - run(findNext)} /> + run(findNext)} + />
- -
@@ -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): 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 ""; } diff --git a/src/features/search/find/FindInFiles.module.css b/src/features/search/find/FindInFiles.module.css index da28768a..c5733dde 100644 --- a/src/features/search/find/FindInFiles.module.css +++ b/src/features/search/find/FindInFiles.module.css @@ -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; +} diff --git a/src/features/search/find/FindInFiles.tsx b/src/features/search/find/FindInFiles.tsx index 7d3687ef..97ff2d59 100644 --- a/src/features/search/find/FindInFiles.tsx +++ b/src/features/search/find/FindInFiles.tsx @@ -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 { 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 ? : 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) => { + setSplit(layout); + saveFindPopupSplit(layout); + }; const [marks, setMarks] = useState(NO_MARKS); const [running, setRunning] = useState(null); const [progress, setProgress] = useState(null); - const [previewMode, setPreviewMode] = useState(false); - const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null); const busy = !!running; const patternRef = useRef(null); const patternBoxRef = useRef(null); @@ -149,6 +169,11 @@ function FindInFilesDialog() { const set = (patch: Partial) => 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) => { 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 = ( +
+ + 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={ + set({ isRegex: true })} /> + } + renderItem={(r, st) => { + const state = s.replace ? groupState(r, marks) : "pending"; + return ( + <> + + + + + {state === "done" && ( + + replaced ·{" "} + + )} + {workspace ? r.relPath : basename(r.relPath)} {r.line + 1} + + + ); + }} + /> +
+ ); return ( - set({ maskEnabled: v })} label="File mask:" /> + } + />
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() { ({ id: t.id, label: t.label }))} + items={SCOPE_TABS.map((t) => ({ + id: t.id, + label: , + 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)} />
{s.scopeTab === "directory" && ( @@ -706,128 +803,82 @@ function FindInFilesDialog() { )}
-
- - 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={{emptyText(state, s.pattern)}} - renderItem={(r, st) => { - const state = s.replace ? groupState(r, marks) : "pending"; - return ( - <> - - + {rows.length ? ( + + + {resultsBox} + + + +
+ {selected ? ( + + {basename(selected.relPath)} + {selected.relPath.includes("/") && dirname(selected.relPath)} + + ) : ( + + )} +
+
+ {!stale && previewMode && s.replace && selected ? ( + - - - {state === "done" && ( - - replaced ·{" "} - - )} - {workspace ? r.relPath : basename(r.relPath)} {r.line + 1} - - - ); - }} - /> -
-
- {selected ? ( - <> - {selected.relPath} - - {selected.line + 1}:{selected.col + 1} - - + ) : ( + + )} +
+
+
) : ( - - )} -
-
- {!stale && previewMode && s.replace && selected ? ( - - ) : ( - + resultsBox )}
- - {s.replace ? ( - ↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window - ) : ( - ↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window - )} - + } + /> + {s.replace && } + {navigationHint && {navigationHint}} + {s.replace && progress && ( {tx`Replacing… ${progress.done}/${progress.total} files`} )} {s.replace && running && ( - )} + {formatBinding(hostBinding("Meta+Enter"))} + {s.replace && ( <> - - - - + )} -
void; + onRegex: () => void; +}) { + const masked = o.maskEnabled && !!o.mask.trim(); + if (!o.pattern && !masked) return
{tx("Type search query to find in files")}
; + if (state.status === "invalid") return
{state.error ?? tx("Invalid pattern")}
; + if (state.status === "error") return
{state.error ?? tx("Search failed")}
; + if (o.pattern && state.status !== "done") return
{tx("Searching…")}
; + 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 ( +
+
+ {o.pattern ? tx("Nothing found") : tx("No search text entered")} + {used.length || regex ? "." : ""} +
+ {regex ? ( +
+
+ {" "} + {formatBinding("Alt+X")} +
+
+ ) : used.length ? ( +
+ {used.length === 1 ? ( +
{tx`Search option used: ${tx(used[0]!.label)}`}
+ ) : ( + <> +
+ Search options used: +
+
+ {used.map((u) => [tx(u.label), u.key && formatBinding(u.key)].filter(Boolean).join(" ")).join(" ")} +
+ + )} +
+ {" "} + {clearKey} +
+
+ ) : null} +
+ ); } diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index db1ae34c..90066080 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -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 ( - {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”.`} ); } @@ -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`}` : "")}