From c47125b8e565a1ab1eb6af9b9e15b9ddf7ed49b6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Feb 2026 00:26:24 -0800 Subject: [PATCH 1/7] feat(desktop): add New Project page with empty, clone, and template tabs Replace the CloneRepoDialog with a dedicated /new-project route that supports creating empty repos, cloning, and creating from templates. Adds a shared path selector defaulting to ~/.superset/projects. --- .../src/lib/trpc/routers/projects/projects.ts | 182 ++++++++++++++++-- .../components/CloneRepoTab/CloneRepoTab.tsx | 69 +++++++ .../components/CloneRepoTab/index.ts | 1 + .../components/EmptyRepoTab/EmptyRepoTab.tsx | 70 +++++++ .../components/EmptyRepoTab/index.ts | 1 + .../components/PathSelector/PathSelector.tsx | 57 ++++++ .../components/PathSelector/index.ts | 1 + .../TemplateRepoTab/TemplateRepoTab.tsx | 144 ++++++++++++++ .../components/TemplateRepoTab/index.ts | 1 + .../_onboarding/new-project/constants.ts | 10 + .../hooks/useProjectCreationHandler/index.ts | 1 + .../useProjectCreationHandler.ts | 36 ++++ .../_onboarding/new-project/page.tsx | 109 +++++++++++ .../components/StartView/CloneRepoDialog.tsx | 106 ---------- .../main/components/StartView/index.tsx | 23 +-- .../WorkspaceSidebarFooter.tsx | 131 +++++-------- biome.jsonc | 2 +- 17 files changed, 730 insertions(+), 214 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/CloneRepoTab.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/TemplateRepoTab.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/StartView/CloneRepoDialog.tsx diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 47b740a678d..f9dbd3c0bec 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -1,5 +1,5 @@ import { existsSync, statSync } from "node:fs"; -import { access } from "node:fs/promises"; +import { access, mkdir, rm } from "node:fs/promises"; import { basename, join } from "node:path"; import { BRANCH_PREFIX_MODES, @@ -62,26 +62,28 @@ type OpenNewMultiResult = | { canceled: false; multi: true; results: FolderOutcome[] } | OpenNewError; -/** - * Initializes a git repository in the given path with an initial commit. - * Reused by openNew, openFromPath, and initGitAndOpen. - */ -async function initGitRepo(path: string): Promise<{ defaultBranch: string }> { +async function initGitRepo( + path: string, + options?: { stageAll?: boolean; commitMessage?: string }, +): Promise<{ defaultBranch: string }> { const git = simpleGit(path); - // Initialize git repository with 'main' as default branch - // Try with --initial-branch=main (Git 2.28+), fall back to plain init try { await git.init(["--initial-branch=main"]); } catch (err) { - // Likely an older Git version that doesn't support --initial-branch console.warn("Git init with --initial-branch failed, using fallback:", err); await git.init(); } - // Create initial commit so we have a valid branch ref + const message = options?.commitMessage ?? "Initial commit"; + try { - await git.raw(["commit", "--allow-empty", "-m", "Initial commit"]); + if (options?.stageAll) { + await git.add("."); + await git.commit(message); + } else { + await git.raw(["commit", "--allow-empty", "-m", message]); + } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); // Check for common git config issues @@ -334,6 +336,28 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .all(); }), + selectDirectory: publicProcedure + .input( + z.object({ + defaultPath: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const window = getWindow(); + if (!window) { + return { canceled: true as const, path: null }; + } + const result = await dialog.showOpenDialog(window, { + properties: ["openDirectory", "createDirectory"], + title: "Select Directory", + defaultPath: input.defaultPath, + }); + if (result.canceled || result.filePaths.length === 0) { + return { canceled: true as const, path: null }; + } + return { canceled: false as const, path: result.filePaths[0] }; + }), + getBranches: publicProcedure .input(z.object({ projectId: z.string() })) .query( @@ -797,6 +821,142 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } }), + createEmptyRepo: publicProcedure + .input( + z.object({ + name: z + .string() + .min(1) + .refine((val) => SAFE_REPO_NAME_REGEX.test(val), { + message: + "Name can only contain letters, numbers, dots, underscores, hyphens, and spaces", + }), + parentDir: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + try { + const repoPath = join(input.parentDir, input.name); + + if (existsSync(repoPath)) { + return { + canceled: false as const, + success: false as const, + error: `A folder named "${input.name}" already exists at this location.`, + }; + } + + await mkdir(repoPath, { recursive: true }); + + const { defaultBranch } = await initGitRepo(repoPath); + const project = upsertProject(repoPath, defaultBranch); + await ensureMainWorkspace(project); + + track("project_opened", { + project_id: project.id, + method: "create_empty", + }); + + return { + canceled: false as const, + success: true as const, + project, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + canceled: false as const, + success: false as const, + error: `Failed to create repository: ${errorMessage}`, + }; + } + }), + + createFromTemplate: publicProcedure + .input( + z.object({ + templateUrl: z + .string() + .min(1) + .refine( + (val) => { + try { + const parsed = new URL(val); + return ALLOWED_URL_PROTOCOLS.has(parsed.protocol); + } catch { + return SSH_GIT_URL_REGEX.test(val); + } + }, + { message: "Must be a valid Git URL (HTTPS or SSH)" }, + ), + name: z + .string() + .trim() + .optional() + .transform((v) => (v && v.length > 0 ? v : undefined)), + parentDir: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + try { + const repoName = input.name ?? extractRepoName(input.templateUrl); + if (!repoName) { + return { + canceled: false as const, + success: false as const, + error: + "Could not determine project name from template URL. Please provide a name.", + }; + } + + const repoPath = join(input.parentDir, repoName); + + if (existsSync(repoPath)) { + return { + canceled: false as const, + success: false as const, + error: `A folder named "${repoName}" already exists at this location.`, + }; + } + + const git = simpleGit(); + await git.clone(input.templateUrl, repoPath, ["--depth", "1"]); + + await rm(join(repoPath, ".git"), { + recursive: true, + force: true, + }); + + const { defaultBranch } = await initGitRepo(repoPath, { + stageAll: true, + commitMessage: `Initial commit from template`, + }); + + const project = upsertProject(repoPath, defaultBranch); + await ensureMainWorkspace(project); + + track("project_opened", { + project_id: project.id, + method: "create_from_template", + }); + + return { + canceled: false as const, + success: true as const, + project, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + canceled: false as const, + success: false as const, + error: `Failed to create from template: ${errorMessage}`, + }; + } + }), + update: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/CloneRepoTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/CloneRepoTab.tsx new file mode 100644 index 00000000000..9403c7d8430 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/CloneRepoTab.tsx @@ -0,0 +1,69 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useProjectCreationHandler } from "../../hooks/useProjectCreationHandler"; + +interface CloneRepoTabProps { + onError: (error: string) => void; + parentDir: string; +} + +export function CloneRepoTab({ onError, parentDir }: CloneRepoTabProps) { + const [url, setUrl] = useState(""); + const cloneRepo = electronTrpc.projects.cloneRepo.useMutation(); + const { handleResult, handleError, isCreatingWorkspace } = + useProjectCreationHandler(onError); + + const isLoading = cloneRepo.isPending || isCreatingWorkspace; + + const handleClone = () => { + if (!url.trim()) { + onError("Please enter a repository URL"); + return; + } + if (!parentDir.trim()) { + onError("Please select a project location"); + return; + } + + cloneRepo.mutate( + { url: url.trim(), targetDirectory: parentDir.trim() }, + { + onSuccess: (result) => handleResult(result, () => setUrl("")), + onError: handleError, + }, + ); + }; + + return ( +
+
+ + setUrl(e.target.value)} + placeholder="https:// or git@github.com:user/repo.git" + disabled={isLoading} + onKeyDown={(e) => { + if (e.key === "Enter" && !isLoading) { + handleClone(); + } + }} + autoFocus + /> +
+
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/index.ts new file mode 100644 index 00000000000..3f48db1cc11 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/CloneRepoTab/index.ts @@ -0,0 +1 @@ +export { CloneRepoTab } from "./CloneRepoTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx new file mode 100644 index 00000000000..7b7876e3d0e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx @@ -0,0 +1,70 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useProjectCreationHandler } from "../../hooks/useProjectCreationHandler"; + +interface EmptyRepoTabProps { + onError: (error: string) => void; + parentDir: string; +} + +export function EmptyRepoTab({ onError, parentDir }: EmptyRepoTabProps) { + const [name, setName] = useState(""); + const createEmptyRepo = electronTrpc.projects.createEmptyRepo.useMutation(); + const { handleResult, handleError, isCreatingWorkspace } = + useProjectCreationHandler(onError); + + const isLoading = createEmptyRepo.isPending || isCreatingWorkspace; + + const handleCreate = () => { + const trimmed = name.trim(); + if (!trimmed) { + onError("Please enter a repository name"); + return; + } + if (!parentDir.trim()) { + onError("Please select a project location"); + return; + } + + createEmptyRepo.mutate( + { name: trimmed, parentDir: parentDir.trim() }, + { + onSuccess: (result) => handleResult(result, () => setName("")), + onError: handleError, + }, + ); + }; + + return ( +
+
+ + setName(e.target.value)} + placeholder="my-project" + disabled={isLoading} + onKeyDown={(e) => { + if (e.key === "Enter" && !isLoading) { + handleCreate(); + } + }} + autoFocus + /> +
+
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/index.ts new file mode 100644 index 00000000000..1d45c599563 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/index.ts @@ -0,0 +1 @@ +export { EmptyRepoTab } from "./EmptyRepoTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx new file mode 100644 index 00000000000..6066f82bdeb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx @@ -0,0 +1,57 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { LuFolderOpen } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface PathSelectorProps { + value: string; + onChange: (path: string) => void; + disabled?: boolean; +} + +export function PathSelector({ value, onChange, disabled }: PathSelectorProps) { + const selectDirectory = electronTrpc.projects.selectDirectory.useMutation(); + + const handleBrowse = () => { + selectDirectory.mutate( + { defaultPath: value || undefined }, + { + onSuccess: (result) => { + if (!result.canceled && result.path) { + onChange(result.path); + } + }, + }, + ); + }; + + return ( +
+ +
+ onChange(e.target.value)} + disabled={disabled} + className="flex-1 font-mono text-xs" + /> + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/index.ts new file mode 100644 index 00000000000..43707207dad --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/index.ts @@ -0,0 +1 @@ +export { PathSelector } from "./PathSelector"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/TemplateRepoTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/TemplateRepoTab.tsx new file mode 100644 index 00000000000..17b7c65a415 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/TemplateRepoTab.tsx @@ -0,0 +1,144 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../constants"; +import { useProjectCreationHandler } from "../../hooks/useProjectCreationHandler"; + +interface TemplateRepoTabProps { + onError: (error: string) => void; + parentDir: string; +} + +export function TemplateRepoTab({ onError, parentDir }: TemplateRepoTabProps) { + const [selectedTemplate, setSelectedTemplate] = + useState(null); + const [customUrl, setCustomUrl] = useState(""); + const [nameOverride, setNameOverride] = useState(""); + const createFromTemplate = + electronTrpc.projects.createFromTemplate.useMutation(); + const { handleResult, handleError, isCreatingWorkspace } = + useProjectCreationHandler(onError); + + const isLoading = createFromTemplate.isPending || isCreatingWorkspace; + + const handleCreate = () => { + const templateUrl = selectedTemplate?.url || customUrl.trim(); + if (!templateUrl) { + onError("Please select a template or enter a custom URL"); + return; + } + if (!parentDir.trim()) { + onError("Please select a project location"); + return; + } + + createFromTemplate.mutate( + { + templateUrl, + name: nameOverride.trim() || undefined, + parentDir: parentDir.trim(), + }, + { + onSuccess: (result) => + handleResult(result, () => { + setSelectedTemplate(null); + setCustomUrl(""); + setNameOverride(""); + }), + onError: handleError, + }, + ); + }; + + return ( +
+ {PROJECT_TEMPLATES.length > 0 && ( +
+ {PROJECT_TEMPLATES.map((template) => ( + + ))} +
+ )} + +
+ + { + setCustomUrl(e.target.value); + if (e.target.value.trim()) { + setSelectedTemplate(null); + } + }} + placeholder="https://github.com/user/template-repo.git" + disabled={isLoading} + onKeyDown={(e) => { + if (e.key === "Enter" && !isLoading) { + handleCreate(); + } + }} + autoFocus + /> +
+ +
+ + setNameOverride(e.target.value)} + placeholder="Derived from template URL if empty" + disabled={isLoading} + onKeyDown={(e) => { + if (e.key === "Enter" && !isLoading) { + handleCreate(); + } + }} + /> +
+ +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/index.ts new file mode 100644 index 00000000000..c13ff44f77d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateRepoTab/index.ts @@ -0,0 +1 @@ +export { TemplateRepoTab } from "./TemplateRepoTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts new file mode 100644 index 00000000000..94ab73c6ee8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts @@ -0,0 +1,10 @@ +export type NewProjectMode = "empty" | "clone" | "template"; + +export interface ProjectTemplate { + id: string; + name: string; + description: string; + url: string; +} + +export const PROJECT_TEMPLATES: ProjectTemplate[] = []; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/index.ts new file mode 100644 index 00000000000..323a8ef3605 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/index.ts @@ -0,0 +1 @@ +export { useProjectCreationHandler } from "./useProjectCreationHandler"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts new file mode 100644 index 00000000000..2612196a590 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts @@ -0,0 +1,36 @@ +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; + +export function useProjectCreationHandler(onError: (error: string) => void) { + const utils = electronTrpc.useUtils(); + const createWorkspace = useCreateWorkspace(); + + const handleResult = ( + result: { + canceled?: boolean; + success?: boolean; + error?: string | null; + project?: { id: string } | null; + }, + resetState?: () => void, + ) => { + if (result.canceled) return; + if (result.success && result.project) { + utils.projects.getRecents.invalidate(); + createWorkspace.mutate({ projectId: result.project.id }); + resetState?.(); + } else if (!result.success && result.error) { + onError(result.error); + } + }; + + const handleError = (err: { message?: string }) => { + onError(err.message || "Operation failed"); + }; + + return { + handleResult, + handleError, + isCreatingWorkspace: createWorkspace.isPending, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx new file mode 100644 index 00000000000..b5af4b41c09 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx @@ -0,0 +1,109 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { LuArrowLeft, LuX } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo"; +import { CloneRepoTab } from "./components/CloneRepoTab"; +import { EmptyRepoTab } from "./components/EmptyRepoTab"; +import { PathSelector } from "./components/PathSelector"; +import { TemplateRepoTab } from "./components/TemplateRepoTab"; +import type { NewProjectMode } from "./constants"; + +export const Route = createFileRoute( + "/_authenticated/_onboarding/new-project/", +)({ + component: NewProjectPage, +}); + +const TABS: { mode: NewProjectMode; label: string }[] = [ + { mode: "empty", label: "Empty" }, + { mode: "clone", label: "Clone" }, + { mode: "template", label: "Template" }, +]; + +function NewProjectPage() { + const navigate = useNavigate(); + const [mode, setMode] = useState("empty"); + const [error, setError] = useState(null); + const [parentDir, setParentDir] = useState(""); + + const { data: homeDir } = electronTrpc.window.getHomeDir.useQuery(); + + useEffect(() => { + if (parentDir || !homeDir) return; + setParentDir(`${homeDir}/.superset/projects`); + }, [homeDir, parentDir]); + + return ( +
+
+ + +
+
+ +

New Project

+
+ +
+
+
+ {TABS.map((tab) => ( + + ))} +
+
+ +
+ +
+ + {mode === "empty" && ( + + )} + {mode === "clone" && ( + + )} + {mode === "template" && ( + + )} +
+ + {error && ( +
+ {error} + +
+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/CloneRepoDialog.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/CloneRepoDialog.tsx deleted file mode 100644 index 8b404d736e1..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/StartView/CloneRepoDialog.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@superset/ui/dialog"; -import { Input } from "@superset/ui/input"; -import { useState } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useCreateWorkspace } from "renderer/react-query/workspaces"; - -interface CloneRepoDialogProps { - isOpen: boolean; - onClose: () => void; - onError: (error: string) => void; -} - -export function CloneRepoDialog({ - isOpen, - onClose, - onError, -}: CloneRepoDialogProps) { - const [url, setUrl] = useState(""); - const utils = electronTrpc.useUtils(); - const cloneRepo = electronTrpc.projects.cloneRepo.useMutation(); - const createWorkspace = useCreateWorkspace(); - - const isLoading = cloneRepo.isPending || createWorkspace.isPending; - - const handleClone = async () => { - if (!url.trim()) { - onError("Please enter a repository URL"); - return; - } - - cloneRepo.mutate( - { url: url.trim() }, - { - onSuccess: (result) => { - if (result.canceled) { - return; - } - - if (result.success && result.project) { - utils.projects.getRecents.invalidate(); - createWorkspace.mutate({ projectId: result.project.id }); - onClose(); - setUrl(""); - } else if (!result.success) { - onError(result.error ?? "Failed to clone repository"); - } - }, - onError: (err) => { - onError(err.message || "Failed to clone repository"); - }, - }, - ); - }; - - return ( - !open && onClose()}> - - - Clone Repository - - Enter a repository URL to clone it locally. - - - -
- - setUrl(e.target.value)} - placeholder="https:// or git@github.com:user/repo.git" - disabled={isLoading} - onKeyDown={(e) => { - if (e.key === "Enter" && !isLoading) { - handleClone(); - } - }} - autoFocus - /> -
- - - - - -
-
- ); -} 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 52947461dd4..e1cc546fde3 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -2,16 +2,14 @@ import { Button } from "@superset/ui/button"; 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 { LuFolderOpen, LuPlus, LuX } from "react-icons/lu"; import { useOpenProject } from "renderer/react-query/projects"; import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo"; -import { CloneRepoDialog } from "./CloneRepoDialog"; export function StartView() { const navigate = useNavigate(); const { openNew, openFromPath, isPending } = useOpenProject(); const [error, setError] = useState(null); - const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); useEffect(() => { @@ -121,10 +119,6 @@ export function StartView() { [isPending, openFromPath, navigate], ); - const handleCloneError = (errorMessage: string) => { - setError(errorMessage); - }; - return (
{/* biome-ignore lint/a11y/noStaticElementInteractions: Drop zone for external files */} @@ -159,7 +153,7 @@ export function StartView() { > {isDragOver ? (
- + Drop git project @@ -187,16 +181,17 @@ export function StartView() { )} > - Don't have a local repo? + Or start a new project
@@ -216,12 +211,6 @@ export function StartView() { )} - - setIsCloneDialogOpen(false)} - onError={handleCloneError} - /> ); } 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 d0115726363..0e3badeb5d8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx @@ -7,11 +7,10 @@ import { } from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; import { LuFolderGit, LuFolderOpen, LuFolderPlus } from "react-icons/lu"; import { useOpenProject } from "renderer/react-query/projects"; import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; -import { CloneRepoDialog } from "../StartView/CloneRepoDialog"; import { STROKE_WIDTH } from "./constants"; interface WorkspaceSidebarFooterProps { @@ -21,9 +20,9 @@ interface WorkspaceSidebarFooterProps { export function WorkspaceSidebarFooter({ isCollapsed = false, }: WorkspaceSidebarFooterProps) { + const navigate = useNavigate(); const { openNew, isPending: isOpenPending } = useOpenProject(); const createBranchWorkspace = useCreateBranchWorkspace(); - const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const handleOpenProject = async () => { try { @@ -49,99 +48,73 @@ export function WorkspaceSidebarFooter({ } }; - const handleCloneError = (error: string) => { - toast.error("Failed to clone repository", { - description: error, - }); - }; - const isLoading = isOpenPending || createBranchWorkspace.isPending; if (isCollapsed) { return ( - <> -
- - - - - - - - Add repository - - - - - Open project - - setIsCloneDialogOpen(true)} - disabled={isLoading} - > - - Clone repo - - - -
- setIsCloneDialogOpen(false)} - onError={handleCloneError} - /> - - ); - } - - return ( - <> -
+
- - - + + + + + + + Add repository + Open project setIsCloneDialogOpen(true)} + onClick={() => navigate({ to: "/new-project" })} disabled={isLoading} > - Clone repo + New project
- setIsCloneDialogOpen(false)} - onError={handleCloneError} - /> - + ); + } + + return ( +
+ + + + + + + + Open project + + navigate({ to: "/new-project" })} + disabled={isLoading} + > + + New project + + + +
); } diff --git a/biome.jsonc b/biome.jsonc index 1fb954d15c3..101e9c7eaf5 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json", "vcs": { "enabled": true, "clientKind": "git", From 548918ff5464e141aa9c00562a88d8246ac5bf2a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Feb 2026 01:43:35 -0800 Subject: [PATCH 2/7] Address issues --- .../src/lib/trpc/routers/projects/projects.ts | 65 ++++++------------- .../components/PathSelector/PathSelector.tsx | 1 + .../useProjectCreationHandler.ts | 9 ++- .../_onboarding/new-project/page.tsx | 1 + .../WorkspaceSidebarFooter.tsx | 10 +-- 5 files changed, 30 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 8105b907b92..74d0494cb03 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -1,5 +1,5 @@ import { existsSync, statSync } from "node:fs"; -import { access, mkdir } from "node:fs/promises"; +import { access, mkdir, rm } from "node:fs/promises"; import { basename, join } from "node:path"; import { BRANCH_PREFIX_MODES, @@ -95,11 +95,6 @@ async function initGitRepo(path: string): Promise<{ defaultBranch: string }> { return { defaultBranch }; } -/** - * Creates or updates a project record in the database. - * If a project with the same mainRepoPath exists, updates lastOpenedAt. - * Otherwise, creates a new project. - */ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { const name = basename(mainRepoPath); @@ -132,22 +127,15 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { return project; } -/** - * Ensures a project has a main (branch) workspace. - * If one doesn't exist, creates it automatically. - * This is called after opening/creating a project to provide a default workspace. - */ async function ensureMainWorkspace(project: Project): Promise { const existingBranchWorkspace = getBranchWorkspace(project.id); - // If branch workspace already exists, just touch it and return if (existingBranchWorkspace) { touchWorkspace(existingBranchWorkspace.id); setLastActiveWorkspace(existingBranchWorkspace.id); return; } - // Get current branch from main repo const branch = await getCurrentBranch(project.mainRepoPath); if (!branch) { console.warn( @@ -156,8 +144,7 @@ async function ensureMainWorkspace(project: Project): Promise { return; } - // Insert new branch workspace with conflict handling for race conditions - // The unique partial index (projectId WHERE type='branch') prevents duplicates + // Unique partial index (projectId WHERE type='branch') prevents duplicates const insertResult = localDb .insert(workspaces) .values({ @@ -173,7 +160,6 @@ async function ensureMainWorkspace(project: Project): Promise { const wasExisting = insertResult.length === 0; - // Only shift existing workspaces if we successfully inserted if (!wasExisting) { const newWorkspaceId = insertResult[0].id; const projectWorkspaces = localDb @@ -197,7 +183,6 @@ async function ensureMainWorkspace(project: Project): Promise { } } - // Get the workspace (either newly created or existing from race condition) const workspace = insertResult[0] ?? getBranchWorkspace(project.id); if (!workspace) { @@ -228,39 +213,28 @@ const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/; const ALLOWED_URL_PROTOCOLS = new Set(["http:", "https:", "ssh:", "git:"]); const SSH_GIT_URL_REGEX = /^[\w.-]+@[\w.-]+:[\w./-]+$/; -/** - * Extracts and validates a repository name from a git URL. - * Handles HTTP/HTTPS URLs, SSH-style URLs (git@host:user/repo), and edge cases. - */ function extractRepoName(urlInput: string): string | null { - // Normalize: trim whitespace and strip trailing slashes let normalized = urlInput.trim().replace(/\/+$/, ""); if (!normalized) return null; let repoSegment: string | undefined; - // Try parsing as HTTP/HTTPS URL first try { const parsed = new URL(normalized); if (parsed.protocol === "http:" || parsed.protocol === "https:") { - // Get pathname and strip query/hash (URL constructor handles this) const pathname = parsed.pathname; repoSegment = pathname.split("/").filter(Boolean).pop(); } } catch { - // Not a valid URL, try SSH-style parsing + // Not a standard URL — fall through to SSH-style parsing } - // Fallback to SSH-style parsing (git@github.com:user/repo.git) if (!repoSegment) { - // Handle SSH format: git@host:path or just path segments const colonIndex = normalized.indexOf(":"); if (colonIndex !== -1 && !normalized.includes("://")) { - // SSH-style: take everything after the colon normalized = normalized.slice(colonIndex + 1); } - // Split by '/' and get the last segment repoSegment = normalized.split("/").filter(Boolean).pop(); } @@ -271,13 +245,10 @@ function extractRepoName(urlInput: string): string | null { try { repoSegment = decodeURIComponent(repoSegment); - } catch { - // Invalid encoding, continue with raw value - } + } catch {} repoSegment = repoSegment.trim(); - // Validate against safe filename regex if (!repoSegment || !SAFE_REPO_NAME_REGEX.test(repoSegment)) { return null; } @@ -373,14 +344,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const git = simpleGit(project.mainRepoPath); - // Check if origin remote exists let hasOrigin = false; try { const remotes = await git.getRemotes(); hasOrigin = remotes.some((r) => r.name === "origin"); - } catch { - // If we can't get remotes, assume no origin - } + } catch {} const branchSummary = await git.branch(["-a"]); @@ -397,13 +365,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } } - // Get branch dates for sorting - fetch from both local and remote const branchMap = new Map< string, { lastCommitDate: number; isLocal: boolean; isRemote: boolean } >(); - // First, get remote branch dates (if origin exists) if (hasOrigin) { try { const remoteBranchInfo = await git.raw([ @@ -436,7 +402,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }); } } catch { - // Fallback for remote branches for (const name of remoteBranchSet) { branchMap.set(name, { lastCommitDate: 0, @@ -447,7 +412,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } } - // Then, add local-only branches try { const localBranchInfo = await git.raw([ "for-each-ref", @@ -817,10 +781,13 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { name: z .string() .min(1) - .refine((val) => SAFE_REPO_NAME_REGEX.test(val), { - message: - "Name can only contain letters, numbers, dots, underscores, hyphens, and spaces", - }), + .refine( + (val) => SAFE_REPO_NAME_REGEX.test(val) && !/^\.+$/.test(val), + { + message: + "Name can only contain letters, numbers, dots, underscores, hyphens, and spaces", + }, + ), parentDir: z.string().min(1), }), ) @@ -838,7 +805,13 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { await mkdir(repoPath, { recursive: true }); - const { defaultBranch } = await initGitRepo(repoPath); + let defaultBranch: string; + try { + ({ defaultBranch } = await initGitRepo(repoPath)); + } catch (gitErr) { + await rm(repoPath, { recursive: true, force: true }); + throw gitErr; + } const project = upsertProject(repoPath, defaultBranch); await ensureMainWorkspace(project); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx index 6066f82bdeb..bb6068453c2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/PathSelector/PathSelector.tsx @@ -48,6 +48,7 @@ export function PathSelector({ value, onChange, disabled }: PathSelectorProps) { onClick={handleBrowse} disabled={disabled || selectDirectory.isPending} className="shrink-0" + aria-label="Browse for directory" > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts index 2612196a590..1c481745cc1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/hooks/useProjectCreationHandler/useProjectCreationHandler.ts @@ -17,8 +17,13 @@ export function useProjectCreationHandler(onError: (error: string) => void) { if (result.canceled) return; if (result.success && result.project) { utils.projects.getRecents.invalidate(); - createWorkspace.mutate({ projectId: result.project.id }); - resetState?.(); + createWorkspace.mutate( + { projectId: result.project.id }, + { + onSuccess: () => resetState?.(), + onError: (err) => onError(err.message || "Failed to open project"), + }, + ); } else if (!result.success && result.error) { onError(result.error); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx index d4b61ac9825..b25b8845fd8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx @@ -42,6 +42,7 @@ function NewProjectPage() { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx index 7b7876e3d0e..9cc1d4daa8a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/EmptyRepoTab/EmptyRepoTab.tsx @@ -38,7 +38,7 @@ export function EmptyRepoTab({ onError, parentDir }: EmptyRepoTabProps) { }; return ( -
+
-
+
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateTab/TemplateTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateTab/TemplateTab.tsx new file mode 100644 index 00000000000..f24421a1e93 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateTab/TemplateTab.tsx @@ -0,0 +1,7 @@ +export function TemplateTab() { + return ( +
+

Templates coming soon.

+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateTab/index.ts new file mode 100644 index 00000000000..90e3ad36444 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/components/TemplateTab/index.ts @@ -0,0 +1 @@ +export { TemplateTab } from "./TemplateTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts index 2534bd3a82f..4876ba106bf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/constants.ts @@ -1 +1 @@ -export type NewProjectMode = "empty" | "clone"; +export type NewProjectMode = "empty" | "clone" | "template"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx index b25b8845fd8..e2f2d8c7345 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx @@ -1,11 +1,19 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Button } from "@superset/ui/button"; +import { cn } from "@superset/ui/utils"; +import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { LuArrowLeft, LuX } from "react-icons/lu"; +import { HiArrowLeft } from "react-icons/hi2"; +import { + LuFolderPlus, + LuGitBranch, + LuLayoutTemplate, + LuX, +} from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo"; import { CloneRepoTab } from "./components/CloneRepoTab"; import { EmptyRepoTab } from "./components/EmptyRepoTab"; import { PathSelector } from "./components/PathSelector"; +import { TemplateTab } from "./components/TemplateTab"; import type { NewProjectMode } from "./constants"; export const Route = createFileRoute( @@ -14,13 +22,33 @@ export const Route = createFileRoute( component: NewProjectPage, }); -const TABS: { mode: NewProjectMode; label: string }[] = [ - { mode: "empty", label: "Empty" }, - { mode: "clone", label: "Clone" }, +const OPTIONS: { + mode: NewProjectMode; + label: string; + description: string; + icon: typeof LuFolderPlus; +}[] = [ + { + mode: "empty", + label: "Empty", + description: "New git repository from scratch", + icon: LuFolderPlus, + }, + { + mode: "clone", + label: "Clone", + description: "Clone from a remote URL", + icon: LuGitBranch, + }, + { + mode: "template", + label: "Template", + description: "Start from a project template", + icon: LuLayoutTemplate, + }, ]; function NewProjectPage() { - const navigate = useNavigate(); const [mode, setMode] = useState("empty"); const [error, setError] = useState(null); const [parentDir, setParentDir] = useState(""); @@ -33,71 +61,84 @@ function NewProjectPage() { }, [homeDir, parentDir]); return ( -
-
- +
+
+ +
-
-
- +
+
+

New Project

-
-
-
-
- {TABS.map((tab) => ( +
+ {OPTIONS.map((option) => { + const selected = mode === option.mode; + return ( - ))} -
+ ); + })}
-
+
+ + {mode === "empty" && ( + + )} + {mode === "clone" && ( + + )} + {mode === "template" && }
- {mode === "empty" && ( - - )} - {mode === "clone" && ( - + {error && ( +
+ {error} + +
)}
- - {error && ( -
- {error} - -
- )}
diff --git a/bun.lock b/bun.lock index 66ab6dc2c04..f5e159c0023 100644 --- a/bun.lock +++ b/bun.lock @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.81", + "version": "0.0.82", "dependencies": { "@ai-sdk/react": "^3.0.0", "@better-auth/stripe": "1.4.18", From 69ea67f1c1e90bb32b5a38fd9f852c333e500fcc Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Feb 2026 02:00:06 -0800 Subject: [PATCH 5/7] Back button --- .../routes/_authenticated/_onboarding/new-project/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx index e2f2d8c7345..38d762615c5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx @@ -62,7 +62,7 @@ function NewProjectPage() { return (
-
+
+ ))} +
+

Templates coming soon

+
); } From d2a0ab3c8c125e505c393a638a4e0636a5d39ea3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 20 Feb 2026 02:04:53 -0800 Subject: [PATCH 7/7] Styling --- .../_onboarding/new-project/page.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx index 38d762615c5..72c8e997bef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_onboarding/new-project/page.tsx @@ -76,6 +76,8 @@ function NewProjectPage() {

New Project

+ +
{OPTIONS.map((option) => { const selected = mode === option.mode; @@ -113,17 +115,13 @@ function NewProjectPage() { })}
-
- - - {mode === "empty" && ( - - )} - {mode === "clone" && ( - - )} - {mode === "template" && } -
+ {mode === "empty" && ( + + )} + {mode === "clone" && ( + + )} + {mode === "template" && } {error && (