Files
vibe-kanban/frontend/src/components/projects/ProjectCard.tsx
Alex Netsch f989b47470 Refactor branch fetching to use repo IDs instead of project IDs (Vibe Kanban) (#1560)
* All tasks are complete. Here's a summary of the changes made:

## Summary

Successfully refactored the project branches API to fetch branches using repo IDs instead of project IDs.

### Files Created
- `frontend/src/hooks/useRepoBranches.ts` - New hook that fetches branches for a single repository using `repoApi.getBranches(repoId)`

### Files Modified

1. **`frontend/src/hooks/index.ts`** - Added export for `useRepoBranches`

2. **`frontend/src/components/dialogs/tasks/GitActionsDialog.tsx`**
   - Removed `useState` for `repoBranches` and `loadingBranches`
   - Removed `useEffect` that called `projectsApi.getBranches(effectiveProjectId)`
   - Removed `useMemo` that filtered branches by `selectedRepoId`
   - Now uses `useRepoBranches(selectedRepoId)` to fetch branches directly for the selected repo from the task attempt

3. **`frontend/src/components/dialogs/tasks/CreatePRDialog.tsx`**
   - Removed `projectId` from props interface and component destructuring
   - Removed `useState` for `branches` and `branchesLoading`
   - Removed branch-fetching logic from useEffect
   - Now uses `useRepoBranches(repoId)` to fetch branches directly for the specified repo
   - Split useEffect into two: one for form initialization, one for setting default base branch

4. **`frontend/src/components/tasks/Toolbar/GitOperations.tsx`**
   - Removed `projectId` from `CreatePRDialog.show()` call

### Benefits
- More efficient - fetches only branches for the specific repo needed instead of all project repos
- Better caching - React Query caches branches per repo for efficient reuse
- Cleaner code - removed manual filtering logic from components
- Type safety - hooks return `GitBranch[]` directly instead of `RepositoryBranches[]`

* All tasks complete. Here's a summary of the changes made:

## Summary

Successfully decoupled the branches API from project IDs.

### Changes Made

**1. `frontend/src/hooks/useBranches.ts`** - Refactored to accept `repos: Array<{ id: string; name: string }>` instead of `projectId`
- Now uses `useQueries` to fetch branches for multiple repos in parallel
- Reuses the same query keys as `useRepoBranches` for cache consistency

**2. `frontend/src/hooks/useRepoBranchSelection.ts`** - Refactored to accept `repos: Repo[]` instead of `projectId`
- Removed internal fetch of project repos (caller now provides them)
- Removed `projectRepos` from return value
- Uses refactored `useBranches(repos)`

**3. `frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx`**
- Added `useQuery` to fetch project repos separately
- Passes `repos` to `useRepoBranchSelection`

**4. `frontend/src/components/dialogs/tasks/TaskFormDialog.tsx`**
- Already fetched `projectRepos`
- Updated to pass `projectRepos` to refactored `useBranches`

**5. `frontend/src/components/layout/Navbar.tsx`**
- Replaced `useBranches(projectId)` with `useQuery` for `projectsApi.getRepositories`
- Only needed repo count, not branches

**6. `frontend/src/components/projects/ProjectCard.tsx`**
- Replaced `useBranches(project.id)` with `useQuery` for `projectsApi.getRepositories`
- Only needed repo count, not branches

### Benefits
- Branch hooks no longer require a project ID
- Repos can come from any source (project, attempt, or future sources without projects)
- Better separation of concerns
- Enables future task attempts without projects attached

* Cleanup script changes for task attempt 6b5b9208-565d-4620-9fe3-20791db77d7a

* The fix is already applied. Type checks pass with `Repo[]` instead of the loose `Array<{ id: string; name: string }>` type.

This makes it explicit that `useBranches` expects actual `Repo` objects (with `Repo.id` being the repository's UUID), not project-repo junction records.

* All done. The refactor is complete:

**Summary of changes:**

1. **Created `useProjectRepos` hook** (`frontend/src/hooks/useProjectRepos.ts`) - A reusable hook that fetches project repositories with proper query key management

2. **Updated 5 files** to use the new hook instead of duplicating the `useQuery` pattern:
   - `CreateAttemptDialog.tsx`
   - `TaskFormDialog.tsx`
   - `Navbar.tsx`
   - `ProjectCard.tsx`
   - `NoServerContent.tsx`

3. **Earlier refactors** (from before the summary):
   - `useBranches.ts` - Now accepts `repos: Repo[]` instead of `projectId`
   - `useRepoBranchSelection.ts` - Now accepts `repos: Repo[]` instead of `projectId`

This enables future task attempts without projects attached, as branch fetching is now decoupled from project IDs.

* No more usages. The branches in `ProjectTasks.tsx` are used for the diffs panel - showing available branches for git operations when viewing an attempt's diffs.

* Cleanup script changes for task attempt 6b5b9208-565d-4620-9fe3-20791db77d7a

* All done. The refactor is complete:

**Summary of changes:**

`GitOperations` now fetches its own branches using `useRepoBranches(selectedRepoId)` instead of receiving them as a prop. This:

1. Removes prop drilling through `ProjectTasks` → `DiffsPanelContainer` → `DiffsPanel` → `GitOperations`
2. Removes the need for `ProjectTasks` to fetch all project repos just to get branches for the selected repo
3. Makes `GitOperations` self-contained - it already has `selectedRepoId` from `useAttemptRepo`, so it can fetch its own branches
4. Simplifies `GitActionsDialog` which no longer needs to fetch branches either

* All done. Here's a summary of the changes:

**Removed `projectId` from GitOperations flow:**

1. **`useRebase.ts`** - Changed param from `projectId` to `repoId`, updated invalidation to use `repoBranchKeys.byRepo(repoId)`

2. **`useChangeTargetBranch.ts`** - Changed param from `projectId` to `repoId`, updated invalidation to use `repoBranchKeys.byRepo(repoId)`

3. **`useMerge.ts`** - Updated invalidation to use `repoBranchKeys.all` instead of `['projectBranches']`

4. **`useGitOperations.ts`** - Changed param from `projectId` to `repoId`

5. **`GitOperations.tsx`** - Removed `projectId` prop, now passes `selectedRepoId` to `useGitOperations`

6. **`GitActionsDialog.tsx`** - Removed `projectId` from props and internal logic

7. **`DiffsPanelContainer`** (in ProjectTasks.tsx) - Removed `projectId` prop

8. **Callers of `GitActionsDialog.show`** - Removed `projectId` from `NextActionCard.tsx` and `actions-dropdown.tsx`

* The cleanup is complete. Here's a summary of what was done:

**Removed dead project branches code:**

1. **Backend** (`crates/server/src/routes/projects.rs`):
   - Removed `get_project_branches` handler and route
   - Removed `RepositoryBranches` and `ProjectBranchesResponse` structs
   - Fixed unused `Serialize` import warning

2. **Type Generation** (`crates/server/src/bin/generate_types.rs`):
   - Removed exports for `ProjectBranchesResponse` and `RepositoryBranches`

3. **Frontend Hooks** (`frontend/src/hooks/useBranches.ts`):
   - Defined local `RepositoryBranches` type instead of importing from shared/types

4. **Frontend API** (`frontend/src/lib/api.ts`):
   - Removed `projectsApi.getBranches` method
   - Removed `RepositoryBranches` and `ProjectBranchesResponse` imports

Both TypeScript and Rust compilation pass without errors.

* Done. Removed the `RepositoryBranches` type and simplified `useBranches` to return `Map<string, GitBranch[]>`. Changes:

1. **`useBranches.ts`** - Returns `Map<string, GitBranch[]>` instead of `RepositoryBranches[]`
2. **`useRepoBranchSelection.ts`** - Uses `branchMap.get(repo.id) ?? []` instead of `.find()`
3. **`TaskFormDialog.tsx`** - Uses `branchMap.get(repo.id) ?? []` instead of `.find()`

Type checks pass.

* Done. Deleted `useBranches` entirely and consolidated everything into `useRepoBranchSelection`. Summary:

1. **Deleted** `frontend/src/hooks/useBranches.ts`
2. **Updated** `useRepoBranchSelection.ts` - inlined the `useQueries` logic directly
3. **Updated** `hooks/index.ts` - removed `useBranches` export
4. **Updated** `TaskFormDialog.tsx` - now uses `useRepoBranchSelection` instead of duplicating the config-building logic

Type checks pass.

* types
2025-12-16 14:21:42 +00:00

212 lines
6.3 KiB
TypeScript

import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card.tsx';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import { Button } from '@/components/ui/button.tsx';
import {
Calendar,
Edit,
ExternalLink,
FolderOpen,
Link2,
MoreHorizontal,
Trash2,
Unlink,
} from 'lucide-react';
import { Project } from 'shared/types';
import { useEffect, useRef } from 'react';
import { useOpenProjectInEditor } from '@/hooks/useOpenProjectInEditor';
import { useNavigateWithSearch, useProjectRepos } from '@/hooks';
import { projectsApi } from '@/lib/api';
import { LinkProjectDialog } from '@/components/dialogs/projects/LinkProjectDialog';
import { useTranslation } from 'react-i18next';
import { useProjectMutations } from '@/hooks/useProjectMutations';
type Props = {
project: Project;
isFocused: boolean;
fetchProjects: () => void;
setError: (error: string) => void;
onEdit: (project: Project) => void;
};
function ProjectCard({
project,
isFocused,
fetchProjects,
setError,
onEdit,
}: Props) {
const navigate = useNavigateWithSearch();
const ref = useRef<HTMLDivElement>(null);
const handleOpenInEditor = useOpenProjectInEditor(project);
const { t } = useTranslation('projects');
const { data: repos } = useProjectRepos(project.id);
const isSingleRepoProject = repos?.length === 1;
const { unlinkProject } = useProjectMutations({
onUnlinkSuccess: () => {
fetchProjects();
},
onUnlinkError: (error) => {
console.error('Failed to unlink project:', error);
setError('Failed to unlink project');
},
});
useEffect(() => {
if (isFocused && ref.current) {
ref.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
ref.current.focus();
}
}, [isFocused]);
const handleDelete = async (id: string, name: string) => {
if (
!confirm(
`Are you sure you want to delete "${name}"? This action cannot be undone.`
)
)
return;
try {
await projectsApi.delete(id);
fetchProjects();
} catch (error) {
console.error('Failed to delete project:', error);
setError('Failed to delete project');
}
};
const handleEdit = (project: Project) => {
onEdit(project);
};
const handleOpenInIDE = () => {
handleOpenInEditor();
};
const handleLinkProject = async () => {
try {
await LinkProjectDialog.show({
projectId: project.id,
projectName: project.name,
});
} catch (error) {
console.error('Failed to link project:', error);
}
};
const handleUnlinkProject = () => {
const confirmed = window.confirm(
`Are you sure you want to unlink "${project.name}"? The local project will remain, but it will no longer be linked to the remote project.`
);
if (confirmed) {
unlinkProject.mutate(project.id);
}
};
return (
<Card
className={`hover:shadow-md transition-shadow cursor-pointer focus:ring-2 focus:ring-primary outline-none border`}
onClick={() => navigate(`/projects/${project.id}/tasks`)}
tabIndex={isFocused ? 0 : -1}
ref={ref}
>
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{project.name}</CardTitle>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/projects/${project.id}`);
}}
>
<ExternalLink className="mr-2 h-4 w-4" />
{t('viewProject')}
</DropdownMenuItem>
{isSingleRepoProject && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleOpenInIDE();
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
{t('openInIDE')}
</DropdownMenuItem>
)}
{project.remote_project_id ? (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleUnlinkProject();
}}
>
<Unlink className="mr-2 h-4 w-4" />
{t('unlinkFromOrganization')}
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleLinkProject();
}}
>
<Link2 className="mr-2 h-4 w-4" />
{t('linkToOrganization')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(project);
}}
>
<Edit className="mr-2 h-4 w-4" />
{t('common:buttons.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(project.id, project.name);
}}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('common:buttons.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<CardDescription className="flex items-center">
<Calendar className="mr-1 h-3 w-3" />
{t('createdDate', {
date: new Date(project.created_at).toLocaleDateString(),
})}
</CardDescription>
</CardHeader>
</Card>
);
}
export default ProjectCard;