mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Keymap - Let AltGr characters through instead of firing Ctrl+Alt shortcuts (Windows/Linux) - Double Shift: strict press/release timing, ignored while typing or after Shift+click - Chords wait 2s and swallow an unmatched second stroke - Drop CodeMirror defaults that clash with IDEA keys; ⌘Enter is Split Line, add Scroll to Center and Code Block Start/End - Add missing IDEA defaults (⌃J, ⌘Y, ⇧⌘C, ⌥⌘Y, ⇧⌘M, F9 Resume, ⌘R Rerun, …) Editor - Tab inserts at the caret; smart Backspace to the proper indent; Enter continues a `//` comment; per-language line comment placement - Back/Forward records navigations only; ⌘F searches the selected text - In-place rename; ⌃Space inserts a sole candidate; commit characters only for a focused lookup; usage highlighting survives edits; ⌥Enter offers fixes from the caret line; ⌘-hover shows declaration info only for navigable targets Workbench - Tool windows: primary and split area per side, six Move To anchors, layout migration - ⌘W closes tool window content; Shift+Esc from the editor; closing a tab activates the left neighbour; preview tab and tab limit follow IDEA defaults - Switcher lists open editors and tool windows; Recent Files Delete removes items - Search Everywhere tab/scope behaviour; popups close when the window deactivates - Project view: Copy/Move dialogs on paste, skippable usage search on delete, new-file input cancels on blur; Settings search Esc clears the filter - Status bar breadcrumbs open a children popup; notification timeouts Run / Debug / Build / Terminal - Run asks to stop a running single-instance config; Rerun restarts silently; "Allow multiple instances" option - Run chooser: Enter runs, Shift debugs, F4/Delete/digits - Type into the console for stdin; Stop covers debug sessions - Log (Shift+click) and temporary (Alt+click) breakpoints; independent suspend/log options; Evaluate prefill and history; changed-value highlighting; expandable hints - Build window opens on failure and jumps to the first error - Terminal confirms closing a busy tab and lets IDE shortcuts through VCS - Correct ours/theirs labels during rebase, cherry-pick and revert - Smart/Force Checkout; remote branch checkout updates the local branch - Partial commit by changed block; inline before-commit check results - F7 continues into the next file; Rollback Lines; Commit and Push; Amend message swap - Branch delete with Restore; force push confirmation; editable push target; update summary with the incoming commits
185 lines
8.4 KiB
TypeScript
185 lines
8.4 KiB
TypeScript
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 { activate } from "./index";
|
|
import { MemoryFs, setProjectFsForTests } from "./fs";
|
|
import { MessagesHost } from "@/workbench/messages";
|
|
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
|
|
import { setRefactorBackendForTests } from "./fileRefactor";
|
|
import { ProjectView } from "./ProjectView";
|
|
import { beginNew } from "./operations";
|
|
import { initProjectTree, setViewOption, useProjectTree } from "./store";
|
|
|
|
const ROOT = "/w/acme";
|
|
let fs: MemoryFs;
|
|
let openFile: ReturnType<typeof vi.fn>;
|
|
let dispose: () => void;
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
fs = new MemoryFs(
|
|
{
|
|
"/w/acme/src/App.kt": "x",
|
|
"/w/acme/src/Util.kt": "x",
|
|
"/w/acme/build/out.class": "x",
|
|
"/w/acme/README.md": "x",
|
|
},
|
|
ROOT,
|
|
);
|
|
fs.ignored.add("/w/acme/build");
|
|
fs.onChange = (e) => queueMicrotask(() => emitLocalIdeEvent({ type: "fsChanged", ...e }));
|
|
setProjectFsForTests(fs, ROOT);
|
|
setRefactorBackendForTests(null);
|
|
openFile = vi.fn(async () => {});
|
|
provideEditorService({ openFile, activePath: () => null } as unknown as EditorService);
|
|
dispose = activate();
|
|
initProjectTree(ROOT, "acme");
|
|
});
|
|
|
|
afterEach(() => {
|
|
dispose();
|
|
setProjectFsForTests(null);
|
|
});
|
|
|
|
function setup() {
|
|
render(
|
|
<>
|
|
<ProjectView />
|
|
<MessagesHost />
|
|
<FileRefactorDialogHost />
|
|
</>,
|
|
);
|
|
const tree = screen.getByRole("tree", { name: "Project" });
|
|
const rows = () =>
|
|
within(tree)
|
|
.queryAllByRole("treeitem")
|
|
.map((r) => r.textContent);
|
|
const row = (label: string) => within(tree).getAllByRole("treeitem").find((r) => r.textContent?.startsWith(label))!;
|
|
return { tree, rows, row };
|
|
}
|
|
|
|
describe("ProjectView", () => {
|
|
it("shows the root with its path and loads children lazily", async () => {
|
|
const { rows, row, tree } = setup();
|
|
await waitFor(() => expect(rows()).toEqual(["acme/w/acme", "build", "src", "README.md"]));
|
|
expect(row("build").querySelector("[class*=excluded]")).not.toBeNull();
|
|
fireEvent.mouseDown(row("src"));
|
|
tree.focus();
|
|
fireEvent.keyDown(tree, { key: "ArrowRight" });
|
|
await waitFor(() => expect(rows()).toEqual(["acme/w/acme", "build", "src", "App.kt", "Util.kt", "README.md"]));
|
|
});
|
|
|
|
it("opens files on double click and previews on single click only when enabled", async () => {
|
|
const { row, rows } = setup();
|
|
await waitFor(() => expect(rows()).toContain("README.md"));
|
|
const content = row("README.md").querySelector("[data-project-path]")!;
|
|
fireEvent.click(content, { detail: 1 });
|
|
expect(openFile).not.toHaveBeenCalled();
|
|
fireEvent.doubleClick(row("README.md"));
|
|
expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: undefined, focus: true });
|
|
act(() => setViewOption("previewTab", true));
|
|
fireEvent.click(content, { detail: 1 });
|
|
expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: true, focus: false });
|
|
act(() => setViewOption("previewTab", false));
|
|
});
|
|
|
|
it("cancels a new file when its name field loses focus", async () => {
|
|
const { rows } = setup();
|
|
await waitFor(() => expect(rows()).toContain("README.md"));
|
|
await act(() => beginNew("file", ROOT));
|
|
const input = await screen.findByRole("textbox", { name: "New file name" });
|
|
fireEvent.change(input, { target: { value: "Notes.md" } });
|
|
await act(async () => void fireEvent.blur(input));
|
|
expect(useProjectTree.getState().editing).toBeNull();
|
|
expect(fs.nodes.has("/w/acme/Notes.md")).toBe(false);
|
|
});
|
|
|
|
it("publishes the focus context key", async () => {
|
|
const { tree, rows } = setup();
|
|
await waitFor(() => expect(rows()).toContain("README.md"));
|
|
fireEvent.focus(tree);
|
|
expect(getContext().projectViewFocus).toBe(true);
|
|
fireEvent.blur(tree);
|
|
expect(getContext().projectViewFocus).toBe(false);
|
|
});
|
|
|
|
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"));
|
|
fireEvent.mouseDown(row("README.md"));
|
|
tree.focus();
|
|
fireEvent.keyDown(tree, { key: "F6", code: "F6", shiftKey: true });
|
|
const dialog = await screen.findByRole("dialog", { name: "Rename" });
|
|
expect(dialog).toHaveTextContent("Rename file 'README.md' and its usages to:");
|
|
expect(within(dialog).getByRole("checkbox", { name: "Search for references" })).toBeChecked();
|
|
expect(within(dialog).getByRole("checkbox", { name: "Search in comments and strings" })).toBeInTheDocument();
|
|
const input = within(dialog).getByRole("textbox", { name: "New name" });
|
|
await waitFor(() => expect((input as HTMLInputElement).selectionEnd).toBe("README".length));
|
|
fireEvent.change(input, { target: { value: "src" } });
|
|
expect(await within(dialog).findByRole("alert")).toHaveTextContent("'src' already exists");
|
|
expect(within(dialog).getByRole("button", { name: "Refactor" })).toBeDisabled();
|
|
fireEvent.change(input, { target: { value: "GUIDE.md" } });
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Refactor" }));
|
|
await waitFor(() => expect(rows()).toContain("GUIDE.md"));
|
|
expect(fs.nodes.has("/w/acme/GUIDE.md")).toBe(true);
|
|
expect(screen.queryByRole("dialog", { name: "Rename" })).toBeNull();
|
|
|
|
// ⌘Z in the project view undoes the rename.
|
|
tree.focus();
|
|
fireEvent.keyDown(tree, { key: "z", code: "KeyZ", metaKey: true });
|
|
await waitFor(() => expect(fs.nodes.has("/w/acme/README.md")).toBe(true));
|
|
expect(fs.nodes.has("/w/acme/GUIDE.md")).toBe(false);
|
|
});
|
|
|
|
it("moves with F6 through the Move dialog", async () => {
|
|
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" });
|
|
const dialog = await screen.findByRole("dialog", { name: "Move" });
|
|
expect(dialog).toHaveTextContent("Move file 'README.md'");
|
|
expect(within(dialog).getByRole("checkbox", { name: "Open moved files in editor" })).toBeInTheDocument();
|
|
const target = within(dialog).getByRole("textbox", { name: "Target directory" });
|
|
expect(target).toHaveValue(ROOT);
|
|
expect(within(dialog).getByRole("alert")).toHaveTextContent("Already in this directory");
|
|
fireEvent.change(target, { target: { value: "src" } });
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Refactor" }));
|
|
await waitFor(() => expect(fs.nodes.has("/w/acme/src/README.md")).toBe(true));
|
|
expect(fs.nodes.has("/w/acme/README.md")).toBe(false);
|
|
});
|
|
|
|
it("deletes the selection after confirmation", async () => {
|
|
const { tree, row, rows } = setup();
|
|
await waitFor(() => expect(rows()).toContain("README.md"));
|
|
fireEvent.mouseDown(row("README.md"));
|
|
tree.focus();
|
|
fireEvent.keyDown(tree, { key: "Delete", code: "Delete" });
|
|
const dialog = await screen.findByRole("dialog", { name: "Delete" });
|
|
expect(dialog).toHaveTextContent("Delete 'README.md'?");
|
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
|
await waitFor(() => expect(rows()).not.toContain("README.md"));
|
|
expect(fs.nodes.has("/w/acme/README.md")).toBe(false);
|
|
});
|
|
|
|
it("refreshes from fsChanged events keeping expansion", async () => {
|
|
const { rows } = setup();
|
|
act(() => useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src"]) }));
|
|
await waitFor(() => expect(rows()).toContain("App.kt"));
|
|
await act(() => fs.createFile("/w/acme/src/New.kt"));
|
|
await waitFor(() => expect(rows()).toEqual(["acme/w/acme", "build", "src", "App.kt", "New.kt", "Util.kt", "README.md"]));
|
|
});
|
|
|
|
it("renders large folders virtualized", async () => {
|
|
for (let i = 0; i < 10_000; i++) fs.nodes.set(`/w/acme/big/f${i}.txt`, { kind: "file", size: 1 });
|
|
fs.nodes.set("/w/acme/big", { kind: "dir" });
|
|
const { tree } = setup();
|
|
act(() => useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/big"]) }));
|
|
await waitFor(() => expect(useProjectTree.getState().listings.get("/w/acme/big")?.length).toBe(10_000));
|
|
const rendered = within(tree).getAllByRole("treeitem").length;
|
|
expect(rendered).toBeLessThan(80);
|
|
});
|
|
});
|