diff --git a/src/renderer/components/modals/createPipelineModal/index.tsx b/src/renderer/components/modals/createPipelineModal/index.tsx new file mode 100644 index 00000000..1532174d --- /dev/null +++ b/src/renderer/components/modals/createPipelineModal/index.tsx @@ -0,0 +1,529 @@ +import React from 'react'; +import { + Box, + Button, + Typography, + Stack, + alpha, + CircularProgress, + Chip, + Dialog, + DialogContent, + IconButton, + Divider, +} from '@mui/material'; +import { + Close, + AccountTree, + CheckCircle, + PlayArrow, + Code, + ArrowForward, +} from '@mui/icons-material'; +import { useTheme } from '@mui/material/styles'; +import { toast } from 'react-toastify'; +import { projectsServices } from '../../../services'; +import { Project } from '../../../../types/backend'; + +interface PipelineTemplate { + id: string; + label: string; + description: string; + badge?: string; + badgeColor?: 'success' | 'warning' | 'primary' | 'info'; + steps: string[]; + fileName: string; + content: string; +} + +const TEMPLATES: PipelineTemplate[] = [ + { + id: 'getting-started', + label: 'Getting Started', + badge: 'Recommended', + badgeColor: 'success', + description: + 'A simple pipeline that runs dbt deps, dbt test, and a teardown step. Perfect for new projects.', + steps: ['dbt deps', 'dbt test', 'teardown'], + fileName: 'pipeline-getting-started.yml', + content: `name: "CI" +jobs: + - name: "setup" + steps: + - name: Install dependencies + plugin: dbt@v1 + command: dbt deps + - name: "run-dbt-command" + steps: + - name: dbt test + plugin: dbt@v1 + command: dbt test + - name: "teardown" + type: "cleanup" + steps: + - name: Run teardown + plugin: command@v1 + command: echo "TEARDOWN" +`, + }, + { + id: 'full-ci', + label: 'Full CI/CD', + badge: 'Advanced', + badgeColor: 'warning', + description: + 'A complete pipeline with dependency install, model runs, tests, freshness checks, and a deploy notification step.', + steps: [ + 'dbt deps', + 'dbt run', + 'dbt test', + 'dbt source freshness', + 'deploy', + ], + fileName: 'pipeline-full-ci.yml', + content: `name: "Full CI/CD" +jobs: + - name: "setup" + steps: + - name: Install dependencies + plugin: dbt@v1 + command: dbt deps + - name: "build" + steps: + - name: Run models + plugin: dbt@v1 + command: dbt run + - name: "test" + steps: + - name: Run tests + plugin: dbt@v1 + command: dbt test + - name: Check source freshness + plugin: dbt@v1 + command: dbt source freshness + - name: "deploy" + steps: + - name: Notify deployment + plugin: command@v1 + command: echo "Deployment complete" + - name: "teardown" + type: "cleanup" + steps: + - name: Run teardown + plugin: command@v1 + command: echo "TEARDOWN" +`, + }, +]; + +interface CreatePipelineModalProps { + isOpen: boolean; + onClose: () => void; + project: Project; + onCreated: (filePath: string) => void; +} + +export const CreatePipelineModal: React.FC = ({ + isOpen, + onClose, + project, + onCreated, +}) => { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + const [selectedTemplateId, setSelectedTemplateId] = React.useState< + string | null + >(null); + const [isCreating, setIsCreating] = React.useState(false); + + React.useEffect(() => { + if (isOpen) { + setSelectedTemplateId(null); + } + }, [isOpen]); + + const handleCreate = async () => { + if (!selectedTemplateId) return; + const template = TEMPLATES.find((t) => t.id === selectedTemplateId); + if (!template) return; + + setIsCreating(true); + try { + await projectsServices.createFolder({ + filePath: project.path, + name: '.rosetta', + }); + + const pipelinePath = `${project.path}/.rosetta/${template.fileName}`; + await projectsServices.saveFileContent({ + path: pipelinePath, + content: template.content, + }); + + toast.success('Pipeline created successfully.'); + onCreated(pipelinePath); + onClose(); + } catch (error) { + const msg = + error instanceof Error ? error.message : 'Failed to create pipeline'; + toast.error(msg); + } finally { + setIsCreating(false); + } + }; + + return ( + + {/* Gradient header */} + + {/* Close button */} + + + + + + + + + + Create Pipeline + + + + + Choose a template to scaffold your pipeline. Files are created inside{' '} + + .rosetta/ + + + + + + }> + {TEMPLATES.map((template) => { + const isSelected = selectedTemplateId === template.id; + + const selectedBgColor = isDark + ? alpha(theme.palette.primary.main, 0.12) + : alpha(theme.palette.primary.main, 0.05); + + const hoverSelectedBg = isDark + ? alpha(theme.palette.primary.main, 0.16) + : alpha(theme.palette.primary.main, 0.07); + + const hoverBgColor = isSelected ? hoverSelectedBg : 'transparent'; + + const unselectedIconBg = isDark + ? alpha(theme.palette.common.white, 0.06) + : alpha(theme.palette.common.black, 0.05); + + return ( + setSelectedTemplateId(template.id)} + sx={{ + px: 3, + py: 2.5, + cursor: 'pointer', + position: 'relative', + bgcolor: isSelected ? selectedBgColor : 'background.paper', + transition: 'background-color 0.15s', + '&:hover': { + bgcolor: hoverBgColor, + }, + }} + > + {/* Selected accent bar */} + {isSelected && ( + + )} + + + {/* Icon */} + + + + + {/* Content */} + + {/* Title row */} + + + {template.label} + + {template.badge && ( + + )} + + + + {template.description} + + + {/* Pipeline steps flow */} + + {template.steps.map((step, i) => ( + + + + + {step} + + + {i < template.steps.length - 1 && ( + + )} + + ))} + + + {/* File path */} + + .rosetta/{template.fileName} + + + + {/* Checkmark */} + + + + + + ); + })} + + + {/* Footer */} + + + + + + + ); +}; diff --git a/src/renderer/components/modals/index.ts b/src/renderer/components/modals/index.ts index 4f5d776e..a602eb79 100644 --- a/src/renderer/components/modals/index.ts +++ b/src/renderer/components/modals/index.ts @@ -17,4 +17,5 @@ export * from './pushToCloudModal'; export * from './rawLayerModal'; export * from './removeConnectionModal'; export * from './pipelineSelectorModal'; +export * from './createPipelineModal'; export * from './sshPassphraseModal'; diff --git a/src/renderer/components/sidebar/project-sidebar.tsx b/src/renderer/components/sidebar/project-sidebar.tsx index 3999e4df..352cf274 100644 --- a/src/renderer/components/sidebar/project-sidebar.tsx +++ b/src/renderer/components/sidebar/project-sidebar.tsx @@ -8,6 +8,7 @@ import { CardContent, Chip, Divider, + Tooltip, } from '@mui/material'; import { Add, @@ -15,6 +16,7 @@ import { DeleteOutline, Storage as DatabaseIcon, SwapHoriz, + AccountTree, } from '@mui/icons-material'; import { useTheme } from '@mui/material/styles'; import { FileTreeViewer } from '../index'; @@ -27,6 +29,8 @@ import { } from '../../../types/backend'; import { SourceControlView } from '../sourceControl'; import connectionIcons from '../../../../assets/connectionIcons'; +import { useListPipelines } from '../../controllers'; +import { CreatePipelineModal } from '../modals'; export type SidebarTab = 'explorer' | 'scm' | 'connections'; @@ -252,6 +256,7 @@ interface ExplorerTabProps { statuses: FileStatus[]; isLoadingDirectories: boolean; selectedFilePath?: string; + project?: Project; onDeleteFile: (deletedFile: string) => void; onFileSelect: (fileNode: any) => Promise; onRefreshFiles: () => Promise; @@ -266,6 +271,7 @@ const ExplorerTab: React.FC = ({ statuses, isLoadingDirectories, selectedFilePath, + project, onDeleteFile, onFileSelect, onRefreshFiles, @@ -274,21 +280,86 @@ const ExplorerTab: React.FC = ({ onRenameFile, onRunPipeline, }) => { + const theme = useTheme(); + const [createPipelineOpen, setCreatePipelineOpen] = React.useState(false); + + const { refetch: refetchPipelines } = useListPipelines(project?.id); + + const handlePipelineCreated = React.useCallback( + async (filePath: string) => { + await refetchPipelines(); + await onRefreshFiles(); + // Open the newly created pipeline file in the editor + onFileSelect({ + id: filePath, + name: 'pipeline.yml', + path: filePath, + type: 'file' as const, + }); + }, + [refetchPipelines, onRefreshFiles, onFileSelect], + ); + return ( - - {directories && ( - + {/* Tree takes all remaining space */} + + {directories && ( + + )} + + + {/* Create Pipeline button — always visible */} + {project && ( + + + + + + )} + + {project && ( + setCreatePipelineOpen(false)} + project={project} + onCreated={(filePath) => handlePipelineCreated(filePath)} /> )} @@ -405,6 +476,7 @@ export const ProjectSidebar: React.FC = ({ statuses={statuses} isLoadingDirectories={isLoadingDirectories} selectedFilePath={selectedFilePath} + project={project} onDeleteFile={onDeleteFile} onFileSelect={onFileSelect} onRefreshFiles={onRefreshFiles}