diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx
index 610dd2e2..47ad7051 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 f54af515..af80a06b 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 00000000..ceb1d89b
--- /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 74a7dfa0..03f2bd2f 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 a2883b21..0f39ea45 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 dc7b2ec8..a5cbcd4d 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 0112ddaa..f9019a0a 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 00000000..4ab66d58
--- /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 8c0d04cd..0c238cf7 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 13258ddc..cd9596f1 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 00000000..e80a8097
--- /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]);
+}