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

This commit is contained in:
l0ng-ai
2026-11-04 09:39:32 +08:00
parent 770178966f
commit 4d086c44c9
11 changed files with 320 additions and 27 deletions
+52
View File
@@ -27,3 +27,55 @@ test("stripe buttons toggle tool windows", async ({ workbench: page }) => {
await stripe.getByRole("button", { name: "Problems", exact: true }).click();
await expect(problems).toBeHidden();
});
for (const reducedMotion of ["no-preference", "reduce"] as const) {
test(`project opening advances simulated progress with ${reducedMotion} motion`, async ({ workbench: page }) => {
await page.emulateMedia({ reducedMotion });
await page.evaluate(async () => {
const path = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ path);
useProject.setState({ opening: true, openingPath: "/work/large-project" });
});
const progress = page.locator("[data-project-opening]").getByRole("progressbar");
await expect(progress).toBeVisible();
for (const theme of ["light", "dark"]) {
await page.evaluate((theme) => (document.documentElement.dataset.theme = theme), theme);
const frames = await progress.evaluate((track) => {
const fill = track.firstElementChild!;
const animation = fill.getAnimations()[0];
if (!animation) throw new Error("Loading progress has no activity indicator");
animation.pause();
const duration = Number(animation.effect!.getComputedTiming().duration);
return [0, 0.25, 0.5, 0.75, 1, 1.5, 2].map((phase) => {
animation.currentTime = duration * phase;
const outer = track.getBoundingClientRect();
const inner = fill.getBoundingClientRect();
const style = getComputedStyle(fill);
return {
trackWidth: outer.width,
fillWidth: inner.width,
height: inner.height,
left: inner.left - outer.left,
right: outer.right - inner.right,
opacity: Number(style.opacity),
color: style.backgroundColor,
};
});
});
for (const frame of frames) {
expect(frame.height).toBeGreaterThan(0);
expect(frame.fillWidth).toBeGreaterThan(0);
expect(frame.fillWidth).toBeLessThan(frame.trackWidth);
expect(frame.left).toBeGreaterThanOrEqual(-0.5);
expect(frame.right).toBeGreaterThanOrEqual(-0.5);
expect(frame.opacity).toBeGreaterThanOrEqual(0.6);
expect(frame.color).not.toBe("rgba(0, 0, 0, 0)");
}
const widths = frames.map((frame) => frame.fillWidth);
expect(widths[0]).toBeLessThan(widths[1]!);
for (let i = 1; i < widths.length; i++) expect(widths[i]).toBeGreaterThanOrEqual(widths[i - 1]!);
expect(widths[4]! / frames[4]!.trackWidth).toBeCloseTo(0.92, 2);
expect(widths[6]).toBeCloseTo(widths[4]!, 2);
}
});
}
+8 -7
View File
@@ -22,8 +22,8 @@ body,
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding-bottom: 38px;
gap: var(--splash-gap, 14px);
padding-bottom: var(--splash-offset, 38px);
box-sizing: border-box;
color: var(--fg-muted, var(--boot-fg, #6c707e));
-webkit-user-select: none;
@@ -40,8 +40,8 @@ body,
color: var(--fg-muted, var(--boot-fg, #8c8f95));
}
.splash-logo {
width: 56px;
height: 56px;
width: var(--splash-logo-size, 56px);
height: var(--splash-logo-size, 56px);
}
/* 15px Inter: 2048 units per em, the line box from ascender to descender. */
.splash-wordmark {
@@ -49,8 +49,8 @@ body,
height: 18.15px;
}
.splash-bar {
width: 120px;
height: 2px;
width: var(--splash-bar-width, 120px);
height: var(--splash-bar-height, 2px);
border-radius: 1px;
overflow: hidden;
background: rgb(127 127 127 / 0.18);
@@ -63,7 +63,8 @@ body,
height: 100%;
background: var(--boot-accent, var(--accent, #3871e1));
transform-origin: left;
animation: splash-fill 4s cubic-bezier(0.1, 0.7, 0.3, 1) both;
animation: splash-fill var(--splash-progress-duration, 4s)
var(--splash-progress-easing, cubic-bezier(0.1, 0.7, 0.3, 1)) both;
}
/* Settings window: a dialog, not a launch. Only the bar, and only if loading takes a while. */
:root[data-window="settings"] .splash {
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
}
}
+7
View File
@@ -18,6 +18,13 @@ html {
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--splash-logo-size: 56px;
--splash-bar-width: 120px;
--splash-bar-height: 2px;
--splash-gap: 14px;
--splash-offset: 38px;
--splash-progress-duration: 4s;
--splash-progress-easing: cubic-bezier(0.1, 0.7, 0.3, 1);
/* surfaces */
--bg-app: #191a1c;
--bg-elev: #212326;
+85
View File
@@ -1,4 +1,5 @@
.workbench {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
@@ -6,6 +7,90 @@
background: var(--bg-app);
color: var(--fg);
}
.content {
display: contents;
}
.content[inert] {
visibility: hidden;
}
.opening {
position: absolute;
inset: 0;
z-index: var(--z-panel);
display: flex;
flex-direction: column;
background: var(--bg-frame);
}
.openingBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--splash-gap);
padding: var(--space-4) var(--space-4) var(--splash-offset);
color: var(--fg-muted);
text-align: center;
animation: opening-in var(--dur-slow) ease-out both;
}
.openingBrand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--splash-gap);
font-size: var(--fs-input-large);
font-weight: 500;
line-height: 1.21;
}
.openingLogo {
width: var(--splash-logo-size);
height: var(--splash-logo-size);
}
.openingProgress {
width: var(--splash-bar-width);
height: var(--splash-bar-height);
flex-shrink: 0;
border-radius: calc(var(--splash-bar-height) / 2);
background: color-mix(in srgb, var(--fg-muted) 24%, transparent);
}
.opening .openingProgress > div {
left: 0;
width: 100%;
opacity: 1;
background: var(--accent);
transform-origin: left;
animation: opening-progress var(--splash-progress-duration) var(--splash-progress-easing) both;
}
.openingTitle {
max-width: 100%;
overflow-wrap: anywhere;
font-size: var(--fs-small);
}
.openingPath {
max-width: 100%;
overflow-wrap: anywhere;
font-size: var(--fs-tiny);
}
@keyframes opening-in {
from {
opacity: 0;
}
}
@keyframes opening-progress {
from {
transform: scaleX(0.04);
}
to {
transform: scaleX(0.92);
}
}
@media (prefers-reduced-motion: reduce) {
.openingBody {
animation: none;
}
}
/* Islands frame; the project colour washes in from the top-left corner (IDEA project gradient). */
.framed {
background:
+35
View File
@@ -325,6 +325,41 @@ describe("tool window split areas", () => {
});
describe("workbench shell", () => {
it("covers the old project with the opening target and keeps its views mounted and inert", async () => {
const { useProject } = await import("@/platform/project");
const { Workbench } = await import("./Workbench");
const dispose = registerToolWindow({
id: "project",
title: "Project",
icon: "folder",
anchor: "left",
component: () => <input aria-label="old project input" />,
});
useProject.setState({
project: { root: "/old", name: "old", trusted: true, isGit: false, settings: {} },
opening: false,
openingPath: null,
});
loadToolWindowLayout("/old");
const { unmount } = render(<Workbench />);
const input = screen.getByRole("textbox", { name: "old project input" });
try {
act(() => useProject.setState({ opening: true, openingPath: "/work/new-project" }));
expect(screen.getByRole("status", { name: "Opening new-project…" })).toBeVisible();
expect(screen.getByText("/work/new-project")).toBeVisible();
expect(input.closest("[inert]")).not.toBeNull();
expect(input).toBeInTheDocument();
act(() => useProject.setState({ opening: false, openingPath: null }));
expect(screen.queryByText("Opening new-project…")).toBeNull();
expect(input.closest("[inert]")).toBeNull();
expect(screen.getByRole("textbox", { name: "old project input" })).toBe(input);
} finally {
unmount();
dispose();
useProject.setState({ project: null, opening: false, openingPath: null });
}
});
it("renders the welcome view without a project and the frame with one", async () => {
const { useProject } = await import("@/platform/project");
const { Workbench } = await import("./Workbench");
+44 -16
View File
@@ -6,6 +6,9 @@
*/
import { useEditorRegistry } from "@/editor/api";
import { basename, useProject } from "@/platform/project";
import { useT } from "@/platform/i18n";
import { ProgressBar } from "@/ui";
import projectMark from "../../src-tauri/icons/source/openidea-mark.svg";
import { useDistractionFree } from "./distractionFree";
import { EditorArea } from "./EditorArea";
import { OverlayHost } from "./OverlayHost";
@@ -37,11 +40,33 @@ function LightEditFrame() {
);
}
function ProjectOpening({ path }: { path: string }) {
const t = useT();
const label = t("app.project.opening", { name: basename(path) });
return (
<div className={styles.opening} data-project-opening>
<TitleBar bare />
<div className={styles.openingBody} role="status" aria-label={label}>
<div className={styles.openingBrand} aria-hidden="true">
<img className={styles.openingLogo} src={projectMark} alt="" draggable={false} />
<span>OpenIDEA</span>
</div>
<ProgressBar className={styles.openingProgress} aria-label={label} />
<div className={styles.openingTitle}>{label}</div>
<div className={styles.openingPath} title={path}>
{path}
</div>
</div>
</div>
);
}
export function Workbench() {
useApplyTheme();
const banners = useContributions((s) => s.workbenchBanners);
const project = useProject((s) => s.project);
const lightEdit = useProject((s) => s.lightEdit);
const openingPath = useProject((s) => s.openingPath);
const root = project?.root ?? null;
const distractionFree = useDistractionFree((s) => s.active);
const frame = project ? styles.framed : lightEdit ? styles.lightEditFrame : undefined;
@@ -51,22 +76,25 @@ export function Workbench() {
className={frame ? `${styles.workbench} ${frame}` : styles.workbench}
style={project ? projectColorVars(project.name) : undefined}
>
{project ? (
<>
{/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */}
<TitleBar bare={distractionFree} />
{!distractionFree && <WindowGroupTabs />}
{[...banners.values()].map(({ id, component: Banner }) => (
<Banner key={id} />
))}
<ToolWindowLayout key={root} editor={<EditorArea />} />
{!distractionFree && <StatusBar />}
</>
) : lightEdit ? (
<LightEditFrame />
) : (
<Welcome />
)}
<div className={styles.content} inert={openingPath !== null} aria-hidden={openingPath ? true : undefined}>
{project ? (
<>
{/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */}
<TitleBar bare={distractionFree} />
{!distractionFree && <WindowGroupTabs />}
{[...banners.values()].map(({ id, component: Banner }) => (
<Banner key={id} />
))}
<ToolWindowLayout key={root} editor={<EditorArea />} />
{!distractionFree && <StatusBar />}
</>
) : lightEdit ? (
<LightEditFrame />
) : (
<Welcome />
)}
</div>
{openingPath && <ProjectOpening path={openingPath} />}
<OverlayHost />
<WindowResizeHandles />
</div>