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 });
+ }
}
}