From 9a428eefe0c89d3b4b249aeee70e004e105d5ab5 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Tue, 16 Dec 2025 02:14:42 -0500 Subject: [PATCH] adding branch switcher support --- apps/app/src/components/views/board-view.tsx | 97 ++++ .../views/board-view/components/index.ts | 1 + .../components/worktree-selector.tsx | 422 ++++++++++++++++++ .../dialogs/commit-worktree-dialog.tsx | 163 +++++++ .../dialogs/create-branch-dialog.tsx | 152 +++++++ .../board-view/dialogs/create-pr-dialog.tsx | 285 ++++++++++++ .../dialogs/create-worktree-dialog.tsx | 166 +++++++ .../dialogs/delete-worktree-dialog.tsx | 158 +++++++ apps/app/src/lib/electron.ts | 123 +++++ apps/app/src/lib/http-api-client.ts | 20 + apps/app/src/store/app-store.ts | 64 +++ apps/app/src/types/electron.d.ts | 150 +++++++ apps/server/src/routes/worktree/index.ts | 18 + .../routes/worktree/routes/checkout-branch.ts | 86 ++++ .../src/routes/worktree/routes/commit.ts | 79 ++++ .../src/routes/worktree/routes/create-pr.ts | 198 ++++++++ .../src/routes/worktree/routes/create.ts | 113 +++++ .../src/routes/worktree/routes/delete.ts | 79 ++++ .../routes/worktree/routes/list-branches.ts | 68 +++ .../server/src/routes/worktree/routes/list.ts | 43 +- .../server/src/routes/worktree/routes/pull.ts | 92 ++++ .../server/src/routes/worktree/routes/push.ts | 60 +++ .../routes/worktree/routes/switch-branch.ts | 151 +++++++ 23 files changed, 2785 insertions(+), 3 deletions(-) create mode 100644 apps/app/src/components/views/board-view/components/worktree-selector.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/create-branch-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/create-pr-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/create-worktree-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx create mode 100644 apps/server/src/routes/worktree/routes/checkout-branch.ts create mode 100644 apps/server/src/routes/worktree/routes/commit.ts create mode 100644 apps/server/src/routes/worktree/routes/create-pr.ts create mode 100644 apps/server/src/routes/worktree/routes/create.ts create mode 100644 apps/server/src/routes/worktree/routes/delete.ts create mode 100644 apps/server/src/routes/worktree/routes/list-branches.ts create mode 100644 apps/server/src/routes/worktree/routes/pull.ts create mode 100644 apps/server/src/routes/worktree/routes/push.ts create mode 100644 apps/server/src/routes/worktree/routes/switch-branch.ts diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 3b7c361c..baa483ad 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -30,6 +30,12 @@ import { FeatureSuggestionsDialog, FollowUpDialog, } from "./board-view/dialogs"; +import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog"; +import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog"; +import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog"; +import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog"; +import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog"; +import { WorktreeSelector } from "./board-view/components"; import { COLUMNS } from "./board-view/constants"; import { useBoardFeatures, @@ -81,6 +87,21 @@ export function BoardView() { const [deleteCompletedFeature, setDeleteCompletedFeature] = useState(null); + // Worktree dialog states + const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false); + const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false); + const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false); + const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); + const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false); + const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{ + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; + } | null>(null); + const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0); + // Follow-up state hook const { showFollowUpDialog, @@ -341,6 +362,29 @@ export function BoardView() { isMounted={isMounted} /> + {/* Worktree Selector */} + setShowCreateWorktreeDialog(true)} + onDeleteWorktree={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowDeleteWorktreeDialog(true); + }} + onCommit={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCommitWorktreeDialog(true); + }} + onCreatePR={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreatePRDialog(true); + }} + onCreateBranch={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreateBranchDialog(true); + }} + /> + {/* Main Content Area */}
{/* Search Bar Row */} @@ -493,6 +537,59 @@ export function BoardView() { isGenerating={isGeneratingSuggestions} setIsGenerating={setIsGeneratingSuggestions} /> + + {/* Create Worktree Dialog */} + setWorktreeRefreshKey((k) => k + 1)} + /> + + {/* Delete Worktree Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Commit Worktree Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Create PR Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + /> + + {/* Create Branch Dialog */} + { + setWorktreeRefreshKey((k) => k + 1); + setSelectedWorktreeForAction(null); + }} + />
); } diff --git a/apps/app/src/components/views/board-view/components/index.ts b/apps/app/src/components/views/board-view/components/index.ts index 49cf06ef..24517ad2 100644 --- a/apps/app/src/components/views/board-view/components/index.ts +++ b/apps/app/src/components/views/board-view/components/index.ts @@ -1,2 +1,3 @@ export { KanbanCard } from "./kanban-card"; export { KanbanColumn } from "./kanban-column"; +export { WorktreeSelector } from "./worktree-selector"; diff --git a/apps/app/src/components/views/board-view/components/worktree-selector.tsx b/apps/app/src/components/views/board-view/components/worktree-selector.tsx new file mode 100644 index 00000000..c37ed19f --- /dev/null +++ b/apps/app/src/components/views/board-view/components/worktree-selector.tsx @@ -0,0 +1,422 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { + GitBranch, + Plus, + Trash2, + MoreHorizontal, + RefreshCw, + GitCommit, + GitPullRequest, + ExternalLink, + ChevronDown, + Download, + GitBranchPlus, + Check, + Search, +} from "lucide-react"; +import { useAppStore } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +interface BranchInfo { + name: string; + isCurrent: boolean; + isRemote: boolean; +} + +interface WorktreeSelectorProps { + projectPath: string; + onCreateWorktree: () => void; + onDeleteWorktree: (worktree: WorktreeInfo) => void; + onCommit: (worktree: WorktreeInfo) => void; + onCreatePR: (worktree: WorktreeInfo) => void; + onCreateBranch: (worktree: WorktreeInfo) => void; +} + +export function WorktreeSelector({ + projectPath, + onCreateWorktree, + onDeleteWorktree, + onCommit, + onCreatePR, + onCreateBranch, +}: WorktreeSelectorProps) { + const [isLoading, setIsLoading] = useState(false); + const [isPulling, setIsPulling] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const [worktrees, setWorktrees] = useState([]); + const [branches, setBranches] = useState([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [branchFilter, setBranchFilter] = useState(""); + const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); + const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); + const setWorktreesInStore = useAppStore((s) => s.setWorktrees); + + const fetchWorktrees = useCallback(async () => { + if (!projectPath) return; + setIsLoading(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.listAll) { + console.warn("Worktree API not available"); + return; + } + const result = await api.worktree.listAll(projectPath, true); + if (result.success && result.worktrees) { + setWorktrees(result.worktrees); + setWorktreesInStore(projectPath, result.worktrees); + } + } catch (error) { + console.error("Failed to fetch worktrees:", error); + } finally { + setIsLoading(false); + } + }, [projectPath, setWorktreesInStore]); + + const fetchBranches = useCallback(async (worktreePath: string) => { + setIsLoadingBranches(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.listBranches) { + console.warn("List branches API not available"); + return; + } + const result = await api.worktree.listBranches(worktreePath); + if (result.success && result.result) { + setBranches(result.result.branches); + } + } catch (error) { + console.error("Failed to fetch branches:", error); + } finally { + setIsLoadingBranches(false); + } + }, []); + + useEffect(() => { + fetchWorktrees(); + }, [fetchWorktrees]); + + const handleSelectWorktree = (worktree: WorktreeInfo) => { + setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path); + }; + + const handleSwitchBranch = async (worktree: WorktreeInfo, branchName: string) => { + if (isSwitching || branchName === worktree.branch) return; + setIsSwitching(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.switchBranch) { + toast.error("Switch branch API not available"); + return; + } + const result = await api.worktree.switchBranch(worktree.path, branchName); + if (result.success && result.result) { + toast.success(result.result.message); + // Refresh worktrees to get updated branch info + fetchWorktrees(); + } else { + toast.error(result.error || "Failed to switch branch"); + } + } catch (error) { + console.error("Switch branch failed:", error); + toast.error("Failed to switch branch"); + } finally { + setIsSwitching(false); + } + }; + + const handlePull = async (worktree: WorktreeInfo) => { + if (isPulling) return; + setIsPulling(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.pull) { + toast.error("Pull API not available"); + return; + } + const result = await api.worktree.pull(worktree.path); + if (result.success && result.result) { + toast.success(result.result.message); + // Refresh worktrees to get updated status + fetchWorktrees(); + } else { + toast.error(result.error || "Failed to pull latest changes"); + } + } catch (error) { + console.error("Pull failed:", error); + toast.error("Failed to pull latest changes"); + } finally { + setIsPulling(false); + } + }; + + const selectedWorktree = + worktrees.find((w) => + currentWorktree ? w.path === currentWorktree : w.isMain + ) || worktrees.find((w) => w.isMain); + + if (worktrees.length === 0 && !isLoading) { + // No git repo or loading + return null; + } + + // Render a worktree tab with branch selector (for main) and actions dropdown + const renderWorktreeTab = (worktree: WorktreeInfo) => { + const isSelected = selectedWorktree?.path === worktree.path; + + return ( +
+ {/* Branch name - clickable dropdown for main repo to switch branches */} + {worktree.isMain ? ( + { + if (open) { + // Select this worktree when opening the dropdown + if (!isSelected) { + handleSelectWorktree(worktree); + } + fetchBranches(worktree.path); + setBranchFilter(""); + } + }}> + + + + + {/* Search input */} +
+
+ + setBranchFilter(e.target.value)} + className="h-7 pl-7 text-xs" + autoFocus + /> +
+
+ +
+ {isLoadingBranches ? ( + + + Loading branches... + + ) : (() => { + const filteredBranches = branches.filter((b) => + b.name.toLowerCase().includes(branchFilter.toLowerCase()) + ); + if (filteredBranches.length === 0) { + return ( + + {branchFilter ? "No matching branches" : "No branches found"} + + ); + } + return filteredBranches.map((branch) => ( + handleSwitchBranch(worktree, branch.name)} + disabled={isSwitching || branch.name === worktree.branch} + className="text-xs font-mono" + > + {branch.name === worktree.branch ? ( + + ) : ( + + )} + {branch.name} + + )); + })()} +
+ + {worktree.hasChanges && ( + onCommit(worktree)} + className="text-xs" + > + + Commit Changes ({worktree.changedFilesCount} file{worktree.changedFilesCount !== 1 ? "s" : ""}) + + )} + onCreateBranch(worktree)} + className="text-xs" + > + + Create New Branch... + +
+
+ ) : ( + // Non-main worktrees - just show branch name (worktrees are tied to branches) + + )} + + {/* Actions dropdown */} + + + + + + {/* Pull latest changes */} + handlePull(worktree)} + disabled={isPulling} + className="text-xs" + > + + {isPulling ? "Pulling..." : "Pull Latest"} + + {/* Create new branch - only for main repo */} + {worktree.isMain && ( + onCreateBranch(worktree)} + className="text-xs" + > + + New Branch + + )} + {worktree.hasChanges && ( + <> + + onCommit(worktree)} + className="text-xs" + > + + Commit Changes + + + )} + {/* Show PR option if not on main branch, or if on main with changes */} + {(worktree.branch !== "main" || worktree.hasChanges) && ( + onCreatePR(worktree)} + className="text-xs" + > + + Create Pull Request + + )} + {/* Only show delete for non-main worktrees */} + {!worktree.isMain && ( + <> + + onDeleteWorktree(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Delete Worktree + + + )} + + +
+ ); + }; + + return ( +
+ + Branch: + + {/* Worktree Tabs */} +
+ {worktrees.map((worktree) => renderWorktreeTab(worktree))} + + {/* Add Worktree Button */} + + + {/* Refresh Button */} + +
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx new file mode 100644 index 00000000..048169f2 --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { GitCommit, Loader2 } from "lucide-react"; +import { getElectronAPI } from "@/lib/electron"; +import { toast } from "sonner"; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +interface CommitWorktreeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktree: WorktreeInfo | null; + onCommitted: () => void; +} + +export function CommitWorktreeDialog({ + open, + onOpenChange, + worktree, + onCommitted, +}: CommitWorktreeDialogProps) { + const [message, setMessage] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleCommit = async () => { + if (!worktree || !message.trim()) return; + + setIsLoading(true); + setError(null); + + try { + const api = getElectronAPI(); + if (!api?.worktree?.commit) { + setError("Worktree API not available"); + return; + } + const result = await api.worktree.commit(worktree.path, message); + + if (result.success && result.result) { + if (result.result.committed) { + toast.success("Changes committed", { + description: `Commit ${result.result.commitHash} on ${result.result.branch}`, + }); + onCommitted(); + onOpenChange(false); + setMessage(""); + } else { + toast.info("No changes to commit", { + description: result.result.message, + }); + } + } else { + setError(result.error || "Failed to commit changes"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to commit"); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && e.metaKey && !isLoading && message.trim()) { + handleCommit(); + } + }; + + if (!worktree) return null; + + return ( + + + + + + Commit Changes + + + Commit changes in the{" "} + + {worktree.branch} + {" "} + worktree. + {worktree.changedFilesCount && ( + + ({worktree.changedFilesCount} file + {worktree.changedFilesCount > 1 ? "s" : ""} changed) + + )} + + + +
+
+ +