дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(search): scope Find in Files to the focused folder (#534)
Find in Files from the keyboard ignored the selected folder and reused the saved scope; only the context menu passed a directory. Focused trees now publish the selected folder, or a file's parent, as an action context, and Find and Replace in Files use it when no explicit directory is given. This covers the Project view, Commit and Changes, Bookmarks and Scratches. Group rows and editor focus keep the previous behaviour. Fixes #526
Цей коміт міститься в:
1 джерело
a553dafa7a
коміт
c5cb217c7c
11 files changed
+352
-9
No files matched your search
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { emitLocalIdeEvent } from "@/platform/ipc";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { provideEditorService, type EditorService } from "@/workbench/contributions";
|
||||
import { provideEditorService, registerProjectViewRoot, type EditorService } from "@/workbench/contributions";
|
||||
import { activate } from "./index";
|
||||
import { setOsClipboardForTests } from "./clipboard";
|
||||
import { MemoryFs, setProjectFsForTests } from "./fs";
|
||||
@@ -164,6 +164,47 @@ describe("ProjectView", () => {
|
||||
expect(getContext().projectViewFocus).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes the selected folder or file parent only while the tree has focus", async () => {
|
||||
const { tree, row, rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("README.md"));
|
||||
fireEvent.mouseDown(row("src"));
|
||||
tree.focus();
|
||||
expect(getContext().focusedDirectory).toBe(`${ROOT}/src`);
|
||||
fireEvent.keyDown(tree, { key: "ArrowRight" });
|
||||
await waitFor(() => expect(rows()).toContain("App.kt"));
|
||||
fireEvent.mouseDown(row("App.kt"));
|
||||
expect(getContext().focusedDirectory).toBe(`${ROOT}/src`);
|
||||
fireEvent.mouseDown(row("README.md"));
|
||||
expect(getContext().focusedDirectory).toBe(ROOT);
|
||||
fireEvent.blur(tree);
|
||||
expect(getContext().focusedDirectory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("publishes Scratches paths through the same Project tree", async () => {
|
||||
fs.nodes.set("/scratches", { kind: "dir" });
|
||||
fs.nodes.set("/scratches/note.txt", { kind: "file", size: 0 });
|
||||
const remove = registerProjectViewRoot({
|
||||
id: "scratch-test",
|
||||
path: "/scratches",
|
||||
label: "Scratches",
|
||||
icon: "scratches",
|
||||
scope: "scratches",
|
||||
});
|
||||
try {
|
||||
const { tree, row, rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("Scratches"));
|
||||
fireEvent.mouseDown(row("Scratches"));
|
||||
tree.focus();
|
||||
expect(getContext().focusedDirectory).toBe("/scratches");
|
||||
fireEvent.keyDown(tree, { key: "ArrowRight" });
|
||||
await waitFor(() => expect(rows()).toContain("note.txt"));
|
||||
fireEvent.mouseDown(row("note.txt"));
|
||||
expect(getContext().focusedDirectory).toBe("/scratches");
|
||||
} finally {
|
||||
act(() => remove());
|
||||
}
|
||||
});
|
||||
|
||||
it("renames in IDEA's Rename dialog with Shift+F6, validating conflicts, and undoes with ⌘Z", async () => {
|
||||
const { tree, row, rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("README.md"));
|
||||
|
||||
@@ -677,6 +677,7 @@ export function ProjectView() {
|
||||
roots={model.roots}
|
||||
getKey={getKey}
|
||||
getLabel={getLabel}
|
||||
getDirectory={(node) => (node.group || node.placeholder ? null : targetFolder(node))}
|
||||
getChildren={getChildren}
|
||||
isExpandable={isExpandable}
|
||||
loadChildren={loadChildren}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import { activate } from "../index";
|
||||
import { hideFindPopup, useFindPopup } from "../find/store";
|
||||
import { BookmarksView } from "../bookmarks/BookmarksView";
|
||||
import { useBookmarks } from "../bookmarks/store";
|
||||
|
||||
let dispose: (() => void) | void;
|
||||
let stopKeys: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
dispose = activate();
|
||||
stopKeys = installKeyboardDispatcher();
|
||||
setContext("projectOpen", true);
|
||||
setContext("focusedDirectory", undefined);
|
||||
useFindPopup.setState({ open: false, scopeTab: "directory", directory: "ideaIssues", pattern: "" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopKeys();
|
||||
dispose?.();
|
||||
setContext("focusedDirectory", undefined);
|
||||
setContext("projectOpen", false);
|
||||
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
||||
});
|
||||
|
||||
describe.each([false, true])("path scope, replace=%s", (replace) => {
|
||||
const id = replace ? "find.replaceInPath" : "find.inPath";
|
||||
|
||||
it.each(["intellij-macos", "intellij-windows"])("takes the focused directory through %s shortcuts", (preset) => {
|
||||
useKeymap.setState({ preset, overrides: {} });
|
||||
setContext("focusedDirectory", "E:/documents/temp/documents");
|
||||
fireEvent.keyDown(window, {
|
||||
key: replace ? "r" : "f",
|
||||
code: replace ? "KeyR" : "KeyF",
|
||||
metaKey: preset === "intellij-macos",
|
||||
ctrlKey: preset === "intellij-windows",
|
||||
shiftKey: true,
|
||||
});
|
||||
expect(useFindPopup.getState()).toMatchObject({
|
||||
open: true,
|
||||
replace,
|
||||
scopeTab: "directory",
|
||||
directory: "E:/documents/temp/documents",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit directory/path precedence and a query-only argument", async () => {
|
||||
setContext("focusedDirectory", "/selected");
|
||||
await executeAction(id, { directory: "/explicit", path: "/legacy", query: "needle" });
|
||||
expect(useFindPopup.getState()).toMatchObject({ directory: "/explicit", pattern: "needle" });
|
||||
await executeAction(id, { path: "/legacy" });
|
||||
expect(useFindPopup.getState().directory).toBe("/legacy");
|
||||
await executeAction(id, { query: "other" });
|
||||
expect(useFindPopup.getState()).toMatchObject({ directory: "/selected", pattern: "other" });
|
||||
});
|
||||
|
||||
it("keeps the previous scope when no path tree owns focus", async () => {
|
||||
await executeAction(id);
|
||||
expect(useFindPopup.getState()).toMatchObject({ scopeTab: "directory", directory: "ideaIssues" });
|
||||
hideFindPopup();
|
||||
useFindPopup.setState({ scopeTab: "project" });
|
||||
await executeAction(id);
|
||||
expect(useFindPopup.getState().scopeTab).toBe("project");
|
||||
});
|
||||
|
||||
it("uses bookmark folders, files and line bookmarks, but not lists or editor focus", async () => {
|
||||
useBookmarks.setState({
|
||||
lists: [{ id: "default", name: "Bookmarks" }],
|
||||
defaultList: "default",
|
||||
paths: [
|
||||
{ path: "/p/documents", dir: true, list: "default", created: 1 },
|
||||
{ path: "/p/src/main.ts", dir: false, list: "default", created: 2 },
|
||||
],
|
||||
items: [{ path: "/p/src/main.ts", line: 0, text: "hello", list: "default", created: 3 }],
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<BookmarksView />
|
||||
<input aria-label="Editor" />
|
||||
</>,
|
||||
);
|
||||
const tree = screen.getByRole("tree", { name: "Bookmarks" });
|
||||
tree.focus();
|
||||
for (const [key, directory] of [
|
||||
["Home", undefined],
|
||||
["ArrowDown", "/p/documents"],
|
||||
["ArrowDown", "/p/src"],
|
||||
["ArrowDown", "/p/src"],
|
||||
] as const) {
|
||||
fireEvent.keyDown(tree, { key });
|
||||
expect(getContext().focusedDirectory).toBe(directory);
|
||||
await act(() => executeAction(id));
|
||||
expect(useFindPopup.getState().directory).toBe(directory ?? "ideaIssues");
|
||||
act(() => hideFindPopup());
|
||||
}
|
||||
screen.getByLabelText("Editor").focus();
|
||||
expect(getContext().focusedDirectory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { dirname } from "@/platform/project";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { tx, useT, useTx } from "@/platform/i18n";
|
||||
@@ -237,6 +238,13 @@ export function BookmarksView() {
|
||||
aria-label="Bookmarks"
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getDirectory={(n) =>
|
||||
n.kind === "list"
|
||||
? null
|
||||
: n.kind === "folder"
|
||||
? n.path
|
||||
: dirname(n.kind === "mark" ? n.bookmark.path : n.path)
|
||||
}
|
||||
getLabel={(n) => (n.kind === "mark" ? bookmarkLabel(n.bookmark) : n.kind === "list" ? n.list.name : n.name)}
|
||||
getChildren={(n) => (n.kind === "list" || n.kind === "file" ? n.children : undefined)}
|
||||
isExpandable={(n) => n.kind === "list" || (n.kind === "file" && n.children.length > 0)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { ContextKeys } from "@/platform/context";
|
||||
import type { ProjectFileSet, SearchContext, SearchQuery, SearchScope } from "@/platform/ipc";
|
||||
import { SEARCH_CONTEXTS, customScopes, findCustomScope, rememberSearch, searchHistory } from "@/platform/search";
|
||||
import { loadJson, saveJson } from "../common/open";
|
||||
@@ -103,11 +104,17 @@ export interface FindArg {
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export function parseFindArg(arg: unknown): FindArg {
|
||||
if (!arg || typeof arg !== "object") return {};
|
||||
const a = arg as Record<string, unknown>;
|
||||
export function parseFindArg(arg: unknown, ctx: ContextKeys = {}): FindArg {
|
||||
const a = (arg && typeof arg === "object" ? arg : {}) as Record<string, unknown>;
|
||||
return {
|
||||
directory: typeof a.directory === "string" ? a.directory : typeof a.path === "string" ? a.path : undefined,
|
||||
directory:
|
||||
typeof a.directory === "string"
|
||||
? a.directory
|
||||
: typeof a.path === "string"
|
||||
? a.path
|
||||
: typeof ctx.focusedDirectory === "string"
|
||||
? ctx.focusedDirectory
|
||||
: undefined,
|
||||
query: typeof a.query === "string" ? a.query : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,9 +327,9 @@ export function activate(): void | (() => void) {
|
||||
icon: "search",
|
||||
keywords: ["grep", "search in path"],
|
||||
when: "projectOpen",
|
||||
run: ({ arg }) => {
|
||||
run: ({ arg, ctx }) => {
|
||||
closeAllPopups("find");
|
||||
showFindPopup(false, { ...parseFindArg(arg), selection: editorSelectionText() });
|
||||
showFindPopup(false, { ...parseFindArg(arg, ctx), selection: editorSelectionText() });
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -355,9 +355,9 @@ export function activate(): void | (() => void) {
|
||||
category: "Find",
|
||||
icon: "replace",
|
||||
when: "projectOpen",
|
||||
run: ({ arg }) => {
|
||||
run: ({ arg, ctx }) => {
|
||||
closeAllPopups("find");
|
||||
showFindPopup(true, { ...parseFindArg(arg), selection: editorSelectionText() });
|
||||
showFindPopup(true, { ...parseFindArg(arg, ctx), selection: editorSelectionText() });
|
||||
},
|
||||
},
|
||||
// Keys of the Find in Files popup that are global shortcuts elsewhere (bound after them).
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, typ
|
||||
import { tx } from "@/platform/i18n";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { dirname } from "@/platform/project";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -481,6 +482,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={nodeText}
|
||||
getDirectory={(n) => (n.path && n.type !== "group" ? (n.type === "dir" ? n.path : dirname(n.path)) : null)}
|
||||
getChildren={(n) => n.children}
|
||||
isExpandable={(n) => n.type !== "file"}
|
||||
expanded={expanded}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { ChangesTree } from "../CommitView";
|
||||
import { useCommit } from "../commitStore";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
beforeEach(() => {
|
||||
useVcs.setState({
|
||||
root: "/p",
|
||||
roots: ["/p"],
|
||||
status: [
|
||||
{
|
||||
path: "/p/documents/note.txt",
|
||||
relPath: "documents/note.txt",
|
||||
staged: null,
|
||||
unstaged: "modified",
|
||||
renamedFrom: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
useCommit.setState({ groupByDir: true, checks: new Map(), selection: [] });
|
||||
});
|
||||
|
||||
describe.each(["commit", "local", "dialog"] as const)("%s change tree directory", (mode) => {
|
||||
it("scopes folders and file parents, and clears on groups and message focus", () => {
|
||||
render(
|
||||
<>
|
||||
<ChangesTree mode={mode} />
|
||||
<textarea aria-label="Commit message" />
|
||||
</>,
|
||||
);
|
||||
const tree = screen.getByRole("tree", { name: "Changes" });
|
||||
tree.focus();
|
||||
fireEvent.keyDown(tree, { key: "Home" });
|
||||
expect(getContext().focusedDirectory).toBeUndefined();
|
||||
fireEvent.keyDown(tree, { key: "ArrowDown" });
|
||||
expect(getContext().focusedDirectory).toBe("/p/documents");
|
||||
fireEvent.keyDown(tree, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(tree, { key: "ArrowDown" });
|
||||
expect(getContext().focusedDirectory).toBe("/p/documents");
|
||||
screen.getByLabelText("Commit message").focus();
|
||||
expect(getContext().focusedDirectory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача