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 <[email protected]>
Цей коміт міститься в:
fuqiuluoтаfuqiuluo authored and GitHub committed 2026-11-05 02:45:36 +08:00
1 джерело 5d19baf06b
коміт 948e93e1a2
48 files changed
+1067 -361

No files matched your search

+43
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 947 B

+5
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 947 B

+6
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 674 B

+6
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 684 B

+5
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 886 B

+5
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 886 B

+3 -3
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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>

До

Ширина:  |  Висота:  |  Розмір: 298 B

-4
Переглянути файл
@@ -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>

До

Ширина:  |  Висота:  |  Розмір: 298 B

-5
Переглянути файл
@@ -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>

До

Ширина:  |  Висота:  |  Розмір: 450 B

-5
Переглянути файл
@@ -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>

До

Ширина:  |  Висота:  |  Розмір: 450 B

-5
Переглянути файл
@@ -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>

До

Ширина:  |  Висота:  |  Розмір: 272 B

-5
Переглянути файл
@@ -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>

До

Ширина:  |  Висота:  |  Розмір: 272 B