mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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]>
342 lines
14 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|