Merge pull request #182 from AutoMaker-Org/worktree-select

worktree-select
This commit is contained in:
Web Dev Cody
2025-12-20 09:36:50 -05:00
committed by GitHub
8 changed files with 390 additions and 126 deletions

View File

@@ -418,6 +418,35 @@ export function BoardView() {
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: (newWorktree) => {
if (!currentProject) return;
// Check if worktree already exists in the store (by branch name)
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find(
(w) => w.branch === newWorktree.branch
);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
},
currentWorktreeBranch,
});

View File

@@ -41,6 +41,7 @@ interface UseBoardActionsProps {
outputFeature: Feature | null;
projectPath: string | null;
onWorktreeCreated?: () => void;
onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void;
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
}
@@ -68,6 +69,7 @@ export function useBoardActions({
outputFeature,
projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const {
@@ -115,15 +117,20 @@ export function useBoardActions({
currentProject.path,
finalBranchName
);
if (result.success) {
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
}`
);
// Auto-select the worktree when creating a feature for it
onWorktreeAutoSelect?.({
path: result.worktree.path,
branch: result.worktree.branch,
});
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
} else if (!result.success) {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
@@ -143,7 +150,8 @@ export function useBoardActions({
}
// Check if we need to generate a title
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
const needsTitleGeneration =
!featureData.title.trim() && featureData.description.trim();
const newFeatureData = {
...featureData,
@@ -161,10 +169,14 @@ export function useBoardActions({
if (needsTitleGeneration) {
const api = getElectronAPI();
if (api?.features?.generateTitle) {
api.features.generateTitle(featureData.description)
api.features
.generateTitle(featureData.description)
.then((result) => {
if (result.success && result.title) {
const titleUpdates = { title: result.title, titleGenerating: false };
const titleUpdates = {
title: result.title,
titleGenerating: false,
};
updateFeature(createdFeature.id, titleUpdates);
persistFeatureUpdate(createdFeature.id, titleUpdates);
} else {
@@ -184,7 +196,17 @@ export function useBoardActions({
}
}
},
[addFeature, persistFeatureCreate, persistFeatureUpdate, updateFeature, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
[
addFeature,
persistFeatureCreate,
persistFeatureUpdate,
updateFeature,
saveCategory,
useWorktrees,
currentProject,
onWorktreeCreated,
onWorktreeAutoSelect,
]
);
const handleUpdateFeature = useCallback(
@@ -257,7 +279,15 @@ export function useBoardActions({
}
setEditingFeature(null);
},
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
[
updateFeature,
persistFeatureUpdate,
saveCategory,
setEditingFeature,
useWorktrees,
currentProject,
onWorktreeCreated,
]
);
const handleDeleteFeature = useCallback(

View File

@@ -138,19 +138,15 @@ export function WorktreeTab({
};
prBadge = (
<button
type="button"
<span
role="button"
tabIndex={0}
className={cn(
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
"appearance-none cursor-pointer hover:opacity-80 active:opacity-70", // Reset button appearance but keep cursor, add hover/active states
"cursor-pointer hover:opacity-80 active:opacity-70",
prStateClasses
)}
style={{
// Override any inherited button styles
backgroundImage: "none",
boxShadow: "none",
}}
title={`${prLabel} - Click to open`}
aria-label={`${prLabel} - Click to open pull request`}
onClick={(e) => {
@@ -177,7 +173,7 @@ export function WorktreeTab({
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
{prState}
</span>
</button>
</span>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
@@ -20,9 +20,12 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
const fetchWorktrees = useCallback(async (options?: { silent?: boolean }) => {
if (!projectPath) return;
setIsLoading(true);
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(true);
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
@@ -40,7 +43,9 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
console.error("Failed to fetch worktrees:", error);
return undefined;
} finally {
setIsLoading(false);
if (!silent) {
setIsLoading(false);
}
}
}, [projectPath, setWorktreesInStore]);
@@ -58,14 +63,25 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
}
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
// Use a ref to track the current worktree to avoid running validation
// when selection changes (which could cause a race condition with stale worktrees list)
const currentWorktreeRef = useRef(currentWorktree);
useEffect(() => {
currentWorktreeRef.current = currentWorktree;
}, [currentWorktree]);
// Validation effect: only runs when worktrees list changes (not on selection change)
// This prevents a race condition where the selection is reset because the
// local worktrees state hasn't been updated yet from the async fetch
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
const current = currentWorktreeRef.current;
const currentPath = current?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
if (current == null || (currentPath !== null && !currentWorktreeExists)) {
// Find the primary worktree and get its branch name
// Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
@@ -73,7 +89,7 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
setCurrentWorktree(projectPath, null, mainBranch);
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
}, [worktrees, projectPath, setCurrentWorktree]);
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {

View File

@@ -1,6 +1,12 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { GitBranch, Plus, RefreshCw, ChevronDown, ChevronUp } from "lucide-react";
import {
GitBranch,
Plus,
RefreshCw,
PanelLeftOpen,
PanelLeftClose,
} from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import {
@@ -96,9 +102,27 @@ export function WorktreePanel({
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
// Periodic interval check (1 second) to detect branch changes on disk
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [fetchWorktrees]);
// Get the currently selected worktree for collapsed view
const selectedWorktree = worktrees.find((w) => {
if (currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null) {
if (
currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
) {
return w.isMain;
}
return pathsEqual(w.path, currentWorktreePath);
@@ -112,22 +136,23 @@ export function WorktreePanel({
: pathsEqual(worktree.path, currentWorktreePath);
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleBranchDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const handleActionsDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
if (!useWorktreesEnabled) {
return null;
}
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Collapsed view - just show current branch and toggle
if (isCollapsed) {
@@ -140,7 +165,7 @@ export function WorktreePanel({
onClick={toggleCollapsed}
title="Expand worktree panel"
>
<ChevronDown className="w-4 h-4" />
<PanelLeftOpen className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
@@ -166,86 +191,153 @@ export function WorktreePanel({
onClick={toggleCollapsed}
title="Collapse worktree panel"
>
<ChevronUp className="w-4 h-4" />
<PanelLeftClose className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<div className="flex items-center gap-2 flex-wrap">
{worktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
mainWorktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
mainWorktree
)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
</Button>
)}
</div>
{/* Worktrees section - only show if enabled */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
worktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
worktree
)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (
removedWorktrees &&
removedWorktrees.length > 0 &&
onRemovedWorktrees
) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</>
)}
</div>
);
}