diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 2e3e69257d6..9a612d1ca10 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -30,6 +30,7 @@ export const createWorkspacesRouter = () => { z.object({ projectId: z.string(), name: z.string().optional(), + branchName: z.string().optional(), }), ) .mutation(async ({ input }) => { @@ -38,7 +39,7 @@ export const createWorkspacesRouter = () => { throw new Error(`Project ${input.projectId} not found`); } - const branch = generateBranchName(); + const branch = input.branchName?.trim() || generateBranchName(); const worktreePath = join( homedir(), diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx new file mode 100644 index 00000000000..2caf62873a0 --- /dev/null +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -0,0 +1,301 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { useEffect, useState } from "react"; +import { + HiCheck, + HiChevronDown, + HiChevronUp, + HiMiniFolderOpen, +} from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useOpenNew } from "renderer/react-query/projects"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { + useCloseNewWorkspaceModal, + useNewWorkspaceModalOpen, +} from "renderer/stores/new-workspace-modal"; + +const INITIAL_PROJECTS_LIMIT = 5; + +/** + * Generates a git-appropriate branch name from a title. + */ +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 formatPath( + path: string, + projectName: string, + homeDir: string | undefined, +): string { + const normalizedPath = path.replace(/\\/g, "/"); + const normalizedHome = homeDir ? homeDir.replace(/\\/g, "/") : null; + + let displayPath = normalizedPath; + if ( + normalizedHome && + (normalizedPath === normalizedHome || + normalizedPath.startsWith(`${normalizedHome}/`)) + ) { + displayPath = `~${normalizedPath.slice(normalizedHome.length)}`; + } else { + displayPath = normalizedPath.replace(/^\/(?:Users|home)\/[^/]+/, "~"); + } + + const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const suffixPattern = new RegExp(`/${escapedProjectName}$`); + return displayPath.replace(suffixPattern, ""); +} + +export function NewWorkspaceModal() { + const isOpen = useNewWorkspaceModalOpen(); + const closeModal = useCloseNewWorkspaceModal(); + const { data: homeDir } = trpc.window.getHomeDir.useQuery(); + const [selectedProjectId, setSelectedProjectId] = useState( + null, + ); + const [title, setTitle] = useState(""); + const [branchName, setBranchName] = useState(""); + const [branchNameEdited, setBranchNameEdited] = useState(false); + const [showAllProjects, setShowAllProjects] = useState(false); + + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); + const createWorkspace = useCreateWorkspace(); + const openNew = useOpenNew(); + + // Sort projects with current project first + const currentProjectId = activeWorkspace?.projectId; + const sortedProjects = [...recentProjects].sort((a, b) => { + if (a.id === currentProjectId) return -1; + if (b.id === currentProjectId) return 1; + return 0; + }); + + const visibleProjects = showAllProjects + ? sortedProjects + : sortedProjects.slice(0, INITIAL_PROJECTS_LIMIT); + const hasMoreProjects = sortedProjects.length > INITIAL_PROJECTS_LIMIT; + + // Auto-select current project when modal opens + useEffect(() => { + if (isOpen && currentProjectId && !selectedProjectId) { + setSelectedProjectId(currentProjectId); + } + }, [isOpen, currentProjectId, selectedProjectId]); + + // Auto-generate branch name from title (unless manually edited) + useEffect(() => { + if (!branchNameEdited) { + setBranchName(generateBranchFromTitle(title)); + } + }, [title, branchNameEdited]); + + const resetForm = () => { + setSelectedProjectId(null); + setTitle(""); + setBranchName(""); + setBranchNameEdited(false); + setShowAllProjects(false); + }; + + const handleClose = () => { + closeModal(); + resetForm(); + }; + + const handleBranchNameChange = (value: string) => { + setBranchName(value); + setBranchNameEdited(true); + }; + + const handleCreateWorkspace = async () => { + if (!selectedProjectId) return; + + const workspaceName = title.trim() || undefined; + const customBranchName = branchName.trim() || undefined; + + toast.promise( + createWorkspace.mutateAsync({ + projectId: selectedProjectId, + name: workspaceName, + branchName: customBranchName, + }), + { + loading: "Creating workspace...", + success: () => { + handleClose(); + return "Workspace created"; + }, + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + ); + }; + + const handleOpenNewProject = async () => { + try { + const result = await openNew.mutateAsync(undefined); + if (result.canceled) { + return; + } + if ("error" 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; + } + setSelectedProjectId(result.project.id); + } catch (error) { + toast.error("Failed to open project", { + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } + }; + + const renderProjectButton = ( + project: { id: string; name: string; mainRepoPath: string }, + isSelected: boolean, + ) => ( + + ); + + return ( + !open && handleClose()}> + + + New Workspace + + Each workspace is an isolated git worktree. + + + +
+ {/* Project Selection */} +
+ +
+ {visibleProjects.map((project) => + renderProjectButton(project, selectedProjectId === project.id), + )} + {hasMoreProjects && ( + + )} + +
+
+ + {/* Optional Fields */} +
+
+ + setTitle(e.target.value)} + /> +
+ +
+ + handleBranchNameChange(e.target.value)} + /> +
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/index.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/index.ts new file mode 100644 index 00000000000..6267ce7fe48 --- /dev/null +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/index.ts @@ -0,0 +1 @@ +export { NewWorkspaceModal } from "./NewWorkspaceModal"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx index 1266cad88d7..9b543a359d5 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx @@ -1,101 +1,50 @@ import { Button } from "@superset/ui/button"; import { ButtonGroup } from "@superset/ui/button-group"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useRef, useState } from "react"; -import { - HiChevronDown, - HiChevronUp, - HiMiniFolderOpen, - HiMiniPlus, -} from "react-icons/hi2"; +import { useRef } from "react"; +import { HiChevronDown, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; - -const INITIAL_PROJECTS_LIMIT = 5; - -/** - * Formats a path for display, replacing the home directory with ~ and - * removing the trailing project name directory. - */ -function formatPath( - path: string, - projectName: string, - homeDir: string | undefined, -): string { - const normalizedPath = path.replace(/\\/g, "/"); - const normalizedHome = homeDir ? homeDir.replace(/\\/g, "/") : null; - - let displayPath = normalizedPath; - if ( - normalizedHome && - (normalizedPath === normalizedHome || - normalizedPath.startsWith(`${normalizedHome}/`)) - ) { - displayPath = `~${normalizedPath.slice(normalizedHome.length)}`; - } else { - displayPath = normalizedPath.replace(/^\/(?:Users|home)\/[^/]+/, "~"); - } - - const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const suffixPattern = new RegExp(`/${escapedProjectName}$`); - return displayPath.replace(suffixPattern, ""); -} +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; export interface WorkspaceDropdownProps { className?: string; } export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { - const [isOpen, setIsOpen] = useState(false); - const [showAllProjects, setShowAllProjects] = useState(false); const primaryButtonRef = useRef(null); - const dropdownTriggerRef = useRef(null); + const chevronButtonRef = useRef(null); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); - const { data: homeDir } = trpc.window.getHomeDir.useQuery(); const createWorkspace = useCreateWorkspace(); const openNew = useOpenNew(); + const openModal = useOpenNewWorkspaceModal(); const currentProject = recentProjects.find( (p) => p.id === activeWorkspace?.projectId, ); - const otherProjects = recentProjects.filter( - (p) => p.id !== activeWorkspace?.projectId, - ); - const visibleProjects = showAllProjects - ? otherProjects - : otherProjects.slice(0, INITIAL_PROJECTS_LIMIT); - const hasMoreProjects = otherProjects.length > INITIAL_PROJECTS_LIMIT; - const closeDropdown = () => { - setIsOpen(false); - setShowAllProjects(false); + const handlePrimaryAction = () => { primaryButtonRef.current?.blur(); - dropdownTriggerRef.current?.blur(); - }; - - const handleCreateWorkspace = async (projectId: string) => { - toast.promise(createWorkspace.mutateAsync({ projectId }), { - loading: "Creating workspace...", - success: () => { - closeDropdown(); - return "Workspace created"; - }, - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }); + if (currentProject) { + toast.promise( + createWorkspace.mutateAsync({ projectId: currentProject.id }), + { + loading: "Creating workspace...", + success: "Workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + ); + } else { + handleOpenNewProject(); + } }; const handleOpenNewProject = async () => { - closeDropdown(); try { const result = await openNew.mutateAsync(undefined); if (result.canceled) { @@ -108,14 +57,21 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { return; } if ("needsGitInit" in result) { - // Folder is not a git repository - inform user to use Start view toast.error("Selected folder is not a git repository", { description: "Please use 'Open project' from the start view to initialize git.", }); return; } - handleCreateWorkspace(result.project.id); + toast.promise( + createWorkspace.mutateAsync({ projectId: result.project.id }), + { + loading: "Creating workspace...", + success: "Workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + ); } catch (error) { toast.error("Failed to open project", { description: @@ -124,22 +80,9 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { } }; - const handlePrimaryAction = () => { - primaryButtonRef.current?.blur(); - if (currentProject) { - handleCreateWorkspace(currentProject.id); - } else { - handleOpenNewProject(); - } - }; - - const handleOpenChange = (open: boolean) => { - if (open) { - setIsOpen(true); - dropdownTriggerRef.current?.blur(); - } else { - closeDropdown(); - } + const handleChevronClick = () => { + chevronButtonRef.current?.blur(); + openModal(); }; return ( @@ -166,113 +109,23 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { : "New workspace"} - - - - - - - - - More options - - - -
-

New Workspace

-

- Select a project to create a workspace -

-
- {currentProject && ( -
-

- Current project -

-
- -
-
- )} - {otherProjects.length > 0 && ( -
-

- {currentProject ? "Other projects" : "Recent projects"} -

-
- {visibleProjects.map((project) => ( - - ))} -
- {hasMoreProjects && ( - - )} -
- )} -
- -
-
-
+ + + + + + More options + + ); } diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 3d5baf72532..745ff6801e0 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -6,6 +6,7 @@ import { useCallback, useState } from "react"; import { DndProvider } from "react-dnd"; import { useHotkeys } from "react-hotkeys-hook"; import { HiArrowPath } from "react-icons/hi2"; +import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { SetupConfigModal } from "renderer/components/SetupConfigModal"; import { trpc } from "renderer/lib/trpc"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; @@ -297,6 +298,7 @@ export function MainScreen() { )} + ); } diff --git a/apps/desktop/src/renderer/stores/new-workspace-modal.ts b/apps/desktop/src/renderer/stores/new-workspace-modal.ts new file mode 100644 index 00000000000..0890c7797e4 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-modal.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +interface NewWorkspaceModalState { + isOpen: boolean; + openModal: () => void; + closeModal: () => void; +} + +export const useNewWorkspaceModalStore = create()( + devtools( + (set) => ({ + isOpen: false, + + openModal: () => { + set({ isOpen: true }); + }, + + closeModal: () => { + set({ isOpen: false }); + }, + }), + { name: "NewWorkspaceModalStore" }, + ), +); + +// Convenience hooks +export const useNewWorkspaceModalOpen = () => + useNewWorkspaceModalStore((state) => state.isOpen); +export const useOpenNewWorkspaceModal = () => + useNewWorkspaceModalStore((state) => state.openModal); +export const useCloseNewWorkspaceModal = () => + useNewWorkspaceModalStore((state) => state.closeModal);