fix(keymap): let the keymap own the project view's shortcuts (#2811)

The project view handled Shift+F6, F6, F4, Delete, copy/cut/paste, Copy Path and New in its own onKeyDown, so unbinding or rebinding those actions in the keymap had no effect there (a cleared Rename File still renamed on Shift+F6, a rebound one was ignored, and Eclipse's Shift+Alt+R opened the file rename). Those keys now go only through the global dispatcher and the preset bindings; the view keeps just the strokes no preset binds (Cmd+Z undoing the last file refactoring, Ctrl+Enter's New popup on macOS). The Eclipse presets bind Delete Project Item explicitly, since the hardcoded Delete used to cover for them.

Fixes #2801

Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com>
This commit is contained in:
Vadim Lazuk
2026-11-12 03:34:06 +08:00
committed by GitHub
co-authored by Inf1mum
parent 46d288b456
commit 4c5df98bb8
4 changed files with 58 additions and 36 deletions
+51 -1
View File
@@ -2,7 +2,9 @@ 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 { installRepeatedDoubleClick } from "@/platform/doubleClick";
import { getContext } from "@/platform/context";
import { getContext, setContext } from "@/platform/context";
import { applyKeymapSettings, installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import {
provideEditorService,
registerProjectViewRoot,
@@ -54,6 +56,19 @@ let fs: MemoryFs;
let openFile: ReturnType<typeof vi.fn>;
let dispose: () => void;
const initialPlatform = usePlatform.getState();
const initialKeymap = useKeymap.getState();
let uninstallDispatcher: (() => void) | null = null;
/** Presses the presets' shortcuts: the global dispatcher runs the keymap's actions. */
function dispatchViaKeymap() {
usePlatform.setState({ os: "macos" });
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
// The workbench activation that publishes this in the app does not run here.
setContext("projectOpen", true);
uninstallDispatcher = installKeyboardDispatcher(window);
}
beforeEach(() => {
localStorage.clear();
fs = new MemoryFs(
@@ -78,6 +93,10 @@ beforeEach(() => {
afterEach(() => {
dispose();
setProjectFsForTests(null);
uninstallDispatcher?.();
uninstallDispatcher = null;
usePlatform.setState(initialPlatform);
useKeymap.setState(initialKeymap);
});
function setup() {
@@ -153,6 +172,7 @@ describe("ProjectView", () => {
});
it("pastes the files Explorer copied into the selected folder, as copies", async () => {
dispatchViaKeymap();
// What Explorer leaves on the clipboard: a path outside the project.
fs.nodes.set("/elsewhere/Photo.png", { kind: "file", size: 3 });
setOsClipboardForTests({
@@ -176,6 +196,7 @@ describe("ProjectView", () => {
});
it("clears cut dimming and disables Paste on focus after the OS clipboard becomes text", async () => {
dispatchViaKeymap();
let files: string[] = [];
setOsClipboardForTests({
files: async () => files,
@@ -412,6 +433,7 @@ describe("ProjectView", () => {
});
it("renames in IDEA's Rename dialog with Shift+F6, validating conflicts, and undoes with ⌘Z", async () => {
dispatchViaKeymap();
const { tree, row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
fireEvent.mouseDown(row("README.md"));
@@ -439,7 +461,34 @@ describe("ProjectView", () => {
expect(fs.nodes.has("/w/acme/GUIDE.md")).toBe(false);
});
it("stops renaming with Shift+F6 once Rename File is unbound, following the keymap", async () => {
dispatchViaKeymap();
// Unbinding used to leave the component's hardcoded Shift+F6 in place (issue #2801).
applyKeymapSettings("intellij-macos", { "project.rename": [] });
const { tree, row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
fireEvent.mouseDown(row("README.md"));
tree.focus();
fireEvent.keyDown(tree, { key: "F6", code: "F6", shiftKey: true });
expect(screen.queryByRole("dialog", { name: "Rename" })).toBeNull();
});
it("renames with the shortcut the keymap rebinds Rename File to", async () => {
dispatchViaKeymap();
applyKeymapSettings("intellij-macos", { "project.rename": ["F2"] });
const { tree, row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
fireEvent.mouseDown(row("README.md"));
tree.focus();
fireEvent.keyDown(tree, { key: "F6", code: "F6", shiftKey: true });
expect(screen.queryByRole("dialog", { name: "Rename" })).toBeNull();
fireEvent.keyDown(tree, { key: "F2", code: "F2" });
const dialog = await screen.findByRole("dialog", { name: "Rename" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
});
it("moves with F6 through the Move dialog", async () => {
dispatchViaKeymap();
const { tree, row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
fireEvent.mouseDown(row("README.md"));
@@ -458,6 +507,7 @@ describe("ProjectView", () => {
});
it("deletes the selection after confirmation", async () => {
dispatchViaKeymap();
const { tree, row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
fireEvent.mouseDown(row("README.md"));
+5 -35
View File
@@ -71,16 +71,9 @@ import {
cancelEditing,
checkEditingName,
commitEditing,
copyPaths,
copySelection,
deleteSelection,
dropPaths,
importExternal,
openFile,
openMoveDialog,
openRenameDialog,
openSelection,
paste,
refreshOsClipboard,
targetFolder,
undoLastFileOperation,
@@ -757,43 +750,20 @@ export function ProjectView() {
return () => window.removeEventListener("focus", refresh);
}, []);
// The keymap owns the action keys (Delete, Shift+F6, F6, F4, copy/paste, New …):
// the global dispatcher runs them from the presets in the capture phase, so a
// cleared binding stays cleared and a rebound one follows the user (issue #2801).
// Only the strokes no preset binds stay local: ⌘Z undoing the last file
// refactoring (`project.undoMove` is unbound) and ⌃Enter's New popup on macOS.
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
const stroke = strokeOf(e.nativeEvent);
let handled = true;
switch (stroke && macStroke(stroke)) {
case "Delete":
case "Meta+Backspace":
void deleteSelection();
break;
case "Shift+F6":
void openRenameDialog();
break;
case "F6":
openMoveDialog();
break;
case "Meta+Z":
handled = undoLastFileOperation();
break;
case "Meta+C":
copySelection(false);
break;
case "Meta+X":
copySelection(true);
break;
case "Meta+V":
void paste();
break;
case "Shift+Meta+C":
void copyPaths("absolute");
break;
case "F4":
case "Meta+Down":
openSelection();
break;
case "Meta+N":
case "Ctrl+Enter":
case "Alt+Insert":
showNewPopup();
break;
default:
+1
View File
@@ -23,6 +23,7 @@ export const eclipseMacos: Record<string, string[]> = {
"code.commentBlock": ["Shift+Meta+/"],
"build.buildProject": ["Meta+B"],
"project.copyElement": [],
"project.delete": ["Meta+Backspace"],
"run.debug": ["Meta+F11"],
"project.open": ["F3"],
"debug.jumpToSource": ["F3"],
+1
View File
@@ -23,6 +23,7 @@ export const eclipseWindows: Record<string, string[]> = {
"code.commentBlock": ["Shift+Ctrl+/"],
"build.buildProject": ["Ctrl+F9", "Ctrl+B"],
"project.copyElement": [],
"project.delete": ["Delete"],
"run.debug": ["Shift+Alt+D", "Shift+F9"],
"project.open": ["F12", "Shift+Ctrl+E"],
"debug.jumpToSource": ["F12", "Shift+Ctrl+E"],