дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
* 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
190 рядки
6.5 KiB
TypeScript
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();
|
|
});
|
|
});
|