mirror of
https://github.com/cline/cline.git
synced 2026-10-30 18:12:49 +08:00
feat: add worktree merging functionality and UI enhancements
Introduces a new feature for merging worktrees, allowing users to merge changes from a worktree's branch into the main branch with options to delete the worktree post-merge. Updates the WorktreesView component to include a merge modal, handling merge conflicts, and integrating with the WorktreeService for seamless operations. Additionally, enhances documentation to reflect these changes.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
@@ -78,6 +78,33 @@ Click the trash icon on any linked worktree to delete it. This removes the workt
|
||||
You cannot delete the main worktree. It's the primary repository where your `.git` directory lives.
|
||||
</Warning>
|
||||
|
||||
### Merging Worktrees
|
||||
|
||||
When you're done working in a worktree and ready to merge your changes back to the main branch:
|
||||
|
||||
1. Click the **merge icon** (git merge symbol) on any linked worktree
|
||||
2. Review the merge details in the confirmation modal
|
||||
3. Choose whether to delete the worktree after merging
|
||||
4. Click **Merge**
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="https://storage.googleapis.com/cline_public_images/docs/assets/worktrees-merge.png"
|
||||
alt="Merge worktree modal"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
#### Handling Merge Conflicts
|
||||
|
||||
If your branch has conflicts with the main branch, Cline will detect them and show you the conflicting files. You have two options:
|
||||
|
||||
1. **Ask Cline to Resolve & Merge** - Creates a new Cline task with a prompt asking Cline to resolve the conflicts, complete the merge, and clean up the worktree
|
||||
2. **Resolve Manually** - Close the modal and resolve conflicts yourself using your preferred Git tools
|
||||
|
||||
<Tip>
|
||||
The "Ask Cline to Resolve" option is particularly useful for complex conflicts. Cline will analyze the conflicting files and attempt to merge them intelligently based on the intent of both branches.
|
||||
</Tip>
|
||||
|
||||
## Worktreeinclude: Automatic File Copying {#worktreeinclude}
|
||||
|
||||
When you create a new worktree, it starts with a fresh checkout—no `node_modules`, no build artifacts, no IDE settings. This means you'd normally need to run `npm install` or similar setup commands.
|
||||
|
||||
@@ -36,6 +36,9 @@ service WorktreeService {
|
||||
|
||||
// Switches to a different branch in the current worktree (git checkout)
|
||||
rpc checkoutBranch(CheckoutBranchRequest) returns (WorktreeResult);
|
||||
|
||||
// Merges a worktree's branch into the target branch and optionally deletes the worktree
|
||||
rpc mergeWorktree(MergeWorktreeRequest) returns (MergeWorktreeResult);
|
||||
}
|
||||
|
||||
// Represents a single git worktree
|
||||
@@ -118,3 +121,21 @@ message CheckoutBranchRequest {
|
||||
string branch = 2; // Branch name to checkout
|
||||
}
|
||||
|
||||
// Request to merge a worktree's branch into target branch
|
||||
message MergeWorktreeRequest {
|
||||
Metadata metadata = 1;
|
||||
string worktree_path = 2; // Path of the worktree to merge
|
||||
string target_branch = 3; // Branch to merge into (e.g., "main")
|
||||
bool delete_after_merge = 4; // Whether to delete the worktree after successful merge
|
||||
}
|
||||
|
||||
// Result of merge operation
|
||||
message MergeWorktreeResult {
|
||||
bool success = 1;
|
||||
string message = 2; // Success or error message
|
||||
bool has_conflicts = 3; // Whether merge resulted in conflicts
|
||||
repeated string conflicting_files = 4; // List of files with conflicts
|
||||
string source_branch = 5; // The branch that was merged
|
||||
string target_branch = 6; // The branch merged into
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { MergeWorktreeRequest, MergeWorktreeResult } from "@shared/proto/cline/worktree"
|
||||
import { getWorkspacePath } from "@utils/path"
|
||||
import { exec } from "child_process"
|
||||
import { promisify } from "util"
|
||||
import { Controller } from ".."
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* Merges a worktree's branch into the target branch and optionally deletes the worktree
|
||||
* @param controller The controller instance
|
||||
* @param request The merge worktree request
|
||||
* @returns MergeWorktreeResult indicating success, failure, or conflicts
|
||||
*/
|
||||
export async function mergeWorktree(_controller: Controller, request: MergeWorktreeRequest): Promise<MergeWorktreeResult> {
|
||||
const cwd = await getWorkspacePath()
|
||||
if (!cwd) {
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: "No workspace folder found",
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
})
|
||||
}
|
||||
|
||||
const { worktreePath, targetBranch, deleteAfterMerge } = request
|
||||
|
||||
if (!worktreePath) {
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: "Worktree path is required",
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (!targetBranch) {
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: "Target branch is required",
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the branch name of the worktree
|
||||
let sourceBranch: string
|
||||
try {
|
||||
const { stdout } = await execAsync(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`)
|
||||
sourceBranch = stdout.trim()
|
||||
} catch {
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: "Failed to get branch name from worktree",
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (sourceBranch === "HEAD") {
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: "Cannot merge a detached HEAD worktree",
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for uncommitted changes in the worktree
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync(`git -C "${worktreePath}" status --porcelain`)
|
||||
if (statusOutput.trim()) {
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: `Worktree has uncommitted changes. Please commit or stash them first.`,
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// If status check fails, continue anyway
|
||||
}
|
||||
|
||||
// Checkout target branch in the main worktree
|
||||
try {
|
||||
await execAsync(`git checkout "${targetBranch}"`, { cwd })
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: `Failed to checkout target branch '${targetBranch}': ${errorMessage}`,
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
}
|
||||
|
||||
// Attempt the merge
|
||||
try {
|
||||
await execAsync(`git merge "${sourceBranch}" --no-edit`, { cwd })
|
||||
} catch (error) {
|
||||
// Check if it's a merge conflict
|
||||
try {
|
||||
const { stdout: conflictOutput } = await execAsync(`git diff --name-only --diff-filter=U`, { cwd })
|
||||
const conflictingFiles = conflictOutput
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((f) => f)
|
||||
|
||||
if (conflictingFiles.length > 0) {
|
||||
// Abort the merge so we don't leave the repo in a conflicted state
|
||||
try {
|
||||
await execAsync(`git merge --abort`, { cwd })
|
||||
} catch {
|
||||
// Ignore abort errors
|
||||
}
|
||||
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: `Merge conflict detected. ${conflictingFiles.length} file(s) have conflicts.`,
|
||||
hasConflicts: true,
|
||||
conflictingFiles,
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// If conflict check fails, return the original error
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: `Merge failed: ${errorMessage}`,
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete worktree if requested
|
||||
if (deleteAfterMerge) {
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd })
|
||||
} catch (error) {
|
||||
// Merge succeeded but deletion failed - still return success
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return MergeWorktreeResult.create({
|
||||
success: true,
|
||||
message: `Merged '${sourceBranch}' into '${targetBranch}' successfully, but failed to delete worktree: ${errorMessage}`,
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
}
|
||||
|
||||
// Optionally delete the branch too
|
||||
try {
|
||||
await execAsync(`git branch -d "${sourceBranch}"`, { cwd })
|
||||
} catch {
|
||||
// Branch deletion is optional, don't fail if it doesn't work
|
||||
}
|
||||
}
|
||||
|
||||
return MergeWorktreeResult.create({
|
||||
success: true,
|
||||
message: deleteAfterMerge
|
||||
? `Successfully merged '${sourceBranch}' into '${targetBranch}' and removed worktree`
|
||||
: `Successfully merged '${sourceBranch}' into '${targetBranch}'`,
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
sourceBranch,
|
||||
targetBranch,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return MergeWorktreeResult.create({
|
||||
success: false,
|
||||
message: `Unexpected error: ${errorMessage}`,
|
||||
hasConflicts: false,
|
||||
conflictingFiles: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { Worktree as WorktreeProto } from "@shared/proto/cline/worktree"
|
||||
import { NewTaskRequest } from "@shared/proto/cline/task"
|
||||
import type { MergeWorktreeResult, Worktree as WorktreeProto } from "@shared/proto/cline/worktree"
|
||||
import {
|
||||
CreateWorktreeIncludeRequest,
|
||||
CreateWorktreeRequest,
|
||||
DeleteWorktreeRequest,
|
||||
MergeWorktreeRequest,
|
||||
SwitchWorktreeRequest,
|
||||
} from "@shared/proto/cline/worktree"
|
||||
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { AlertCircle, Check, ExternalLink, FolderOpen, GitBranch, Loader2, Plus, Trash2, X } from "lucide-react"
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { AlertCircle, Check, ExternalLink, FolderOpen, GitBranch, GitMerge, Loader2, Plus, Trash2, X } from "lucide-react"
|
||||
import { memo, useCallback, useEffect, useState } from "react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { FileServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
|
||||
import { FileServiceClient, TaskServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
|
||||
import { getEnvironmentColor } from "@/utils/environmentColors"
|
||||
|
||||
type WorktreesViewProps = {
|
||||
@@ -32,6 +34,13 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => {
|
||||
const [deleteConfirmPath, setDeleteConfirmPath] = useState<string | null>(null)
|
||||
const [isLoadingDefaults, setIsLoadingDefaults] = useState(false)
|
||||
|
||||
// Merge worktree state
|
||||
const [mergeWorktree, setMergeWorktree] = useState<WorktreeProto | null>(null)
|
||||
const [isMerging, setIsMerging] = useState(false)
|
||||
const [mergeError, setMergeError] = useState<string | null>(null)
|
||||
const [mergeResult, setMergeResult] = useState<MergeWorktreeResult | null>(null)
|
||||
const [deleteAfterMerge, setDeleteAfterMerge] = useState(true)
|
||||
|
||||
// .worktreeinclude status
|
||||
const [hasWorktreeInclude, setHasWorktreeInclude] = useState(false)
|
||||
const [hasGitignore, setHasGitignore] = useState(false)
|
||||
@@ -102,11 +111,21 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => {
|
||||
}
|
||||
}, [gitignoreContent])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadWorktrees()
|
||||
loadWorktreeIncludeStatus()
|
||||
}, [loadWorktrees, loadWorktreeIncludeStatus])
|
||||
|
||||
// Poll for updates every 3 seconds while the view is open
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
loadWorktrees()
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [loadWorktrees])
|
||||
|
||||
// Fetch and apply suggested defaults for branch name and path
|
||||
const loadDefaults = useCallback(async () => {
|
||||
setIsLoadingDefaults(true)
|
||||
@@ -195,6 +214,77 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get the main branch name (first worktree's branch, usually main/master)
|
||||
const getMainBranch = useCallback(() => {
|
||||
if (worktrees.length === 0) return "main"
|
||||
return worktrees[0]?.branch || "main"
|
||||
}, [worktrees])
|
||||
|
||||
// Open merge modal for a worktree
|
||||
const openMergeModal = useCallback((worktree: WorktreeProto) => {
|
||||
setMergeWorktree(worktree)
|
||||
setMergeError(null)
|
||||
setMergeResult(null)
|
||||
setDeleteAfterMerge(true)
|
||||
}, [])
|
||||
|
||||
// Close merge modal
|
||||
const closeMergeModal = useCallback(() => {
|
||||
setMergeWorktree(null)
|
||||
setMergeError(null)
|
||||
setMergeResult(null)
|
||||
}, [])
|
||||
|
||||
// Handle merge
|
||||
const handleMergeWorktree = useCallback(async () => {
|
||||
if (!mergeWorktree) return
|
||||
|
||||
setIsMerging(true)
|
||||
setMergeError(null)
|
||||
setMergeResult(null)
|
||||
|
||||
try {
|
||||
const result = await WorktreeServiceClient.mergeWorktree(
|
||||
MergeWorktreeRequest.create({
|
||||
worktreePath: mergeWorktree.path,
|
||||
targetBranch: getMainBranch(),
|
||||
deleteAfterMerge,
|
||||
}),
|
||||
)
|
||||
|
||||
setMergeResult(result)
|
||||
|
||||
if (result.success) {
|
||||
// Reload worktrees to reflect changes
|
||||
await loadWorktrees()
|
||||
} else if (!result.hasConflicts) {
|
||||
setMergeError(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
setMergeError(err instanceof Error ? err.message : "Failed to merge worktree")
|
||||
} finally {
|
||||
setIsMerging(false)
|
||||
}
|
||||
}, [mergeWorktree, getMainBranch, deleteAfterMerge, loadWorktrees, closeMergeModal])
|
||||
|
||||
// Ask Cline to resolve conflicts
|
||||
const handleAskClineToResolve = useCallback(async () => {
|
||||
if (!mergeResult || !mergeResult.hasConflicts) return
|
||||
|
||||
const conflictList = mergeResult.conflictingFiles.join(", ")
|
||||
const prompt = `I tried to merge branch '${mergeResult.sourceBranch}' into '${mergeResult.targetBranch}' but there are merge conflicts in the following files: ${conflictList}
|
||||
|
||||
Please help me resolve these merge conflicts, then complete the merge, and delete the worktree at: ${mergeWorktree?.path}`
|
||||
|
||||
try {
|
||||
// Create a new task with this prompt
|
||||
await TaskServiceClient.newTask(NewTaskRequest.create({ text: prompt }))
|
||||
closeMergeModal()
|
||||
} catch (err) {
|
||||
setMergeError(err instanceof Error ? err.message : "Failed to create task for Cline")
|
||||
}
|
||||
}, [mergeResult, mergeWorktree, closeMergeModal])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col overflow-hidden">
|
||||
{/* Sticky Header with title and Done button */}
|
||||
@@ -383,6 +473,18 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => {
|
||||
)}
|
||||
{!worktree.isCurrent && !isMainWorktree(worktree) && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={() => openMergeModal(worktree)}>
|
||||
<GitMerge className="w-4 h-4 text-[var(--vscode-testing-iconPassed)]" />
|
||||
</VSCodeButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Merge into {getMainBranch()} and close
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{deleteConfirmPath === worktree.path ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<VSCodeButton
|
||||
@@ -531,6 +633,126 @@ const WorktreesView = ({ onDone }: WorktreesViewProps) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge Worktree Modal */}
|
||||
{mergeWorktree && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !isMerging) {
|
||||
closeMergeModal()
|
||||
}
|
||||
}}>
|
||||
<div className="bg-[var(--vscode-editor-background)] border border-[var(--vscode-panel-border)] rounded-lg p-5 w-[450px] max-w-[90vw] relative">
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute top-3 right-3 p-1 rounded hover:bg-[var(--vscode-toolbar-hoverBackground)] text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] transition-colors cursor-pointer"
|
||||
disabled={isMerging}
|
||||
onClick={closeMergeModal}
|
||||
type="button">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitMerge className="w-5 h-5 text-[var(--vscode-testing-iconPassed)]" />
|
||||
<h4 className="m-0 pr-6">Merge & Close Worktree</h4>
|
||||
</div>
|
||||
|
||||
{/* Success state */}
|
||||
{mergeResult?.success ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 p-3 rounded bg-[var(--vscode-testing-iconPassed)]/10 border border-[var(--vscode-testing-iconPassed)]">
|
||||
<Check className="w-5 h-5 text-[var(--vscode-testing-iconPassed)]" />
|
||||
<p className="text-sm m-0">{mergeResult.message}</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<VSCodeButton onClick={closeMergeModal}>Done</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
) : mergeResult?.hasConflicts ? (
|
||||
/* Conflict state */
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-2 p-3 rounded bg-[var(--vscode-inputValidation-warningBackground)] border border-[var(--vscode-inputValidation-warningBorder)]">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 text-[var(--vscode-inputValidation-warningForeground)] mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium m-0 mb-1">Merge conflicts detected</p>
|
||||
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0">
|
||||
The following files have conflicts that need to be resolved:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--vscode-textCodeBlock-background)] rounded p-3 max-h-32 overflow-y-auto">
|
||||
<ul className="m-0 pl-4 text-sm font-mono">
|
||||
{mergeResult.conflictingFiles.map((file) => (
|
||||
<li key={file}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<VSCodeButton onClick={handleAskClineToResolve} style={{ width: "100%" }}>
|
||||
<GitMerge className="w-4 h-4 mr-1" />
|
||||
Ask Cline to Resolve & Merge
|
||||
</VSCodeButton>
|
||||
<VSCodeButton appearance="secondary" onClick={closeMergeModal} style={{ width: "100%" }}>
|
||||
I'll Resolve Manually
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Default state - confirm merge */
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0">
|
||||
This will merge branch{" "}
|
||||
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
|
||||
{mergeWorktree.branch}
|
||||
</code>{" "}
|
||||
into{" "}
|
||||
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
|
||||
{getMainBranch()}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<VSCodeCheckbox
|
||||
checked={deleteAfterMerge}
|
||||
onChange={(e) => setDeleteAfterMerge((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span className="text-sm">Delete worktree after successful merge</span>
|
||||
</label>
|
||||
|
||||
{mergeError && (
|
||||
<div className="flex items-start gap-2 p-3 rounded bg-[var(--vscode-inputValidation-errorBackground)] border border-[var(--vscode-inputValidation-errorBorder)]">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 text-[var(--vscode-errorForeground)] mt-0.5" />
|
||||
<p className="text-sm text-[var(--vscode-errorForeground)] m-0">{mergeError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<VSCodeButton appearance="secondary" disabled={isMerging} onClick={closeMergeModal}>
|
||||
Cancel
|
||||
</VSCodeButton>
|
||||
<VSCodeButton disabled={isMerging} onClick={handleMergeWorktree}>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="w-4 h-4 mr-1" />
|
||||
Merge
|
||||
</>
|
||||
)}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user