Файли
openidea/src/ui/Popup.test.tsx
T
l0ng-ai 81e0e8e2d0 feat: Align interactions with IntelliJ IDEA (#22)
* feat(nav): Complete file paths and flag unresolved web file references

Path completion in href/src/url()/@import, Markdown links and path-like strings, folders first. HTML and CSS paths that name no file get a 'Cannot resolve file' warning. File references resolve in the backend like IDEA's: relative to the file, /-rooted against the project and resource roots.

* feat: Align editor, workbench, run/debug and VCS interactions with IntelliJ IDEA

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

* chore: Regenerate bindings
2026-10-25 09:34:02 +08:00

190 рядки
6.5 KiB
TypeScript

import { useRef, useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { getContext, setContext } from "@/platform/context";
import { registerAction } from "@/platform/actions";
import { Popup } from "./Popup";
import { Menu, MenuList } from "./Menu";
import { Dialog } from "./Dialog";
function Harness({ onClose }: { onClose?: (r: string) => void }) {
const [open, setOpen] = useState(true);
const ref = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={ref}>anchor</button>
<button>outside</button>
<Popup
open={open}
anchor={ref}
aria-label="Test popup"
onClose={(r) => {
onClose?.(r);
setOpen(false);
}}
>
<button>inside</button>
</Popup>
</>
);
}
describe("Popup", () => {
it("closes on Escape, publishes popupOpen and restores focus", () => {
const onClose = vi.fn();
const anchorFocus = document.createElement("button");
document.body.appendChild(anchorFocus);
anchorFocus.focus();
render(<Harness onClose={onClose} />);
expect(screen.getByRole("dialog", { name: "Test popup" })).toBeInTheDocument();
expect(getContext().popupOpen).toBe(true);
expect(document.activeElement).toBe(screen.getByText("inside"));
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(onClose).toHaveBeenCalledWith("escape");
expect(screen.queryByRole("dialog")).toBeNull();
expect(getContext().popupOpen).toBe(false);
expect(document.activeElement).toBe(anchorFocus);
anchorFocus.remove();
});
it("closes on outside pointer down but not inside or on the anchor", () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} />);
fireEvent.pointerDown(screen.getByText("inside"));
fireEvent.pointerDown(screen.getByText("anchor"));
expect(onClose).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByText("outside"));
expect(onClose).toHaveBeenCalledWith("outside");
});
it("closes when the app window is deactivated, but not when focus moves into a frame", () => {
vi.useFakeTimers();
const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(true);
try {
const onClose = vi.fn();
render(<Harness onClose={onClose} />);
fireEvent.blur(window);
act(() => void vi.runAllTimers());
expect(onClose).not.toHaveBeenCalled();
hasFocus.mockReturnValue(false);
fireEvent.blur(window);
act(() => void vi.runAllTimers());
expect(onClose).toHaveBeenCalledWith("outside");
} finally {
hasFocus.mockRestore();
vi.useRealTimers();
}
});
it("only the top-most layer handles Escape", () => {
const outer = vi.fn();
const inner = vi.fn();
const ui = (withPopup: boolean) => (
<Dialog open onClose={outer} title="Outer">
<Popup open={withPopup} anchor={{ x: 0, y: 0 }} onClose={inner} aria-label="Inner">
<button>x</button>
</Popup>
</Dialog>
);
const { rerender } = render(ui(false));
rerender(ui(true));
fireEvent.keyDown(document.body, { key: "Escape" });
expect(inner).toHaveBeenCalledTimes(1);
expect(outer).not.toHaveBeenCalled();
});
});
describe("Menu", () => {
it("navigates with arrows, skips disabled items, runs actions and closes", async () => {
const run = vi.fn();
const actionRun = vi.fn();
const dispose = registerAction({ id: "test.menuAction", title: "From Action", run: actionRun });
const onClose = vi.fn();
render(
<Menu
open
onClose={onClose}
anchor={{ x: 0, y: 0 }}
items={[
{ label: "First", run },
{ label: "Disabled", disabled: true },
{ kind: "separator" },
{ kind: "action", action: "test.menuAction" },
{ kind: "action", action: "missing.action" },
]}
/>,
);
const items = screen.getAllByRole("menuitem");
expect(items.map((i) => i.textContent)).toEqual(["First", "Disabled", "From Action"]);
expect(document.activeElement).toBe(items[0]);
fireEvent.keyDown(items[0]!, { key: "ArrowDown" });
expect(document.activeElement).toBe(items[2]);
await act(async () => {
fireEvent.keyDown(items[2]!, { key: "Enter" });
});
expect(onClose).toHaveBeenCalled();
expect(actionRun).toHaveBeenCalled();
dispose();
});
});
describe("Dialog deactivation", () => {
it("closes popups but not modal dialogs when the app window loses focus", () => {
vi.useFakeTimers();
const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false);
try {
const popup = vi.fn();
const modal = vi.fn();
render(
<>
<Dialog open onClose={modal} title="Modal">
<p>body</p>
</Dialog>
<Dialog open onClose={popup} position="top" aria-label="Popup">
<p>body</p>
</Dialog>
</>,
);
fireEvent.blur(window);
act(() => void vi.runAllTimers());
expect(popup).toHaveBeenCalledTimes(1);
expect(modal).not.toHaveBeenCalled();
} finally {
hasFocus.mockRestore();
vi.useRealTimers();
}
});
});
describe("Dialog initial focus", () => {
it("focuses the first control of the content, not the header close button", () => {
render(
<Dialog open onClose={() => {}} title="Configs" footer={<button>OK</button>}>
<input aria-label="Name" />
</Dialog>,
);
expect(document.activeElement).toBe(screen.getByLabelText("Name"));
});
it("falls back to the footer when the content has no controls", () => {
render(
<Dialog open onClose={() => {}} title="Info" footer={<button>OK</button>}>
<p>Nothing to edit</p>
</Dialog>,
);
expect(document.activeElement).toBe(screen.getByText("OK"));
});
});
describe("Menu action enablement", () => {
it("follows context changes after the menu opened", () => {
const dispose = registerAction({ id: "test.menuWhen", title: "Needs Flag", when: "qaMenuFlag", run: () => {} });
setContext("qaMenuFlag", false);
render(<MenuList items={[{ kind: "action", action: "test.menuWhen" }]} onDone={() => {}} />);
expect(screen.getByRole("menuitem", { name: /Needs Flag/ })).toHaveAttribute("aria-disabled", "true");
act(() => setContext("qaMenuFlag", true));
expect(screen.getByRole("menuitem", { name: /Needs Flag/ })).not.toHaveAttribute("aria-disabled", "true");
dispose();
});
});