From c5cb217c7c24065c8e11bcade4e71016f2d5b68c Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Sat, 31 Oct 2026 12:43:42 +0800
Subject: [PATCH] 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
---
.../project-tree/ProjectView.test.tsx | 43 +++++++-
src/features/project-tree/ProjectView.tsx | 1 +
.../__tests__/pathScopeActions.test.tsx | 103 ++++++++++++++++++
.../search/bookmarks/BookmarksView.tsx | 8 ++
src/features/search/find/store.ts | 15 ++-
src/features/search/index.ts | 8 +-
src/features/vcs/CommitView.tsx | 2 +
.../vcs/__tests__/changesDirectory.test.tsx | 45 ++++++++
src/ui/Tree.test.tsx | 92 ++++++++++++++++
src/ui/Tree.tsx | 8 ++
src/ui/treeDirectoryContext.ts | 36 ++++++
11 files changed, 352 insertions(+), 9 deletions(-)
create mode 100644 src/features/search/__tests__/pathScopeActions.test.tsx
create mode 100644 src/features/vcs/__tests__/changesDirectory.test.tsx
create mode 100644 src/ui/treeDirectoryContext.ts
diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx
index 610dd2e21..47ad70519 100644
--- a/src/features/project-tree/ProjectView.test.tsx
+++ b/src/features/project-tree/ProjectView.test.tsx
@@ -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"));
diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx
index f54af5159..af80a06bc 100644
--- a/src/features/project-tree/ProjectView.tsx
+++ b/src/features/project-tree/ProjectView.tsx
@@ -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}
diff --git a/src/features/search/__tests__/pathScopeActions.test.tsx b/src/features/search/__tests__/pathScopeActions.test.tsx
new file mode 100644
index 000000000..ceb1d89be
--- /dev/null
+++ b/src/features/search/__tests__/pathScopeActions.test.tsx
@@ -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(
+ <>
+
+
+ >,
+ );
+ 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();
+ });
+});
diff --git a/src/features/search/bookmarks/BookmarksView.tsx b/src/features/search/bookmarks/BookmarksView.tsx
index 74a7dfa02..03f2bd2fa 100644
--- a/src/features/search/bookmarks/BookmarksView.tsx
+++ b/src/features/search/bookmarks/BookmarksView.tsx
@@ -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)}
diff --git a/src/features/search/find/store.ts b/src/features/search/find/store.ts
index a2883b214..0f39ea451 100644
--- a/src/features/search/find/store.ts
+++ b/src/features/search/find/store.ts
@@ -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;
+export function parseFindArg(arg: unknown, ctx: ContextKeys = {}): FindArg {
+ const a = (arg && typeof arg === "object" ? arg : {}) as Record;
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,
};
}
diff --git a/src/features/search/index.ts b/src/features/search/index.ts
index dc7b2ec8f..a5cbcd4dc 100644
--- a/src/features/search/index.ts
+++ b/src/features/search/index.ts
@@ -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).
diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx
index 0112ddaab..f9019a0a3 100644
--- a/src/features/vcs/CommitView.tsx
+++ b/src/features/vcs/CommitView.tsx
@@ -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}
diff --git a/src/features/vcs/__tests__/changesDirectory.test.tsx b/src/features/vcs/__tests__/changesDirectory.test.tsx
new file mode 100644
index 000000000..4ab66d58a
--- /dev/null
+++ b/src/features/vcs/__tests__/changesDirectory.test.tsx
@@ -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(
+ <>
+
+
+ >,
+ );
+ 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();
+ });
+});
diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx
index 8c0d04cd4..0c238cf7b 100644
--- a/src/ui/Tree.test.tsx
+++ b/src/ui/Tree.test.tsx
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { createRef } from "react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { Tree, type TreeHandle } from "./Tree";
+import { getContext } from "@/platform/context";
interface N {
id: string;
@@ -45,6 +46,97 @@ const labels = (tree: HTMLElement) =>
.queryAllByRole("treeitem")
.map((el) => el.textContent);
+describe("Tree directory context", () => {
+ it("does not contribute virtual document URIs as filesystem directories", () => {
+ const { key } = setup({ getDirectory: () => "jdt://contents/library.jar/package" });
+ key("Home");
+ expect(getContext().focusedDirectory).toBeUndefined();
+ });
+
+ it("follows the selected lead, clears on non-path rows, blur and unmount", () => {
+ const { tree, key, unmount } = setup({
+ getDirectory: (n) => (n.id === "main" ? null : `/p/${n.id}`),
+ });
+ expect(getContext().focusedDirectory).toBeUndefined();
+ key("ArrowDown");
+ expect(getContext().focusedDirectory).toBe("/p/src");
+ key("End", { shiftKey: true });
+ expect(getContext().focusedDirectory).toBe("/p/README.md");
+ key("Home");
+ key("ArrowRight");
+ key("ArrowRight");
+ expect(getContext().focusedDirectory).toBeUndefined();
+ key("Home");
+ fireEvent.blur(tree);
+ expect(getContext().focusedDirectory).toBeUndefined();
+ fireEvent.focus(tree);
+ expect(getContext().focusedDirectory).toBe("/p/src");
+ unmount();
+ expect(getContext().focusedDirectory).toBeUndefined();
+ });
+
+ it("updates a controlled selection while focused and clears a removed selection", () => {
+ const view = (selected: Set, roots = data) => (
+
+ roots={roots}
+ getKey={(n) => n.id}
+ getLabel={(n) => n.id}
+ getChildren={(n) => n.children}
+ isExpandable={(n) => !!n.children}
+ selected={selected}
+ getDirectory={(n) => `/p/${n.id}`}
+ />
+ );
+ const { rerender } = render(view(new Set(["src"])));
+ screen.getByRole("tree").focus();
+ expect(getContext().focusedDirectory).toBe("/p/src");
+ rerender(view(new Set()));
+ expect(getContext().focusedDirectory).toBeUndefined();
+ rerender(view(new Set(["src"])));
+ expect(getContext().focusedDirectory).toBe("/p/src");
+ rerender(view(new Set(["src"]), []));
+ expect(getContext().focusedDirectory).toBeUndefined();
+ });
+
+ it("clears on focus transfer and an old tree's unmount does not clear the new owner", () => {
+ const view = (showFirst: boolean) => (
+ <>
+ {showFirst && (
+
+ roots={data}
+ getKey={(n) => n.id}
+ getLabel={(n) => n.id}
+ getChildren={() => []}
+ isExpandable={() => false}
+ defaultSelected={["src"]}
+ getDirectory={() => "/first"}
+ />
+ )}
+
+ roots={data}
+ getKey={(n) => n.id}
+ getLabel={(n) => n.id}
+ getChildren={() => []}
+ isExpandable={() => false}
+ defaultSelected={["src"]}
+ getDirectory={() => "/second"}
+ />
+
+ >
+ );
+ const { rerender } = render(view(true));
+ const [first, second] = screen.getAllByRole("tree");
+ first!.focus();
+ expect(getContext().focusedDirectory).toBe("/first");
+ second!.focus();
+ expect(getContext().focusedDirectory).toBe("/second");
+ rerender(view(false));
+ expect(getContext().focusedDirectory).toBe("/second");
+ screen.getByLabelText("Editor").focus();
+ expect(getContext().focusedDirectory).toBeUndefined();
+ });
+});
+
describe("Tree keyboard navigation", () => {
it("moves with arrows, expands with Right and collapses / goes to parent with Left", () => {
const { tree, lead, key } = setup();
diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx
index 13258ddc4..cd9596f10 100644
--- a/src/ui/Tree.tsx
+++ b/src/ui/Tree.tsx
@@ -32,6 +32,7 @@ import styles from "./Tree.module.css";
import { T } from "./T";
import { tx } from "@/platform/i18n";
import { useTreeReorder, type TreeReorder } from "./treeReorder";
+import { useTreeDirectoryContext } from "./treeDirectoryContext";
export type { TreeReorder } from "./treeReorder";
export interface TreeRowState {
@@ -85,6 +86,8 @@ export interface TreeProps {
getDetail?: (node: T) => string | undefined;
/** Label colour, e.g. VCS status. */
getColor?: (node: T) => string | undefined;
+ /** Filesystem directory for actions on the focused selection; groups return null. */
+ getDirectory?: (node: T) => string | null | undefined;
expanded?: ReadonlySet;
defaultExpanded?: Iterable;
@@ -540,6 +543,11 @@ export function Tree(props: TreeProps) {
};
const leadRow = leadIndex >= 0 ? rows[leadIndex] : undefined;
+ useTreeDirectoryContext(
+ scrollRef,
+ leadRow && !leadRow.loading && selected.has(leadRow.key) ? props.getDirectory?.(leadRow.node) : undefined,
+ !!props.getDirectory && !emptyState,
+ );
const noMatch = !!query && matchMap.size === 0;
if (emptyState) return {empty}
;
diff --git a/src/ui/treeDirectoryContext.ts b/src/ui/treeDirectoryContext.ts
new file mode 100644
index 000000000..e80a80972
--- /dev/null
+++ b/src/ui/treeDirectoryContext.ts
@@ -0,0 +1,36 @@
+import { useLayoutEffect, type RefObject } from "react";
+import { setContext } from "@/platform/context";
+import { isVirtualUri } from "@/platform/virtualDocs";
+
+let owner: HTMLElement | null = null;
+
+/** Only the focused tree contributes its selected directory to actions. */
+export function useTreeDirectoryContext(
+ ref: RefObject,
+ directory: string | null | undefined,
+ enabled: boolean,
+) {
+ useLayoutEffect(() => {
+ if (!enabled) return;
+ const element = ref.current;
+ if (!element) return;
+ const clear = () => {
+ if (owner !== element) return;
+ owner = null;
+ setContext("focusedDirectory", undefined);
+ };
+ const publish = () => {
+ if (document.activeElement !== element) return;
+ owner = element;
+ setContext("focusedDirectory", directory && !isVirtualUri(directory) ? directory : undefined);
+ };
+ element.addEventListener("focus", publish);
+ element.addEventListener("blur", clear);
+ publish();
+ return () => {
+ element.removeEventListener("focus", publish);
+ element.removeEventListener("blur", clear);
+ clear();
+ };
+ }, [ref, directory, enabled]);
+}