diff --git a/e2e/workbench.spec.ts b/e2e/workbench.spec.ts index 7c028f2e..3d3968c3 100644 --- a/e2e/workbench.spec.ts +++ b/e2e/workbench.spec.ts @@ -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); + } + }); +} diff --git a/public/splash.css b/public/splash.css index 2414c529..ecb40b61 100644 --- a/public/splash.css +++ b/public/splash.css @@ -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 { diff --git a/src/platform/actions.ts b/src/platform/actions.ts index 19f3ed32..2b561ad5 100644 --- a/src/platform/actions.ts +++ b/src/platform/actions.ts @@ -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); } diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index d63d811c..42b99e00 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -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", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 79f8adec..20801b94 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -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": "不保存", diff --git a/src/platform/project.test.ts b/src/platform/project.test.ts index 6675236e..c226cef2 100644 --- a/src/platform/project.test.ts +++ b/src/platform/project.test.ts @@ -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()), + commands: { openProject: vi.fn(), projectCurrent: vi.fn() }, +})); + +type BackendProject = NonNullable>>; +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>) => 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", () => { diff --git a/src/platform/project.ts b/src/platform/project.ts index 4c009b02..2aed87fc 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -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(() => ({ project: null, settings: null, opening: false, + openingPath: null, lightEdit: false, })); +useProject.subscribe((s) => setContext("projectOpening", s.openingPath !== null)); + let leaveGuard: (() => Promise) | null = null; /** Installs the check run before this window leaves its project (unsaved changes). */ @@ -50,13 +55,21 @@ export function startupLaunchHandled(): Promise { export async function openProject(path: string): Promise { 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 }); } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 6d27f313..a572274a 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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; diff --git a/src/workbench/Workbench.module.css b/src/workbench/Workbench.module.css index 48723829..7993b2b9 100644 --- a/src/workbench/Workbench.module.css +++ b/src/workbench/Workbench.module.css @@ -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: diff --git a/src/workbench/Workbench.test.tsx b/src/workbench/Workbench.test.tsx index f8f8f717..2ebf6fcd 100644 --- a/src/workbench/Workbench.test.tsx +++ b/src/workbench/Workbench.test.tsx @@ -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: () => , + }); + useProject.setState({ + project: { root: "/old", name: "old", trusted: true, isGit: false, settings: {} }, + opening: false, + openingPath: null, + }); + loadToolWindowLayout("/old"); + const { unmount } = render(); + 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"); diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 56c55c83..9b5c832c 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -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 ( +
+ +
+ + +
{label}
+
+ {path} +
+
+
+ ); +} + 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. */} - - {!distractionFree && } - {[...banners.values()].map(({ id, component: Banner }) => ( - - ))} - } /> - {!distractionFree && } - - ) : lightEdit ? ( - - ) : ( - - )} +
+ {project ? ( + <> + {/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */} + + {!distractionFree && } + {[...banners.values()].map(({ id, component: Banner }) => ( + + ))} + } /> + {!distractionFree && } + + ) : lightEdit ? ( + + ) : ( + + )} +
+ {openingPath && }