Files
openidea/src/editor/core/controller.test.ts
Deng Liuanddengliu 1f76e71621 fix(viewers): yield image viewer to registered text editor wrappers (#1638)
A plugin's side-by-side custom editor for an image-extension subtype (such as *.excalidraw.svg) was intercepted by the image viewer. The image viewer now yields to a matching text editor wrapper, and an image tab already open switches to the text editor when such a wrapper registers later (after plugins load).

Co-authored-by: dengliu <[email protected]>
2026-11-06 06:54:57 +08:00

342 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorController, openFilePaths, useEditorStatus, useTabs } from "./controller";
import { createMemoryBackend } from "./backend";
import { initialTabsState } from "./model/tabs";
import { useDocMeta } from "./model/document";
import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree";
import { registerTextEditorWrapper } from "@/workbench/contributions";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { resolveMenuItems } from "@/ui";
import { getContext } from "@/platform/context";
import { installKeyboardDispatcher } from "@/platform/keymap";
import { TEXT_WHEN } from "./actions";
let ctrl: EditorController | null = null;
afterEach(() => {
ctrl?.stop();
ctrl = null;
useTabs.setState(initialTabsState(), true);
useEditorStatus.setState({ status: null });
});
describe("EditorController", () => {
it("autosaves on blur, but not while focus is in a popup over the editor", async () => {
const backend = createMemoryBackend({ "/p/a.c": "int main() {\n \n}\n" });
const save = vi.spyOn(backend, "save");
ctrl = new EditorController(backend);
ctrl.start();
await ctrl.openFile("/p/a.c", { focus: false });
const slot = ctrl.activeSlot!;
const host = document.createElement("div");
const popup = document.createElement("div");
popup.setAttribute("data-popup", "");
const popupList = document.createElement("button");
popup.appendChild(popupList);
const elsewhere = document.createElement("button");
document.body.append(host, popup, elsewhere);
try {
const view = slot.mount(host);
ctrl.onMounted(slot);
const type = () => view.dispatch({ changes: { from: 0, insert: "x" }, userEvent: "input.type" });
// Into a popup (⌥↵ intentions): nothing is saved while it is open.
view.focus();
type();
popupList.focus();
await new Promise((r) => setTimeout(r, 20));
expect(save).not.toHaveBeenCalled();
// Back to the editor (popup closed): still nothing.
view.focus();
await new Promise((r) => setTimeout(r, 20));
expect(save).not.toHaveBeenCalled();
// From the popup elsewhere: saved then.
popupList.focus();
await new Promise((r) => setTimeout(r, 20));
elsewhere.focus();
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
// Straight elsewhere: saved.
view.focus();
type();
elsewhere.focus();
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2));
} finally {
host.remove();
popup.remove();
elsewhere.remove();
}
});
it("leaves modified Enter to a non-text editor after switching from a focused file", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "one\ntwo\n" }));
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
const slot = ctrl.activeSlot!;
const host = document.createElement("div");
document.body.appendChild(host);
const view = slot.mount(host);
ctrl.onMounted(slot);
view.focus();
await vi.waitFor(() => expect(getContext().editorTextFocus).toBe(true));
const splitLine = vi.fn();
const dispose = registerActions([
{
id: "code.splitLine",
title: "Split Line",
when: TEXT_WHEN,
run: splitLine,
},
]);
const uninstall = installKeyboardDispatcher(window);
const message = document.createElement("textarea");
const send = vi.fn();
message.addEventListener("keydown", send);
try {
await ctrl.openInput({ kind: "agent-session", key: "task" });
slot.unmount();
host.appendChild(message);
message.focus();
message.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
metaKey: true,
bubbles: true,
cancelable: true,
}),
);
expect(splitLine).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledOnce();
expect(getContext().editorTextFocus).toBe(false);
await ctrl.openFile("/p/a.ts", { focus: false });
const reopened = slot.mount(host);
ctrl.onMounted(slot);
reopened.focus();
await vi.waitFor(() => expect(getContext().editorTextFocus).toBe(true));
reopened.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
metaKey: true,
bubbles: true,
cancelable: true,
}),
);
expect(splitLine).toHaveBeenCalledOnce();
} finally {
uninstall();
dispose();
slot.unmount();
host.remove();
}
});
it("updates the caret status synchronously on programmatic selection changes", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "one\ntwo\nthree\n" }));
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
const s = useTabs.getState();
const slot = ctrl.slotFor(s.panes[s.activePane]!.active!)!;
const host = document.createElement("div");
document.body.appendChild(host);
const view = slot.mount(host);
ctrl.onMounted(slot);
view.dispatch({ selection: { anchor: 6 } });
expect(useEditorStatus.getState().status).toMatchObject({ path: "/p/a.ts", line: 1, col: 2, offset: 6 });
view.dispatch({ selection: { anchor: 4, head: 9 } });
expect(useEditorStatus.getState().status).toMatchObject({ line: 2, col: 1, selectedChars: 5, selectedLines: 1 });
slot.unmount();
host.remove();
});
it("reconfigures open editors when Distraction Free Mode toggles", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "one\ntwo\n" }));
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
const s = useTabs.getState();
const slot = ctrl.slotFor(s.panes[s.activePane]!.active!)!;
const host = document.createElement("div");
document.body.appendChild(host);
const view = slot.mount(host);
expect(view.dom.querySelector(".cm-lineNumbers")).not.toBeNull();
setDistractionFree(true);
expect(view.dom.querySelector(".cm-lineNumbers")).toBeNull();
expect(view.dom.classList.contains("cm-openidea-distractionFree")).toBe(true);
setDistractionFree(false);
expect(view.dom.querySelector(".cm-lineNumbers")).not.toBeNull();
expect(view.dom.classList.contains("cm-openidea-distractionFree")).toBe(false);
resetDistractionFree();
slot.unmount();
host.remove();
});
it("lists open file paths across panes", async () => {
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
await ctrl.openFile("/p/b.ts", { focus: false, side: true });
await ctrl.openFile("/p/a.ts", { focus: false, side: true });
expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts", "/p/b.ts"]);
});
it("opens a copy of a tab in the opposite group, splitting when there is none", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "", "/p/b.ts": "" }));
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
await ctrl.openFile("/p/b.ts", { focus: false });
const paths = (paneId: string) => useTabs.getState().panes[paneId]!.tabs.map((t) => t.input.path);
const first = useTabs.getState().activePane;
const tabA = useTabs.getState().panes[first]!.tabs[0]!.id;
ctrl.openInOpposite(first, tabA);
const second = useTabs.getState().activePane;
expect(second).not.toBe(first);
expect(paths(second)).toEqual(["/p/a.ts"]);
ctrl.openInOpposite(first, useTabs.getState().panes[first]!.tabs[1]!.id);
expect(paths(second)).toEqual(["/p/a.ts", "/p/b.ts"]);
expect(paths(first)).toEqual(["/p/a.ts", "/p/b.ts"]);
});
it("closes read-only tabs and keeps a preview tab open from the tab menu", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "", "/p/b.ts": "", "/p/c.ts": "" }));
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
await ctrl.openFile("/p/b.ts", { focus: false });
await ctrl.openFile("/p/c.ts", { focus: false, preview: true });
const pane = () => useTabs.getState().panes[useTabs.getState().activePane]!;
const labels = () => resolveMenuItems(ctrl!.tabMenu(pane().id, pane().tabs[2]!)).map((i) => i.label);
expect(labels()).toContain("Keep Tab Open");
expect(labels()).not.toContain("Close All Read-Only Tabs");
ctrl.keepTab(pane().id, pane().tabs[2]!.id);
expect(pane().tabs[2]!.preview).toBe(false);
useDocMeta.setState({ "/p/b.ts": { ...useDocMeta.getState()["/p/b.ts"]!, readOnly: true } });
expect(labels()).toContain("Close All Read-Only Tabs");
await ctrl.closeReadOnly(pane().id);
expect(pane().tabs.map((t) => t.input.path)).toEqual(["/p/a.ts", "/p/c.ts"]);
});
it("carries the plain project's editor tabs into its automatic workspace", async () => {
const settings = {};
useProject.setState({
project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings },
settings,
lightEdit: false,
});
ctrl = new EditorController(createMemoryBackend({ "/attach-a/one.txt": "one", "/attach-a/two.txt": "two" }));
ctrl.start();
await ctrl.openFile("/attach-a/one.txt", { focus: false, preview: false });
await ctrl.openFile("/attach-a/two.txt", { focus: false, preview: false });
useProject.setState({
project: {
root: "/attach-workspace",
name: "A",
trusted: true,
isGit: false,
settings,
workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] },
},
});
await vi.waitFor(() =>
expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]),
);
useProject.setState({ project: null });
});
it("reopens the LightEdit files the next time a window switches to LightEdit", async () => {
ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b" }));
ctrl.start();
useProject.setState({ lightEdit: true });
await ctrl.openFile("/x/a.txt", { focus: false });
await ctrl.openFile("/y/b.md", { focus: false });
// The LightEdit window closes with its tabs open.
useProject.setState({ lightEdit: false });
expect(openFilePaths(useTabs.getState())).toEqual([]);
useProject.setState({ lightEdit: true });
await vi.waitFor(() => expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md"]));
// Clearing the session must not resurrect its other files on the next explicit open.
await ctrl.closeEverything();
useProject.setState({ lightEdit: false });
useProject.setState({ lightEdit: true });
await ctrl.openFile("/x/a.txt", { focus: false });
expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt"]);
useProject.setState({ lightEdit: false });
});
it("opens a file requested while the LightEdit session is being restored", async () => {
ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b", "/z/launch.lua": "l" }));
ctrl.start();
useProject.setState({ lightEdit: true });
await ctrl.openFile("/x/a.txt", { focus: false });
await ctrl.openFile("/y/b.md", { focus: false });
useProject.setState({ lightEdit: false });
// A launch with a file path: LightEdit starts and the file opens before the old tabs are back.
useProject.setState({ lightEdit: true });
await ctrl.openFile("/z/launch.lua", { focus: false });
expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md", "/z/launch.lua"]);
const s = useTabs.getState();
const active = s.panes[s.activePane]!;
expect(active.tabs.find((t) => t.id === active.active)?.input.path).toBe("/z/launch.lua");
await ctrl.closeEverything();
useProject.setState({ lightEdit: false });
});
it("closes a tab by its input key", async () => {
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
await ctrl.openFile("/p/b.ts", { focus: false });
const key = Object.values(useTabs.getState().panes)
.flatMap((p) => p.tabs)
.find((t) => t.input.path === "/p/b.ts")!.input.key;
await ctrl.closeInput(key);
expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]);
});
it("builds IDEA's editor tab menu", async () => {
useProject.setState({
project: { name: "p", root: "/p", trusted: true, isGit: true, settings: {} } as never,
settings: {} as never,
});
const ids = [
"navigate.selectIn",
"project.revealInFinder",
"vcs.localHistory",
"vcs.commit",
"project.copyAbsolutePath",
"project.rename",
"file.settings",
];
const dispose = registerActions(ids.map((id) => ({ id, title: id, run: () => undefined })));
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "", "/p/b.ts": "" }));
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
await ctrl.openFile("/p/b.ts", { focus: false });
const s = useTabs.getState();
const pane = s.panes[s.activePane]!;
const items = resolveMenuItems(ctrl.tabMenu(pane.id, pane.tabs[0]!));
expect(items.find((i) => i.label === "Close Tabs to the Left")?.disabled).toBe(true);
expect(items.find((i) => i.label === "Close Tabs to the Right")?.disabled).toBe(false);
dispose();
useProject.setState({ project: null, settings: null });
});
it("switches an open image tab to a text tab when a matching text editor wrapper registers", async () => {
ctrl = new EditorController(createMemoryBackend({ "/p/arch.diagram.svg": "<svg/>" }));
ctrl.start();
await ctrl.openInput({ kind: "image", key: "image:/p/arch.diagram.svg", path: "/p/arch.diagram.svg" });
expect(openFilePaths(useTabs.getState())).toEqual([]);
const offWrapper = registerTextEditorWrapper({
id: "plugin.acme.diagram.canvas",
matches: (p) => p.endsWith(".diagram.svg"),
component: () => null,
});
try {
expect(openFilePaths(useTabs.getState())).toEqual(["/p/arch.diagram.svg"]);
await vi.waitFor(() => expect(ctrl?.activeSlot?.model.path).toBe("/p/arch.diagram.svg"));
} finally {
offWrapper();
}
});
});