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>
This commit is contained in:
Deng Liu
2026-11-06 13:10:09 +08:00
committed by GitHub
co-authored by dengliu
parent 09c0af504e
commit b7d886ea34
4 changed files with 103 additions and 13 deletions
+5 -2
View File
@@ -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 }) {
<ProgressBar value={progress.fraction} aria-label={tx(stageLabel(progress.stage))} />
</div>
)}
<Button size="small" onClick={cancelReopen}>
<T>Cancel</T>
</Button>
</div>
);
}
+47 -2
View File
@@ -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<typeof import("@/platform/project")>();
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();
});
});
+35 -5
View File
@@ -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<void> {
@@ -114,16 +129,30 @@ export async function reopenLastProject(): Promise<void> {
}
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" });
}
+16 -4
View File
@@ -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<RemoteProgress>();
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 });
}
}
}