diff --git a/web/src/components/task/GitSection.tsx b/web/src/components/task/GitSection.tsx new file mode 100644 index 00000000..9acc862f --- /dev/null +++ b/web/src/components/task/GitSection.tsx @@ -0,0 +1,232 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useConfig, useRepoBranches } from '@/hooks/useConfig'; +import { GitBranch, FolderGit2, Loader2, AlertCircle } from 'lucide-react'; +import type { Task, TaskGit } from '@veritas-kanban/shared'; + +interface GitSectionProps { + task: Task; + onGitChange: (git: Partial | undefined) => void; +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + +export function GitSection({ task, onGitChange }: GitSectionProps) { + const { data: config, isLoading: configLoading } = useConfig(); + const [selectedRepo, setSelectedRepo] = useState(task.git?.repo || ''); + const [baseBranch, setBaseBranch] = useState(task.git?.baseBranch || ''); + const [featureBranch, setFeatureBranch] = useState(task.git?.branch || ''); + const [autoGenerateBranch, setAutoGenerateBranch] = useState(!task.git?.branch); + + const { data: branches, isLoading: branchesLoading } = useRepoBranches(selectedRepo || undefined); + + // Get the repo config for the selected repo + const repoConfig = useMemo(() => { + return config?.repos.find(r => r.name === selectedRepo); + }, [config, selectedRepo]); + + // Auto-generate feature branch name from task title + useEffect(() => { + if (autoGenerateBranch && task.title) { + const slug = slugify(task.title); + setFeatureBranch(`feature/${slug}`); + } + }, [task.title, autoGenerateBranch]); + + // Set default base branch when repo changes + useEffect(() => { + if (repoConfig && !baseBranch) { + setBaseBranch(repoConfig.defaultBranch); + } + }, [repoConfig, baseBranch]); + + // Sync from task.git when it changes + useEffect(() => { + if (task.git) { + setSelectedRepo(task.git.repo || ''); + setBaseBranch(task.git.baseBranch || ''); + setFeatureBranch(task.git.branch || ''); + setAutoGenerateBranch(!task.git.branch); + } + }, [task.id]); // Only re-sync when task changes + + // Update parent when values change + const handleRepoChange = (repo: string) => { + setSelectedRepo(repo); + const newRepoConfig = config?.repos.find(r => r.name === repo); + const newBaseBranch = newRepoConfig?.defaultBranch || 'main'; + setBaseBranch(newBaseBranch); + + onGitChange({ + repo, + baseBranch: newBaseBranch, + branch: featureBranch, + }); + }; + + const handleBaseBranchChange = (branch: string) => { + setBaseBranch(branch); + onGitChange({ + repo: selectedRepo, + baseBranch: branch, + branch: featureBranch, + }); + }; + + const handleFeatureBranchChange = (branch: string) => { + setFeatureBranch(branch); + setAutoGenerateBranch(false); + onGitChange({ + repo: selectedRepo, + baseBranch, + branch, + }); + }; + + const handleClearGit = () => { + setSelectedRepo(''); + setBaseBranch(''); + setFeatureBranch(''); + setAutoGenerateBranch(true); + onGitChange(undefined); + }; + + if (configLoading) { + return ( +
+ +
+ + Loading... +
+
+ ); + } + + if (!config?.repos.length) { + return ( +
+ +
+ + No repositories configured. Add one in Settings. +
+
+ ); + } + + return ( +
+
+ + {selectedRepo && ( + + )} +
+ +
+ {/* Repository Selection */} +
+ + +
+ + {selectedRepo && ( + <> + {/* Base Branch */} +
+ + {branchesLoading ? ( +
+ + Loading branches... +
+ ) : ( + + )} +
+ + {/* Feature Branch */} +
+ + handleFeatureBranchChange(e.target.value)} + placeholder="feature/my-feature" + /> + {autoGenerateBranch && featureBranch && ( +

+ Auto-generated from task title +

+ )} +
+ + {/* Worktree Status */} + {task.git?.worktreePath && ( +
+ + Worktree active: {task.git.worktreePath} +
+ )} + + )} +
+
+ ); +} diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx index 802a715b..698c4982 100644 --- a/web/src/components/task/TaskDetailPanel.tsx +++ b/web/src/components/task/TaskDetailPanel.tsx @@ -30,6 +30,7 @@ import { import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks'; import { Trash2, Code, Search, FileText, Zap, Calendar, Clock } from 'lucide-react'; import type { Task, TaskType, TaskStatus, TaskPriority } from '@veritas-kanban/shared'; +import { GitSection } from './GitSection'; interface TaskDetailPanelProps { task: Task | null; @@ -89,6 +90,7 @@ function useDebouncedSave(task: Task | null, updateTask: ReturnType + {/* Git Integration (code tasks only) */} + {localTask.type === 'code' && ( + updateField('git', git as Task['git'])} + /> + )} + {/* Metadata */}