mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
refactor worktree into smaller components
This commit is contained in:
@@ -36,7 +36,7 @@ import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialo
|
|||||||
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
|
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
|
||||||
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
||||||
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-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 { COLUMNS } from "./board-view/constants";
|
||||||
import {
|
import {
|
||||||
useBoardFeatures,
|
useBoardFeatures,
|
||||||
@@ -433,8 +433,8 @@ export function BoardView() {
|
|||||||
isMounted={isMounted}
|
isMounted={isMounted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Worktree Selector */}
|
{/* Worktree Panel */}
|
||||||
<WorktreeSelector
|
<WorktreePanel
|
||||||
refreshTrigger={worktreeRefreshKey}
|
refreshTrigger={worktreeRefreshKey}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export { KanbanCard } from "./kanban-card";
|
export { KanbanCard } from "./kanban-card";
|
||||||
export { KanbanColumn } from "./kanban-column";
|
export { KanbanColumn } from "./kanban-column";
|
||||||
export { WorktreeSelector } from "./worktree-selector";
|
|
||||||
|
|||||||
@@ -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<WorktreeInfo[]>([]);
|
|
||||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
|
||||||
const [aheadCount, setAheadCount] = useState(0);
|
|
||||||
const [behindCount, setBehindCount] = useState(0);
|
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
|
||||||
const [branchFilter, setBranchFilter] = useState("");
|
|
||||||
const [runningDevServers, setRunningDevServers] = useState<
|
|
||||||
Map<string, DevServerInfo>
|
|
||||||
>(new Map());
|
|
||||||
const [defaultEditorName, setDefaultEditorName] = useState<string>("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<string, DevServerInfo>();
|
|
||||||
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 (
|
|
||||||
<div key={worktree.path} className="flex items-center">
|
|
||||||
{/* Main branch: clickable button + separate branch switch dropdown */}
|
|
||||||
{worktree.isMain ? (
|
|
||||||
<>
|
|
||||||
{/* Clickable button to select/preview main */}
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
|
||||||
)}
|
|
||||||
onClick={() => handleSelectWorktree(worktree)}
|
|
||||||
disabled={isActivating}
|
|
||||||
title="Click to preview main"
|
|
||||||
>
|
|
||||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
||||||
{isActivating && !isRunning && (
|
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
||||||
)}
|
|
||||||
{worktree.branch}
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
|
||||||
{worktree.changedFilesCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{/* Branch switch dropdown button */}
|
|
||||||
<DropdownMenu
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (open) {
|
|
||||||
fetchBranches(worktree.path);
|
|
||||||
setBranchFilter("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
|
||||||
)}
|
|
||||||
title="Switch branch"
|
|
||||||
>
|
|
||||||
<GitBranch className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-64">
|
|
||||||
<DropdownMenuLabel className="text-xs">
|
|
||||||
Switch Branch
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{/* Search input */}
|
|
||||||
<div className="px-2 py-1.5">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Filter branches..."
|
|
||||||
value={branchFilter}
|
|
||||||
onChange={(e) => setBranchFilter(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
|
||||||
onKeyPress={(e) => e.stopPropagation()}
|
|
||||||
className="h-7 pl-7 text-xs"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="max-h-[250px] overflow-y-auto">
|
|
||||||
{isLoadingBranches ? (
|
|
||||||
<DropdownMenuItem disabled className="text-xs">
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
|
||||||
Loading branches...
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const filteredBranches = branches.filter((b) =>
|
|
||||||
b.name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(branchFilter.toLowerCase())
|
|
||||||
);
|
|
||||||
if (filteredBranches.length === 0) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem disabled className="text-xs">
|
|
||||||
{branchFilter
|
|
||||||
? "No matching branches"
|
|
||||||
: "No branches found"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return filteredBranches.map((branch) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={branch.name}
|
|
||||||
onClick={() =>
|
|
||||||
handleSwitchBranch(worktree, branch.name)
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
isSwitching || branch.name === worktree.branch
|
|
||||||
}
|
|
||||||
className="text-xs font-mono"
|
|
||||||
>
|
|
||||||
{branch.name === worktree.branch ? (
|
|
||||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{branch.name}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
));
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCreateBranch(worktree)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Create New Branch...
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Non-main branches - click to switch to this branch
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
|
||||||
!worktree.hasWorktree && !isSelected && "opacity-70" // Dim if no active worktree
|
|
||||||
)}
|
|
||||||
onClick={() => handleSelectWorktree(worktree)}
|
|
||||||
disabled={isActivating}
|
|
||||||
title={
|
|
||||||
worktree.hasWorktree
|
|
||||||
? "Click to switch to this worktree's branch"
|
|
||||||
: "Click to switch to this branch"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
||||||
{isActivating && !isRunning && (
|
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
||||||
)}
|
|
||||||
{worktree.branch}
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
|
||||||
{worktree.changedFilesCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dev server indicator */}
|
|
||||||
{runningDevServers.has(getWorktreeKey(worktree)) && (
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
|
||||||
"text-green-500"
|
|
||||||
)}
|
|
||||||
onClick={() => handleOpenDevServerUrl(worktree)}
|
|
||||||
title={`Open dev server (port ${
|
|
||||||
runningDevServers.get(getWorktreeKey(worktree))?.port
|
|
||||||
})`}
|
|
||||||
>
|
|
||||||
<Globe className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions dropdown */}
|
|
||||||
<DropdownMenu
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (open) {
|
|
||||||
fetchBranches(worktree.path);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={isSelected ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-7 w-7 p-0 rounded-l-none",
|
|
||||||
isSelected && "bg-primary text-primary-foreground",
|
|
||||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
|
||||||
{/* Dev server controls */}
|
|
||||||
{runningDevServers.has(getWorktreeKey(worktree)) ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
|
||||||
Dev Server Running (:
|
|
||||||
{runningDevServers.get(getWorktreeKey(worktree))?.port})
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleOpenDevServerUrl(worktree)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Open in Browser
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStopDevServer(worktree)}
|
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Square className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Stop Dev Server
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleStartDevServer(worktree)}
|
|
||||||
disabled={isStartingDevServer}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Play
|
|
||||||
className={cn(
|
|
||||||
"w-3.5 h-3.5 mr-2",
|
|
||||||
isStartingDevServer && "animate-pulse"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* Pull option */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handlePull(worktree)}
|
|
||||||
disabled={isPulling}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Download
|
|
||||||
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
|
||||||
/>
|
|
||||||
{isPulling ? "Pulling..." : "Pull"}
|
|
||||||
{behindCount > 0 && (
|
|
||||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
|
||||||
{behindCount} behind
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{/* Push option */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handlePush(worktree)}
|
|
||||||
disabled={isPushing || aheadCount === 0}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Upload
|
|
||||||
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
|
||||||
/>
|
|
||||||
{isPushing ? "Pushing..." : "Push"}
|
|
||||||
{aheadCount > 0 && (
|
|
||||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
|
||||||
{aheadCount} ahead
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{/* Open in editor */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleOpenInEditor(worktree)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Open in {defaultEditorName}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{/* Commit changes */}
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCommit(worktree)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Commit Changes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{/* Show PR option if not on main branch, or if on main with changes */}
|
|
||||||
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onCreatePR(worktree)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Create Pull Request
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{/* Only show delete for non-main worktrees */}
|
|
||||||
{!worktree.isMain && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onDeleteWorktree(worktree)}
|
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Delete Worktree
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render the worktree selector if the feature is disabled
|
|
||||||
if (!useWorktreesEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
|
||||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
|
||||||
|
|
||||||
{/* Worktree Tabs */}
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
{worktrees.map((worktree) => renderWorktreeTab(worktree))}
|
|
||||||
|
|
||||||
{/* Add Worktree Button */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Refresh Button */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={fetchWorktrees}
|
|
||||||
disabled={isLoading}
|
|
||||||
title="Refresh worktrees"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
title="Switch branch"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter branches..."
|
||||||
|
value={branchFilter}
|
||||||
|
onChange={(e) => onFilterChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyUp={(e) => e.stopPropagation()}
|
||||||
|
onKeyPress={(e) => e.stopPropagation()}
|
||||||
|
className="h-7 pl-7 text-xs"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="max-h-[250px] overflow-y-auto">
|
||||||
|
{isLoadingBranches ? (
|
||||||
|
<DropdownMenuItem disabled className="text-xs">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||||
|
Loading branches...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : filteredBranches.length === 0 ? (
|
||||||
|
<DropdownMenuItem disabled className="text-xs">
|
||||||
|
{branchFilter ? "No matching branches" : "No branches found"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
filteredBranches.map((branch) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={branch.name}
|
||||||
|
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||||
|
disabled={isSwitching || branch.name === worktree.branch}
|
||||||
|
className="text-xs font-mono"
|
||||||
|
>
|
||||||
|
{branch.name === worktree.branch ? (
|
||||||
|
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{branch.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onCreateBranch(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<GitBranchPlus className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Create New Branch...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||||
|
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||||
|
export { WorktreeTab } from "./worktree-tab";
|
||||||
@@ -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 (
|
||||||
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 rounded-l-none",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
{isDevServerRunning ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
Dev Server Running (:{devServerInfo?.port})
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Open in Browser
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onStopDevServer(worktree)}
|
||||||
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Square className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Stop Dev Server
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onStartDevServer(worktree)}
|
||||||
|
disabled={isStartingDevServer}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Play
|
||||||
|
className={cn(
|
||||||
|
"w-3.5 h-3.5 mr-2",
|
||||||
|
isStartingDevServer && "animate-pulse"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onPull(worktree)}
|
||||||
|
disabled={isPulling}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Download
|
||||||
|
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
||||||
|
/>
|
||||||
|
{isPulling ? "Pulling..." : "Pull"}
|
||||||
|
{behindCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{behindCount} behind
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onPush(worktree)}
|
||||||
|
disabled={isPushing || aheadCount === 0}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
||||||
|
/>
|
||||||
|
{isPushing ? "Pushing..." : "Push"}
|
||||||
|
{aheadCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
|
{aheadCount} ahead
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onOpenInEditor(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Open in {defaultEditorName}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
||||||
|
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Commit Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{(worktree.branch !== "main" || worktree.hasChanges) && (
|
||||||
|
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||||
|
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Create Pull Request
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{!worktree.isMain && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDeleteWorktree(worktree)}
|
||||||
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Delete Worktree
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{worktree.isMain ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectWorktree(worktree)}
|
||||||
|
disabled={isActivating}
|
||||||
|
title="Click to preview main"
|
||||||
|
>
|
||||||
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{isActivating && !isRunning && (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
{worktree.branch}
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
|
{worktree.changedFilesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<BranchSwitchDropdown
|
||||||
|
worktree={worktree}
|
||||||
|
isSelected={isSelected}
|
||||||
|
branches={branches}
|
||||||
|
filteredBranches={filteredBranches}
|
||||||
|
branchFilter={branchFilter}
|
||||||
|
isLoadingBranches={isLoadingBranches}
|
||||||
|
isSwitching={isSwitching}
|
||||||
|
onOpenChange={onBranchDropdownOpenChange}
|
||||||
|
onFilterChange={onBranchFilterChange}
|
||||||
|
onSwitchBranch={onSwitchBranch}
|
||||||
|
onCreateBranch={onCreateBranch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||||
|
!worktree.hasWorktree && !isSelected && "opacity-70"
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectWorktree(worktree)}
|
||||||
|
disabled={isActivating}
|
||||||
|
title={
|
||||||
|
worktree.hasWorktree
|
||||||
|
? "Click to switch to this worktree's branch"
|
||||||
|
: "Click to switch to this branch"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{isActivating && !isRunning && (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
)}
|
||||||
|
{worktree.branch}
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||||
|
{worktree.changedFilesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDevServerRunning && (
|
||||||
|
<Button
|
||||||
|
variant={isSelected ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||||
|
isSelected && "bg-primary text-primary-foreground",
|
||||||
|
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||||
|
"text-green-500"
|
||||||
|
)}
|
||||||
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
|
title={`Open dev server (port ${devServerInfo?.port})`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WorktreeActionsDropdown
|
||||||
|
worktree={worktree}
|
||||||
|
isSelected={isSelected}
|
||||||
|
defaultEditorName={defaultEditorName}
|
||||||
|
aheadCount={aheadCount}
|
||||||
|
behindCount={behindCount}
|
||||||
|
isPulling={isPulling}
|
||||||
|
isPushing={isPushing}
|
||||||
|
isStartingDevServer={isStartingDevServer}
|
||||||
|
isDevServerRunning={isDevServerRunning}
|
||||||
|
devServerInfo={devServerInfo}
|
||||||
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
|
onPull={onPull}
|
||||||
|
onPush={onPush}
|
||||||
|
onOpenInEditor={onOpenInEditor}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCreatePR={onCreatePR}
|
||||||
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
|
onStartDevServer={onStartDevServer}
|
||||||
|
onStopDevServer={onStopDevServer}
|
||||||
|
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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<BranchInfo[]>([]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
|
export function useDefaultEditor() {
|
||||||
|
const [defaultEditorName, setDefaultEditorName] = useState<string>("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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Map<string, DevServerInfo>>(
|
||||||
|
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<string, DevServerInfo>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<WorktreeInfo[]>([]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { WorktreePanel } from "./worktree-panel";
|
||||||
|
export type {
|
||||||
|
WorktreeInfo,
|
||||||
|
BranchInfo,
|
||||||
|
DevServerInfo,
|
||||||
|
FeatureInfo,
|
||||||
|
WorktreePanelProps,
|
||||||
|
} from "./types";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||||
|
<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-1 flex-wrap">
|
||||||
|
{worktrees.map((worktree) => (
|
||||||
|
<WorktreeTab
|
||||||
|
key={worktree.path}
|
||||||
|
worktree={worktree}
|
||||||
|
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}
|
||||||
|
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={fetchWorktrees}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Refresh worktrees"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user