From 40a3046a3bf1793a9b8fbb4f0f15ffcea98712d5 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Wed, 17 Dec 2025 08:20:00 -0500 Subject: [PATCH] refactor worktree into smaller components --- apps/app/src/components/views/board-view.tsx | 6 +- .../views/board-view/components/index.ts | 1 - .../components/worktree-selector.tsx | 833 ------------------ .../components/branch-switch-dropdown.tsx | 123 +++ .../worktree-panel/components/index.ts | 3 + .../components/worktree-actions-dropdown.tsx | 194 ++++ .../components/worktree-tab.tsx | 192 ++++ .../board-view/worktree-panel/hooks/index.ts | 6 + .../worktree-panel/hooks/use-branches.ts | 54 ++ .../hooks/use-default-editor.ts | 31 + .../worktree-panel/hooks/use-dev-servers.ts | 154 ++++ .../hooks/use-running-features.ts | 50 ++ .../hooks/use-worktree-actions.ts | 133 +++ .../worktree-panel/hooks/use-worktrees.ts | 95 ++ .../views/board-view/worktree-panel/index.ts | 8 + .../views/board-view/worktree-panel/types.ts | 39 + .../worktree-panel/worktree-panel.tsx | 177 ++++ 17 files changed, 1262 insertions(+), 837 deletions(-) delete mode 100644 apps/app/src/components/views/board-view/components/worktree-selector.tsx create mode 100644 apps/app/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx create mode 100644 apps/app/src/components/views/board-view/worktree-panel/components/index.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx create mode 100644 apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx create mode 100644 apps/app/src/components/views/board-view/worktree-panel/hooks/index.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/hooks/use-branches.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/hooks/use-default-editor.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/index.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/types.ts create mode 100644 apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index 040afb51..3a0fb270 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -36,7 +36,7 @@ import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialo 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 { WorktreePanel } from "./board-view/worktree-panel"; import { COLUMNS } from "./board-view/constants"; import { useBoardFeatures, @@ -433,8 +433,8 @@ export function BoardView() { isMounted={isMounted} /> - {/* Worktree Selector */} - setShowCreateWorktreeDialog(true)} 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 24517ad2..49cf06ef 100644 --- a/apps/app/src/components/views/board-view/components/index.ts +++ b/apps/app/src/components/views/board-view/components/index.ts @@ -1,3 +1,2 @@ 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 deleted file mode 100644 index a4856dc2..00000000 --- a/apps/app/src/components/views/board-view/components/worktree-selector.tsx +++ /dev/null @@ -1,833 +0,0 @@ -"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, - Upload, - GitBranchPlus, - Check, - Search, - Play, - Square, - Globe, - Loader2, -} from "lucide-react"; -import { useAppStore } from "@/store/app-store"; -import { getElectronAPI } from "@/lib/electron"; -import { cn, pathsEqual, normalizePath } from "@/lib/utils"; -import { toast } from "sonner"; - -interface WorktreeInfo { - path: string; - branch: string; - isMain: boolean; - isCurrent: boolean; // Is this the currently checked out branch? - hasWorktree: boolean; // Does this branch have an active worktree? - hasChanges?: boolean; - changedFilesCount?: number; -} - -interface BranchInfo { - name: string; - isCurrent: boolean; - isRemote: boolean; -} - -interface DevServerInfo { - worktreePath: string; - port: number; - url: string; -} - -interface FeatureInfo { - id: string; - worktreePath?: string; - branchName?: string; // Used as fallback to determine which worktree the spinner should show on -} - -interface WorktreeSelectorProps { - projectPath: string; - onCreateWorktree: () => void; - onDeleteWorktree: (worktree: WorktreeInfo) => void; - onCommit: (worktree: WorktreeInfo) => void; - onCreatePR: (worktree: WorktreeInfo) => void; - onCreateBranch: (worktree: WorktreeInfo) => void; - runningFeatureIds?: string[]; - features?: FeatureInfo[]; - /** Increment this to trigger a refresh without unmounting the component */ - refreshTrigger?: number; -} - -export function WorktreeSelector({ - projectPath, - onCreateWorktree, - onDeleteWorktree, - onCommit, - onCreatePR, - onCreateBranch, - runningFeatureIds = [], - features = [], - refreshTrigger = 0, -}: WorktreeSelectorProps) { - const [isLoading, setIsLoading] = useState(false); - const [isPulling, setIsPulling] = useState(false); - const [isPushing, setIsPushing] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); - const [isActivating, setIsActivating] = useState(false); - const [isStartingDevServer, setIsStartingDevServer] = useState(false); - const [worktrees, setWorktrees] = useState([]); - const [branches, setBranches] = useState([]); - const [aheadCount, setAheadCount] = useState(0); - const [behindCount, setBehindCount] = useState(0); - const [isLoadingBranches, setIsLoadingBranches] = useState(false); - const [branchFilter, setBranchFilter] = useState(""); - const [runningDevServers, setRunningDevServers] = useState< - Map - >(new Map()); - const [defaultEditorName, setDefaultEditorName] = useState("Editor"); - const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); - const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); - const setWorktreesInStore = useAppStore((s) => s.setWorktrees); - const useWorktreesEnabled = useAppStore((s) => s.useWorktrees); - - 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 fetchDevServers = useCallback(async () => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.listDevServers) { - return; - } - const result = await api.worktree.listDevServers(); - if (result.success && result.result?.servers) { - const serversMap = new Map(); - for (const server of result.result.servers) { - serversMap.set(server.worktreePath, server); - } - setRunningDevServers(serversMap); - } - } catch (error) { - console.error("Failed to fetch dev servers:", error); - } - }, []); - - const fetchDefaultEditor = useCallback(async () => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.getDefaultEditor) { - return; - } - const result = await api.worktree.getDefaultEditor(); - if (result.success && result.result?.editorName) { - setDefaultEditorName(result.result.editorName); - } - } catch (error) { - console.error("Failed to fetch default editor:", error); - } - }, []); - - 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); - setAheadCount(result.result.aheadCount || 0); - setBehindCount(result.result.behindCount || 0); - } - } catch (error) { - console.error("Failed to fetch branches:", error); - } finally { - setIsLoadingBranches(false); - } - }, []); - - useEffect(() => { - fetchWorktrees(); - fetchDevServers(); - fetchDefaultEditor(); - }, [fetchWorktrees, fetchDevServers, fetchDefaultEditor]); - - // Refresh when refreshTrigger changes (but skip the initial render) - useEffect(() => { - if (refreshTrigger > 0) { - fetchWorktrees(); - } - }, [refreshTrigger, fetchWorktrees]); - - // Initialize selection to main if not set OR if the stored worktree no longer exists - // This handles stale data (e.g., a worktree that was deleted) - useEffect(() => { - if (worktrees.length > 0) { - const currentPath = currentWorktree?.path; - - // Check if the currently selected worktree still exists - // null path means main (which always exists if worktrees has items) - // Non-null path means we need to verify it exists in the worktrees list - const currentWorktreeExists = currentPath === null - ? true - : worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath)); - - // Reset to main if: - // 1. No worktree is set (currentWorktree is null/undefined) - // 2. Current worktree has a path that doesn't exist in the list (stale data) - if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) { - const mainWorktree = worktrees.find((w) => w.isMain); - const mainBranch = mainWorktree?.branch || "main"; - setCurrentWorktree(projectPath, null, mainBranch); // null = main worktree - } - } - }, [worktrees, currentWorktree, projectPath, setCurrentWorktree]); - - const handleSelectWorktree = async (worktree: WorktreeInfo) => { - // Simply select the worktree in the UI with both path and branch - setCurrentWorktree( - projectPath, - worktree.isMain ? null : worktree.path, - worktree.branch - ); - }; - - const handleStartDevServer = async (worktree: WorktreeInfo) => { - if (isStartingDevServer) return; - setIsStartingDevServer(true); - - try { - const api = getElectronAPI(); - if (!api?.worktree?.startDevServer) { - toast.error("Start dev server API not available"); - return; - } - - // Use projectPath for main, worktree.path for others - const targetPath = worktree.isMain ? projectPath : worktree.path; - const result = await api.worktree.startDevServer(projectPath, targetPath); - - if (result.success && result.result) { - // Update running servers map (normalize path for cross-platform compatibility) - setRunningDevServers((prev) => { - const next = new Map(prev); - next.set(normalizePath(targetPath), { - worktreePath: result.result!.worktreePath, - port: result.result!.port, - url: result.result!.url, - }); - return next; - }); - toast.success(`Dev server started on port ${result.result.port}`); - } else { - toast.error(result.error || "Failed to start dev server"); - } - } catch (error) { - console.error("Start dev server failed:", error); - toast.error("Failed to start dev server"); - } finally { - setIsStartingDevServer(false); - } - }; - - const handleStopDevServer = async (worktree: WorktreeInfo) => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.stopDevServer) { - toast.error("Stop dev server API not available"); - return; - } - - // Use projectPath for main, worktree.path for others - const targetPath = worktree.isMain ? projectPath : worktree.path; - const result = await api.worktree.stopDevServer(targetPath); - - if (result.success) { - // Update running servers map (normalize path for cross-platform compatibility) - setRunningDevServers((prev) => { - const next = new Map(prev); - next.delete(normalizePath(targetPath)); - return next; - }); - toast.success(result.result?.message || "Dev server stopped"); - } else { - toast.error(result.error || "Failed to stop dev server"); - } - } catch (error) { - console.error("Stop dev server failed:", error); - toast.error("Failed to stop dev server"); - } - }; - - const handleOpenDevServerUrl = (worktree: WorktreeInfo) => { - const targetPath = worktree.isMain ? projectPath : worktree.path; - const serverInfo = runningDevServers.get(targetPath); - if (serverInfo) { - window.open(serverInfo.url, "_blank"); - } - }; - - // Helper to get the path key for a worktree (for looking up in runningDevServers) - // Normalizes path for cross-platform compatibility - const getWorktreeKey = (worktree: WorktreeInfo) => { - const path = worktree.isMain ? projectPath : worktree.path; - return path ? normalizePath(path) : path; - }; - - // Helper to check if a worktree has running features - const hasRunningFeatures = (worktree: WorktreeInfo) => { - if (runningFeatureIds.length === 0) return false; - - const worktreeKey = getWorktreeKey(worktree); - - // Check if any running feature belongs to this worktree - return runningFeatureIds.some((featureId) => { - const feature = features.find((f) => f.id === featureId); - if (!feature) return false; - - // First, check if worktreePath is set and matches - // Use pathsEqual for cross-platform compatibility (Windows uses backslashes) - if (feature.worktreePath) { - if (worktree.isMain) { - // Feature has worktreePath - show on main only if it matches projectPath - return pathsEqual(feature.worktreePath, projectPath); - } - // For non-main worktrees, check if worktreePath matches - return pathsEqual(feature.worktreePath, worktreeKey); - } - - // If worktreePath is not set, use branchName as fallback - if (feature.branchName) { - // Feature has a branchName - show spinner on the worktree with matching branch - return worktree.branch === feature.branchName; - } - - // No worktreePath and no branchName - default to main - return worktree.isMain; - }); - }; - - const handleOpenInEditor = async (worktree: WorktreeInfo) => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.openInEditor) { - console.warn("Open in editor API not available"); - return; - } - const result = await api.worktree.openInEditor(worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - } else if (result.error) { - toast.error(result.error); - } - } catch (error) { - console.error("Open in editor failed:", error); - } - }; - - 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 handlePush = async (worktree: WorktreeInfo) => { - if (isPushing) return; - setIsPushing(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.push) { - toast.error("Push API not available"); - return; - } - const result = await api.worktree.push(worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - // Refresh to update ahead/behind counts - fetchBranches(worktree.path); - fetchWorktrees(); - } else { - toast.error(result.error || "Failed to push changes"); - } - } catch (error) { - console.error("Push failed:", error); - toast.error("Failed to push changes"); - } finally { - setIsPushing(false); - } - }; - - // The "selected" worktree is based on UI state, not git's current branch - // currentWorktree.path is null for main, or the worktree path for others - const currentWorktreePath = currentWorktree?.path ?? null; - const selectedWorktree = currentWorktreePath - ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) - : worktrees.find((w) => w.isMain); - - // Render a worktree tab with branch selector (for main) and actions dropdown - const renderWorktreeTab = (worktree: WorktreeInfo) => { - // Selection is based on UI state, not git's current branch - // Default to main selected if currentWorktree is null/undefined or path is null - const isSelected = worktree.isMain - ? currentWorktree === null || - currentWorktree === undefined || - currentWorktree.path === null - : pathsEqual(worktree.path, currentWorktreePath); - - const isRunning = hasRunningFeatures(worktree); - - return ( -
- {/* Main branch: clickable button + separate branch switch dropdown */} - {worktree.isMain ? ( - <> - {/* Clickable button to select/preview main */} - - {/* Branch switch dropdown button */} - { - if (open) { - fetchBranches(worktree.path); - setBranchFilter(""); - } - }} - > - - - - - - Switch Branch - - - {/* Search input */} -
-
- - setBranchFilter(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - onKeyUp={(e) => e.stopPropagation()} - onKeyPress={(e) => e.stopPropagation()} - 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} - - )); - })() - )} -
- - onCreateBranch(worktree)} - className="text-xs" - > - - Create New Branch... - -
-
- - ) : ( - // Non-main branches - click to switch to this branch - - )} - - {/* Dev server indicator */} - {runningDevServers.has(getWorktreeKey(worktree)) && ( - - )} - - {/* Actions dropdown */} - { - if (open) { - fetchBranches(worktree.path); - } - }} - > - - - - - {/* Dev server controls */} - {runningDevServers.has(getWorktreeKey(worktree)) ? ( - <> - - - Dev Server Running (: - {runningDevServers.get(getWorktreeKey(worktree))?.port}) - - handleOpenDevServerUrl(worktree)} - className="text-xs" - > - - Open in Browser - - handleStopDevServer(worktree)} - className="text-xs text-destructive focus:text-destructive" - > - - Stop Dev Server - - - - ) : ( - <> - handleStartDevServer(worktree)} - disabled={isStartingDevServer} - className="text-xs" - > - - {isStartingDevServer ? "Starting..." : "Start Dev Server"} - - - - )} - {/* Pull option */} - handlePull(worktree)} - disabled={isPulling} - className="text-xs" - > - - {isPulling ? "Pulling..." : "Pull"} - {behindCount > 0 && ( - - {behindCount} behind - - )} - - {/* Push option */} - handlePush(worktree)} - disabled={isPushing || aheadCount === 0} - className="text-xs" - > - - {isPushing ? "Pushing..." : "Push"} - {aheadCount > 0 && ( - - {aheadCount} ahead - - )} - - - {/* Open in editor */} - handleOpenInEditor(worktree)} - className="text-xs" - > - - Open in {defaultEditorName} - - - {/* Commit changes */} - {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 - - - )} - - -
- ); - }; - - // Don't render the worktree selector if the feature is disabled - if (!useWorktreesEnabled) { - return null; - } - - return ( -
- - Branch: - - {/* Worktree Tabs */} -
- {worktrees.map((worktree) => renderWorktreeTab(worktree))} - - {/* Add Worktree Button */} - - - {/* Refresh Button */} - -
-
- ); -} diff --git a/apps/app/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx b/apps/app/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx new file mode 100644 index 00000000..8824d5b8 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { + GitBranch, + RefreshCw, + GitBranchPlus, + Check, + Search, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { WorktreeInfo, BranchInfo } from "../types"; + +interface BranchSwitchDropdownProps { + worktree: WorktreeInfo; + isSelected: boolean; + branches: BranchInfo[]; + filteredBranches: BranchInfo[]; + branchFilter: string; + isLoadingBranches: boolean; + isSwitching: boolean; + onOpenChange: (open: boolean) => void; + onFilterChange: (value: string) => void; + onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void; + onCreateBranch: (worktree: WorktreeInfo) => void; +} + +export function BranchSwitchDropdown({ + worktree, + isSelected, + filteredBranches, + branchFilter, + isLoadingBranches, + isSwitching, + onOpenChange, + onFilterChange, + onSwitchBranch, + onCreateBranch, +}: BranchSwitchDropdownProps) { + return ( + + + + + + Switch Branch + +
+
+ + onFilterChange(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + onKeyUp={(e) => e.stopPropagation()} + onKeyPress={(e) => e.stopPropagation()} + className="h-7 pl-7 text-xs" + autoFocus + /> +
+
+ +
+ {isLoadingBranches ? ( + + + Loading branches... + + ) : filteredBranches.length === 0 ? ( + + {branchFilter ? "No matching branches" : "No branches found"} + + ) : ( + filteredBranches.map((branch) => ( + onSwitchBranch(worktree, branch.name)} + disabled={isSwitching || branch.name === worktree.branch} + className="text-xs font-mono" + > + {branch.name === worktree.branch ? ( + + ) : ( + + )} + {branch.name} + + )) + )} +
+ + onCreateBranch(worktree)} + className="text-xs" + > + + Create New Branch... + +
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/components/index.ts b/apps/app/src/components/views/board-view/worktree-panel/components/index.ts new file mode 100644 index 00000000..c38c0721 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/components/index.ts @@ -0,0 +1,3 @@ +export { BranchSwitchDropdown } from "./branch-switch-dropdown"; +export { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; +export { WorktreeTab } from "./worktree-tab"; diff --git a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx new file mode 100644 index 00000000..b0cc7870 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { + Trash2, + MoreHorizontal, + GitCommit, + GitPullRequest, + ExternalLink, + Download, + Upload, + Play, + Square, + Globe, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { WorktreeInfo, DevServerInfo } from "../types"; + +interface WorktreeActionsDropdownProps { + worktree: WorktreeInfo; + isSelected: boolean; + defaultEditorName: string; + aheadCount: number; + behindCount: number; + isPulling: boolean; + isPushing: boolean; + isStartingDevServer: boolean; + isDevServerRunning: boolean; + devServerInfo?: DevServerInfo; + onOpenChange: (open: boolean) => void; + onPull: (worktree: WorktreeInfo) => void; + onPush: (worktree: WorktreeInfo) => void; + onOpenInEditor: (worktree: WorktreeInfo) => void; + onCommit: (worktree: WorktreeInfo) => void; + onCreatePR: (worktree: WorktreeInfo) => void; + onDeleteWorktree: (worktree: WorktreeInfo) => void; + onStartDevServer: (worktree: WorktreeInfo) => void; + onStopDevServer: (worktree: WorktreeInfo) => void; + onOpenDevServerUrl: (worktree: WorktreeInfo) => void; +} + +export function WorktreeActionsDropdown({ + worktree, + isSelected, + defaultEditorName, + aheadCount, + behindCount, + isPulling, + isPushing, + isStartingDevServer, + isDevServerRunning, + devServerInfo, + onOpenChange, + onPull, + onPush, + onOpenInEditor, + onCommit, + onCreatePR, + onDeleteWorktree, + onStartDevServer, + onStopDevServer, + onOpenDevServerUrl, +}: WorktreeActionsDropdownProps) { + return ( + + + + + + {isDevServerRunning ? ( + <> + + + Dev Server Running (:{devServerInfo?.port}) + + onOpenDevServerUrl(worktree)} + className="text-xs" + > + + Open in Browser + + onStopDevServer(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Stop Dev Server + + + + ) : ( + <> + onStartDevServer(worktree)} + disabled={isStartingDevServer} + className="text-xs" + > + + {isStartingDevServer ? "Starting..." : "Start Dev Server"} + + + + )} + onPull(worktree)} + disabled={isPulling} + className="text-xs" + > + + {isPulling ? "Pulling..." : "Pull"} + {behindCount > 0 && ( + + {behindCount} behind + + )} + + onPush(worktree)} + disabled={isPushing || aheadCount === 0} + className="text-xs" + > + + {isPushing ? "Pushing..." : "Push"} + {aheadCount > 0 && ( + + {aheadCount} ahead + + )} + + + onOpenInEditor(worktree)} + className="text-xs" + > + + Open in {defaultEditorName} + + + {worktree.hasChanges && ( + onCommit(worktree)} className="text-xs"> + + Commit Changes + + )} + {(worktree.branch !== "main" || worktree.hasChanges) && ( + onCreatePR(worktree)} className="text-xs"> + + Create Pull Request + + )} + {!worktree.isMain && ( + <> + + onDeleteWorktree(worktree)} + className="text-xs text-destructive focus:text-destructive" + > + + Delete Worktree + + + )} + + + ); +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx new file mode 100644 index 00000000..8332e71d --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { RefreshCw, Globe, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types"; +import { BranchSwitchDropdown } from "./branch-switch-dropdown"; +import { WorktreeActionsDropdown } from "./worktree-actions-dropdown"; + +interface WorktreeTabProps { + worktree: WorktreeInfo; + isSelected: boolean; + isRunning: boolean; + isActivating: boolean; + isDevServerRunning: boolean; + devServerInfo?: DevServerInfo; + defaultEditorName: string; + branches: BranchInfo[]; + filteredBranches: BranchInfo[]; + branchFilter: string; + isLoadingBranches: boolean; + isSwitching: boolean; + isPulling: boolean; + isPushing: boolean; + isStartingDevServer: boolean; + aheadCount: number; + behindCount: number; + onSelectWorktree: (worktree: WorktreeInfo) => void; + onBranchDropdownOpenChange: (open: boolean) => void; + onActionsDropdownOpenChange: (open: boolean) => void; + onBranchFilterChange: (value: string) => void; + onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void; + onCreateBranch: (worktree: WorktreeInfo) => void; + onPull: (worktree: WorktreeInfo) => void; + onPush: (worktree: WorktreeInfo) => void; + onOpenInEditor: (worktree: WorktreeInfo) => void; + onCommit: (worktree: WorktreeInfo) => void; + onCreatePR: (worktree: WorktreeInfo) => void; + onDeleteWorktree: (worktree: WorktreeInfo) => void; + onStartDevServer: (worktree: WorktreeInfo) => void; + onStopDevServer: (worktree: WorktreeInfo) => void; + onOpenDevServerUrl: (worktree: WorktreeInfo) => void; +} + +export function WorktreeTab({ + worktree, + isSelected, + isRunning, + isActivating, + isDevServerRunning, + devServerInfo, + defaultEditorName, + branches, + filteredBranches, + branchFilter, + isLoadingBranches, + isSwitching, + isPulling, + isPushing, + isStartingDevServer, + aheadCount, + behindCount, + onSelectWorktree, + onBranchDropdownOpenChange, + onActionsDropdownOpenChange, + onBranchFilterChange, + onSwitchBranch, + onCreateBranch, + onPull, + onPush, + onOpenInEditor, + onCommit, + onCreatePR, + onDeleteWorktree, + onStartDevServer, + onStopDevServer, + onOpenDevServerUrl, +}: WorktreeTabProps) { + return ( +
+ {worktree.isMain ? ( + <> + + + + ) : ( + + )} + + {isDevServerRunning && ( + + )} + + +
+ ); +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/index.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/index.ts new file mode 100644 index 00000000..54d57840 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/index.ts @@ -0,0 +1,6 @@ +export { useWorktrees } from "./use-worktrees"; +export { useDevServers } from "./use-dev-servers"; +export { useBranches } from "./use-branches"; +export { useWorktreeActions } from "./use-worktree-actions"; +export { useDefaultEditor } from "./use-default-editor"; +export { useRunningFeatures } from "./use-running-features"; diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-branches.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-branches.ts new file mode 100644 index 00000000..38fbfda3 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-branches.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { getElectronAPI } from "@/lib/electron"; +import type { BranchInfo } from "../types"; + +export function useBranches() { + const [branches, setBranches] = useState([]); + const [aheadCount, setAheadCount] = useState(0); + const [behindCount, setBehindCount] = useState(0); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [branchFilter, setBranchFilter] = useState(""); + + 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); + setAheadCount(result.result.aheadCount || 0); + setBehindCount(result.result.behindCount || 0); + } + } catch (error) { + console.error("Failed to fetch branches:", error); + } finally { + setIsLoadingBranches(false); + } + }, []); + + const resetBranchFilter = useCallback(() => { + setBranchFilter(""); + }, []); + + const filteredBranches = branches.filter((b) => + b.name.toLowerCase().includes(branchFilter.toLowerCase()) + ); + + return { + branches, + filteredBranches, + aheadCount, + behindCount, + isLoadingBranches, + branchFilter, + setBranchFilter, + resetBranchFilter, + fetchBranches, + }; +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-default-editor.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-default-editor.ts new file mode 100644 index 00000000..9d35beca --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-default-editor.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { getElectronAPI } from "@/lib/electron"; + +export function useDefaultEditor() { + const [defaultEditorName, setDefaultEditorName] = useState("Editor"); + + const fetchDefaultEditor = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.getDefaultEditor) { + return; + } + const result = await api.worktree.getDefaultEditor(); + if (result.success && result.result?.editorName) { + setDefaultEditorName(result.result.editorName); + } + } catch (error) { + console.error("Failed to fetch default editor:", error); + } + }, []); + + useEffect(() => { + fetchDefaultEditor(); + }, [fetchDefaultEditor]); + + return { + defaultEditorName, + }; +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts new file mode 100644 index 00000000..8d6cfb31 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts @@ -0,0 +1,154 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { getElectronAPI } from "@/lib/electron"; +import { normalizePath } from "@/lib/utils"; +import { toast } from "sonner"; +import type { DevServerInfo, WorktreeInfo } from "../types"; + +interface UseDevServersOptions { + projectPath: string; +} + +export function useDevServers({ projectPath }: UseDevServersOptions) { + const [isStartingDevServer, setIsStartingDevServer] = useState(false); + const [runningDevServers, setRunningDevServers] = useState>( + new Map() + ); + + const fetchDevServers = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.listDevServers) { + return; + } + const result = await api.worktree.listDevServers(); + if (result.success && result.result?.servers) { + const serversMap = new Map(); + for (const server of result.result.servers) { + serversMap.set(server.worktreePath, server); + } + setRunningDevServers(serversMap); + } + } catch (error) { + console.error("Failed to fetch dev servers:", error); + } + }, []); + + useEffect(() => { + fetchDevServers(); + }, [fetchDevServers]); + + const getWorktreeKey = useCallback( + (worktree: WorktreeInfo) => { + const path = worktree.isMain ? projectPath : worktree.path; + return path ? normalizePath(path) : path; + }, + [projectPath] + ); + + const handleStartDevServer = useCallback( + async (worktree: WorktreeInfo) => { + if (isStartingDevServer) return; + setIsStartingDevServer(true); + + try { + const api = getElectronAPI(); + if (!api?.worktree?.startDevServer) { + toast.error("Start dev server API not available"); + return; + } + + const targetPath = worktree.isMain ? projectPath : worktree.path; + const result = await api.worktree.startDevServer(projectPath, targetPath); + + if (result.success && result.result) { + setRunningDevServers((prev) => { + const next = new Map(prev); + next.set(normalizePath(targetPath), { + worktreePath: result.result!.worktreePath, + port: result.result!.port, + url: result.result!.url, + }); + return next; + }); + toast.success(`Dev server started on port ${result.result.port}`); + } else { + toast.error(result.error || "Failed to start dev server"); + } + } catch (error) { + console.error("Start dev server failed:", error); + toast.error("Failed to start dev server"); + } finally { + setIsStartingDevServer(false); + } + }, + [isStartingDevServer, projectPath] + ); + + const handleStopDevServer = useCallback( + async (worktree: WorktreeInfo) => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.stopDevServer) { + toast.error("Stop dev server API not available"); + return; + } + + const targetPath = worktree.isMain ? projectPath : worktree.path; + const result = await api.worktree.stopDevServer(targetPath); + + if (result.success) { + setRunningDevServers((prev) => { + const next = new Map(prev); + next.delete(normalizePath(targetPath)); + return next; + }); + toast.success(result.result?.message || "Dev server stopped"); + } else { + toast.error(result.error || "Failed to stop dev server"); + } + } catch (error) { + console.error("Stop dev server failed:", error); + toast.error("Failed to stop dev server"); + } + }, + [projectPath] + ); + + const handleOpenDevServerUrl = useCallback( + (worktree: WorktreeInfo) => { + const targetPath = worktree.isMain ? projectPath : worktree.path; + const serverInfo = runningDevServers.get(targetPath); + if (serverInfo) { + window.open(serverInfo.url, "_blank"); + } + }, + [projectPath, runningDevServers] + ); + + const isDevServerRunning = useCallback( + (worktree: WorktreeInfo) => { + return runningDevServers.has(getWorktreeKey(worktree)); + }, + [runningDevServers, getWorktreeKey] + ); + + const getDevServerInfo = useCallback( + (worktree: WorktreeInfo) => { + return runningDevServers.get(getWorktreeKey(worktree)); + }, + [runningDevServers, getWorktreeKey] + ); + + return { + isStartingDevServer, + runningDevServers, + getWorktreeKey, + isDevServerRunning, + getDevServerInfo, + handleStartDevServer, + handleStopDevServer, + handleOpenDevServerUrl, + }; +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts new file mode 100644 index 00000000..46bc7af1 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useCallback } from "react"; +import { pathsEqual } from "@/lib/utils"; +import type { WorktreeInfo, FeatureInfo } from "../types"; + +interface UseRunningFeaturesOptions { + projectPath: string; + runningFeatureIds: string[]; + features: FeatureInfo[]; + getWorktreeKey: (worktree: WorktreeInfo) => string; +} + +export function useRunningFeatures({ + projectPath, + runningFeatureIds, + features, + getWorktreeKey, +}: UseRunningFeaturesOptions) { + const hasRunningFeatures = useCallback( + (worktree: WorktreeInfo) => { + if (runningFeatureIds.length === 0) return false; + + const worktreeKey = getWorktreeKey(worktree); + + return runningFeatureIds.some((featureId) => { + const feature = features.find((f) => f.id === featureId); + if (!feature) return false; + + if (feature.worktreePath) { + if (worktree.isMain) { + return pathsEqual(feature.worktreePath, projectPath); + } + return pathsEqual(feature.worktreePath, worktreeKey); + } + + if (feature.branchName) { + return worktree.branch === feature.branchName; + } + + return worktree.isMain; + }); + }, + [runningFeatureIds, features, projectPath, getWorktreeKey] + ); + + return { + hasRunningFeatures, + }; +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts new file mode 100644 index 00000000..02224ad9 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { getElectronAPI } from "@/lib/electron"; +import { toast } from "sonner"; +import type { WorktreeInfo } from "../types"; + +interface UseWorktreeActionsOptions { + fetchWorktrees: () => Promise; + fetchBranches: (worktreePath: string) => Promise; +} + +export function useWorktreeActions({ + fetchWorktrees, + fetchBranches, +}: UseWorktreeActionsOptions) { + const [isPulling, setIsPulling] = useState(false); + const [isPushing, setIsPushing] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const [isActivating, setIsActivating] = useState(false); + + const handleSwitchBranch = useCallback( + 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); + 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); + } + }, + [isSwitching, fetchWorktrees] + ); + + const handlePull = useCallback( + 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); + 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); + } + }, + [isPulling, fetchWorktrees] + ); + + const handlePush = useCallback( + async (worktree: WorktreeInfo) => { + if (isPushing) return; + setIsPushing(true); + try { + const api = getElectronAPI(); + if (!api?.worktree?.push) { + toast.error("Push API not available"); + return; + } + const result = await api.worktree.push(worktree.path); + if (result.success && result.result) { + toast.success(result.result.message); + fetchBranches(worktree.path); + fetchWorktrees(); + } else { + toast.error(result.error || "Failed to push changes"); + } + } catch (error) { + console.error("Push failed:", error); + toast.error("Failed to push changes"); + } finally { + setIsPushing(false); + } + }, + [isPushing, fetchBranches, fetchWorktrees] + ); + + const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.openInEditor) { + console.warn("Open in editor API not available"); + return; + } + const result = await api.worktree.openInEditor(worktree.path); + if (result.success && result.result) { + toast.success(result.result.message); + } else if (result.error) { + toast.error(result.error); + } + } catch (error) { + console.error("Open in editor failed:", error); + } + }, []); + + return { + isPulling, + isPushing, + isSwitching, + isActivating, + setIsActivating, + handleSwitchBranch, + handlePull, + handlePush, + handleOpenInEditor, + }; +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts new file mode 100644 index 00000000..39b3ae60 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useAppStore } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; +import { pathsEqual } from "@/lib/utils"; +import type { WorktreeInfo } from "../types"; + +interface UseWorktreesOptions { + projectPath: string; + refreshTrigger?: number; +} + +export function useWorktrees({ projectPath, refreshTrigger = 0 }: UseWorktreesOptions) { + const [isLoading, setIsLoading] = useState(false); + const [worktrees, setWorktrees] = useState([]); + + const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); + const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); + const setWorktreesInStore = useAppStore((s) => s.setWorktrees); + const useWorktreesEnabled = useAppStore((s) => s.useWorktrees); + + 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]); + + useEffect(() => { + fetchWorktrees(); + }, [fetchWorktrees]); + + useEffect(() => { + if (refreshTrigger > 0) { + fetchWorktrees(); + } + }, [refreshTrigger, fetchWorktrees]); + + useEffect(() => { + if (worktrees.length > 0) { + const currentPath = currentWorktree?.path; + const currentWorktreeExists = currentPath === null + ? true + : worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath)); + + if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) { + const mainWorktree = worktrees.find((w) => w.isMain); + const mainBranch = mainWorktree?.branch || "main"; + setCurrentWorktree(projectPath, null, mainBranch); + } + } + }, [worktrees, currentWorktree, projectPath, setCurrentWorktree]); + + const handleSelectWorktree = useCallback( + (worktree: WorktreeInfo) => { + setCurrentWorktree( + projectPath, + worktree.isMain ? null : worktree.path, + worktree.branch + ); + }, + [projectPath, setCurrentWorktree] + ); + + const currentWorktreePath = currentWorktree?.path ?? null; + const selectedWorktree = currentWorktreePath + ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) + : worktrees.find((w) => w.isMain); + + return { + isLoading, + worktrees, + currentWorktree, + currentWorktreePath, + selectedWorktree, + useWorktreesEnabled, + fetchWorktrees, + handleSelectWorktree, + }; +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/index.ts b/apps/app/src/components/views/board-view/worktree-panel/index.ts new file mode 100644 index 00000000..76d901a3 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/index.ts @@ -0,0 +1,8 @@ +export { WorktreePanel } from "./worktree-panel"; +export type { + WorktreeInfo, + BranchInfo, + DevServerInfo, + FeatureInfo, + WorktreePanelProps, +} from "./types"; diff --git a/apps/app/src/components/views/board-view/worktree-panel/types.ts b/apps/app/src/components/views/board-view/worktree-panel/types.ts new file mode 100644 index 00000000..630aa953 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/types.ts @@ -0,0 +1,39 @@ +export interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + isCurrent: boolean; + hasWorktree: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +export interface BranchInfo { + name: string; + isCurrent: boolean; + isRemote: boolean; +} + +export interface DevServerInfo { + worktreePath: string; + port: number; + url: string; +} + +export interface FeatureInfo { + id: string; + worktreePath?: string; + branchName?: string; +} + +export interface WorktreePanelProps { + projectPath: string; + onCreateWorktree: () => void; + onDeleteWorktree: (worktree: WorktreeInfo) => void; + onCommit: (worktree: WorktreeInfo) => void; + onCreatePR: (worktree: WorktreeInfo) => void; + onCreateBranch: (worktree: WorktreeInfo) => void; + runningFeatureIds?: string[]; + features?: FeatureInfo[]; + refreshTrigger?: number; +} diff --git a/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx new file mode 100644 index 00000000..53470fd8 --- /dev/null +++ b/apps/app/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { GitBranch, Plus, RefreshCw } from "lucide-react"; +import { cn, pathsEqual } from "@/lib/utils"; +import type { WorktreePanelProps, WorktreeInfo } from "./types"; +import { + useWorktrees, + useDevServers, + useBranches, + useWorktreeActions, + useDefaultEditor, + useRunningFeatures, +} from "./hooks"; +import { WorktreeTab } from "./components"; + +export function WorktreePanel({ + projectPath, + onCreateWorktree, + onDeleteWorktree, + onCommit, + onCreatePR, + onCreateBranch, + runningFeatureIds = [], + features = [], + refreshTrigger = 0, +}: WorktreePanelProps) { + const { + isLoading, + worktrees, + currentWorktree, + currentWorktreePath, + useWorktreesEnabled, + fetchWorktrees, + handleSelectWorktree, + } = useWorktrees({ projectPath, refreshTrigger }); + + const { + isStartingDevServer, + getWorktreeKey, + isDevServerRunning, + getDevServerInfo, + handleStartDevServer, + handleStopDevServer, + handleOpenDevServerUrl, + } = useDevServers({ projectPath }); + + const { + branches, + filteredBranches, + aheadCount, + behindCount, + isLoadingBranches, + branchFilter, + setBranchFilter, + resetBranchFilter, + fetchBranches, + } = useBranches(); + + const { + isPulling, + isPushing, + isSwitching, + isActivating, + handleSwitchBranch, + handlePull, + handlePush, + handleOpenInEditor, + } = useWorktreeActions({ + fetchWorktrees, + fetchBranches, + }); + + const { defaultEditorName } = useDefaultEditor(); + + const { hasRunningFeatures } = useRunningFeatures({ + projectPath, + runningFeatureIds, + features, + getWorktreeKey, + }); + + const isWorktreeSelected = (worktree: WorktreeInfo) => { + return worktree.isMain + ? currentWorktree === null || + currentWorktree === undefined || + currentWorktree.path === null + : pathsEqual(worktree.path, currentWorktreePath); + }; + + const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => { + if (open) { + fetchBranches(worktree.path); + resetBranchFilter(); + } + }; + + const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => { + if (open) { + fetchBranches(worktree.path); + } + }; + + if (!useWorktreesEnabled) { + return null; + } + + return ( +
+ + Branch: + +
+ {worktrees.map((worktree) => ( + + ))} + + + + +
+
+ ); +}