From 56f2a2403cfd13c14d6a38b73f4e14f00a39376b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 6 Feb 2026 15:27:24 -0800 Subject: [PATCH 1/2] fix(desktop): fix race condition and clean up multi-select Open Project Fix navigate + InitGitDialog race where navigation unmounts StartView before the git-init dialog is shown. Defer navigation until dialog closes. Propagate real error messages instead of static strings, remove dead exported types, extract shared processOpenNewResults utility to deduplicate filtering + toast logic across 3 call sites, and fix pre-existing type narrowing bug with "multi" in result checks. --- .../src/lib/trpc/routers/projects/projects.ts | 25 ++---- .../NewWorkspaceModal/NewWorkspaceModal.tsx | 41 +++------- .../renderer/react-query/projects/index.ts | 1 + .../projects/processOpenNewResults.ts | 78 +++++++++++++++++++ .../main/components/StartView/index.tsx | 75 +++++++++--------- .../WorkspaceSidebarFooter.tsx | 50 +++--------- 6 files changed, 142 insertions(+), 128 deletions(-) create mode 100644 apps/desktop/src/renderer/react-query/projects/processOpenNewResults.ts diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 11c4567650a..4f9ba1d92f0 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -40,34 +40,23 @@ type Project = SelectProject; // Return types for openNew procedure (single project) type OpenNewCanceled = { canceled: true }; -type OpenNewSuccess = { canceled: false; project: Project }; -type OpenNewNeedsGitInit = { - canceled: false; - needsGitInit: true; - selectedPath: string; -}; type OpenNewError = { canceled: false; error: string }; -export type OpenNewResult = +type OpenNewResult = | OpenNewCanceled - | OpenNewSuccess - | OpenNewNeedsGitInit + | { canceled: false; project: Project } + | { canceled: false; needsGitInit: true; selectedPath: string } | OpenNewError; // Per-folder outcome for multi-select -export type FolderOutcome = +type FolderOutcome = | { status: "success"; project: Project } | { status: "needsGitInit"; selectedPath: string } | { status: "error"; selectedPath: string; error: string }; // Return types for openNew procedure (multi-select) -type OpenNewMultiSuccess = { - canceled: false; - multi: true; - results: FolderOutcome[]; -}; -export type OpenNewMultiResult = +type OpenNewMultiResult = | OpenNewCanceled - | OpenNewMultiSuccess + | { canceled: false; multi: true; results: FolderOutcome[] } | OpenNewError; /** @@ -515,7 +504,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { outcomes.push({ status: "error", selectedPath, - error: "Failed to open project", + error: error instanceof Error ? error.message : String(error), }); } } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 6110e8e156e..b97d5bc20b2 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -39,7 +39,10 @@ import { import { LuFolderOpen } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; -import { useOpenNew } from "renderer/react-query/projects"; +import { + processOpenNewResults, + useOpenNew, +} from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { useCloseNewWorkspaceModal, @@ -201,47 +204,25 @@ export function NewWorkspaceModal() { const result = await openNew.mutateAsync(undefined); if (result.canceled) return; - if ("error" in result && !("multi" in result)) { + if ("error" in result) { toast.error("Failed to open project", { description: result.error }); return; } - if ("multi" in result) { - const successes = result.results.filter((r) => r.status === "success"); - const needsGitInit = result.results.filter( - (r) => r.status === "needsGitInit", - ); - const errors = result.results.filter((r) => r.status === "error"); + if ("results" in result) { + const { successes } = processOpenNewResults({ + results: result.results, + showSuccessToast: false, + showGitInitToast: true, + }); - // Show summary toast when multiple projects imported if (successes.length > 1) { toast.success(`${successes.length} projects imported`); } - // Select the first successful project if (successes.length > 0) { setSelectedProjectId(successes[0].project.id); } - - // Show errors - for (const err of errors) { - toast.error(`Failed to open ${err.selectedPath.split("/").pop()}`, { - description: err.error, - }); - } - - // Show git init warnings - if (needsGitInit.length > 0) { - const names = needsGitInit - .map((r) => r.selectedPath.split("/").pop()) - .join(", "); - toast.error( - needsGitInit.length === 1 - ? "Folder is not a git repository" - : `${needsGitInit.length} folders are not git repositories`, - { description: names }, - ); - } } } catch (error) { toast.error("Failed to open project", { diff --git a/apps/desktop/src/renderer/react-query/projects/index.ts b/apps/desktop/src/renderer/react-query/projects/index.ts index 1cb8bea78d8..2f35fedde42 100644 --- a/apps/desktop/src/renderer/react-query/projects/index.ts +++ b/apps/desktop/src/renderer/react-query/projects/index.ts @@ -1,3 +1,4 @@ +export { processOpenNewResults } from "./processOpenNewResults"; export { useOpenFromPath } from "./useOpenFromPath"; export { useOpenNew } from "./useOpenNew"; export { useReorderProjects } from "./useReorderProjects"; diff --git a/apps/desktop/src/renderer/react-query/projects/processOpenNewResults.ts b/apps/desktop/src/renderer/react-query/projects/processOpenNewResults.ts new file mode 100644 index 00000000000..dc621f51f6a --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/processOpenNewResults.ts @@ -0,0 +1,78 @@ +import { toast } from "@superset/ui/sonner"; +import type { ElectronRouterOutputs } from "renderer/lib/electron-trpc"; + +type OpenNewResult = ElectronRouterOutputs["projects"]["openNew"]; + +type MultiResults = Extract["results"]; + +type SuccessOutcome = Extract; +type NeedsGitInitOutcome = Extract< + MultiResults[number], + { status: "needsGitInit" } +>; +type ErrorOutcome = Extract; + +export interface CategorizedResults { + successes: SuccessOutcome[]; + needsGitInit: NeedsGitInitOutcome[]; + errors: ErrorOutcome[]; +} + +/** + * Categorizes multi-select open-project results and shows standard toasts. + * + * Always: + * - Categorizes results into successes / needsGitInit / errors + * - Shows per-error toasts + * + * Optionally: + * - Shows a success summary toast ("N projects opened") — on by default + * - Shows a git-init warning toast (used by sidebar + modal where no InitGitDialog is available) + */ +export function processOpenNewResults({ + results, + showSuccessToast = true, + showGitInitToast = false, +}: { + results: MultiResults; + showSuccessToast?: boolean; + showGitInitToast?: boolean; +}): CategorizedResults { + const successes = results.filter( + (r): r is SuccessOutcome => r.status === "success", + ); + const needsGitInit = results.filter( + (r): r is NeedsGitInitOutcome => r.status === "needsGitInit", + ); + const errors = results.filter((r): r is ErrorOutcome => r.status === "error"); + + for (const err of errors) { + toast.error(`Failed to open ${err.selectedPath.split("/").pop()}`, { + description: err.error, + }); + } + + if (showSuccessToast && successes.length > 0) { + toast.success( + successes.length === 1 + ? "Project opened" + : `${successes.length} projects opened`, + ); + } + + if (showGitInitToast && needsGitInit.length > 0) { + const names = needsGitInit + .map((r) => r.selectedPath.split("/").pop()) + .join(", "); + toast.error( + needsGitInit.length === 1 + ? "Folder is not a git repository" + : `${needsGitInit.length} folders are not git repositories`, + { + description: `${names} - use 'Open project' from the start view to initialize git.`, + }, + ); + } + + return { successes, needsGitInit, errors }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx index 6dd41347b77..982126f763a 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -1,10 +1,13 @@ import { Button } from "@superset/ui/button"; -import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; import { LuFolderGit, LuFolderOpen, LuX } from "react-icons/lu"; -import { useOpenFromPath, useOpenNew } from "renderer/react-query/projects"; +import { + processOpenNewResults, + useOpenFromPath, + useOpenNew, +} from "renderer/react-query/projects"; import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo"; import { CloneRepoDialog } from "./CloneRepoDialog"; import { InitGitDialog } from "./InitGitDialog"; @@ -19,6 +22,9 @@ export function StartView() { selectedPath: string; selectedPaths?: string[]; }>({ isOpen: false, selectedPath: "" }); + const [pendingNavigateProjectId, setPendingNavigateProjectId] = useState< + string | null + >(null); const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); @@ -52,56 +58,35 @@ export function StartView() { return; } - if ("error" in result && !("multi" in result)) { + if ("error" in result) { setError(result.error); return; } - if ("multi" in result) { - const successes = result.results.filter( - (r) => r.status === "success", - ); - const needsGitInit = result.results.filter( - (r) => r.status === "needsGitInit", - ); - const errors = result.results.filter((r) => r.status === "error"); - - // Show summary toast for opened projects - if (successes.length > 0) { - toast.success( - successes.length === 1 - ? "Project opened" - : `${successes.length} projects opened`, - ); - - // Navigate to the first successfully opened project - navigate({ - to: "/project/$projectId", - params: { projectId: successes[0].project.id }, - replace: true, - }); - } + if ("results" in result) { + const { successes, needsGitInit } = processOpenNewResults({ + results: result.results, + }); - // Show errors - if (errors.length > 0) { - for (const err of errors) { - toast.error( - `Failed to open ${err.selectedPath.split("/").pop()}`, - { - description: err.error, - }, - ); - } - } + const firstProjectId = successes[0]?.project.id; - // Prompt for git init if needed if (needsGitInit.length > 0) { const paths = needsGitInit.map((r) => r.selectedPath); + // Defer navigation until git-init dialog is closed + if (firstProjectId) { + setPendingNavigateProjectId(firstProjectId); + } setInitGitDialog({ isOpen: true, selectedPath: paths[0], selectedPaths: paths, }); + } else if (firstProjectId) { + navigate({ + to: "/project/$projectId", + params: { projectId: firstProjectId }, + replace: true, + }); } return; @@ -305,7 +290,17 @@ export function StartView() { isOpen={initGitDialog.isOpen} selectedPath={initGitDialog.selectedPath} selectedPaths={initGitDialog.selectedPaths} - onClose={() => setInitGitDialog({ isOpen: false, selectedPath: "" })} + onClose={() => { + setInitGitDialog({ isOpen: false, selectedPath: "" }); + if (pendingNavigateProjectId) { + navigate({ + to: "/project/$projectId", + params: { projectId: pendingNavigateProjectId }, + replace: true, + }); + setPendingNavigateProjectId(null); + } + }} onError={setError} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx index 123656ce07d..70ddf1a7590 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx @@ -9,7 +9,10 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useState } from "react"; import { LuFolderGit, LuFolderOpen, LuFolderPlus } from "react-icons/lu"; -import { useOpenNew } from "renderer/react-query/projects"; +import { + processOpenNewResults, + useOpenNew, +} from "renderer/react-query/projects"; import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; import { CloneRepoDialog } from "../StartView/CloneRepoDialog"; import { STROKE_WIDTH } from "./constants"; @@ -31,21 +34,19 @@ export function WorkspaceSidebarFooter({ if (result.canceled) { return; } - if ("error" in result && !("multi" in result)) { + if ("error" in result) { toast.error("Failed to open project", { description: result.error, }); return; } - if ("multi" in result) { - const successes = result.results.filter((r) => r.status === "success"); - const needsGitInit = result.results.filter( - (r) => r.status === "needsGitInit", - ); - const errors = result.results.filter((r) => r.status === "error"); + if ("results" in result) { + const { successes } = processOpenNewResults({ + results: result.results, + showGitInitToast: true, + }); - // Create branch workspaces for all successful projects for (const s of successes) { try { await createBranchWorkspace.mutateAsync({ @@ -60,37 +61,6 @@ export function WorkspaceSidebarFooter({ }); } } - - // Summary toast - if (successes.length > 0) { - toast.success( - successes.length === 1 - ? "Project opened" - : `${successes.length} projects opened`, - ); - } - - // Show errors - for (const err of errors) { - toast.error(`Failed to open ${err.selectedPath.split("/").pop()}`, { - description: err.error, - }); - } - - // Show git init warnings - if (needsGitInit.length > 0) { - const names = needsGitInit - .map((r) => r.selectedPath.split("/").pop()) - .join(", "); - toast.error( - needsGitInit.length === 1 - ? "Folder is not a git repository" - : `${needsGitInit.length} folders are not git repositories`, - { - description: `${names} - use 'Open project' from the start view to initialize git.`, - }, - ); - } } } catch (error) { toast.error("Failed to open project", { From 392f899de1ceec470b7ce5dafab632067d148ee7 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 6 Feb 2026 17:50:23 -0800 Subject: [PATCH 2/2] fix(desktop): prevent navigation when InitGitDialog reports an error Clear pendingNavigateProjectId in onError so that the subsequent onClose callback does not navigate away before the user sees the error banner. --- .../src/renderer/screens/main/components/StartView/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx index 982126f763a..cc2af19ae9a 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -301,7 +301,10 @@ export function StartView() { setPendingNavigateProjectId(null); } }} - onError={setError} + onError={(msg) => { + setError(msg); + setPendingNavigateProjectId(null); + }} />