fix(workbench): show splash-style progress when opening projects

Цей коміт міститься в:
l0ng-ai committed 2026-11-04 09:39:32 +08:00
1 джерело 770178966f
коміт 4d086c44c9
11 files changed
+320 -27

No files matched your search

+5
Переглянути файл
@@ -122,6 +122,11 @@ export function allActions(): Action[] {
}
export function isActionEnabled(action: Action, ctx: ContextKeys = getContext()): boolean {
if (
ctx.projectOpening &&
!["app.quit", "window.next", "window.previous", "window.toggleFullScreen"].includes(action.id)
)
return false;
return evaluateWhen(action.when, ctx);
}
+1
Переглянути файл
@@ -49,6 +49,7 @@
"app.lightEdit.openProject": "Open as Project",
"app.lightEdit.openProjectTooltip": "Open {folder} as a project",
"app.log.failed": "Cannot show the log",
"app.project.opening": "Opening {name}…",
"app.title.default": "OpenIDEA",
"app.unsaved.body": "{count, plural, one {# file has} other {# files have}} unsaved changes in {project}. Save before closing?",
"app.unsaved.discard": "Don't Save",
+1
Переглянути файл
@@ -49,6 +49,7 @@
"app.lightEdit.openProject": "作为项目打开",
"app.lightEdit.openProjectTooltip": "将 {folder} 作为项目打开",
"app.log.failed": "无法显示日志",
"app.project.opening": "正在打开 {name}…",
"app.title.default": "OpenIDEA",
"app.unsaved.body": "{project} 中有 {count} 个文件尚未保存。关闭前是否保存?",
"app.unsaved.discard": "不保存",
+67 -2
Переглянути файл
@@ -1,5 +1,70 @@
import { describe, expect, it } from "vitest";
import { relativeDir } from "./project";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getContext } from "./context";
import { executeAction, isActionKeyEnabled, registerAction } from "./actions";
import { commands } from "./ipc";
import { openProject, relativeDir, useProject } from "./project";
import { confirmProjectTrust } from "./projectTrust";
vi.mock("./projectTrust", () => ({ confirmProjectTrust: vi.fn(async () => {}) }));
vi.mock("./ipc", async (original) => ({
...(await original<typeof import("./ipc")>()),
commands: { openProject: vi.fn(), projectCurrent: vi.fn() },
}));
type BackendProject = NonNullable<Awaited<ReturnType<typeof commands.projectCurrent>>>;
const old: BackendProject = {
root: "/old",
name: "old",
trusted: true,
isGit: false,
settings: {} as BackendProject["settings"],
};
const next: BackendProject = { ...old, root: "/new", name: "new" };
beforeEach(() => {
vi.clearAllMocks();
useProject.setState({ project: old, settings: old.settings, lightEdit: false, opening: false, openingPath: null });
});
afterEach(() => useProject.setState({ project: null, settings: null, opening: false, openingPath: null }));
it("shows the target while opening and blocks actions from both menus and shortcuts", async () => {
let finish!: (value: Awaited<ReturnType<typeof commands.openProject>>) => void;
vi.mocked(commands.openProject).mockReturnValue(new Promise((resolve) => (finish = resolve)));
const opening = openProject(next.root);
await Promise.resolve();
expect(useProject.getState()).toMatchObject({ project: old, opening: true, openingPath: next.root });
const run = vi.fn();
const action = { id: "test.projectMutation", title: "test", run };
const dispose = registerAction(action);
try {
expect(isActionKeyEnabled(action)).toBe(false);
expect(await executeAction(action.id)).toBe(false);
expect(run).not.toHaveBeenCalled();
expect(isActionKeyEnabled({ ...action, id: "app.quit" })).toBe(true);
finish({ status: "ok", data: next });
await opening;
expect(useProject.getState()).toMatchObject({ project: next, opening: false, openingPath: null });
expect(getContext().projectOpening).toBe(false);
expect(await executeAction(action.id)).toBe(true);
} finally {
dispose();
}
});
it.each([old, null, next])("reconciles a failed open with the backend's remaining project: %s", async (current) => {
vi.mocked(commands.openProject).mockRejectedValue(new Error("cannot open project"));
vi.mocked(commands.projectCurrent).mockResolvedValue(current && { ...current });
await expect(openProject(next.root)).rejects.toThrow("cannot open project");
expect(useProject.getState()).toMatchObject({ project: current, opening: false, openingPath: null });
if (current === old) expect(useProject.getState().project).toBe(old);
});
it("leaves the current project alone when trust is cancelled", async () => {
vi.mocked(confirmProjectTrust).mockRejectedValueOnce(new Error("cancelled"));
await expect(openProject(next.root)).rejects.toThrow("cancelled");
expect(commands.openProject).not.toHaveBeenCalled();
expect(useProject.getState()).toMatchObject({ project: old, opening: false, openingPath: null });
});
describe("relativeDir", () => {
it("is empty for files directly in the project root", () => {
+15 -2
Переглянути файл
@@ -1,6 +1,7 @@
/** The project open in this window. */
import { confirmProjectTrust } from "./projectTrust";
import { create } from "zustand";
import { setContext } from "./context";
import { call, commands, type ProjectInfo, type Settings } from "./ipc";
import { isVirtualUri, virtualContainer, virtualName, virtualParent } from "./virtualDocs";
@@ -9,6 +10,7 @@ interface ProjectState {
/** Effective settings (project-layered when a project is open). */
settings: Settings | null;
opening: boolean;
openingPath: string | null;
/** LightEdit: this window edits loose files without a project (`project` stays null). */
lightEdit: boolean;
}
@@ -17,9 +19,12 @@ export const useProject = create<ProjectState>(() => ({
project: null,
settings: null,
opening: false,
openingPath: null,
lightEdit: false,
}));
useProject.subscribe((s) => setContext("projectOpening", s.openingPath !== null));
let leaveGuard: (() => Promise<boolean>) | null = null;
/** Installs the check run before this window leaves its project (unsaved changes). */
@@ -50,13 +55,21 @@ export function startupLaunchHandled(): Promise<void> {
export async function openProject(path: string): Promise<ProjectInfo> {
await confirmProjectTrust(path, "local");
useProject.setState({ opening: true });
useProject.setState({ opening: true, openingPath: path });
try {
const project = await call(commands.openProject(path));
useProject.setState({ project, settings: project.settings, lightEdit: false });
return project;
} catch (error) {
// Opening may fail after the backend has already closed the old project.
if (useProject.getState().project) {
const project = await commands.projectCurrent().catch(() => null);
if (project?.root !== useProject.getState().project?.root)
useProject.setState({ project, ...(project ? { settings: project.settings } : {}), lightEdit: false });
}
throw error;
} finally {
useProject.setState({ opening: false });
useProject.setState({ opening: false, openingPath: null });
}
}