From 4e35346df0ed6d79d73d28ffd0818a976ef515a0 Mon Sep 17 00:00:00 2001 From: Andrew Qu Date: Thu, 5 Feb 2026 20:59:49 -0800 Subject: [PATCH] feat(desktop): support multi-folder selection in Open Project dialog Add multiSelections to the native file picker so users can select multiple folders at once. Each folder is processed independently with per-folder success/error/needsGitInit outcomes. - Add FolderOutcome and OpenNewMultiResult types for multi-select results - Update openNew tRPC mutation to iterate all selected paths - Update all 4 UI call sites (StartView, WorkspaceSidebarFooter, NewWorkspaceModal, SidebarDropZone) to handle multi-results - Update InitGitDialog to display and process multiple non-git folders - Show summary toast (e.g. '3 projects opened') after multi-select - Navigate to first successfully opened project from StartView - Cache getGitHubUsername to avoid GitHub API rate limit spam - Fix SKIP_ENV_VALIDATION dev bypass not working (isPending guard and sign-in page were redirecting before bypass could take effect) --- .../src/lib/trpc/routers/projects/projects.ts | 79 ++++++++++------ .../lib/trpc/routers/workspaces/utils/git.ts | 16 +++- .../NewWorkspaceModal/NewWorkspaceModal.tsx | 44 ++++++++- .../renderer/routes/_authenticated/layout.tsx | 2 +- .../src/renderer/routes/sign-in/page.tsx | 6 ++ .../components/StartView/InitGitDialog.tsx | 94 +++++++++++++------ .../main/components/StartView/index.tsx | 65 ++++++++++--- .../WorkspaceSidebarFooter.tsx | 72 ++++++++++---- 8 files changed, 283 insertions(+), 95 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index f4949580408..11c4567650a 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -38,7 +38,7 @@ import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; type Project = SelectProject; -// Return types for openNew procedure +// Return types for openNew procedure (single project) type OpenNewCanceled = { canceled: true }; type OpenNewSuccess = { canceled: false; project: Project }; type OpenNewNeedsGitInit = { @@ -53,6 +53,23 @@ export type OpenNewResult = | OpenNewNeedsGitInit | OpenNewError; +// Per-folder outcome for multi-select +export 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 = + | OpenNewCanceled + | OpenNewMultiSuccess + | OpenNewError; + /** * Creates or updates a project record in the database. * If a project with the same mainRepoPath exists, updates lastOpenedAt. @@ -453,13 +470,13 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }, ), - openNew: publicProcedure.mutation(async (): Promise => { + openNew: publicProcedure.mutation(async (): Promise => { const window = getWindow(); if (!window) { return { canceled: false, error: "No window available" }; } const result = await dialog.showOpenDialog(window, { - properties: ["openDirectory"], + properties: ["openDirectory", "multiSelections"], title: "Open Project", }); @@ -467,35 +484,43 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return { canceled: true }; } - const selectedPath = result.filePaths[0]; + const outcomes: FolderOutcome[] = []; - let mainRepoPath: string; - try { - mainRepoPath = await getGitRoot(selectedPath); - } catch (_error) { - // Return a special response so the UI can offer to initialize git - return { - canceled: false, - needsGitInit: true, - selectedPath, - }; - } + for (const selectedPath of result.filePaths) { + let mainRepoPath: string; + try { + mainRepoPath = await getGitRoot(selectedPath); + } catch { + outcomes.push({ status: "needsGitInit", selectedPath }); + continue; + } - const defaultBranch = await getDefaultBranch(mainRepoPath); - const project = upsertProject(mainRepoPath, defaultBranch); + try { + const defaultBranch = await getDefaultBranch(mainRepoPath); + const project = upsertProject(mainRepoPath, defaultBranch); + await ensureMainWorkspace(project); - // Auto-create main workspace if it doesn't exist - await ensureMainWorkspace(project); + track("project_opened", { + project_id: project.id, + method: "open", + }); - track("project_opened", { - project_id: project.id, - method: "open", - }); + outcomes.push({ status: "success", project }); + } catch (error) { + console.error( + "[projects/openNew] Failed to open project:", + selectedPath, + error, + ); + outcomes.push({ + status: "error", + selectedPath, + error: "Failed to open project", + }); + } + } - return { - canceled: false, - project, - }; + return { canceled: false, multi: true, results: outcomes }; }), openFromPath: publicProcedure diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 75231c64c4e..43c99e9bf02 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -312,9 +312,20 @@ export async function getGitAuthorName( } } +let cachedGitHubUsername: { value: string | null; timestamp: number } | null = + null; +const GITHUB_USERNAME_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + export async function getGitHubUsername( _repoPath?: string, ): Promise { + if ( + cachedGitHubUsername && + Date.now() - cachedGitHubUsername.timestamp < GITHUB_USERNAME_CACHE_TTL + ) { + return cachedGitHubUsername.value; + } + const env = await getGitEnv(); try { @@ -323,12 +334,15 @@ export async function getGitHubUsername( ["api", "user", "--jq", ".login"], { env, timeout: 10_000 }, ); - return stdout.trim() || null; + const value = stdout.trim() || null; + cachedGitHubUsername = { value, timestamp: Date.now() }; + return value; } catch (error) { console.warn( "[git/getGitHubUsername] Failed to get GitHub username:", error instanceof Error ? error.message : String(error), ); + cachedGitHubUsername = { value: null, timestamp: Date.now() }; return null; } } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index ec523660651..6110e8e156e 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -200,15 +200,49 @@ export function NewWorkspaceModal() { try { const result = await openNew.mutateAsync(undefined); if (result.canceled) return; - if ("error" in result) { + + if ("error" in result && !("multi" in result)) { toast.error("Failed to open project", { description: result.error }); return; } - if ("needsGitInit" in result) { - toast.error("Selected folder is not a git repository"); - 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 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 }, + ); + } } - setSelectedProjectId(result.project.id); } catch (error) { toast.error("Failed to open project", { description: diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index d7dd4a3c609..ba0c981d94c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -72,7 +72,7 @@ function AuthenticatedLayout() { }, }); - if (isPending) { + if (isPending && !env.SKIP_ENV_VALIDATION) { if (hasLocalToken) { return (
diff --git a/apps/desktop/src/renderer/routes/sign-in/page.tsx b/apps/desktop/src/renderer/routes/sign-in/page.tsx index 77ec7067b05..b1679314e82 100644 --- a/apps/desktop/src/renderer/routes/sign-in/page.tsx +++ b/apps/desktop/src/renderer/routes/sign-in/page.tsx @@ -4,6 +4,7 @@ import { Spinner } from "@superset/ui/spinner"; import { createFileRoute, Navigate } from "@tanstack/react-router"; import { FaGithub } from "react-icons/fa"; import { FcGoogle } from "react-icons/fc"; +import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { posthog } from "renderer/lib/posthog"; @@ -17,6 +18,11 @@ function SignInPage() { const { data: session, isPending } = authClient.useSession(); const signInMutation = electronTrpc.auth.signIn.useMutation(); + // Dev bypass: skip sign-in entirely + if (env.SKIP_ENV_VALIDATION) { + return ; + } + // Show loading while session is being fetched if (isPending) { return ( diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/InitGitDialog.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/InitGitDialog.tsx index 8052610a586..8d7067176dc 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/InitGitDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/InitGitDialog.tsx @@ -20,6 +20,8 @@ const FOCUSABLE_SELECTOR = interface InitGitDialogProps { isOpen: boolean; selectedPath: string; + /** Additional paths that need git init (multi-select). */ + selectedPaths?: string[]; onClose: () => void; onError: (error: string) => void; } @@ -27,9 +29,17 @@ interface InitGitDialogProps { export function InitGitDialog({ isOpen, selectedPath, + selectedPaths, onClose, onError, }: InitGitDialogProps) { + // Normalize: if selectedPaths provided, use that; otherwise fall back to single selectedPath + const allPaths = + selectedPaths && selectedPaths.length > 0 + ? selectedPaths + : selectedPath + ? [selectedPath] + : []; const utils = electronTrpc.useUtils(); const initGitAndOpen = electronTrpc.projects.initGitAndOpen.useMutation(); const createWorkspace = useCreateWorkspace(); @@ -109,28 +119,37 @@ export function InitGitDialog({ if (isProcessing) return; // Prevent double-clicks setIsProcessing(true); - try { - let result: Awaited>; - try { - result = await initGitAndOpen.mutateAsync({ path: selectedPath }); - } catch (err) { - onError(`Failed to initialize git repository: ${getErrorMessage(err)}`); - return; - } + const errors: string[] = []; - if (!result.project) { - onError("Unexpected error: project was not created"); - return; + try { + for (const path of allPaths) { + let result: Awaited>; + try { + result = await initGitAndOpen.mutateAsync({ path }); + } catch (err) { + errors.push(`${getBasename(path)}: ${getErrorMessage(err)}`); + continue; + } + + if (!result.project) { + errors.push(`${getBasename(path)}: project was not created`); + continue; + } + + try { + await createWorkspace.mutateAsync({ projectId: result.project.id }); + } catch (err) { + errors.push(`${getBasename(path)}: ${getErrorMessage(err)}`); + } } - // Invalidate cache in background - don't block the primary workflow + // Invalidate cache in background utils.projects.getRecents.invalidate().catch(console.error); - try { - await createWorkspace.mutateAsync({ projectId: result.project.id }); - } catch (err) { - onError(`Failed to create workspace: ${getErrorMessage(err)}`); - return; + if (errors.length > 0) { + onError( + `Failed to initialize ${errors.length} folder(s): ${errors.join("; ")}`, + ); } onClose(); @@ -141,9 +160,9 @@ export function InitGitDialog({ } }; - if (!isOpen) return null; + if (!isOpen || allPaths.length === 0) return null; - const folderName = getBasename(selectedPath); + const isMultiple = allPaths.length > 1; return ( // biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Modal backdrop dismiss pattern @@ -160,24 +179,37 @@ export function InitGitDialog({ className="bg-card border border-border rounded-lg p-8 w-full max-w-md shadow-2xl" >

- Initialize Git Repository + Initialize Git {isMultiple ? "Repositories" : "Repository"}

- The selected folder is not a git repository: + {isMultiple + ? `${allPaths.length} selected folders are not git repositories:` + : "The selected folder is not a git repository:"}

-
- - {folderName} - - - {selectedPath} - +
+ {allPaths.map((path) => ( +
+ + {getBasename(path)} + + + {path} + +
+ ))}

- Would you like to initialize a git repository in this folder? + Would you like to initialize{" "} + {isMultiple + ? "git repositories in these folders" + : "a git repository in this folder"} + ?

@@ -185,7 +217,9 @@ export function InitGitDialog({ Cancel
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 8141c13266b..6dd41347b77 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -1,4 +1,5 @@ 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"; @@ -16,6 +17,7 @@ export function StartView() { const [initGitDialog, setInitGitDialog] = useState<{ isOpen: boolean; selectedPath: string; + selectedPaths?: string[]; }>({ isOpen: false, selectedPath: "" }); const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); @@ -50,25 +52,59 @@ export function StartView() { return; } - if ("error" in result) { + if ("error" in result && !("multi" in result)) { setError(result.error); return; } - if ("needsGitInit" in result) { - setInitGitDialog({ - isOpen: true, - selectedPath: result.selectedPath, - }); - 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, + }); + } + + // Show errors + if (errors.length > 0) { + for (const err of errors) { + toast.error( + `Failed to open ${err.selectedPath.split("/").pop()}`, + { + description: err.error, + }, + ); + } + } + + // Prompt for git init if needed + if (needsGitInit.length > 0) { + const paths = needsGitInit.map((r) => r.selectedPath); + setInitGitDialog({ + isOpen: true, + selectedPath: paths[0], + selectedPaths: paths, + }); + } - if ("project" in result && result.project) { - navigate({ - to: "/project/$projectId", - params: { projectId: result.project.id }, - replace: true, - }); + return; } }, onError: (err) => { @@ -268,6 +304,7 @@ export function StartView() { setInitGitDialog({ isOpen: false, selectedPath: "" })} 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 439c6e17feb..123656ce07d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx @@ -31,29 +31,67 @@ export function WorkspaceSidebarFooter({ if (result.canceled) { return; } - if ("error" in result) { + if ("error" in result && !("multi" in result)) { toast.error("Failed to open project", { description: result.error, }); return; } - if ("needsGitInit" in result) { - toast.error("Selected folder is not a git repository", { - description: - "Please use 'Open project' from the start view to initialize git.", - }); - 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"); + + // Create branch workspaces for all successful projects + for (const s of successes) { + try { + await createBranchWorkspace.mutateAsync({ + projectId: s.project.id, + }); + } catch (err) { + toast.error(`Failed to open ${s.project.name}`, { + description: + err instanceof Error + ? err.message + : "Failed to create workspace", + }); + } + } + + // 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.`, + }, + ); + } } - // Create a main workspace on the current branch for the new project - toast.promise( - createBranchWorkspace.mutateAsync({ projectId: result.project.id }), - { - loading: "Opening project...", - success: "Project opened", - error: (err) => - err instanceof Error ? err.message : "Failed to open project", - }, - ); } catch (error) { toast.error("Failed to open project", { description: