дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(workbench): show splash-style progress when opening projects
Цей коміт міститься в:
1 джерело
770178966f
коміт
4d086c44c9
11 files changed
+320
-27
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "不保存",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача