diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 0f2cf665..0bf7f6b7 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -90,6 +90,7 @@ fn main() { vibe_kanban::models::project::SearchResult::decl(), vibe_kanban::models::project::SearchMatchType::decl(), vibe_kanban::models::project::GitBranch::decl(), + vibe_kanban::models::project::CreateBranch::decl(), vibe_kanban::models::task::CreateTask::decl(), vibe_kanban::models::task::CreateTaskAndStart::decl(), vibe_kanban::models::task::TaskStatus::decl(), diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs index 3cf66ad5..adbf75cb 100644 --- a/backend/src/models/project.rs +++ b/backend/src/models/project.rs @@ -77,6 +77,15 @@ pub struct GitBranch { pub name: String, pub is_current: bool, pub is_remote: bool, + #[ts(type = "Date")] + pub last_commit_date: DateTime, +} + +#[derive(Debug, Deserialize, TS)] +#[ts(export)] +pub struct CreateBranch { + pub name: String, + pub base_branch: Option, } impl Project { @@ -219,15 +228,28 @@ impl Project { let current_branch = self.get_current_branch().unwrap_or_default(); let mut branches = Vec::new(); + // Helper function to get last commit date for a branch + let get_last_commit_date = |branch: &git2::Branch| -> Result, git2::Error> { + if let Some(target) = branch.get().target() { + if let Ok(commit) = repo.find_commit(target) { + let timestamp = commit.time().seconds(); + return Ok(DateTime::from_timestamp(timestamp, 0).unwrap_or_else(Utc::now)); + } + } + Ok(Utc::now()) // Default to now if we can't get the commit date + }; + // Get local branches let local_branches = repo.branches(Some(BranchType::Local))?; for branch_result in local_branches { let (branch, _) = branch_result?; if let Some(name) = branch.name()? { + let last_commit_date = get_last_commit_date(&branch)?; branches.push(GitBranch { name: name.to_string(), is_current: name == current_branch, is_remote: false, + last_commit_date, }); } } @@ -239,30 +261,83 @@ impl Project { if let Some(name) = branch.name()? { // Skip remote HEAD references if !name.ends_with("/HEAD") { + let last_commit_date = get_last_commit_date(&branch)?; branches.push(GitBranch { name: name.to_string(), is_current: false, is_remote: true, + last_commit_date, }); } } } - // Sort branches: current first, then local, then remote + // Sort branches: current first, then by most recent commit date branches.sort_by(|a, b| { if a.is_current && !b.is_current { std::cmp::Ordering::Less } else if !a.is_current && b.is_current { std::cmp::Ordering::Greater - } else if !a.is_remote && b.is_remote { - std::cmp::Ordering::Less - } else if a.is_remote && !b.is_remote { - std::cmp::Ordering::Greater } else { - a.name.cmp(&b.name) + // Sort by most recent commit date (newest first) + b.last_commit_date.cmp(&a.last_commit_date) } }); Ok(branches) } + + pub fn create_branch( + &self, + branch_name: &str, + base_branch: Option<&str>, + ) -> Result { + let repo = Repository::open(&self.git_repo_path)?; + + // Get the base branch reference - default to current branch if not specified + let base_branch_name = match base_branch { + Some(name) => name.to_string(), + None => self + .get_current_branch() + .unwrap_or_else(|_| "HEAD".to_string()), + }; + + // Find the base commit + let base_commit = if base_branch_name == "HEAD" { + repo.head()?.peel_to_commit()? + } else { + // Try to find the branch as local first, then remote + let base_ref = if let Ok(local_ref) = + repo.find_reference(&format!("refs/heads/{}", base_branch_name)) + { + local_ref + } else if let Ok(remote_ref) = + repo.find_reference(&format!("refs/remotes/{}", base_branch_name)) + { + remote_ref + } else { + return Err(git2::Error::from_str(&format!( + "Base branch '{}' not found", + base_branch_name + ))); + }; + base_ref.peel_to_commit()? + }; + + // Create the new branch + let _new_branch = repo.branch(branch_name, &base_commit, false)?; + + // Get the commit date for the new branch (same as base commit) + let last_commit_date = { + let timestamp = base_commit.time().seconds(); + DateTime::from_timestamp(timestamp, 0).unwrap_or_else(Utc::now) + }; + + Ok(GitBranch { + name: branch_name.to_string(), + is_current: false, + is_remote: false, + last_commit_date, + }) + } } diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs index 386470f0..a282b451 100644 --- a/backend/src/routes/projects.rs +++ b/backend/src/routes/projects.rs @@ -12,8 +12,8 @@ use uuid::Uuid; use crate::models::{ project::{ - CreateProject, GitBranch, Project, ProjectWithBranch, SearchMatchType, SearchResult, - UpdateProject, + CreateBranch, CreateProject, GitBranch, Project, ProjectWithBranch, SearchMatchType, + SearchResult, UpdateProject, }, ApiResponse, }; @@ -94,6 +94,60 @@ pub async fn get_project_branches( } } +pub async fn create_project_branch( + Path(id): Path, + Extension(pool): Extension, + Json(payload): Json, +) -> Result>, StatusCode> { + // Validate branch name + if payload.name.trim().is_empty() { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("Branch name cannot be empty".to_string()), + })); + } + + // Check if branch name contains invalid characters + if payload.name.contains(' ') { + return Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("Branch name cannot contain spaces".to_string()), + })); + } + + match Project::find_by_id(&pool, id).await { + Ok(Some(project)) => { + match project.create_branch(&payload.name, payload.base_branch.as_deref()) { + Ok(branch) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(branch), + message: Some(format!("Branch '{}' created successfully", payload.name)), + })), + Err(e) => { + tracing::error!( + "Failed to create branch '{}' for project {}: {}", + payload.name, + id, + e + ); + Ok(ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to create branch: {}", e)), + })) + } + } + } + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to fetch project: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub async fn create_project( Extension(pool): Extension, Json(payload): Json, @@ -436,6 +490,9 @@ pub fn projects_router() -> Router { get(get_project).put(update_project).delete(delete_project), ) .route("/projects/:id/with-branch", get(get_project_with_branch)) - .route("/projects/:id/branches", get(get_project_branches)) + .route( + "/projects/:id/branches", + get(get_project_branches).post(create_project_branch), + ) .route("/projects/:id/search", get(search_project_files)) } diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index e8525c74..1e126717 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom'; +import { useState, useMemo } from 'react'; import { History, Settings2, @@ -7,13 +8,20 @@ import { GitCompare, ExternalLink, GitBranch as GitBranchIcon, + Search, + Plus, + Check, + X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, } from '@/components/ui/dropdown-menu'; import { Tooltip, @@ -91,6 +99,102 @@ export function TaskDetailsToolbar({ onSetIsHoveringDevServer, }: TaskDetailsToolbarProps) { const { config } = useConfig(); + const [branchSearchTerm, setBranchSearchTerm] = useState(''); + const [isCreatingBranch, setIsCreatingBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(''); + const [baseBranchForNew, setBaseBranchForNew] = useState(''); + const [showBaseBranchDropdown, setShowBaseBranchDropdown] = useState(false); + const [baseBranchSearchTerm, setBaseBranchSearchTerm] = useState(''); + + // Filter branches based on search term + const filteredBranches = useMemo(() => { + if (!branchSearchTerm.trim()) { + return branches; + } + return branches.filter((branch) => + branch.name.toLowerCase().includes(branchSearchTerm.toLowerCase()) + ); + }, [branches, branchSearchTerm]); + + // Filter branches for base branch selection + const filteredBaseBranches = useMemo(() => { + if (!baseBranchSearchTerm.trim()) { + return branches; + } + return branches.filter((branch) => + branch.name.toLowerCase().includes(baseBranchSearchTerm.toLowerCase()) + ); + }, [branches, baseBranchSearchTerm]); + + // Get display name for selected branch + const selectedBranchDisplayName = useMemo(() => { + if (!selectedBranch) return 'current'; + + // For remote branches, show just the branch name without the remote prefix + if (selectedBranch.includes('/')) { + const parts = selectedBranch.split('/'); + return parts[parts.length - 1]; + } + return selectedBranch; + }, [selectedBranch]); + + // Get display name for base branch + const baseBranchDisplayName = useMemo(() => { + if (!baseBranchForNew) return 'Current branch'; + + // For remote branches, show just the branch name without the remote prefix + if (baseBranchForNew.includes('/')) { + const parts = baseBranchForNew.split('/'); + return parts[parts.length - 1]; + } + return baseBranchForNew; + }, [baseBranchForNew]); + + // Handle creating new branch + const handleCreateBranch = async () => { + if (!newBranchName.trim()) return; + + try { + const response = await fetch(`/api/projects/${projectId}/branches`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: newBranchName.trim(), + base_branch: baseBranchForNew || null, + }), + }); + + const result = await response.json(); + + if (result.success) { + // Select the newly created branch + onSetSelectedBranch(result.data.name); + // Reset form + setIsCreatingBranch(false); + setNewBranchName(''); + setBaseBranchForNew(''); + setBranchSearchTerm(''); + setShowBaseBranchDropdown(false); + setBaseBranchSearchTerm(''); + } else { + alert(`Failed to create branch: ${result.message}`); + } + } catch (error) { + console.error('Failed to create branch:', error); + alert('Failed to create branch. Please try again.'); + } + }; + + // Cancel creating branch + const handleCancelCreateBranch = () => { + setIsCreatingBranch(false); + setNewBranchName(''); + setBaseBranchForNew(''); + setShowBaseBranchDropdown(false); + setBaseBranchSearchTerm(''); + }; return (
@@ -219,9 +323,12 @@ export function TaskDetailsToolbar({ @@ -230,36 +337,240 @@ export function TaskDetailsToolbar({ - - {branches.map((branch) => ( - onSetSelectedBranch(branch.name)} - className={ - selectedBranch === branch.name ? 'bg-accent' : '' - } - > -
- - {branch.name} - -
- {branch.is_current && ( - - current - - )} - {branch.is_remote && ( - - remote - - )} + + {!isCreatingBranch ? ( + <> +
+
+ + + setBranchSearchTerm(e.target.value) + } + className="pl-8" + />
- - ))} + + { + e.preventDefault(); + e.stopPropagation(); + setIsCreatingBranch(true); + setBaseBranchForNew( + branches.find((b) => b.is_current)?.name || '' + ); + }} + className="text-blue-600 hover:text-blue-700" + > + + Create new branch... + + +
+ {filteredBranches.length === 0 ? ( +
+ No branches found +
+ ) : ( + filteredBranches.map((branch) => ( + { + onSetSelectedBranch(branch.name); + setBranchSearchTerm(''); + }} + className={ + selectedBranch === branch.name + ? 'bg-accent' + : '' + } + > +
+ + {branch.name} + +
+ {branch.is_current && ( + + current + + )} + {branch.is_remote && ( + + remote + + )} +
+
+
+ )) + )} +
+ + ) : ( + <> + Create New Branch + +
+
+ + setNewBranchName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreateBranch(); + } else if (e.key === 'Escape') { + handleCancelCreateBranch(); + } + }} + className="mt-1" + autoFocus + /> +
+
+ + + + + + +
+
+ + + setBaseBranchSearchTerm(e.target.value) + } + className="pl-8" + /> +
+
+ + { + e.preventDefault(); + e.stopPropagation(); + setBaseBranchForNew(''); + setShowBaseBranchDropdown(false); + setBaseBranchSearchTerm(''); + }} + className={ + !baseBranchForNew ? 'bg-accent' : '' + } + > +
+ + Current branch + + + default + +
+
+ +
+ {filteredBaseBranches.length === 0 ? ( +
+ No branches found +
+ ) : ( + filteredBaseBranches.map((branch) => ( + { + e.preventDefault(); + e.stopPropagation(); + setBaseBranchForNew(branch.name); + setShowBaseBranchDropdown(false); + setBaseBranchSearchTerm(''); + }} + className={ + baseBranchForNew === branch.name + ? 'bg-accent' + : '' + } + > +
+ + {branch.name} + +
+ {branch.is_current && ( + + current + + )} + {branch.is_remote && ( + + remote + + )} +
+
+
+ )) + )} +
+
+
+
+
+ + +
+
+ + )}
diff --git a/shared/types.ts b/shared/types.ts index ce105fd9..f3d6cade 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -36,7 +36,9 @@ export type SearchResult = { path: string, is_file: boolean, match_type: SearchM export type SearchMatchType = "FileName" | "DirectoryName" | "FullPath"; -export type GitBranch = { name: string, is_current: boolean, is_remote: boolean, }; +export type GitBranch = { name: string, is_current: boolean, is_remote: boolean, last_commit_date: Date, }; + +export type CreateBranch = { name: string, base_branch: string | null, }; export type CreateTask = { project_id: string, title: string, description: string | null, };