Файли
openidea/src/features/app/__tests__/windows.test.ts
T
l0ng-ai 01defdefe4 feat: multi-project workspaces and project window groups (#448)
Workspaces open several local project folders in one IDE session (File › New / Open / Manage Workspace…, or Attach when opening a project, which creates a workspace automatically for a plain project window). Member folders are the Project view roots and take part in indexing, Find in Files, Git, language servers, run configurations and builds; agent tasks choose which member they work in. Window › Merge All Project Windows groups project windows into one window with project tabs (switch, reorder, close, move to a new window), restored on reopen.

Closes #241
Closes #198
Closes #286
2026-10-30 22:45:42 +08:00

285 рядки
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const workspaceAttach = vi.fn();
const workspaceUpdate = vi.fn();
const projectCurrent = vi.fn();
const windowOpenProject = vi.fn();
const windowOpenFiles = vi.fn();
const windowTakeLaunch = vi.fn();
// The backend answers with the global settings, here: whatever the test set up.
const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} }));
const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] }));
const openProject = vi.fn(async (path: string) => {
const project = { root: path, name: path, isGit: false, settings: {} };
useProject.setState({ project, lightEdit: false } as never);
return project;
});
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: {
...real.commands,
workspaceAttach: (...a: unknown[]) => workspaceAttach(...a),
workspaceUpdate: (...a: unknown[]) => workspaceUpdate(...a),
projectCurrent: () => projectCurrent(),
windowOpenProject: (...a: unknown[]) => windowOpenProject(...a),
windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a),
windowTakeLaunch: () => windowTakeLaunch(),
lightEditOpen: (p: string) => lightEditOpen(p),
fsReadDir: (p: string) => fsReadDir(p),
},
};
});
vi.mock("@/platform/project", async (orig) => {
const real = await orig<typeof import("@/platform/project")>();
return { ...real, openProject: (p: string) => openProject(p) };
});
const {
openProjectInWindow,
consumeLaunch,
openFiles,
openPaths,
openLightEditFileAsProject,
openProjectWithPolicy,
windowActions,
} = await import("../windows");
const { useProject } = await import("@/platform/project");
const { provideEditorService } = await import("@/workbench/contributions");
const opened: string[] = [];
let activePath: string | null = null;
provideEditorService({
openFile: async (path, options) => void opened.push(`${path}${options?.focus ? " (focus)" : ""}`),
openInput: async () => {},
activePath: () => activePath,
activeCursor: () => null,
saveAll: async () => {},
});
const ok = (data: unknown) => ({ status: "ok", data });
const projectState = (root: string) => ({ project: { root, name: root, isGit: false, settings: {} } });
beforeEach(() => {
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset();
for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear();
opened.length = 0;
activePath = null;
useProject.setState({ project: null, lightEdit: false, settings: null } as never);
});
describe("openProjectInWindow", () => {
it("opens in this window when the backend says `current`", async () => {
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
await openProjectInWindow("/p/a", false);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", false, []);
expect(openProject).toHaveBeenCalledWith("/p/a");
});
it("asks for a new window when this one already has a project", async () => {
useProject.setState(projectState("/p/b") as never);
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
await openProjectInWindow("/p/a", false);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true, []);
expect(openProject).not.toHaveBeenCalled();
});
it("asks for a new window from a LightEdit window", async () => {
useProject.setState({ lightEdit: true });
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
await openProjectInWindow("/p/a", false);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true, []);
});
it("does nothing locally when another window was focused", async () => {
windowOpenProject.mockResolvedValue(ok({ kind: "focused", label: "main" }));
await openProjectInWindow("/p/a", true);
expect(openProject).not.toHaveBeenCalled();
});
});
describe("loose files (LightEdit)", () => {
it("turns a window without a project into LightEdit when no LightEdit window exists", async () => {
windowOpenFiles.mockResolvedValue(ok(["/x/a.txt", "/y/b.md"]));
await openFiles(["/x/a.txt", "/y/b.md"]);
expect(windowOpenFiles).toHaveBeenCalledWith(["/x/a.txt", "/y/b.md"]);
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
expect(useProject.getState()).toMatchObject({ project: null, lightEdit: true });
expect(opened).toEqual(["/x/a.txt", "/y/b.md (focus)"]);
});
it("leaves files to the existing LightEdit window or their project's window", async () => {
windowOpenFiles.mockResolvedValue(ok([]));
await openFiles(["/x/a.txt"]);
expect(lightEditOpen).not.toHaveBeenCalled();
expect(opened).toEqual([]);
expect(useProject.getState().lightEdit).toBe(false);
});
it("opens only the files the backend leaves to this window", async () => {
windowOpenFiles.mockResolvedValue(ok(["/x/b.txt"]));
await openFiles(["/p/a.rs", "/x/b.txt"]);
expect(lightEditOpen).toHaveBeenCalledWith("/x/b.txt");
expect(opened).toEqual(["/x/b.txt (focus)"]);
});
it("adds tabs to this LightEdit window", async () => {
useProject.setState({ lightEdit: true });
windowOpenFiles.mockResolvedValue(ok(["/x/c.txt"]));
await openFiles(["/x/c.txt"]);
expect(windowOpenFiles).toHaveBeenCalledWith(["/x/c.txt"]);
expect(lightEditOpen).not.toHaveBeenCalled();
expect(opened).toEqual(["/x/c.txt (focus)"]);
});
it("opens files in the project's editor in a project window", async () => {
useProject.setState(projectState("/p") as never);
await openFiles(["/elsewhere/a.txt"]);
expect(windowOpenFiles).not.toHaveBeenCalled();
expect(opened).toEqual(["/elsewhere/a.txt (focus)"]);
expect(useProject.getState().lightEdit).toBe(false);
});
it("routes dropped files to LightEdit and the dropped folder to a project", async () => {
windowOpenFiles.mockResolvedValue(ok(["/x/a.txt"]));
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
useProject.setState({ settings: { general: { openProjectIn: "new" } } } as never);
await openPaths(["/x/a.txt", "/work/app"], async (p) => p === "/work/app");
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
expect(windowOpenProject).toHaveBeenCalledWith("/work/app", true, []);
});
it("opens a dropped folder alone exactly as before", async () => {
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
await openPaths(["/work/app"], async () => true);
expect(windowOpenFiles).not.toHaveBeenCalled();
expect(lightEditOpen).not.toHaveBeenCalled();
expect(openProject).toHaveBeenCalledWith("/work/app");
});
it("classifies paths through the file system by default", async () => {
fsReadDir.mockResolvedValueOnce({ status: "error", error: { kind: "notFound", message: "not a dir" } } as never);
windowOpenFiles.mockResolvedValue(ok(["/x/a.txt"]));
await openPaths(["/x/a.txt"]);
expect(fsReadDir).toHaveBeenCalledWith("/x/a.txt");
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
});
});
describe("Open Containing Folder as Project", () => {
it("is an action available in LightEdit only", () => {
const action = windowActions().find((a) => a.id === "lightEdit.openProject");
expect(action?.when).toBe("lightEdit");
expect(windowActions().some((a) => a.id === "file.openFile")).toBe(true);
});
it("opens the folder in a new window with the file, per 'Open project in'", async () => {
useProject.setState({ lightEdit: true, settings: { general: { openProjectIn: "new" } } } as never);
activePath = "/x/notes/a.txt";
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
await openLightEditFileAsProject();
expect(windowOpenProject).toHaveBeenCalledWith("/x/notes", true, ["/x/notes/a.txt"]);
expect(openProject).not.toHaveBeenCalled();
});
it("or turns this window into the project and reopens the file there", async () => {
useProject.setState({ lightEdit: true, settings: { general: { openProjectIn: "same" } } } as never);
activePath = "/x/notes/a.txt";
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
await openLightEditFileAsProject();
expect(windowOpenProject).toHaveBeenCalledWith("/x/notes", false, ["/x/notes/a.txt"]);
expect(openProject).toHaveBeenCalledWith("/x/notes");
expect(useProject.getState()).toMatchObject({ lightEdit: false, project: { root: "/x/notes" } });
expect(opened).toEqual(["/x/notes/a.txt (focus)"]);
});
it("does nothing outside LightEdit", async () => {
useProject.setState(projectState("/p") as never);
activePath = "/p/a.txt";
await openLightEditFileAsProject();
expect(windowOpenProject).not.toHaveBeenCalled();
});
});
describe("launch", () => {
it("opens the pending project once", async () => {
windowTakeLaunch.mockResolvedValueOnce({ project: "/p/launch", files: [] }).mockResolvedValueOnce(null);
await consumeLaunch();
await consumeLaunch();
expect(openProject).toHaveBeenCalledTimes(1);
expect(openProject).toHaveBeenCalledWith("/p/launch");
});
it("opens launched files in LightEdit without a project", async () => {
windowTakeLaunch.mockResolvedValueOnce({ project: null, files: ["/x/a.txt"] });
await consumeLaunch();
expect(windowOpenFiles).not.toHaveBeenCalled();
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
expect(opened).toEqual(["/x/a.txt (focus)"]);
});
it("opens launched files in the launched project", async () => {
windowTakeLaunch.mockResolvedValueOnce({ project: "/x/notes", files: ["/x/notes/a.txt"] });
await consumeLaunch();
expect(openProject).toHaveBeenCalledWith("/x/notes");
expect(lightEditOpen).not.toHaveBeenCalled();
expect(opened).toEqual(["/x/notes/a.txt (focus)"]);
});
});
describe("Attach", () => {
it("switches a plain project to the automatic workspace in place", async () => {
useProject.setState({
...projectState("/p/a"),
settings: { general: { openProjectIn: "attach" } },
} as never);
workspaceAttach.mockResolvedValue(ok("/data/workspaces/a"));
await openProjectWithPolicy("/p/b");
expect(workspaceAttach).toHaveBeenCalledWith("/p/b");
expect(openProject).toHaveBeenCalledWith("/data/workspaces/a");
expect(windowOpenProject).not.toHaveBeenCalled();
});
it("refreshes an existing workspace without reopening its editor session", async () => {
const before = {
root: "/workspace",
name: "A",
isGit: false,
settings: {},
workspace: { version: 1, name: "A", projects: ["/p/a"] },
};
useProject.setState({
project: before,
settings: { general: { openProjectIn: "attach" } },
} as never);
workspaceAttach.mockResolvedValue(ok("/workspace"));
projectCurrent.mockResolvedValue({
...before,
workspace: { ...before.workspace, projects: ["/p/a", "/p/b"] },
});
await openProjectWithPolicy("/p/b");
expect(useProject.getState().project?.workspace?.projects).toEqual(["/p/a", "/p/b"]);
expect(openProject).not.toHaveBeenCalled();
});
});
it("removes the first workspace member while retaining the workspace state root", async () => {
const project = {
root: "/workspace",
name: "A",
isGit: false,
settings: {},
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
};
useProject.setState({ project });
workspaceUpdate.mockResolvedValue(ok({ ...project, workspace: { ...project.workspace, projects: ["/b"] } }));
await windowActions()
.find((a) => a.id === "workspace.removeProject")!
.run({ arg: "/a" } as never);
expect(workspaceUpdate).toHaveBeenCalledWith("A", ["/b"]);
expect(useProject.getState().project?.root).toBe("/workspace");
expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]);
});