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( + <> + +