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