From b7d886ea34631ed3f61e789941723d48ed41db1f Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Thu, 5 Nov 2026 21:10:09 -0800 Subject: [PATCH] fix(welcome): allow cancelling project reopen and prevent startup reopen loop (#1706) Reopening the last project at startup had no way out: a slow or unreachable remote host left the Opening screen spinning with no Cancel, and force-quitting kept the project remembered, so the next launch hung on it again. The remembered project is now cleared before reopening and written back only once it has opened, and the Opening screen has a Cancel button that disconnects a remote connection and returns to the Welcome screen. A local open that finishes after Cancel is closed again, unless another project was opened meanwhile. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- src/features/welcome/WelcomeView.tsx | 7 ++-- src/features/welcome/session.test.ts | 49 ++++++++++++++++++++++++++-- src/features/welcome/session.ts | 40 ++++++++++++++++++++--- src/platform/remote.ts | 20 +++++++++--- 4 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/features/welcome/WelcomeView.tsx b/src/features/welcome/WelcomeView.tsx index 8fc8307b..caa28730 100644 --- a/src/features/welcome/WelcomeView.tsx +++ b/src/features/welcome/WelcomeView.tsx @@ -7,14 +7,14 @@ import { isTauri } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { useRemoteProgress } from "@/platform/remote"; import { stageLabel, useConnectDialog } from "@/features/remote-dev/model"; -import { Icon, IconButton, Menu, ProgressBar, Spinner, type MenuItem, T } from "@/ui"; +import { Icon, IconButton, Menu, ProgressBar, Spinner, type MenuItem, Button, T } from "@/ui"; import { useSettingsPages } from "@/workbench/contributions"; import { ErrorBoundary, Slot } from "@/workbench/ErrorBoundary"; import logo from "../../../src-tauri/icons/128x128.png"; import { CustomizePage } from "./CustomizePage"; import { ProjectsPage } from "./ProjectsPage"; import { loadRecent, useRecent } from "./recent"; -import { useSession } from "./session"; +import { cancelReopen, useSession } from "./session"; import styles from "./Welcome.module.css"; export type WelcomePage = "projects" | "customize" | "plugins"; @@ -135,6 +135,9 @@ function Reopening({ location }: { location: string }) { )} + ); } diff --git a/src/features/welcome/session.test.ts b/src/features/welcome/session.test.ts index 4e6ff43b..4b4bd1b9 100644 --- a/src/features/welcome/session.test.ts +++ b/src/features/welcome/session.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const openProject = vi.fn(); +const closeProject = vi.fn(); const connectRemote = vi.fn(); const UNC = String.raw`\\wsl.localhost\Ubuntu\home\me\app`; @@ -24,14 +25,15 @@ vi.mock("@/platform/remote", async (orig) => { vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: "main" }) })); vi.mock("@/platform/project", async (orig) => { const real = await orig(); - return { ...real, openProject: (p: string) => openProject(p) }; + return { ...real, openProject: (p: string) => openProject(p), closeProject: () => closeProject() }; }); -const { reopenLastProject, _resetSessionForTests } = await import("./session"); +const { cancelReopen, reopenLastProject, useSession, _resetSessionForTests } = await import("./session"); const { provideStartupLaunch, useProject } = await import("@/platform/project"); beforeEach(() => { openProject.mockReset(); + closeProject.mockReset().mockResolvedValue(undefined); connectRemote.mockReset(); useProject.setState({ project: null, lightEdit: false }); sessionStorage.clear(); @@ -87,4 +89,47 @@ describe("reopenLastProject", () => { expect(connectRemote).toHaveBeenCalledWith({ kind: "wsl", distro: "Ubuntu" }, "/home/me/app"); expect(openProject).not.toHaveBeenCalled(); }); + + it("clears the remembered project while reopening and lets the user cancel", async () => { + let resolveOpen!: () => void; + openProject.mockImplementation( + () => + new Promise((r) => { + resolveOpen = () => { + useProject.setState({ project: { root: "/p/last" } as never }); + r({ root: "/p/last" }); + }; + }), + ); + provideStartupLaunch(Promise.resolve()); + const promise = reopenLastProject(); + await new Promise((r) => setTimeout(r, 0)); + expect(localStorage.getItem("openidea.session.openProject")).toBeNull(); + expect(useSession.getState().reopening).toBe("/p/last"); + + cancelReopen(); + expect(useSession.getState().reopening).toBeNull(); + resolveOpen(); + await promise; + expect(closeProject).toHaveBeenCalledOnce(); + expect(localStorage.getItem("openidea.session.openProject")).toBeNull(); + }); + + it("keeps a project the user opened after cancelling", async () => { + let resolveOpen!: () => void; + openProject.mockImplementation( + () => + new Promise((r) => { + resolveOpen = () => r({ root: "/p/last" }); + }), + ); + provideStartupLaunch(Promise.resolve()); + const promise = reopenLastProject(); + await new Promise((r) => setTimeout(r, 0)); + cancelReopen(); + useProject.setState({ project: { root: "/p/other" } as never }); + resolveOpen(); + await promise; + expect(closeProject).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/welcome/session.ts b/src/features/welcome/session.ts index 0ea89cf7..80290b18 100644 --- a/src/features/welcome/session.ts +++ b/src/features/welcome/session.ts @@ -6,8 +6,8 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { create } from "zustand"; import { commands, isCancelled, isTauri, type Settings } from "@/platform/ipc"; -import { openProject, startupLaunchHandled, useProject } from "@/platform/project"; -import { connectRemote, projectLocation, resolveRemoteLocation } from "@/platform/remote"; +import { closeProject, openProject, startupLaunchHandled, useProject } from "@/platform/project"; +import { connectRemote, projectLocation, resolveRemoteLocation, useRemoteProgress } from "@/platform/remote"; import { notifications } from "@/workbench/contributions"; const SESSION_KEY = "openidea.session.openProject"; @@ -82,6 +82,21 @@ export function lastSessionProject(): string | null { } let attempted = false; +let reopenEpoch = 0; + +/** Cancels an in-flight startup project reopen or remote connection and returns to the Welcome screen. */ +export function cancelReopen() { + reopenEpoch++; + write(SESSION_KEY, null); + windowProject(null); + useSession.setState({ reopening: null }); + // A local open can't be interrupted; `reopen` closes it again once it finishes. + if (useRemoteProgress.getState().location) { + useRemoteProgress.setState({ progress: null, location: null }); + useProject.setState({ opening: false, openingPath: null }); + if (isTauri()) void commands.remoteDisconnect().catch(() => {}); + } +} /** On startup: reopens the project that was open at exit. Robust against a missing folder. */ export async function reopenLastProject(): Promise { @@ -114,16 +129,30 @@ export async function reopenLastProject(): Promise { } async function reopen(path: string) { + const epoch = ++reopenEpoch; const name = path.slice(path.lastIndexOf("/") + 1) || path; + // Clear before connecting so force-quitting a hung reopen does not loop on the next launch. + write(SESSION_KEY, null); + windowProject(null); useSession.setState({ reopening: path }); try { const remote = await resolveRemoteLocation(path); if (remote) await connectRemote(remote.target, remote.path); - else await openProject(path); + else { + const project = await openProject(path); + // Cancelled while opening: close it again unless the user has opened another project since. + if (reopenEpoch !== epoch) { + if (useProject.getState().project?.root === project.root) await closeProject().catch(() => {}); + return; + } + } + const opened = projectLocation(useProject.getState().project?.root) ?? path; + write(SESSION_KEY, opened); + windowProject(opened); } catch (e) { write(SESSION_KEY, null); windowProject(null); - if (isCancelled(e)) return; + if (reopenEpoch !== epoch || isCancelled(e)) return; notifications().notify({ level: "warning", title: `Cannot reopen ${name}`, @@ -131,12 +160,13 @@ async function reopen(path: string) { key: "welcome.reopen", }); } finally { - useSession.setState({ reopening: null }); + if (reopenEpoch === epoch) useSession.setState({ reopening: null }); } } /** Tests only. */ export function _resetSessionForTests() { attempted = false; + reopenEpoch = 0; useSession.setState({ reopening: null, reopenPref: read(PREF_KEY) !== "false" }); } diff --git a/src/platform/remote.ts b/src/platform/remote.ts index 655cf504..504fb121 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -11,6 +11,7 @@ import { create } from "zustand"; import { call, commands, + IpcError, isTauri, type ChannelFrame, type RemoteProgress, @@ -156,14 +157,23 @@ export const useRemoteProgress = create<{ progress: RemoteProgress | null; locat /** Opens `path` of `target` (an SSH host or a WSL distribution) in this window. */ export async function connectRemote(target: RemoteTarget, path: string) { useProject.setState({ opening: true }); + const location = remoteLocation(target, path); const onProgress = new Channel(); - onProgress.onmessage = (progress) => useRemoteProgress.setState({ progress }); + onProgress.onmessage = (progress) => { + if (useRemoteProgress.getState().location === location) { + useRemoteProgress.setState({ progress }); + } + }; useRemoteProgress.setState({ progress: { stage: "connecting", fraction: null }, - location: remoteLocation(target, path), + location, }); try { const r = await call(commands.remoteConnect(target, path, onProgress)); + if (useRemoteProgress.getState().location !== location) { + await commands.remoteDisconnect().catch(() => {}); + throw new IpcError({ kind: "cancelled", message: "Remote connection cancelled" }); + } enableRemote({ target: r.target, root: r.root, host: r.host, os: r.os, commands: r.commands }); if (takeLegacyRemoteTrust(r.target, path, r.host)) { const status = await call(commands.projectTrustStatus(r.root)); @@ -189,8 +199,10 @@ export async function connectRemote(target: RemoteTarget, path: string) { }); return r; } finally { - useProject.setState({ opening: false }); - useRemoteProgress.setState({ progress: null, location: null }); + if (useRemoteProgress.getState().location === location) { + useProject.setState({ opening: false }); + useRemoteProgress.setState({ progress: null, location: null }); + } } }