Improve branch select (#22)

* Task attempt e5665be6-2bdc-4ec9-8e7d-8e3e1c684d54 - Final changes

* Task attempt e5665be6-2bdc-4ec9-8e7d-8e3e1c684d54 - Final changes

* Task attempt e5665be6-2bdc-4ec9-8e7d-8e3e1c684d54 - Final changes

* Cargo fmt

* Clippy

* Prettier
This commit is contained in:
Louis Knight-Webb
2025-06-30 15:34:18 +01:00
committed by GitHub
parent bec5f6b8f5
commit dd40b653d6
5 changed files with 486 additions and 40 deletions

View File

@@ -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(),

View File

@@ -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<Utc>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export)]
pub struct CreateBranch {
pub name: String,
pub base_branch: Option<String>,
}
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<DateTime<Utc>, 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<GitBranch, git2::Error> {
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,
})
}
}

View File

@@ -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<Uuid>,
Extension(pool): Extension<SqlitePool>,
Json(payload): Json<CreateBranch>,
) -> Result<ResponseJson<ApiResponse<GitBranch>>, 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<SqlitePool>,
Json(payload): Json<CreateProject>,
@@ -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))
}

View File

@@ -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<string>('');
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 (
<div className="px-6 pb-4">
@@ -219,9 +323,12 @@ export function TaskDetailsToolbar({
<Button
variant="outline"
size="sm"
className="rounded-none border-x-0 px-2"
className="rounded-none border-x-0 px-3 max-w-32"
>
<GitBranchIcon className="h-4 w-4" />
<GitBranchIcon className="h-4 w-4 mr-1 flex-shrink-0" />
<span className="truncate text-xs">
{selectedBranchDisplayName}
</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -230,36 +337,240 @@ export function TaskDetailsToolbar({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="center" className="w-56">
{branches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => onSetSelectedBranch(branch.name)}
className={
selectedBranch === branch.name ? 'bg-accent' : ''
}
>
<div className="flex items-center justify-between w-full">
<span
className={branch.is_current ? 'font-medium' : ''}
>
{branch.name}
</span>
<div className="flex gap-1">
{branch.is_current && (
<span className="text-xs bg-green-100 text-green-800 px-1 rounded">
current
</span>
)}
{branch.is_remote && (
<span className="text-xs bg-blue-100 text-blue-800 px-1 rounded">
remote
</span>
)}
<DropdownMenuContent align="center" className="w-80">
{!isCreatingBranch ? (
<>
<div className="p-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search branches..."
value={branchSearchTerm}
onChange={(e) =>
setBranchSearchTerm(e.target.value)
}
className="pl-8"
/>
</div>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsCreatingBranch(true);
setBaseBranchForNew(
branches.find((b) => b.is_current)?.name || ''
);
}}
className="text-blue-600 hover:text-blue-700"
>
<Plus className="h-4 w-4 mr-2" />
Create new branch...
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto">
{filteredBranches.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
No branches found
</div>
) : (
filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => {
onSetSelectedBranch(branch.name);
setBranchSearchTerm('');
}}
className={
selectedBranch === branch.name
? 'bg-accent'
: ''
}
>
<div className="flex items-center justify-between w-full">
<span
className={
branch.is_current ? 'font-medium' : ''
}
>
{branch.name}
</span>
<div className="flex gap-1">
{branch.is_current && (
<span className="text-xs bg-green-100 text-green-800 px-1 rounded">
current
</span>
)}
{branch.is_remote && (
<span className="text-xs bg-blue-100 text-blue-800 px-1 rounded">
remote
</span>
)}
</div>
</div>
</DropdownMenuItem>
))
)}
</div>
</>
) : (
<>
<DropdownMenuLabel>Create New Branch</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="p-3 space-y-3">
<div>
<label className="text-sm font-medium">
Branch name
</label>
<Input
placeholder="feature/my-feature"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateBranch();
} else if (e.key === 'Escape') {
handleCancelCreateBranch();
}
}}
className="mt-1"
autoFocus
/>
</div>
<div>
<label className="text-sm font-medium">
Base branch
</label>
<DropdownMenu
open={showBaseBranchDropdown}
onOpenChange={setShowBaseBranchDropdown}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="mt-1 w-full justify-between"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<span className="truncate">
{baseBranchDisplayName}
</span>
<GitBranchIcon className="h-4 w-4 ml-2 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80">
<div className="p-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search branches..."
value={baseBranchSearchTerm}
onChange={(e) =>
setBaseBranchSearchTerm(e.target.value)
}
className="pl-8"
/>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setBaseBranchForNew('');
setShowBaseBranchDropdown(false);
setBaseBranchSearchTerm('');
}}
className={
!baseBranchForNew ? 'bg-accent' : ''
}
>
<div className="flex items-center justify-between w-full">
<span className="font-medium">
Current branch
</span>
<span className="text-xs bg-green-100 text-green-800 px-1 rounded">
default
</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="max-h-48 overflow-y-auto">
{filteredBaseBranches.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
No branches found
</div>
) : (
filteredBaseBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setBaseBranchForNew(branch.name);
setShowBaseBranchDropdown(false);
setBaseBranchSearchTerm('');
}}
className={
baseBranchForNew === branch.name
? 'bg-accent'
: ''
}
>
<div className="flex items-center justify-between w-full">
<span
className={
branch.is_current
? 'font-medium'
: ''
}
>
{branch.name}
</span>
<div className="flex gap-1">
{branch.is_current && (
<span className="text-xs bg-green-100 text-green-800 px-1 rounded">
current
</span>
)}
{branch.is_remote && (
<span className="text-xs bg-blue-100 text-blue-800 px-1 rounded">
remote
</span>
)}
</div>
</div>
</DropdownMenuItem>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex gap-2 pt-2">
<Button
size="sm"
onClick={handleCreateBranch}
disabled={!newBranchName.trim()}
className="flex-1"
>
<Check className="h-4 w-4 mr-1" />
Create
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCancelCreateBranch}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>

View File

@@ -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, };