mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
fix(workbench): show splash-style progress when opening projects
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user