diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx new file mode 100644 index 00000000000..e0393c30cd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx @@ -0,0 +1,322 @@ +import { Button } from "@superset/ui/button"; +import { Collapsible, CollapsibleTrigger } from "@superset/ui/collapsible"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Input } from "@superset/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { toast } from "@superset/ui/sonner"; +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiCheck, HiChevronDown, HiChevronUpDown } from "react-icons/hi2"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; +import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { NotFound } from "renderer/routes/not-found"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/project/$projectId/", +)({ + component: ProjectPage, + notFoundComponent: NotFound, + loader: async ({ params, context }) => { + const queryKey = [ + ["projects", "get"], + { input: { id: params.projectId }, type: "query" }, + ]; + + try { + await context.queryClient.ensureQueryData({ + queryKey, + queryFn: () => trpcClient.projects.get.query({ id: params.projectId }), + }); + } catch (error) { + if (error instanceof Error && error.message.includes("not found")) { + throw notFound(); + } + throw error; + } + }, +}); + +function generateBranchFromTitle(title: string): string { + if (!title.trim()) return ""; + + return title + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); +} + +function ProjectPage() { + const { projectId } = Route.useParams(); + + const { data: project } = electronTrpc.projects.get.useQuery({ + id: projectId, + }); + const { + data: branchData, + isLoading: isBranchesLoading, + isError: isBranchesError, + } = electronTrpc.projects.getBranches.useQuery( + { projectId }, + { enabled: !!projectId }, + ); + + const createWorkspace = useCreateWorkspace(); + + const [title, setTitle] = useState(""); + const [baseBranch, setBaseBranch] = useState(null); + const [baseBranchOpen, setBaseBranchOpen] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); + const titleInputRef = useRef(null); + + const filteredBranches = useMemo(() => { + if (!branchData?.branches) return []; + if (!branchSearch) return branchData.branches; + const searchLower = branchSearch.toLowerCase(); + return branchData.branches.filter((b) => + b.name.toLowerCase().includes(searchLower), + ); + }, [branchData?.branches, branchSearch]); + + const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? null; + + useEffect(() => { + const timer = setTimeout(() => { + titleInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !createWorkspace.isPending) { + e.preventDefault(); + handleCreateWorkspace(); + } + }; + + const handleCreateWorkspace = async () => { + const workspaceName = title.trim() || undefined; + + try { + await createWorkspace.mutateAsync({ + projectId, + name: workspaceName, + baseBranch: effectiveBaseBranch || undefined, + }); + + toast.success("Workspace created", { + description: "Setting up in the background...", + }); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to create workspace", + ); + } + }; + + if (!project) { + return null; + } + + return ( +
+
+ {/* Main content */} +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Form container handles Enter key for submission */} +
+ {/* Project context */} +
+ + {project.name} + + ยท + + {branchData?.defaultBranch ?? "main"} + +
+ + {/* Headline */} +

+ What are you building? +

+ + {/* Subtext */} +

+ Each workspace is an isolated copy of your codebase. Work on + multiple tasks without conflicts. +

+ + {/* Form */} +
+
+ + setTitle(e.target.value)} + /> +
+ +

+ + + {generateBranchFromTitle(title) || "branch-name"} + + + from {effectiveBaseBranch} + +

+ + + + + Advanced + + + {showAdvanced && ( + +
+ + Change base branch + + {isBranchesError ? ( +
+ Failed to load branches +
+ ) : ( + + + + + e.stopPropagation()} + > + + + + No branches found + {filteredBranches.map((branch) => ( + { + setBaseBranch(branch.name); + setBaseBranchOpen(false); + setBranchSearch(""); + }} + className="flex items-center justify-between" + > + + + + {branch.name} + + {branch.name === + branchData?.defaultBranch && ( + + default + + )} + + + {branch.lastCommitDate > 0 && ( + + {formatRelativeTime( + branch.lastCommitDate, + )} + + )} + {effectiveBaseBranch === + branch.name && ( + + )} + + + ))} + + + + + )} +
+
+ )} +
+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/StartView/StartTopBar.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/StartTopBar.tsx index aa9d2f76282..02e53f550fc 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/StartTopBar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/StartTopBar.tsx @@ -1,5 +1,4 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; -import { SettingsButton } from "../SettingsButton"; import { WindowControls } from "../TopBar/WindowControls"; export function StartTopBar() { @@ -9,22 +8,12 @@ export function StartTopBar() { const showWindowControls = !isLoading && !isMac; return ( -
-
- {/* Empty space on left for symmetry */} -
-
- {/* Empty middle section - no tabs */} -
-
- - {showWindowControls && } -
+
+ {showWindowControls && ( +
+ +
+ )}
); } 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 f3ccd2d9984..d6b1f25bcf1 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -1,30 +1,45 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useState } from "react"; -import { HiExclamationTriangle } from "react-icons/hi2"; -import { LuChevronUp, LuFolderGit, LuFolderOpen, LuX } from "react-icons/lu"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { formatPathWithProject } from "renderer/lib/formatPath"; -import { useOpenNew } from "renderer/react-query/projects"; -import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; -import { ActionCard } from "./ActionCard"; -import { CloneRepoDialog } from "./CloneRepoDialog"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useState } from "react"; +import { LuFolderOpen, LuX } from "react-icons/lu"; +import { useOpenFromPath, useOpenNew } from "renderer/react-query/projects"; +import { SupersetLogo } from "renderer/routes/sign-in/components/SupersetLogo"; import { InitGitDialog } from "./InitGitDialog"; import { StartTopBar } from "./StartTopBar"; export function StartView() { - const { data: recentProjects = [] } = - electronTrpc.projects.getRecents.useQuery(); - const { data: homeDir } = electronTrpc.window.getHomeDir.useQuery(); + const navigate = useNavigate(); const openNew = useOpenNew(); - const createBranchWorkspace = useCreateBranchWorkspace(); + const openFromPath = useOpenFromPath(); const [error, setError] = useState(null); - const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [initGitDialog, setInitGitDialog] = useState<{ isOpen: boolean; selectedPath: string; }>({ isOpen: false, selectedPath: "" }); - const [showAllProjects, setShowAllProjects] = useState(false); - const [visibleCount, setVisibleCount] = useState(50); + const [isDragOver, setIsDragOver] = useState(false); + + const isLoading = openNew.isPending || openFromPath.isPending; + + // Auto-dismiss error after 5 seconds + useEffect(() => { + if (!error) return; + const timer = setTimeout(() => setError(null), 5000); + return () => clearTimeout(timer); + }, [error]); + + // Clear drag state when drag ends anywhere + useEffect(() => { + const handleWindowDragEnd = () => setIsDragOver(false); + const handleWindowDrop = () => setIsDragOver(false); + + window.addEventListener("dragend", handleWindowDragEnd); + window.addEventListener("drop", handleWindowDrop); + + return () => { + window.removeEventListener("dragend", handleWindowDragEnd); + window.removeEventListener("drop", handleWindowDrop); + }; + }, []); const handleOpenProject = () => { setError(null); @@ -40,7 +55,6 @@ export function StartView() { } if ("needsGitInit" in result) { - // Show dialog to offer git initialization setInitGitDialog({ isOpen: true, selectedPath: result.selectedPath, @@ -48,8 +62,13 @@ export function StartView() { return; } - // Create a main workspace on the current branch - createBranchWorkspace.mutate({ projectId: result.project.id }); + // Navigate to project view + if ("project" in result && result.project) { + navigate({ + to: "/project/$projectId", + params: { projectId: result.project.id }, + }); + } }, onError: (err) => { setError(err.message || "Failed to open project"); @@ -57,182 +76,181 @@ export function StartView() { }); }; - const handleOpenRecentProject = (projectId: string) => { - setError(null); - // Create/activate main workspace on current branch - createBranchWorkspace.mutate( - { projectId }, - { - onError: (err) => { - setError(err.message || "Failed to open workspace"); - }, - }, - ); - }; + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.dataTransfer.types.includes("Files")) { + setIsDragOver(true); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const rect = e.currentTarget.getBoundingClientRect(); + const { clientX, clientY } = e; + + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + setIsDragOver(false); + } + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (isLoading) return; + + setError(null); + + const files = Array.from(e.dataTransfer.files); + const firstFile = files[0]; + if (!firstFile) return; + + let filePath: string; + try { + filePath = window.webUtils.getPathForFile(firstFile); + } catch { + setError("Could not get path from dropped item"); + return; + } + + if (!filePath) { + setError("Could not get path from dropped item"); + return; + } - const hasMoreProjects = recentProjects.length > 5; - const displayedProjects = showAllProjects - ? recentProjects.slice(0, visibleCount) - : recentProjects.slice(0, 5); - const hasMoreToLoad = showAllProjects && recentProjects.length > visibleCount; - const isLoading = openNew.isPending || createBranchWorkspace.isPending; + openFromPath.mutate( + { path: filePath }, + { + onSuccess: (result) => { + if ("canceled" in result && result.canceled) { + return; + } + + if ("error" in result) { + setError(result.error); + return; + } + + if ("needsGitInit" in result) { + setInitGitDialog({ + isOpen: true, + selectedPath: result.selectedPath, + }); + return; + } + + // Navigate to project view + if ("project" in result && result.project) { + navigate({ + to: "/project/$projectId", + params: { projectId: result.project.id }, + }); + } + }, + onError: (err) => { + setError(err.message || "Failed to open project"); + }, + }, + ); + }, + [openFromPath, isLoading, navigate], + ); return ( -
+
-
-
- {/* Logo */} -
- - Superset - - + {/* biome-ignore lint/a11y/noStaticElementInteractions: Drop zone for external files */} +
+
+
+ + Welcome to + +
- {/* Error Display */} - {error && ( -
-
-
- -
-

{error}

- + {isDragOver + ? "Drop to open" + : "Drag and drop a git folder to open"} +

+

+ Any folder with a .git directory +

-
- )} + +
- {/* Action Cards and Recent Projects Container */} -
- {/* Action Cards */} -
- - - { - setError(null); - setIsCloneDialogOpen(true); - }} - isLoading={isLoading} - /> + {error && ( +
+ {error} +
- - {/* Recent Projects */} - {displayedProjects.length > 0 && ( -
-
-
- - Recent projects - - {hasMoreProjects && ( - - )} -
- -
- {displayedProjects.map((project) => { - const pathInfo = formatPathWithProject( - project.mainRepoPath, - project.name, - homeDir, - ); - return ( - - ); - })} - - {hasMoreToLoad && ( - - )} -
-
-
- )} -
+ )}
- {/* Dialogs */} - setIsCloneDialogOpen(false)} - onError={setError} - /> - + {closeProject.isPending ? "Closing..." : "Close Project"} @@ -316,7 +319,10 @@ export function ProjectHeader({ disabled={closeProject.isPending} className="text-destructive focus:text-destructive" > - + {closeProject.isPending ? "Closing..." : "Close Project"} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx index b194b5bc8a7..74e42cd6e9d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx @@ -1,9 +1,9 @@ import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { type ReactNode, useCallback, useEffect, useState } from "react"; import { LuFolderPlus, LuLoader, LuX } from "react-icons/lu"; import { useOpenFromPath } from "renderer/react-query/projects"; -import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; import { InitGitDialog } from "../../StartView/InitGitDialog"; interface SidebarDropZoneProps { @@ -12,6 +12,7 @@ interface SidebarDropZoneProps { } export function SidebarDropZone({ children, className }: SidebarDropZoneProps) { + const navigate = useNavigate(); const [isDragOver, setIsDragOver] = useState(false); const [error, setError] = useState(null); const [initGitDialog, setInitGitDialog] = useState<{ @@ -20,10 +21,8 @@ export function SidebarDropZone({ children, className }: SidebarDropZoneProps) { }>({ isOpen: false, selectedPath: "" }); const openFromPath = useOpenFromPath(); - const createBranchWorkspace = useCreateBranchWorkspace(); - const isProcessing = - openFromPath.isPending || createBranchWorkspace.isPending; + const isProcessing = openFromPath.isPending; // Auto-dismiss error after 5 seconds useEffect(() => { @@ -138,19 +137,12 @@ export function SidebarDropZone({ children, className }: SidebarDropZoneProps) { return; } - // Create a main workspace on the current branch + // Navigate to project view if ("project" in result && result.project) { - createBranchWorkspace.mutate( - { projectId: result.project.id }, - { - onError: (err) => { - setError( - err.message || - "Project added but failed to create workspace", - ); - }, - }, - ); + navigate({ + to: "/project/$projectId", + params: { projectId: result.project.id }, + }); } }, onError: (err) => { @@ -159,7 +151,7 @@ export function SidebarDropZone({ children, className }: SidebarDropZoneProps) { }, ); }, - [openFromPath, createBranchWorkspace, isProcessing], + [openFromPath, isProcessing, navigate], ); return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 9da785799f7..087b9ff545a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -52,7 +52,7 @@ export function WorkspaceSidebar({
No workspaces yet - Add a project or drag a folder here + Add project or drag a Git repo folder here
)}