Compare commits

...

54 Commits

Author SHA1 Message Date
Saoud Rizwan 740317e147 fix: clean up shadow git checkpoint data when deleting worktrees 2026-01-12 17:51:33 -08:00
Saoud Rizwan 8184d0376a Revert "fix(e2e): increase getSidebar timeout for slower macOS CI runners"
This reverts commit 19479a019c.
2026-01-12 17:06:25 -08:00
Saoud Rizwan 19479a019c fix(e2e): increase getSidebar timeout for slower macOS CI runners 2026-01-12 17:00:35 -08:00
Saoud Rizwan 4d7646a3e1 Merge branch 'main' into saoudrizwan/worktree-view 2026-01-12 16:27:13 -08:00
Saoud Rizwan 2e81610db5 Fix merge conflict artifacts 2026-01-12 16:16:44 -08:00
Saoud Rizwan f2a19928bb Fix merge conflict artifacts
Removed old banner service initialization and fetching logic.
2026-01-12 15:21:36 -08:00
Saoud Rizwan 3a4efce342 Merge branch 'main' into saoudrizwan/worktree-view 2026-01-12 15:19:07 -08:00
Saoud Rizwan b93c908028 Merge branch 'main' into saoudrizwan/worktree-view 2026-01-12 10:30:13 -08:00
Saoud Rizwan ed52d936f8 fix: replace DangerButton with Button variant="danger"
DangerButton component was removed from main. Use the standard
Button component with variant="danger" instead.
2026-01-10 00:38:05 -08:00
Saoud Rizwan ab4cdfe7d4 feat: add telemetry for worktree feature usage
Track worktree feature engagement:
- worktree.view_opened: when users open worktrees view (with source)
- worktree.created: when worktrees are created (with total count)
- worktree.merge_attempted: when merge is attempted (success/conflicts)
2026-01-10 00:29:11 -08:00
Saoud Rizwan 266832ae69 feat: add worktree-exp feature flag for worktrees feature
Put the worktrees feature behind a feature flag (worktree-exp) that
defaults to false. When enabled, users can toggle the feature in
settings. The home page worktree section only shows when both the
feature flag is enabled and the user setting is on.
2026-01-10 00:29:10 -08:00
Saoud Rizwan b42a39333f fix(ui): temporarily disable new worktree button, add tooltip to current worktree
Comment out "New Worktree Window" button until worktree creation is stable.
Add tooltip to current worktree info with "View and manage git worktrees.
Great for running parallel Cline tasks."
2026-01-10 00:27:44 -08:00
Saoud Rizwan cf71c32053 fix: remove worktrees menu button from sidebar
Remove the worktrees button from the VS Code extension menu bar.
2026-01-10 00:27:44 -08:00
Saoud Rizwan 43b58cc734 fix(worktree): add option to delete branch when deleting worktree
- Update delete modal copy to accurately describe behavior
- Add checkbox to optionally delete branch (unchecked by default)
- Show warning about unpushed commits when checkbox is checked
- Update proto, handler, and UI to support delete_branch option
2026-01-10 00:27:44 -08:00
Saoud Rizwan 6f80b3298b fix(e2e): update auth test to use aria-label instead of removed class 2026-01-10 00:27:44 -08:00
Saoud Rizwan 029bdd3c6d fix(worktree): wrap path instead of truncating 2026-01-10 00:27:44 -08:00
Saoud Rizwan dbbf8ef56f fix(worktree): improve non-git repo message 2026-01-10 00:27:43 -08:00
Saoud Rizwan 52b4a0879e fix(e2e): update tests to match renamed Recent header 2026-01-10 00:27:43 -08:00
Saoud Rizwan db50a262cf fix(worktree): UI polish
- Change New Worktree Window tooltip to show above button instead of below
- Add break-all to branch names for long branch text wrapping
- Simplify merge button tooltip and modal title (remove 'and close')
2026-01-10 00:27:43 -08:00
Saoud Rizwan 7497266ba8 feat(worktree): add worktrees button to sidebar header
Adds a git-branch icon button to the Cline sidebar header for quick
access to the Worktrees view. Also updates docs to mention this new
entry point and adds a typical workflow section.
2026-01-10 00:27:43 -08:00
Saoud Rizwan e08e75e9f7 fix(worktree): rename Main badge to Primary 2026-01-10 00:27:43 -08:00
Saoud Rizwan a623f2097e docs(worktrees): update for new UI features
- Document quick launch button on home screen
- Update getting started to reflect auto-filled defaults
- Document Cline auto-open behavior when switching worktrees
- Update delete section with confirmation modal details
- Add limitations section for multi-root and subfolder workspaces
2026-01-10 00:27:43 -08:00
Saoud Rizwan 0d513b2df3 fix(worktree): improve .worktreeinclude warning styling 2026-01-10 00:27:43 -08:00
Saoud Rizwan 60026520a9 refactor(ui): remove worktrees button from title bar 2026-01-10 00:27:43 -08:00
Saoud Rizwan d5630ca2b9 feat(worktree): add delete confirmation modal 2026-01-10 00:27:43 -08:00
Saoud Rizwan c22c672370 docs: add state management patterns to CLAUDE.md
Document how to add new global state keys and the pattern for
reading state at extension startup (bypassing StateManager cache).
2026-01-10 00:27:43 -08:00
Saoud Rizwan bb11715fd1 chore: ignore .worktrees directory and CLAUDE.local.md 2026-01-10 00:27:43 -08:00
Saoud Rizwan 5aed40b18f fix(worktree): improve quick launch UX
- Make current branch/path clickable to navigate to worktrees view
- Fix word wrap for long branch names and paths
- Show .worktreeinclude warning in create modal with learn more link
2026-01-10 00:27:42 -08:00
Saoud Rizwan 3a90e4aeda feat(worktree): auto-open Cline sidebar on worktree launch
When switching to a worktree via quick launch button, automatically
open the Cline sidebar in the new/reloaded window. Uses globalState
to pass the target path between windows, reading directly from
context.globalState at startup to bypass StateManager cache timing.
2026-01-10 00:27:42 -08:00
Saoud Rizwan cfed851601 refactor(ui): polish home screen and worktree modal
- Update HistoryPreview: rename to Recent, move View All to header with chevron
- Remove logo pop-in animation from HomeHeader
- Remove info icon tooltip from What can I do for you heading
- Remove fade-in animations from WelcomeSection
- Move worktree button below history preview with more spacing
- Update CreateWorktreeModal copy and reduce spacing between fields
- Add Current label with branch icon above path in worktree info
2026-01-10 00:27:42 -08:00
Saoud Rizwan db362c64a1 feat(worktree): add quick launch button on home page
Extract CreateWorktreeModal as reusable component with openAfterCreate prop.
Add New Worktree Window button to WelcomeSection that creates a worktree
and opens it in a new window. Shows current worktree branch and path info.
2026-01-10 00:27:42 -08:00
Saoud Rizwan 552169c7a0 feat(worktree): add clear buttons to create modal input fields 2026-01-10 00:27:42 -08:00
Saoud Rizwan 1a484d7040 fix(worktree): add cursor pointer to create modal close button 2026-01-10 00:27:42 -08:00
Saoud Rizwan b32d4ca21c refactor(worktree): auto-fill defaults when create modal opens 2026-01-10 00:27:42 -08:00
Saoud Rizwan c4f2c8abf1 feat(worktree): add multi-root and subfolder workspace warnings
- Detect and warn when multiple workspace folders are open (worktrees not supported in multi-root)
- Detect and warn when a subfolder of a git repo is open instead of the root, showing the actual git root path
- Fix UI overflow on narrow widths by using min-h-32 instead of fixed h-32
2026-01-10 00:27:42 -08:00
Saoud Rizwan ae564467e7 perf(worktree): optimize file copying for .worktreeinclude
Address performance feedback - worktree creation was taking ~20 seconds
for large directories like node_modules (50k+ files).

Optimizations:
- Use native `cp -r` for entire directories (10-20x faster)
- Parallelize file copying with batches of 100 (5-10x faster)
- Parallelize directory traversal with Promise.all

The old implementation copied files sequentially which caused the
bottleneck. Now directories like node_modules are copied using the
system's native cp command, and individual files are copied in
parallel batches.

Also adds unit tests for the worktree-include module.
2026-01-10 00:27:42 -08:00
Juan Pablo 6e75cdb578 Fixes docs not rendering 2026-01-10 00:27:42 -08:00
Saoud Rizwan f03ba8bfbb Update webview-ui/src/components/worktrees/WorktreesView.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-10 00:27:42 -08:00
Saoud Rizwan ef2d04e1da Update docs/features/worktrees.mdx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-10 00:27:42 -08:00
Saoud Rizwan 6dba8a5a97 fix: simplify merge request button in WorktreesView component
Removes the "Merge" text from the button label in the WorktreesView component, streamlining the user interface. This change focuses on clarity by allowing the button to simply prompt users to "Ask Cline to Resolve," enhancing the overall user experience during merge conflict resolution.
2026-01-10 00:27:42 -08:00
Saoud Rizwan 90ddc14ce6 feat: add symlink functionality for .worktreeinclude to sync with .gitignore
Introduces a new section in the documentation explaining how to create a symlink from .gitignore to .worktreeinclude. This allows users to automatically sync patterns between the two files, simplifying worktree setup. Additionally, includes a note for users needing different patterns to create a regular .worktreeinclude file instead.
2026-01-10 00:27:42 -08:00
Saoud Rizwan 8d5e081b6e fix: improve tooltip functionality and clean up WorktreesView component
Enhances the tooltip for the current worktree indicator to provide additional context for users. Additionally, removes the display of commit hashes in the worktree list to streamline the UI, improving overall clarity and user experience.
2026-01-10 00:27:41 -08:00
Saoud Rizwan c745ba2898 feat: update merge conflict display and task creation flow in WorktreesView
Enhances the merge conflict notification by providing a clearer list of conflicting files, including a summary for additional files. Additionally, modifies the task creation flow to close the worktrees view upon task creation, improving user experience during the merge process.
2026-01-10 00:27:41 -08:00
Saoud Rizwan b342aeb4ca refactor: optimize worktree loading to prevent UI flickering
Enhances the loadWorktrees function in WorktreesView to only update the component's state if the fetched data has changed, reducing unnecessary re-renders and preventing flickering. This change improves the user experience by providing a smoother interface when loading worktrees. Additionally, simplifies the polling mechanism for updates.
2026-01-10 00:27:41 -08:00
Saoud Rizwan d07ab5fba4 feat: enhance mergeWorktree functionality to check target worktree status
Implements a check for uncommitted changes in the target worktree before merging, ensuring that users are informed if the target branch has uncommitted changes. This update improves error handling and user feedback during the merge process by verifying the state of both the source and target worktrees. Additionally, it integrates the listWorktrees utility to identify the correct worktree for the target branch.
2026-01-10 00:27:41 -08:00
Saoud Rizwan 63d622d1f7 refactor: replace exec with simple-git for worktree operations
Refactors the worktree management code to utilize the simple-git library instead of child_process exec for executing Git commands. This change enhances code readability and maintainability by providing a more streamlined interface for Git operations in the checkoutBranch, mergeWorktree, and git-worktree modules. Additionally, it improves error handling and reduces the complexity of command execution.
2026-01-10 00:27:41 -08:00
Saoud Rizwan 2ebecdb405 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.
2026-01-10 00:27:41 -08:00
Saoud Rizwan afd8620def feat: update documentation links in WorktreesView component
Modifies the documentation links in the WorktreesView component to point to the correct feature sections, ensuring users have access to accurate resources. Additionally, adds the "features/worktrees" entry in the documentation JSON for better organization.
2026-01-10 00:27:41 -08:00
Saoud Rizwan bd4434a8ed feat: reposition New Worktree button for improved UI layout
Moves the New Worktree button to a fixed position at the bottom of the WorktreesView component, enhancing accessibility and user experience. The button is now styled to occupy the full width, ensuring better visibility and interaction within the UI.
2026-01-10 00:27:41 -08:00
Saoud Rizwan 9a9b7523f9 feat: add checkout branch functionality to WorktreeService and UI
Introduces a new RPC method `checkoutBranch` to the WorktreeService for switching branches within the current worktree. Updates the WorktreesView component to support this functionality, enhancing user experience by allowing seamless branch switching. Additionally, refines the UI layout for better responsiveness and improves loading/error state handling.
2026-01-10 00:27:41 -08:00
Saoud Rizwan f52ca3444e feat: implement .worktreeinclude file management in WorktreeService
Adds new RPC methods to the WorktreeService for managing .worktreeinclude files, including retrieving the status of the file and creating it with specified content. Updates the WorktreesView component to handle the creation and status checking of .worktreeinclude, enhancing user experience by automating file management for worktrees. Additionally, modifies the UI to reflect these changes, including updated tooltips and improved error handling.
2026-01-10 00:27:41 -08:00
Saoud Rizwan 2e86c8afdc feat: add worktree defaults retrieval to WorktreeService and UI
Introduces a new RPC method `getWorktreeDefaults` to fetch suggested defaults for branch names and paths when creating new worktrees. Updates the WorktreesView component to utilize this method, enhancing the user experience by auto-generating branch names and paths. Additionally, integrates tooltips for improved UI interactions and adds a close button to the worktree creation modal.
2026-01-10 00:27:40 -08:00
Saoud Rizwan abf20d196f feat: enhance worktree creation error handling in WorktreesView
Adds error state management for worktree creation in the WorktreesView component. Introduces a new state variable to capture and display error messages when worktree creation fails, improving user feedback during the process.
2026-01-10 00:27:40 -08:00
Saoud Rizwan 1385b0d24c feat: add git worktree management UI
Adds a worktrees view accessible from the navbar that allows users to:
- View all existing worktrees with their branch and path info
- Create new worktrees from local/remote branches or new branches
- Switch between worktrees (opens folder in VS Code)
- Delete worktrees with confirmation

Implementation includes:
- New proto definitions for worktree service RPCs
- Controller handlers for CRUD operations
- Git worktree utility functions
- WorktreesView React component with full UI
- Navbar integration with worktree button
2026-01-10 00:27:40 -08:00
51 changed files with 3343 additions and 120 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Add git worktree management UI for running parallel Cline sessions
+2
View File
@@ -1,6 +1,8 @@
# Default
.vscode/**
.vscode-test/**
.worktrees/**
CLAUDE.local.md
out/
dist-standalone/
node_modules/
+31
View File
@@ -17,6 +17,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
## Miscellaneous
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
@@ -100,6 +101,36 @@ Three places need updates:
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## Adding New Global State Keys
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
**Required steps:**
1. **Type definition** in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. **Read from globalState** in `src/core/storage/utils/state-helpers.ts`:
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
- Add to the return object: `myKey: myKey ?? defaultValue,`
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
**Common mistake:** Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
**Exception: State needed immediately at extension startup (before cache is ready)**
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
```
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
+1
View File
@@ -177,6 +177,7 @@
"features/tasks/task-management"
]
},
"features/worktrees",
"features/yolo-mode"
]
},
+270
View File
@@ -0,0 +1,270 @@
---
title: "Worktrees"
sidebarTitle: "Worktrees"
---
Worktrees let you work on multiple branches simultaneously, each in its own folder. This enables Cline to work on tasks in parallel across separate VS Code windows, or lets Cline work independently while you continue coding in your main workspace.
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/worktrees-overview.png"
alt="Worktrees view showing multiple linked worktrees"
/>
</Frame>
## What Are Git Worktrees?
A Git worktree is a linked copy of your repository in a separate folder, checked out to a specific branch. All worktrees share the same Git history and `.git` directory, but each has its own working directory with different code checked out.
Key concepts:
- **Main worktree**: Your original repository folder where the `.git` directory lives
- **Linked worktrees**: Additional folders you create, each checked out to a different branch
- **Shared history**: All worktrees share commits, branches, and Git configuration
<Tip>
Unlike regular branch switching, worktrees let you have multiple branches checked out at the same time in different folders. This means you can have VS Code windows open for different features simultaneously.
</Tip>
## Why Use Worktrees with Cline?
Worktrees solve a common problem: **Cline takes over your VS Code window while working on a task**. With worktrees, you can:
1. **Run Cline in parallel** - Have Cline work on multiple tasks simultaneously, each in its own worktree and VS Code window
2. **Keep working while Cline works** - Let Cline handle a task in a separate worktree while you continue coding in your main workspace
3. **Isolate experimental changes** - Test risky changes in a worktree without affecting your main branch
4. **Quick context switching** - Jump between features without stashing or committing incomplete work
## Getting Started
### Quick Launch (Recommended)
The fastest way to start using worktrees is the **New Worktree Window** button on Cline's home screen:
1. Click **New Worktree Window** on the home screen
2. Enter a branch name and folder path (defaults are auto-filled)
3. Click **Create & Open**
A new VS Code window opens with your worktree, and Cline automatically opens ready to work.
<Tip>
The home screen also shows your current branch and worktree path. Click it to open the full Worktrees view.
</Tip>
### Full Worktrees View
For more control, open the full Worktrees view by clicking the **Worktrees** button in the Cline sidebar header, or by clicking your current branch info on the home screen:
<Steps>
<Step title="Create a New Worktree">
Click **New Worktree** at the bottom of the view. Enter a branch name and path (defaults are auto-filled).
</Step>
<Step title="Open in New Window">
Once created, click the **Open in new window** button to open the worktree in a separate VS Code window. Cline will automatically open in the new window.
</Step>
</Steps>
## Typical Workflow
Here's how a typical worktree session looks:
<Steps>
<Step title="Create a new worktree">
Click **New Worktree Window** on the home screen or use the Worktrees view. A new VS Code window opens with Cline ready to go.
</Step>
<Step title="Do your work">
Work on your feature or let Cline handle a task. Make commits as you go.
</Step>
<Step title="Close the worktree window">
When you're done, close the worktree's VS Code window.
</Step>
<Step title="Merge from your primary worktree">
Back in your main VS Code window, open the Worktrees view and click the **merge button** on the worktree you just worked in. This merges the branch and optionally deletes the worktree.
</Step>
</Steps>
## Managing Worktrees
### Viewing Worktrees
The Worktrees view shows all worktrees for your repository:
- **Current**: The worktree you're currently in (highlighted)
- **Main**: The primary worktree where your `.git` directory lives (cannot be deleted)
- **Locked**: Worktrees that are locked to prevent accidental deletion
### Opening Worktrees
Each worktree has two open options:
- **Open in current window**: Replace your current workspace with the worktree
- **Open in new window**: Open the worktree in a separate VS Code window (recommended for parallel Cline sessions)
Either way, Cline automatically opens in the new workspace, ready to start a task.
### Deleting Worktrees
Click the trash icon on any linked worktree to delete it. A confirmation dialog will show you exactly what will be deleted:
- The branch itself
- All project files in the worktree folder
<Warning>
Deleting a worktree permanently removes the branch and all files in that folder. Make sure any important changes are committed and pushed first.
</Warning>
<Note>
You cannot delete the main worktree. It's the primary repository where your `.git` directory lives.
</Note>
### 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
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.
The `.worktreeinclude` file solves this by automatically copying specified files to new worktrees.
### How It Works
1. Create a `.worktreeinclude` file in your repository root
2. Add glob patterns for files you want copied (using `.gitignore` syntax)
3. When Cline creates a new worktree, files matching **both** `.worktreeinclude` **and** `.gitignore` are copied automatically
<Note>
Only files that are both matched by `.worktreeinclude` AND listed in `.gitignore` are copied. This prevents accidentally duplicating tracked files.
</Note>
### Example `.worktreeinclude`
```gitignore
# Copy node_modules to avoid npm install
node_modules/
# Copy IDE settings
.vscode/
# Copy build cache
.next/
dist/
# Copy environment files (if gitignored)
.env.local
```
### Creating a `.worktreeinclude` File
The Worktrees view will show a tip if you don't have a `.worktreeinclude` file. If you have a `.gitignore`, you can click **Create from .gitignore** to create one pre-filled with your gitignore contents. Then edit it to keep only the patterns you want copied.
<Tip>
For most JavaScript/TypeScript projects, just including `node_modules/` in your `.worktreeinclude` saves significant setup time for each new worktree.
</Tip>
### Pro Tip: Symlink to .gitignore
Since `.gitignore` usually contains most of the files you'd want copied to new worktrees (dependencies, environment files, build caches, etc.), you can create a symlink so they stay in sync automatically:
```bash
# In your repository root
ln -s .gitignore .worktreeinclude
```
Now whenever you update your `.gitignore`, your `.worktreeinclude` will have the same patterns. This is especially useful for projects where gitignored files are exactly what you want copied—no need to maintain two separate files.
<Note>
If you need different patterns than your `.gitignore`, create a regular `.worktreeinclude` file instead of a symlink.
</Note>
## Best Practices
<AccordionGroup>
<Accordion title="For Parallel Cline Sessions">
1. **Create purpose-specific worktrees** - Name branches clearly (e.g., `cline/refactor-auth`, `cline/add-tests`)
2. **Open in new windows** - Always use "Open in new window" for true parallelism
3. **Use .worktreeinclude** - Set up automatic file copying to reduce setup time
</Accordion>
<Accordion title="For Solo Development">
1. **Keep your main branch clean** - Use worktrees for experimental or risky changes
2. **Quick feature switches** - Instead of stashing, create a worktree for interruptions
3. **Review in isolation** - Create worktrees to review PRs without disrupting your work
</Accordion>
<Accordion title="Worktree Hygiene">
1. **Delete unused worktrees** - Remove worktrees when their branches are merged
2. **Use meaningful names** - Branch names should indicate the worktree's purpose
3. **Check for stale worktrees** - Periodically review and clean up old worktrees
</Accordion>
</AccordionGroup>
## Limitations
Worktrees are not available in certain workspace configurations:
- **Multi-root workspaces**: If you have multiple folders open in VS Code, worktrees are disabled. Open a single repository folder instead.
- **Subfolder of a repository**: If you've opened a subfolder within a Git repository (not the root), worktrees are disabled. Open the repository root folder instead.
The Worktrees view will display a message explaining the limitation if either of these applies to your workspace.
## Troubleshooting
<AccordionGroup>
<Accordion title="Branch already exists error">
Git doesn't allow the same branch to be checked out in multiple worktrees. Either:
- Use a different branch name
- Delete the existing worktree using that branch
</Accordion>
<Accordion title="Worktree folder already exists">
The path you specified already contains files. Choose a different path or delete the existing folder first.
</Accordion>
<Accordion title="Can't delete worktree">
If a worktree is locked, you'll need to unlock it first using `git worktree unlock <path>` in the terminal. If the worktree has uncommitted changes, you may need to use force delete.
</Accordion>
<Accordion title=".worktreeinclude files not copying">
Make sure the files you want copied are:
1. Listed in your `.worktreeinclude` file
2. Also listed in your `.gitignore` (only gitignored files are copied)
3. Actually exist in your current worktree
</Accordion>
</AccordionGroup>
## Technical Details
<AccordionGroup>
<Accordion title="How Worktrees Work Internally">
- Worktrees are a native Git feature (`git worktree` command)
- All worktrees share the same `.git` directory and object database
- Each worktree has its own index, working directory, and HEAD
- Worktree list is stored in `.git/worktrees/`
</Accordion>
<Accordion title="Storage Considerations">
- Each worktree contains a full checkout of the repository
- `.worktreeinclude` can significantly increase worktree size (e.g., copying `node_modules`)
- Consider your disk space when creating many worktrees
</Accordion>
<Accordion title="Relationship with Checkpoints">
Worktrees are separate from Cline's [checkpoint system](/features/checkpoints). Each worktree has its own checkpoint history. Checkpoints track changes within a single worktree, while worktrees let you work across multiple branches simultaneously.
</Accordion>
</AccordionGroup>
Worktrees unlock true parallel development with Cline. Create a worktree, open it in a new window, and let Cline work independently while you continue coding!
+2 -2
View File
@@ -270,12 +270,12 @@
},
{
"command": "cline.accountButtonClicked",
"group": "navigation@5",
"group": "navigation@4",
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.settingsButtonClicked",
"group": "navigation@6",
"group": "navigation@5",
"when": "view == claude-dev.SidebarProvider"
}
],
+2
View File
@@ -232,6 +232,7 @@ message Settings {
optional bool azure_identity = 136;
optional bool skills_enabled = 137;
optional bool opt_out_of_remote_config = 138;
optional bool worktrees_enabled = 139;
}
message DictationSettings {
@@ -376,6 +377,7 @@ message UpdateSettingsRequest {
optional string oca_reasoning_effort = 37;
optional bool skills_enabled = 38;
optional bool opt_out_of_remote_config = 39;
optional bool worktrees_enabled = 40;
}
message UpdateTerminalConnectionTimeoutRequest {
+3
View File
@@ -256,6 +256,9 @@ service UiService {
// Subscribe to settings button clicked events
rpc subscribeToSettingsButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to worktrees button clicked events
rpc subscribeToWorktreesButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to partial message updates (streaming Cline messages as they're built)
rpc subscribeToPartialMessage(EmptyRequest) returns (stream ClineMessage);
+153
View File
@@ -0,0 +1,153 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
// Service for git worktree operations
service WorktreeService {
// Lists all worktrees in the current repository
rpc listWorktrees(EmptyRequest) returns (WorktreeList);
// Creates a new worktree
rpc createWorktree(CreateWorktreeRequest) returns (WorktreeResult);
// Deletes an existing worktree
rpc deleteWorktree(DeleteWorktreeRequest) returns (WorktreeResult);
// Switches to a different worktree (opens in VS Code)
rpc switchWorktree(SwitchWorktreeRequest) returns (WorktreeResult);
// Gets available branches for creating worktrees
rpc getAvailableBranches(EmptyRequest) returns (BranchList);
// Gets suggested defaults for creating a new worktree (auto-generated branch name and path)
rpc getWorktreeDefaults(EmptyRequest) returns (WorktreeDefaults);
// Gets the status of .worktreeinclude file and .gitignore contents for creating one
rpc getWorktreeIncludeStatus(EmptyRequest) returns (WorktreeIncludeStatus);
// Creates a .worktreeinclude file with the provided content
rpc createWorktreeInclude(CreateWorktreeIncludeRequest) returns (WorktreeResult);
// 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);
// Tracks when the worktrees view is opened (for telemetry)
rpc trackWorktreeViewOpened(TrackWorktreeViewOpenedRequest) returns (Empty);
}
// Represents a single git worktree
message Worktree {
string path = 1; // Absolute path to the worktree
string branch = 2; // Branch name (empty if detached)
string commit_hash = 3; // Current commit hash
bool is_current = 4; // Whether this is the current worktree
bool is_bare = 5; // Whether this is the bare repository
bool is_detached = 6; // Whether HEAD is detached
bool is_locked = 7; // Whether the worktree is locked
optional string lock_reason = 8; // Reason for lock if locked
}
// Response containing list of worktrees
message WorktreeList {
repeated Worktree worktrees = 1;
bool is_git_repo = 2; // Whether the current workspace is a git repo
string error = 3; // Error message if any
bool is_multi_root = 4; // Whether multiple workspace folders are open (worktrees not supported)
bool is_subfolder = 5; // Whether workspace is a subfolder of a git repo (not at repo root)
string git_root_path = 6; // The actual git root path (useful when is_subfolder is true)
}
// Request to create a new worktree
message CreateWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path for the new worktree
optional string branch = 3; // Branch name (creates new if doesn't exist)
optional string base_branch = 4; // Base branch for new branch creation
bool create_new_branch = 5; // Whether to create a new branch
}
// Request to delete a worktree
message DeleteWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path of the worktree to delete
bool force = 3; // Force deletion even if dirty
bool delete_branch = 4; // Also delete the branch
string branch_name = 5; // Name of the branch to delete (required if delete_branch is true)
}
// Request to switch to a worktree
message SwitchWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path of the worktree to switch to
bool new_window = 3; // Whether to open in a new window
}
// Result of worktree operations
message WorktreeResult {
bool success = 1;
string message = 2; // Success or error message
optional Worktree worktree = 3; // The affected worktree (for create)
}
// List of available branches
message BranchList {
repeated string local_branches = 1;
repeated string remote_branches = 2;
string current_branch = 3;
}
// Suggested defaults for creating a new worktree
message WorktreeDefaults {
string suggested_branch = 1; // Auto-generated branch name like "worktree/cline-abc12"
string suggested_path = 2; // Path in Documents/Cline/Worktrees/<project>-<suffix>
}
// Status of .worktreeinclude file
message WorktreeIncludeStatus {
bool exists = 1; // Whether .worktreeinclude exists
string gitignore_content = 2; // Content of .gitignore (for prefilling)
bool has_gitignore = 3; // Whether .gitignore exists
}
// Request to create .worktreeinclude file
message CreateWorktreeIncludeRequest {
string content = 1; // Content for the .worktreeinclude file
}
// Request to checkout a branch in the current worktree
message CheckoutBranchRequest {
Metadata metadata = 1;
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
}
// Request to track worktree view opened (for telemetry)
message TrackWorktreeViewOpenedRequest {
string source = 1; // Where the view was opened from: "home_page" or "menu_bar"
}
+13
View File
@@ -35,6 +35,9 @@ service WorkspaceService {
// Executes a command in a new terminal
rpc executeCommandInTerminal(ExecuteCommandInTerminalRequest) returns (ExecuteCommandInTerminalResponse);
// Opens a folder/workspace in the IDE
rpc openFolder(OpenFolderRequest) returns (OpenFolderResponse);
}
message GetWorkspacePathsRequest {
@@ -107,3 +110,13 @@ message ExecuteCommandInTerminalRequest {
message ExecuteCommandInTerminalResponse {
bool success = 1; // Whether the command was successfully sent to the terminal
}
// Request to open a folder/workspace
message OpenFolderRequest {
string path = 1; // The path to the folder to open
bool new_window = 2; // Whether to open in a new window
}
message OpenFolderResponse {
bool success = 1;
}
+37
View File
@@ -24,6 +24,7 @@ import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
/**
* Performs intialization for Cline that is common to all platforms.
*
@@ -76,6 +77,9 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
await showVersionUpdateAnnouncement(context)
// Check if this workspace was opened from worktree quick launch
await checkWorktreeAutoOpen(context)
// Initialize banner service and fetch banners from the API.
BannerService.initialize(webview.controller).getActiveBanners(true)
@@ -116,6 +120,39 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
}
}
/**
* Checks if this workspace was opened from the worktree quick launch button.
* If so, opens the Cline sidebar and clears the state.
*/
async function checkWorktreeAutoOpen(context: vscode.ExtensionContext): Promise<void> {
try {
// Read directly from globalState (not StateManager cache) since this may have been
// set by another window right before this one opened
const worktreeAutoOpenPath = context.globalState.get<string>("worktreeAutoOpenPath")
if (!worktreeAutoOpenPath) {
return
}
// Get current workspace path
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
if (workspacePaths.length === 0) {
return
}
const currentPath = workspacePaths[0]
// Check if current workspace matches the worktree path
if (arePathsEqual(currentPath, worktreeAutoOpenPath)) {
// Clear the state first to prevent re-triggering
await context.globalState.update("worktreeAutoOpenPath", undefined)
// Open the Cline sidebar
await HostProvider.workspace.openClineSidebarPanel({})
}
} catch (error) {
Logger.error("Error checking worktree auto-open", error)
}
}
/**
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
+4
View File
@@ -953,6 +953,10 @@ export class Controller {
user: this.stateManager.getGlobalSettingsKey("clineWebToolsEnabled"),
featureFlag: featureFlagsService.getWebtoolsEnabled(),
},
worktreesEnabled: {
user: this.stateManager.getGlobalSettingsKey("worktreesEnabled"),
featureFlag: featureFlagsService.getWorktreesEnabled(),
},
hooksEnabled: this.stateManager.getGlobalSettingsKey("hooksEnabled"),
lastDismissedInfoBannerVersion,
lastDismissedModelBannerVersion,
@@ -195,6 +195,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("clineWebToolsEnabled", request.clineWebToolsEnabled)
}
// Update worktrees setting
if (request.worktreesEnabled !== undefined) {
controller.stateManager.setGlobalState("worktreesEnabled", request.worktreesEnabled)
}
if (request.dictationSettings !== undefined) {
// Convert from protobuf format (snake_case) to TypeScript format (camelCase)
const dictationSettings = {
@@ -65,6 +65,7 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
yoloModeToggled,
useAutoCondense,
clineWebToolsEnabled,
worktreesEnabled,
focusChainSettings,
browserSettings,
defaultTerminalProfile,
@@ -167,6 +168,11 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
controller.stateManager.setGlobalState("clineWebToolsEnabled", clineWebToolsEnabled)
}
// Update worktrees setting
if (worktreesEnabled !== undefined) {
controller.stateManager.setGlobalState("worktreesEnabled", worktreesEnabled)
}
// Update focus chain settings (requires telemetry on state change)
if (focusChainSettings !== undefined) {
const currentSettings = controller.stateManager.getGlobalSettingsKey("focusChainSettings")
@@ -0,0 +1,60 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
// Keep track of active worktrees button clicked subscriptions
const activeWorktreesButtonClickedSubscriptions = new Set<StreamingResponseHandler<Empty>>()
/**
* Subscribe to worktrees button clicked events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToWorktreesButtonClicked(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<Empty>,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeWorktreesButtonClickedSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeWorktreesButtonClickedSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(
requestId,
cleanup,
{ type: "worktrees_button_clicked_subscription" },
responseStream,
)
}
}
/**
* Send a worktrees button clicked event to all active subscribers
*/
export async function sendWorktreesButtonClickedEvent(): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeWorktreesButtonClickedSubscriptions).map(async (responseStream) => {
try {
const event = Empty.create({})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending worktrees button clicked event:", error)
// Remove the subscription if there was an error
activeWorktreesButtonClickedSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
@@ -0,0 +1,45 @@
import { CheckoutBranchRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import simpleGit from "simple-git"
import { Controller } from ".."
/**
* Checks out a branch in the current worktree (git checkout)
* @param controller The controller instance
* @param request The checkout branch request containing the branch name
* @returns WorktreeResult indicating success or failure
*/
export async function checkoutBranch(_controller: Controller, request: CheckoutBranchRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder found",
})
}
const { branch } = request
if (!branch) {
return WorktreeResult.create({
success: false,
message: "Branch name is required",
})
}
try {
const git = simpleGit(cwd)
await git.checkout(branch)
return WorktreeResult.create({
success: true,
message: `Switched to branch '${branch}'`,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return WorktreeResult.create({
success: false,
message: `Failed to checkout branch: ${errorMessage}`,
})
}
}
@@ -0,0 +1,64 @@
import { CreateWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { createWorktree as createWorktreeUtil, listWorktrees } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Creates a new git worktree
* @param controller The controller instance
* @param request The request containing path and branch information
* @returns WorktreeResult with success status and created worktree info
*/
export async function createWorktree(_controller: Controller, request: CreateWorktreeRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const result = await createWorktreeUtil(cwd, request.path, {
branch: request.branch,
baseBranch: request.baseBranch,
createNewBranch: request.createNewBranch,
})
// Track worktree creation with count of total worktrees
if (result.success) {
try {
const { worktrees } = await listWorktrees(cwd)
telemetryService.captureWorktreeCreated(true, worktrees.length)
} catch {
telemetryService.captureWorktreeCreated(true)
}
} else {
telemetryService.captureWorktreeCreated(false)
}
return WorktreeResult.create({
success: result.success,
message: result.message,
worktree: result.worktree
? {
path: result.worktree.path,
branch: result.worktree.branch,
commitHash: result.worktree.commitHash,
isCurrent: result.worktree.isCurrent,
isBare: result.worktree.isBare,
isDetached: result.worktree.isDetached,
isLocked: result.worktree.isLocked,
lockReason: result.worktree.lockReason,
}
: undefined,
})
} catch (error) {
console.error(`Error creating worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,39 @@
import { CreateWorktreeIncludeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs/promises"
import * as path from "path"
import { Controller } from ".."
/**
* Creates a .worktreeinclude file with the provided content
* @param controller The controller instance
* @param request The request containing the file content
* @returns WorktreeResult with success status
*/
export async function createWorktreeInclude(
_controller: Controller,
request: CreateWorktreeIncludeRequest,
): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const filePath = path.join(cwd, ".worktreeinclude")
await fs.writeFile(filePath, request.content, "utf-8")
return WorktreeResult.create({
success: true,
message: "Created .worktreeinclude file",
})
} catch (error) {
return WorktreeResult.create({
success: false,
message: `Failed to create .worktreeinclude: ${error instanceof Error ? error.message : String(error)}`,
})
}
}
@@ -0,0 +1,71 @@
import { DeleteWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { deleteWorktree as deleteWorktreeUtil } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { rm } from "fs/promises"
import path from "path"
import simpleGit from "simple-git"
import { HostProvider } from "@/hosts/host-provider"
import { hashWorkingDir } from "@/integrations/checkpoints/CheckpointUtils"
import { Controller } from ".."
/**
* Deletes an existing git worktree
* @param controller The controller instance
* @param request The request containing path and force flag
* @returns WorktreeResult with success status
*/
export async function deleteWorktree(_controller: Controller, request: DeleteWorktreeRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const result = await deleteWorktreeUtil(cwd, request.path, request.force)
if (!result.success) {
return WorktreeResult.create({
success: result.success,
message: result.message,
})
}
// Clean up checkpoint data (shadow git repo) for the deleted worktree
try {
const cwdHash = hashWorkingDir(request.path)
const checkpointDir = path.join(HostProvider.get().globalStorageFsPath, "checkpoints", cwdHash)
await rm(checkpointDir, { recursive: true, force: true })
} catch (error) {
// Log but don't fail - checkpoint cleanup is best-effort
console.log(`Failed to cleanup checkpoints for deleted worktree: ${error}`)
}
// Delete the branch if requested
if (request.deleteBranch && request.branchName) {
try {
const git = simpleGit(cwd)
await git.deleteLocalBranch(request.branchName)
} catch {
// Branch deletion failed, but worktree was deleted successfully
return WorktreeResult.create({
success: true,
message: `${result.message}, but failed to delete branch '${request.branchName}'`,
})
}
}
return WorktreeResult.create({
success: result.success,
message: request.deleteBranch ? `${result.message} and deleted branch '${request.branchName}'` : result.message,
})
} catch (error) {
console.error(`Error deleting worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,39 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { BranchList } from "@shared/proto/cline/worktree"
import { getAvailableBranches as getAvailableBranchesUtil } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { Controller } from ".."
/**
* Gets available branches for creating worktrees
* @param controller The controller instance
* @param request Empty request
* @returns BranchList containing local and remote branches
*/
export async function getAvailableBranches(_controller: Controller, _request: EmptyRequest): Promise<BranchList> {
const cwd = await getWorkspacePath()
if (!cwd) {
return BranchList.create({
localBranches: [],
remoteBranches: [],
currentBranch: "",
})
}
try {
const result = await getAvailableBranchesUtil(cwd)
return BranchList.create({
localBranches: result.localBranches,
remoteBranches: result.remoteBranches,
currentBranch: result.currentBranch,
})
} catch (error) {
console.error(`Error getting available branches: ${JSON.stringify(error)}`)
return BranchList.create({
localBranches: [],
remoteBranches: [],
currentBranch: "",
})
}
}
@@ -0,0 +1,49 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeDefaults } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import path from "path"
import { getDocumentsPath } from "@/core/storage/disk"
import { Controller } from ".."
/**
* Generates a random suffix for worktree names
* Returns a 5-character alphanumeric string
*/
function generateRandomSuffix(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
let result = ""
for (let i = 0; i < 5; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* Gets suggested defaults for creating a new worktree
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeDefaults with suggested branch name and path
*/
export async function getWorktreeDefaults(_controller: Controller, _request: EmptyRequest): Promise<WorktreeDefaults> {
const suffix = generateRandomSuffix()
// Generate suggested branch name
const suggestedBranch = `worktree/cline-${suffix}`
// Generate suggested path in Documents/Cline/Worktrees/<project-name>-<suffix>
const documentsPath = await getDocumentsPath()
const cwd = await getWorkspacePath()
// Get project name from workspace path
let projectName = "project"
if (cwd) {
projectName = path.basename(cwd)
}
const suggestedPath = path.join(documentsPath, "Cline", "Worktrees", `${projectName}-${suffix}`)
return WorktreeDefaults.create({
suggestedBranch,
suggestedPath,
})
}
@@ -0,0 +1,48 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeIncludeStatus } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs/promises"
import * as path from "path"
import { Controller } from ".."
/**
* Gets the status of .worktreeinclude file and .gitignore contents
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeIncludeStatus with exists flag and gitignore content
*/
export async function getWorktreeIncludeStatus(_controller: Controller, _request: EmptyRequest): Promise<WorktreeIncludeStatus> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeIncludeStatus.create({
exists: false,
hasGitignore: false,
gitignoreContent: "",
})
}
// Check if .worktreeinclude exists
let exists = false
try {
await fs.access(path.join(cwd, ".worktreeinclude"))
exists = true
} catch {
exists = false
}
// Read .gitignore content if it exists
let gitignoreContent = ""
let hasGitignore = false
try {
gitignoreContent = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8")
hasGitignore = true
} catch {
hasGitignore = false
}
return WorktreeIncludeStatus.create({
exists,
hasGitignore,
gitignoreContent,
})
}
@@ -0,0 +1,88 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeList } from "@shared/proto/cline/worktree"
import { getGitRootPath, listWorktrees as listWorktreesUtil } from "@utils/git-worktree"
import { arePathsEqual, getWorkspacePath } from "@utils/path"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from ".."
/**
* Lists all git worktrees in the current repository
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeList containing all worktrees
*/
export async function listWorktrees(_controller: Controller, _request: EmptyRequest): Promise<WorktreeList> {
// Check for multi-root workspace
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
const isMultiRoot = workspacePaths.length > 1
if (isMultiRoot) {
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: true,
isSubfolder: false,
gitRootPath: "",
error: "",
})
}
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: "",
error: "No workspace folder open",
})
}
// Check if workspace is a subfolder of a git repo (not at repo root)
const gitRootPath = await getGitRootPath(cwd)
const isSubfolder = gitRootPath !== null && !arePathsEqual(cwd, gitRootPath)
if (isSubfolder) {
return WorktreeList.create({
worktrees: [],
isGitRepo: true,
isMultiRoot: false,
isSubfolder: true,
gitRootPath: gitRootPath || "",
error: "",
})
}
try {
const result = await listWorktreesUtil(cwd)
return WorktreeList.create({
worktrees: result.worktrees.map((wt) => ({
path: wt.path,
branch: wt.branch,
commitHash: wt.commitHash,
isCurrent: wt.isCurrent,
isBare: wt.isBare,
isDetached: wt.isDetached,
isLocked: wt.isLocked,
lockReason: wt.lockReason,
})),
isGitRepo: result.isGitRepo,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: gitRootPath || "",
error: result.error || "",
})
} catch (error) {
console.error(`Error listing worktrees: ${JSON.stringify(error)}`)
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: "",
error: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,215 @@
import { MergeWorktreeRequest, MergeWorktreeResult } from "@shared/proto/cline/worktree"
import { listWorktrees } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import simpleGit from "simple-git"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* 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 {
// Find the worktree that has the target branch checked out
// This is where we need to perform the merge
const { worktrees } = await listWorktrees(cwd)
const targetWorktree = worktrees.find((w) => w.branch === targetBranch)
if (!targetWorktree) {
return MergeWorktreeResult.create({
success: false,
message: `Target branch '${targetBranch}' is not checked out in any worktree. Please checkout the branch first.`,
hasConflicts: false,
conflictingFiles: [],
})
}
// Use the target worktree's path for merge operations
const targetWorktreePath = targetWorktree.path
const git = simpleGit(targetWorktreePath)
const worktreeGit = simpleGit(worktreePath)
// Get the branch name of the worktree
let sourceBranch: string
try {
sourceBranch = await worktreeGit.revparse(["--abbrev-ref", "HEAD"])
sourceBranch = sourceBranch.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 source worktree
try {
const status = await worktreeGit.status()
if (!status.isClean()) {
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
}
// Check for uncommitted changes in the target worktree
try {
const targetStatus = await git.status()
if (!targetStatus.isClean()) {
return MergeWorktreeResult.create({
success: false,
message: `Target worktree (${targetBranch}) has uncommitted changes. Please commit or stash them first.`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
} catch {
// If status check fails, continue anyway
}
// Attempt the merge in the target worktree (which already has targetBranch checked out)
try {
await git.merge([sourceBranch, "--no-edit"])
} catch (error) {
// Check if it's a merge conflict
try {
const diffResult = await git.diff(["--name-only", "--diff-filter=U"])
const conflictingFiles = diffResult
.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 git.merge(["--abort"])
} catch {
// Ignore abort errors
}
telemetryService.captureWorktreeMergeAttempted(false, true, deleteAfterMerge)
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)
telemetryService.captureWorktreeMergeAttempted(false, false, deleteAfterMerge)
return MergeWorktreeResult.create({
success: false,
message: `Merge failed: ${errorMessage}`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
// Delete worktree if requested
if (deleteAfterMerge) {
try {
await git.raw(["worktree", "remove", worktreePath, "--force"])
} 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 git.deleteLocalBranch(sourceBranch)
} catch {
// Branch deletion is optional, don't fail if it doesn't work
}
}
telemetryService.captureWorktreeMergeAttempted(true, false, deleteAfterMerge)
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: [],
})
}
}
@@ -0,0 +1,45 @@
import { SwitchWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from ".."
/**
* Switches to a different worktree by opening it in VS Code
* @param controller The controller instance
* @param request The request containing the worktree path
* @returns WorktreeResult with success status
*/
export async function switchWorktree(controller: Controller, request: SwitchWorktreeRequest): Promise<WorktreeResult> {
try {
// Set state so Cline auto-opens when the worktree folder loads
controller.stateManager.setGlobalState("worktreeAutoOpenPath", request.path)
// When opening in current window, the window reloads immediately and StateManager's
// 500ms debounce won't complete. Flush to ensure state is persisted before reload.
if (!request.newWindow) {
await controller.stateManager.flushPendingState()
}
const result = await HostProvider.workspace.openFolder({
path: request.path,
newWindow: request.newWindow,
})
if (!result.success) {
return WorktreeResult.create({
success: false,
message: `Failed to open worktree at ${request.path}`,
})
}
return WorktreeResult.create({
success: true,
message: `Switched to worktree at ${request.path}`,
})
} catch (error) {
console.error(`Error switching worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,16 @@
import { Empty } from "@shared/proto/cline/common"
import { TrackWorktreeViewOpenedRequest } from "@shared/proto/cline/worktree"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Tracks when the worktrees view is opened (for telemetry)
* @param controller The controller instance
* @param request The request containing the source of the navigation
* @returns Empty response
*/
export async function trackWorktreeViewOpened(_controller: Controller, request: TrackWorktreeViewOpenedRequest): Promise<Empty> {
const source = request.source === "home_page" ? "home_page" : "menu_bar"
telemetryService.captureWorktreeViewOpened(source)
return Empty.create({})
}
+7
View File
@@ -179,6 +179,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
const useAutoCondense = context.globalState.get<GlobalStateAndSettings["useAutoCondense"]>("useAutoCondense")
const clineWebToolsEnabled =
context.globalState.get<GlobalStateAndSettings["clineWebToolsEnabled"]>("clineWebToolsEnabled")
const worktreesEnabled = context.globalState.get<GlobalStateAndSettings["worktreesEnabled"]>("worktreesEnabled")
const isNewUser = context.globalState.get<GlobalStateAndSettings["isNewUser"]>("isNewUser")
const welcomeViewCompleted =
context.globalState.get<GlobalStateAndSettings["welcomeViewCompleted"]>("welcomeViewCompleted")
@@ -544,6 +545,10 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
const remoteWorkflowToggles =
context.globalState.get<GlobalStateAndSettings["remoteWorkflowToggles"]>("remoteWorkflowToggles")
// Worktree auto-open path for quick launch feature
const worktreeAutoOpenPath =
context.globalState.get<GlobalStateAndSettings["worktreeAutoOpenPath"]>("worktreeAutoOpenPath")
return {
// api configuration fields
claudeCodePath,
@@ -682,6 +687,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
yoloModeToggled: yoloModeToggled ?? false,
useAutoCondense: useAutoCondense ?? false,
clineWebToolsEnabled: clineWebToolsEnabled ?? true,
worktreesEnabled: worktreesEnabled ?? true,
isNewUser: isNewUser ?? true,
welcomeViewCompleted,
lastShownAnnouncementId,
@@ -747,6 +753,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
openTelemetryLogMaxQueueSize: openTelemetryLogMaxQueueSize ?? 2048,
remoteRulesToggles: remoteRulesToggles || {},
remoteWorkflowToggles: remoteWorkflowToggles || {},
worktreeAutoOpenPath,
}
} catch (error) {
console.error("[StateHelpers] Failed to read global state:", error)
+8
View File
@@ -9,6 +9,7 @@ import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChat
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
import { sendWorktreesButtonClickedEvent } from "./core/controller/ui/subscribeToWorktreesButtonClicked"
import { WebviewProvider } from "./core/webview"
import { createClineAPI } from "./exports"
import { Logger } from "./services/logging/Logger"
@@ -140,6 +141,13 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
context.subscriptions.push(
vscode.commands.registerCommand(commands.WorktreesButton, () => {
// Send event to all subscribers using the gRPC streaming method
sendWorktreesButtonClickedEvent()
}),
)
/*
We use the text document content provider API to show the left side for diff view by creating a
virtual document for the original content. This makes it readonly so users know to edit the right
@@ -0,0 +1,13 @@
import * as vscode from "vscode"
import { OpenFolderRequest, OpenFolderResponse } from "@/shared/proto/host/workspace"
export async function openFolder(request: OpenFolderRequest): Promise<OpenFolderResponse> {
try {
const uri = vscode.Uri.file(request.path)
await vscode.commands.executeCommand("vscode.openFolder", uri, { forceNewWindow: request.newWindow })
return OpenFolderResponse.create({ success: true })
} catch (error) {
console.error("Failed to open folder:", error)
return OpenFolderResponse.create({ success: false })
}
}
+1
View File
@@ -14,6 +14,7 @@ const ClineCommands = {
SettingsButton: prefix + ".settingsButtonClicked",
HistoryButton: prefix + ".historyButtonClicked",
AccountButton: prefix + ".accountButtonClicked",
WorktreesButton: prefix + ".worktreesButtonClicked",
TerminalOutput: prefix + ".addTerminalOutputToChat",
AddToChat: prefix + ".addToChat",
FixWithCline: prefix + ".fixWithCline",
@@ -99,6 +99,10 @@ export class FeatureFlagsService {
return this.getBooleanFlagEnabled(FeatureFlag.WEBTOOLS)
}
public getWorktreesEnabled(): boolean {
return this.getBooleanFlagEnabled(FeatureFlag.WORKTREES)
}
public getOnboardingOverrides() {
const payload = this.cache.get(FeatureFlag.ONBOARDING_MODELS)
// Check if payload is object
@@ -309,6 +309,15 @@ export class TelemetryService {
// Tracks when hook discovery completes
DISCOVERY_COMPLETED: "hooks.discovery_completed",
},
// Worktree-related events for tracking worktree feature usage
WORKTREE: {
// Tracks when user opens worktrees view from home page
VIEW_OPENED: "worktree.view_opened",
// Tracks when a worktree is created
CREATED: "worktree.created",
// Tracks when a worktree merge is attempted
MERGE_ATTEMPTED: "worktree.merge_attempted",
},
}
public static async create(): Promise<TelemetryService> {
@@ -1836,6 +1845,51 @@ export class TelemetryService {
})
}
/**
* Records when user opens the worktrees view
* @param source Where the user opened the view from (home_page or menu_bar)
*/
public captureWorktreeViewOpened(source: "home_page" | "menu_bar") {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.VIEW_OPENED,
properties: {
source,
},
})
}
/**
* Records when a worktree is created
* @param success Whether the creation was successful
* @param worktreeCount Total number of worktrees after creation (to track power users)
*/
public captureWorktreeCreated(success: boolean, worktreeCount?: number) {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.CREATED,
properties: {
success,
worktree_count: worktreeCount,
},
})
}
/**
* Records when a worktree merge is attempted
* @param success Whether the merge was successful
* @param hasConflicts Whether merge conflicts were detected
* @param deleteAfterMerge Whether user chose to delete worktree after merge
*/
public captureWorktreeMergeAttempted(success: boolean, hasConflicts: boolean, deleteAfterMerge: boolean) {
this.capture({
event: TelemetryService.EVENTS.WORKTREE.MERGE_ATTEMPTED,
properties: {
success,
has_conflicts: hasConflicts,
delete_after_merge: deleteAfterMerge,
},
})
}
/**
* Checks if a specific telemetry category is enabled
* @param category The telemetry category to check
+1
View File
@@ -89,6 +89,7 @@ export interface ExtensionState {
yoloModeToggled?: boolean
useAutoCondense?: boolean
clineWebToolsEnabled?: ClineFeatureSetting
worktreesEnabled?: ClineFeatureSetting
focusChainSettings: FocusChainSettings
dictationSettings: DictationSettings
customPrompt?: string
@@ -7,6 +7,7 @@ export enum FeatureFlag {
DO_NOTHING = "do_nothing",
HOOKS = "hooks",
WEBTOOLS = "webtools",
WORKTREES = "worktree-exp",
// Feature flag for showing the new onboarding flow or old welcome view.
ONBOARDING_MODELS = "onboarding_models",
}
@@ -15,6 +16,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
[FeatureFlag.DO_NOTHING]: false,
[FeatureFlag.HOOKS]: false,
[FeatureFlag.WEBTOOLS]: false,
[FeatureFlag.WORKTREES]: false,
[FeatureFlag.ONBOARDING_MODELS]: process.env.E2E_TEST === "true" ? { models: {} } : undefined,
}
+3
View File
@@ -60,6 +60,8 @@ export interface GlobalState {
remoteRulesToggles: ClineRulesToggles
remoteWorkflowToggles: ClineRulesToggles
dismissedBanners: Array<{ bannerId: string; dismissedAt: number }>
// Path to worktree that should auto-open Cline sidebar when launched
worktreeAutoOpenPath: string | undefined
}
export interface Settings {
@@ -116,6 +118,7 @@ export interface Settings {
yoloModeToggled: boolean
useAutoCondense: boolean
clineWebToolsEnabled: boolean
worktreesEnabled: boolean
preferredLanguage: string
openaiReasoningEffort: OpenaiReasoningEffort
mode: Mode
+1 -1
View File
@@ -59,7 +59,7 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
// Verify What's New Section is showing and starts with first banner,
// and the navigation buttons work
await expect(sidebar.locator(".animate-fade-in")).toBeVisible()
await expect(sidebar.locator('[aria-label="Announcements"]')).toBeVisible()
await expect(
sidebar
.locator("div")
+1 -1
View File
@@ -15,7 +15,7 @@ e2e("Chat - can send messages and switch between modes", async ({ helper, sideba
// Starting a new task should clear the current chat view and show the recent tasks
await sidebar.getByRole("button", { name: "New Task", exact: true }).first().click()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
await expect(sidebar.getByText("Recent")).toBeVisible()
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
// Makes sure the act and plan switches are working correctly
+1 -1
View File
@@ -21,7 +21,7 @@ e2e.describe("Diff Editor", () => {
// Back to home page with history
await sidebar.getByRole("button", { name: "Start New Task" }).click()
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
await expect(sidebar.getByText("Recent")).toBeVisible()
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible() // History with the previous sent message
// Submit a file edit request
+324
View File
@@ -0,0 +1,324 @@
import * as path from "path"
import simpleGit from "simple-git"
import { copyWorktreeIncludeFiles } from "./worktree-include"
export interface Worktree {
path: string
branch: string
commitHash: string
isCurrent: boolean
isBare: boolean
isDetached: boolean
isLocked: boolean
lockReason?: string
}
export interface WorktreeResult {
success: boolean
message: string
worktree?: Worktree
}
export interface BranchInfo {
localBranches: string[]
remoteBranches: string[]
currentBranch: string
}
/**
* Check if git is installed
*/
async function checkGitInstalled(): Promise<boolean> {
try {
await simpleGit().version()
return true
} catch (_error) {
return false
}
}
/**
* Check if a directory is a git repository
*/
async function checkGitRepo(cwd: string): Promise<boolean> {
try {
const git = simpleGit(cwd)
return await git.checkIsRepo()
} catch (_error) {
return false
}
}
/**
* Get the current worktree path (same as git root for main worktree)
*/
async function getCurrentWorktreePath(cwd: string): Promise<string> {
try {
const git = simpleGit(cwd)
const root = await git.revparse(["--show-toplevel"])
return root.trim()
} catch (_error) {
return cwd
}
}
/**
* Get the git repository root path for a given directory.
* Returns null if not in a git repository.
*/
export async function getGitRootPath(cwd: string): Promise<string | null> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return null
}
try {
const git = simpleGit(cwd)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return null
}
const root = await git.revparse(["--show-toplevel"])
return root.trim()
} catch (_error) {
return null
}
}
/**
* List all worktrees in the repository
*/
export async function listWorktrees(cwd: string): Promise<{ worktrees: Worktree[]; isGitRepo: boolean; error?: string }> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { worktrees: [], isGitRepo: false, error: "Git is not installed" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { worktrees: [], isGitRepo: false, error: "Not a git repository" }
}
try {
const currentPath = await getCurrentWorktreePath(cwd)
const git = simpleGit(cwd)
const stdout = await git.raw(["worktree", "list", "--porcelain"])
const worktrees: Worktree[] = []
const entries = stdout.trim().split("\n\n").filter(Boolean)
for (const entry of entries) {
const lines = entry.split("\n")
const worktree: Partial<Worktree> = {
isLocked: false,
isDetached: false,
isBare: false,
isCurrent: false,
}
for (const line of lines) {
if (line.startsWith("worktree ")) {
worktree.path = line.substring(9)
worktree.isCurrent = worktree.path === currentPath
} else if (line.startsWith("HEAD ")) {
worktree.commitHash = line.substring(5)
} else if (line.startsWith("branch ")) {
// Branch ref like "refs/heads/main" -> "main"
const branchRef = line.substring(7)
worktree.branch = branchRef.replace("refs/heads/", "")
} else if (line === "bare") {
worktree.isBare = true
} else if (line === "detached") {
worktree.isDetached = true
worktree.branch = ""
} else if (line === "locked") {
worktree.isLocked = true
} else if (line.startsWith("locked ")) {
worktree.isLocked = true
worktree.lockReason = line.substring(7)
}
}
if (worktree.path) {
worktrees.push(worktree as Worktree)
}
}
return { worktrees, isGitRepo: true }
} catch (error) {
return {
worktrees: [],
isGitRepo: true,
error: `Failed to list worktrees: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Create a new worktree
*/
export async function createWorktree(
cwd: string,
worktreePath: string,
options: {
branch?: string
baseBranch?: string
createNewBranch?: boolean
} = {},
): Promise<WorktreeResult> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { success: false, message: "Git is not installed" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { success: false, message: "Not a git repository" }
}
try {
const git = simpleGit(cwd)
const args: string[] = ["worktree", "add"]
if (options.createNewBranch && options.branch) {
// Create a new branch and worktree
args.push("-b", options.branch, worktreePath)
if (options.baseBranch) {
args.push(options.baseBranch)
}
} else if (options.branch) {
// Checkout existing branch
args.push(worktreePath, options.branch)
} else {
// Create detached worktree at HEAD
args.push("--detach", worktreePath)
}
await git.raw(args)
// Resolve the absolute path of the new worktree
const absoluteWorktreePath = path.isAbsolute(worktreePath) ? worktreePath : path.resolve(cwd, worktreePath)
// Copy files matched by .worktreeinclude (if it exists)
const { copiedCount, errors: copyErrors } = await copyWorktreeIncludeFiles(cwd, absoluteWorktreePath)
// Get the created worktree info
const { worktrees } = await listWorktrees(cwd)
const createdWorktree = worktrees.find((w) => w.path === absoluteWorktreePath)
let message = `Worktree created at ${worktreePath}`
if (copiedCount > 0) {
message += ` (copied ${copiedCount} file${copiedCount === 1 ? "" : "s"} from .worktreeinclude)`
}
if (copyErrors.length > 0) {
message += `. Some files failed to copy: ${copyErrors.slice(0, 3).join(", ")}`
if (copyErrors.length > 3) {
message += ` and ${copyErrors.length - 3} more`
}
}
return {
success: true,
message,
worktree: createdWorktree,
}
} catch (error) {
return {
success: false,
message: `Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Delete a worktree
*/
export async function deleteWorktree(cwd: string, path: string, force: boolean = false): Promise<WorktreeResult> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { success: false, message: "Git is not installed" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { success: false, message: "Not a git repository" }
}
try {
const git = simpleGit(cwd)
const args = force ? ["worktree", "remove", "--force", path] : ["worktree", "remove", path]
await git.raw(args)
return {
success: true,
message: `Worktree at ${path} has been removed`,
}
} catch (error) {
return {
success: false,
message: `Failed to remove worktree: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Get available branches for creating worktrees
*/
export async function getAvailableBranches(cwd: string): Promise<BranchInfo> {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return { localBranches: [], remoteBranches: [], currentBranch: "" }
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return { localBranches: [], remoteBranches: [], currentBranch: "" }
}
try {
const git = simpleGit(cwd)
// Get current branch
let currentBranch = ""
try {
currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
currentBranch = currentBranch.trim()
if (currentBranch === "HEAD") {
// Detached HEAD state
currentBranch = ""
}
} catch {
// Detached HEAD state
currentBranch = ""
}
// Get all branches using branchLocal and branch -r
const branchSummary = await git.branchLocal()
const localBranches = branchSummary.all
// Get remote branches
const remoteBranchSummary = await git.branch(["-r"])
const remoteBranches = remoteBranchSummary.all.filter((b) => !b.includes("HEAD"))
// Filter out branches that already have worktrees
const { worktrees } = await listWorktrees(cwd)
const usedBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean))
const availableLocalBranches = localBranches.filter((b) => !usedBranches.has(b))
const availableRemoteBranches = remoteBranches.filter((b) => {
// Remote branches like "origin/main" -> check if "main" is used
const shortName = b.split("/").slice(1).join("/")
return !usedBranches.has(shortName)
})
return {
localBranches: availableLocalBranches,
remoteBranches: availableRemoteBranches,
currentBranch,
}
} catch (error) {
console.error("Error getting available branches:", error)
return { localBranches: [], remoteBranches: [], currentBranch: "" }
}
}
+139
View File
@@ -0,0 +1,139 @@
import * as fs from "fs/promises"
import { after, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
import { copyWorktreeIncludeFiles, hasWorktreeInclude } from "./worktree-include"
describe("Worktree Include Utilities", () => {
const tmpDir = path.join(os.tmpdir(), "cline-worktree-test-" + Math.random().toString(36).slice(2))
const sourceDir = path.join(tmpDir, "source")
const targetDir = path.join(tmpDir, "target")
// Clean up after tests
after(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe("hasWorktreeInclude", () => {
it("should return true when .worktreeinclude exists", async () => {
const testDir = path.join(tmpDir, "has-include")
await fs.mkdir(testDir, { recursive: true })
await fs.writeFile(path.join(testDir, ".worktreeinclude"), "node_modules/")
const result = await hasWorktreeInclude(testDir)
result.should.be.true()
})
it("should return false when .worktreeinclude does not exist", async () => {
const testDir = path.join(tmpDir, "no-include")
await fs.mkdir(testDir, { recursive: true })
const result = await hasWorktreeInclude(testDir)
result.should.be.false()
})
})
describe("copyWorktreeIncludeFiles", () => {
it("should return empty result when no .worktreeinclude file exists", async () => {
const src = path.join(tmpDir, "no-worktreeinclude-src")
const tgt = path.join(tmpDir, "no-worktreeinclude-tgt")
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.equal(0)
result.errors.should.be.empty()
})
it("should return empty result when no .gitignore file exists", async () => {
const src = path.join(tmpDir, "no-gitignore-src")
const tgt = path.join(tmpDir, "no-gitignore-tgt")
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "node_modules/")
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.equal(0)
result.errors.should.be.empty()
})
it("should copy individual files matching both patterns", async () => {
const src = path.join(tmpDir, "file-copy-src")
const tgt = path.join(tmpDir, "file-copy-tgt")
// Setup source with files
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "*.log\nbuild/")
await fs.writeFile(path.join(src, ".gitignore"), "*.log\nbuild/")
await fs.writeFile(path.join(src, "test.log"), "log content")
await fs.writeFile(path.join(src, "test.txt"), "txt content") // Should not be copied
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.equal(1)
result.errors.should.be.empty()
// Verify the log file was copied
const logExists = await fs.access(path.join(tgt, "test.log")).then(
() => true,
() => false,
)
logExists.should.be.true()
// Verify the txt file was NOT copied
const txtExists = await fs.access(path.join(tgt, "test.txt")).then(
() => true,
() => false,
)
txtExists.should.be.false()
})
it("should copy entire directories using native cp", async () => {
const src = path.join(tmpDir, "dir-copy-src")
const tgt = path.join(tmpDir, "dir-copy-tgt")
// Setup source with directory
await fs.mkdir(path.join(src, "node_modules", "pkg"), { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "node_modules/")
await fs.writeFile(path.join(src, ".gitignore"), "node_modules/")
await fs.writeFile(path.join(src, "node_modules", "pkg", "index.js"), "module code")
await fs.writeFile(path.join(src, "node_modules", "file.txt"), "file in node_modules")
const result = await copyWorktreeIncludeFiles(src, tgt)
result.copiedCount.should.be.greaterThan(0)
result.errors.should.be.empty()
// Verify the directory was copied
const pkgExists = await fs.access(path.join(tgt, "node_modules", "pkg", "index.js")).then(
() => true,
() => false,
)
pkgExists.should.be.true()
})
it("should only copy files that are in both .worktreeinclude AND .gitignore", async () => {
const src = path.join(tmpDir, "intersection-src")
const tgt = path.join(tmpDir, "intersection-tgt")
await fs.mkdir(src, { recursive: true })
await fs.mkdir(tgt, { recursive: true })
await fs.writeFile(path.join(src, ".worktreeinclude"), "*.log")
await fs.writeFile(path.join(src, ".gitignore"), "*.tmp") // Different pattern
await fs.writeFile(path.join(src, "test.log"), "log")
await fs.writeFile(path.join(src, "test.tmp"), "tmp")
const result = await copyWorktreeIncludeFiles(src, tgt)
// Neither file should be copied since there's no intersection
result.copiedCount.should.equal(0)
})
})
})
+253
View File
@@ -0,0 +1,253 @@
import { exec } from "child_process"
import * as fs from "fs/promises"
import ignore from "ignore"
import * as path from "path"
import { promisify } from "util"
const execAsync = promisify(exec)
/** Batch size for parallel file operations */
const COPY_BATCH_SIZE = 100
/**
* Parses a .gitignore-style file and returns the patterns
*/
async function parseIgnoreFile(filePath: string): Promise<string[]> {
try {
const content = await fs.readFile(filePath, "utf-8")
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
} catch {
return []
}
}
/**
* Check if a pattern represents a directory (ends with / or is a bare name that exists as a directory)
*/
async function isDirectoryPattern(sourceDir: string, pattern: string): Promise<string | null> {
// Normalize pattern - remove trailing slash
const cleanPattern = pattern.replace(/\/$/, "")
// Skip patterns with wildcards - these need file-by-file matching
if (cleanPattern.includes("*") || cleanPattern.includes("?") || cleanPattern.includes("[")) {
return null
}
// Check if this is a top-level directory
const dirPath = path.join(sourceDir, cleanPattern)
try {
const stat = await fs.stat(dirPath)
if (stat.isDirectory()) {
return cleanPattern
}
} catch {
// Path doesn't exist or can't be accessed
}
return null
}
/**
* Copy a directory using native cp -r (much faster than recursive Node.js copy)
*/
async function copyDirectoryNative(source: string, target: string): Promise<void> {
// Create parent directory if needed
await fs.mkdir(path.dirname(target), { recursive: true })
// Use native cp for performance (10-20x faster than Node.js)
const isWindows = process.platform === "win32"
if (isWindows) {
// Windows: use robocopy or xcopy
await execAsync(`xcopy "${source}" "${target}" /E /I /H /Y /Q`)
} else {
// Unix: use cp -r
await execAsync(`cp -r "${source}" "${target}"`)
}
}
/**
* Recursively gets all files in a directory (parallelized)
*/
async function getAllFiles(dir: string, baseDir: string): Promise<string[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
const results = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name)
const relativePath = path.relative(baseDir, fullPath)
if (entry.isDirectory()) {
// Skip .git directory
if (entry.name === ".git") return []
return getAllFiles(fullPath, baseDir)
} else {
return [relativePath]
}
}),
)
return results.flat()
} catch {
// Directory doesn't exist or can't be read
return []
}
}
/**
* Copy files in parallel batches
*/
async function copyFilesInBatches(
files: string[],
sourceDir: string,
targetDir: string,
): Promise<{ copiedCount: number; errors: string[] }> {
const errors: string[] = []
let copiedCount = 0
// Process in batches for controlled parallelism
for (let i = 0; i < files.length; i += COPY_BATCH_SIZE) {
const batch = files.slice(i, i + COPY_BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(async (file) => {
const sourcePath = path.join(sourceDir, file)
const targetPath = path.join(targetDir, file)
// Create target directory if it doesn't exist
await fs.mkdir(path.dirname(targetPath), { recursive: true })
// Copy the file
await fs.copyFile(sourcePath, targetPath)
return file
}),
)
for (const result of results) {
if (result.status === "fulfilled") {
copiedCount++
} else {
errors.push(result.reason?.message || "Unknown error")
}
}
}
return { copiedCount, errors }
}
/**
* Copies files matched by .worktreeinclude patterns that are also in .gitignore.
* Uses optimized strategies for performance:
* - Native cp -r for entire directories (10-20x faster)
* - Parallel file copying with batches (5-10x faster)
*
* @param sourceDir The source worktree directory (original repo)
* @param targetDir The target worktree directory (newly created)
* @returns Object with copied files count and any errors
*/
export async function copyWorktreeIncludeFiles(
sourceDir: string,
targetDir: string,
): Promise<{ copiedCount: number; errors: string[] }> {
const errors: string[] = []
let copiedCount = 0
// Read .worktreeinclude file
const worktreeIncludePath = path.join(sourceDir, ".worktreeinclude")
const includePatterns = await parseIgnoreFile(worktreeIncludePath)
if (includePatterns.length === 0) {
return { copiedCount: 0, errors: [] }
}
// Read .gitignore file
const gitignorePath = path.join(sourceDir, ".gitignore")
const gitignorePatterns = await parseIgnoreFile(gitignorePath)
if (gitignorePatterns.length === 0) {
return { copiedCount: 0, errors: [] }
}
// Create ignore matchers
const includeMatcher = ignore().add(includePatterns)
const gitignoreMatcher = ignore().add(gitignorePatterns)
// Separate patterns into directory patterns and file patterns
const directoryPatterns: string[] = []
const filePatterns: string[] = []
for (const pattern of includePatterns) {
const dirName = await isDirectoryPattern(sourceDir, pattern)
if (dirName) {
// Verify the directory is also gitignored
if (gitignoreMatcher.ignores(dirName) || gitignoreMatcher.ignores(dirName + "/")) {
directoryPatterns.push(dirName)
}
} else {
filePatterns.push(pattern)
}
}
// Handle directory patterns with native cp -r (fast path)
for (const dir of directoryPatterns) {
const sourcePath = path.join(sourceDir, dir)
const targetPath = path.join(targetDir, dir)
try {
await copyDirectoryNative(sourcePath, targetPath)
// Count files in the copied directory
const files = await getAllFiles(sourcePath, sourcePath)
copiedCount += files.length
} catch (error) {
errors.push(`Failed to copy directory ${dir}: ${error instanceof Error ? error.message : String(error)}`)
}
}
// Handle file patterns with parallel copying (if any remain)
if (filePatterns.length > 0) {
// Create matcher for just file patterns
const fileMatcher = ignore().add(filePatterns)
// Get all files, excluding already-copied directories
const dirSet = new Set(directoryPatterns)
const allFiles = await getAllFiles(sourceDir, sourceDir)
// Filter files that:
// 1. Are not in already-copied directories
// 2. Match file patterns
// 3. Are gitignored
const filesToCopy = allFiles.filter((file) => {
// Skip if in an already-copied directory
const topDir = file.split(path.sep)[0]
if (dirSet.has(topDir)) return false
// Must match both file patterns and gitignore
const isIncluded = fileMatcher.ignores(file) || includeMatcher.ignores(file)
const isGitignored = gitignoreMatcher.ignores(file)
return isIncluded && isGitignored
})
if (filesToCopy.length > 0) {
const result = await copyFilesInBatches(filesToCopy, sourceDir, targetDir)
copiedCount += result.copiedCount
errors.push(...result.errors)
}
}
return { copiedCount, errors }
}
/**
* Checks if a .worktreeinclude file exists in the given directory
*/
export async function hasWorktreeInclude(dir: string): Promise<boolean> {
try {
await fs.access(path.join(dir, ".worktreeinclude"))
return true
} catch {
return false
}
}
+5 -1
View File
@@ -7,6 +7,7 @@ import McpView from "./components/mcp/configuration/McpConfigurationView"
import OnboardingView from "./components/onboarding/OnboardingView"
import SettingsView from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import WorktreesView from "./components/worktrees/WorktreesView"
import { useClineAuth } from "./context/ClineAuthContext"
import { useExtensionState } from "./context/ExtensionStateContext"
import { Providers } from "./Providers"
@@ -23,6 +24,7 @@ const AppContent = () => {
settingsTargetSection,
showHistory,
showAccount,
showWorktrees,
showAnnouncement,
onboardingModels,
setShowAnnouncement,
@@ -32,6 +34,7 @@ const AppContent = () => {
hideSettings,
hideHistory,
hideAccount,
hideWorktrees,
hideAnnouncement,
} = useExtensionState()
@@ -73,10 +76,11 @@ const AppContent = () => {
organizations={organizations}
/>
)}
{showWorktrees && <WorktreesView onDone={hideWorktrees} />}
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
<ChatView
hideAnnouncement={hideAnnouncement}
isHidden={showSettings || showHistory || showMcp || showAccount}
isHidden={showSettings || showHistory || showMcp || showAccount || showWorktrees}
showAnnouncement={showAnnouncement}
showHistoryView={navigateToHistory}
/>
@@ -1,14 +1,20 @@
import { BANNER_DATA, BannerAction, BannerActionType, BannerCardData } from "@shared/cline/banner"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { Worktree } from "@shared/proto/cline/worktree"
import { TrackWorktreeViewOpenedRequest } from "@shared/proto/cline/worktree"
import { GitBranch } from "lucide-react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import BannerCarousel from "@/components/common/BannerCarousel"
import WhatsNewModal from "@/components/common/WhatsNewModal"
import HistoryPreview from "@/components/history/HistoryPreview"
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import HomeHeader from "@/components/welcome/HomeHeader"
import { SuggestedTasks } from "@/components/welcome/SuggestedTasks"
import CreateWorktreeModal from "@/components/worktrees/CreateWorktreeModal"
import { useClineAuth } from "@/context/ClineAuthContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AccountServiceClient, StateServiceClient, UiServiceClient } from "@/services/grpc-client"
import { AccountServiceClient, StateServiceClient, UiServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
import { convertBannerData } from "@/utils/bannerUtils"
import { getCurrentPlatform } from "@/utils/platformUtils"
import { WelcomeSectionProps } from "../../types/chatTypes"
@@ -31,8 +37,37 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
const [hasShownWhatsNewModal, setHasShownWhatsNewModal] = useState(false)
const [showWhatsNewModal, setShowWhatsNewModal] = useState(false)
// Quick launch worktree modal
const [showCreateWorktreeModal, setShowCreateWorktreeModal] = useState(false)
const [isGitRepo, setIsGitRepo] = useState<boolean | null>(null)
const [currentWorktree, setCurrentWorktree] = useState<Worktree | null>(null)
// Check if we're in a git repo and get current worktree info on mount
useEffect(() => {
WorktreeServiceClient.listWorktrees(EmptyRequest.create({}))
.then((result) => {
const canUseWorktrees = result.isGitRepo && !result.isMultiRoot && !result.isSubfolder
setIsGitRepo(canUseWorktrees)
if (canUseWorktrees) {
const current = result.worktrees.find((w) => w.isCurrent)
setCurrentWorktree(current || null)
}
})
.catch(() => setIsGitRepo(false))
}, [])
const { clineUser } = useClineAuth()
const { openRouterModels, setShowChatModelSelector, navigateToSettings, subagentsEnabled, banners } = useExtensionState()
const {
openRouterModels,
setShowChatModelSelector,
navigateToSettings,
navigateToWorktrees,
subagentsEnabled,
worktreesEnabled,
banners,
} = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
// Show modal when there's a new announcement and we haven't shown it this session
@@ -49,6 +84,14 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
hideAnnouncement()
}, [hideAnnouncement])
// Handle click on home page worktree element with telemetry
const handleWorktreeClick = useCallback(() => {
WorktreeServiceClient.trackWorktreeViewOpened(TrackWorktreeViewOpenedRequest.create({ source: "home_page" })).catch(
console.error,
)
navigateToWorktrees()
}, [navigateToWorktrees])
/**
* Check if a banner has been dismissed based on its version
*/
@@ -195,18 +238,65 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
{!showWhatsNewModal && (
<>
<div className="animate-fade-in">
<BannerCarousel banners={activeBanners} />
</div>
{!shouldShowQuickWins && taskHistory.length > 0 && (
<div className="animate-fade-in opacity-0">
<HistoryPreview showHistoryView={showHistoryView} />
<BannerCarousel banners={activeBanners} />
{!shouldShowQuickWins && taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
{/* Quick launch worktree button */}
{isGitRepo && worktreesEnabled?.featureFlag && worktreesEnabled?.user && (
<div className="flex flex-col items-center gap-3 mt-2 mb-4 px-5">
{/* TODO: Re-enable once worktree creation is stable
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-2 px-4 py-2 rounded-full border border-[var(--vscode-foreground)]/30 text-[var(--vscode-foreground)] bg-transparent hover:bg-[var(--vscode-list-hoverBackground)] active:opacity-80 text-sm font-medium cursor-pointer"
onClick={() => setShowCreateWorktreeModal(true)}
type="button">
<span className="codicon codicon-empty-window"></span>
New Worktree Window
</button>
</TooltipTrigger>
<TooltipContent side="top">
Create a new git worktree and open it in a separate window. Great for running parallel
Cline tasks.
</TooltipContent>
</Tooltip>
*/}
{currentWorktree && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex flex-col items-center gap-0.5 text-xs text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] cursor-pointer bg-transparent border-none p-1 rounded"
onClick={handleWorktreeClick}
type="button">
<div className="flex items-center gap-1.5 text-xs">
<GitBranch className="w-3 h-3 stroke-[2.5] flex-shrink-0" />
<span className="break-all text-center">
<span className="font-semibold">Current:</span>{" "}
{currentWorktree.branch || "detached HEAD"}
</span>
</div>
<span className="break-all text-center max-w-[300px]">
{currentWorktree.path}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
View and manage git worktrees. Great for running parallel Cline tasks.
</TooltipContent>
</Tooltip>
)}
</div>
)}
</>
)}
</div>
<SuggestedTasks shouldShowQuickWins={shouldShowQuickWins} />
{/* Quick launch worktree modal */}
<CreateWorktreeModal
onClose={() => setShowCreateWorktreeModal(false)}
open={showCreateWorktreeModal}
openAfterCreate={true}
/>
</div>
)
}
@@ -1,5 +1,4 @@
import { StringRequest } from "@shared/proto/cline/common"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { memo } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { TaskServiceClient } from "@/services/grpc-client"
@@ -82,6 +81,25 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
font-weight: 500;
white-space: nowrap;
}
.history-view-all-btn {
background: none;
border: none;
padding: 4px 0 4px 8px;
cursor: pointer;
font-size: 0.85em;
font-weight: 500;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
display: flex;
align-items: center;
gap: 2px;
}
.history-view-all-btn .codicon {
font-size: 1.2em;
}
.history-view-all-btn:hover {
color: var(--vscode-foreground);
}
`}
</style>
@@ -92,79 +110,65 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
margin: "10px 16px 10px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<span
className="codicon codicon-comment-discussion"
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent Tasks
</span>
<div style={{ display: "flex", alignItems: "center" }}>
<span
className="codicon codicon-comment-discussion"
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent
</span>
</div>
{taskHistory.filter((item) => item.ts && item.task).length > 0 && (
<button
aria-label="View all history"
className="history-view-all-btn"
onClick={() => showHistoryView()}
type="button">
View All
<span className="codicon codicon-chevron-right" />
</button>
)}
</div>
{
<div className="px-4">
{taskHistory.filter((item) => item.ts && item.task).length > 0 ? (
<>
{taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
<div
className="history-preview-item"
key={item.id}
onClick={() => handleHistorySelect(item.id)}>
<div className="history-task-content">
{item.isFavorited && (
<span
aria-label="Favorited"
className="codicon codicon-star-full"
style={{
color: "var(--vscode-button-background)",
flexShrink: 0,
}}
/>
)}
<div className="history-task-description ph-no-capture">{item.task}</div>
</div>
<div className="history-meta-stack">
<span className="history-date">{formatDate(item.ts)}</span>
{item.totalCost != null && (
<span className="history-cost-chip">${item.totalCost.toFixed(2)}</span>
)}
</div>
taskHistory
.filter((item) => item.ts && item.task)
.slice(0, 3)
.map((item) => (
<div className="history-preview-item" key={item.id} onClick={() => handleHistorySelect(item.id)}>
<div className="history-task-content">
{item.isFavorited && (
<span
aria-label="Favorited"
className="codicon codicon-star-full"
style={{
color: "var(--vscode-button-background)",
flexShrink: 0,
}}
/>
)}
<div className="history-task-description ph-no-capture">{item.task}</div>
</div>
))}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<VSCodeButton
appearance="icon"
aria-label="View all history"
onClick={() => showHistoryView()}
style={{
opacity: 0.9,
}}>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
}}>
View All
<div className="history-meta-stack">
<span className="history-date">{formatDate(item.ts)}</span>
{item.totalCost != null && (
<span className="history-cost-chip">${item.totalCost.toFixed(2)}</span>
)}
</div>
</VSCodeButton>
</div>
</>
</div>
))
) : (
<div
style={{
+11 -3
View File
@@ -1,4 +1,4 @@
import { HistoryIcon, PlusIcon, SettingsIcon, UserCircleIcon } from "lucide-react"
import { GitBranchIcon, HistoryIcon, PlusIcon, SettingsIcon, UserCircleIcon } from "lucide-react"
import { useMemo } from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@@ -14,7 +14,8 @@ const McpServerIcon = ({ className, size }: { className?: string; size?: number
)
export const Navbar = () => {
const { navigateToHistory, navigateToSettings, navigateToAccount, navigateToMcp, navigateToChat } = useExtensionState()
const { navigateToHistory, navigateToSettings, navigateToAccount, navigateToMcp, navigateToChat, navigateToWorktrees } =
useExtensionState()
const SETTINGS_TABS = useMemo(
() => [
@@ -46,6 +47,13 @@ export const Navbar = () => {
icon: HistoryIcon,
navigate: navigateToHistory,
},
{
id: "worktrees",
name: "Worktrees",
tooltip: "Worktrees",
icon: GitBranchIcon,
navigate: navigateToWorktrees,
},
{
id: "account",
name: "Account",
@@ -61,7 +69,7 @@ export const Navbar = () => {
navigate: navigateToSettings,
},
],
[navigateToAccount, navigateToChat, navigateToHistory, navigateToMcp, navigateToSettings],
[navigateToAccount, navigateToChat, navigateToHistory, navigateToMcp, navigateToSettings, navigateToWorktrees],
)
return (
@@ -27,6 +27,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
dictationSettings,
useAutoCondense,
clineWebToolsEnabled,
worktreesEnabled,
focusChainSettings,
multiRootSetting,
hooksEnabled,
@@ -333,6 +334,21 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
</p>
</div>
)}
{worktreesEnabled?.featureFlag && (
<div style={{ marginTop: 10 }}>
<VSCodeCheckbox
checked={worktreesEnabled?.user}
onChange={(e: any) => {
const checked = e.target.checked === true
updateSetting("worktreesEnabled", checked)
}}>
Enable Worktrees
</VSCodeCheckbox>
<p className="text-xs text-(--vscode-descriptionForeground)">
Enables git worktree management for running parallel Cline tasks.
</p>
</div>
)}
<div className="mt-2.5">
<VSCodeCheckbox
checked={nativeToolCallSetting}
@@ -1,8 +1,6 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { InfoIcon } from "lucide-react"
import ClineLogoSanta from "@/assets/ClineLogoSanta"
import ClineLogoVariable from "@/assets/ClineLogoVariable"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { UiServiceClient } from "@/services/grpc-client"
@@ -27,41 +25,11 @@ const HomeHeader = ({ shouldShowQuickWins = false }: HomeHeaderProps) => {
return (
<div className="flex flex-col items-center mb-5">
<style>
{`
@keyframes logo-pop-in {
0% {
opacity: 0;
transform: scale(0.95);
}
60% {
opacity: 1;
transform: scale(1.02);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.logo-animate {
animation: logo-pop-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
`}
</style>
<div className="my-7 logo-animate">
<div className="my-7">
<LogoComponent className="size-20" environment={environment} />
</div>
<div className="text-center flex items-center justify-center px-4">
<h1 className="m-0 font-bold">What can I do for you?</h1>
<Tooltip>
<TooltipContent side="bottom">
I can develop software step-by-step by editing files, exploring projects, running commands, and using
browsers. I can even extend my capabilities with MCP tools to assist beyond basic code completion.
</TooltipContent>
<TooltipTrigger asChild>
<InfoIcon className="ml-2 cursor-pointer text-link text-sm size-2 shrink-0" />
</TooltipTrigger>
</Tooltip>
</div>
{shouldShowQuickWins && (
<div className="mt-4">
@@ -0,0 +1,228 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { CreateWorktreeRequest, SwitchWorktreeRequest } from "@shared/proto/cline/worktree"
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { AlertCircle, AlertTriangle, Loader2, X } from "lucide-react"
import { memo, useCallback, useEffect, useState } from "react"
import { WorktreeServiceClient } from "@/services/grpc-client"
interface CreateWorktreeModalProps {
open: boolean
onClose: () => void
/** When true, opens the worktree in a new window after creation */
openAfterCreate?: boolean
/** Called after successful creation (and opening if openAfterCreate is true) */
onSuccess?: () => void
}
const CreateWorktreeModal = ({ open, onClose, openAfterCreate = false, onSuccess }: CreateWorktreeModalProps) => {
const [newWorktreePath, setNewWorktreePath] = useState("")
const [newBranchName, setNewBranchName] = useState("")
const [isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [isLoadingDefaults, setIsLoadingDefaults] = useState(false)
const [hasWorktreeInclude, setHasWorktreeInclude] = useState<boolean | null>(null)
// Load defaults and check .worktreeinclude status when modal opens
const loadDefaults = useCallback(async () => {
setIsLoadingDefaults(true)
try {
const [defaults, includeStatus] = await Promise.all([
WorktreeServiceClient.getWorktreeDefaults(EmptyRequest.create({})),
WorktreeServiceClient.getWorktreeIncludeStatus(EmptyRequest.create({})),
])
setNewBranchName(defaults.suggestedBranch)
setNewWorktreePath(defaults.suggestedPath)
setHasWorktreeInclude(includeStatus.exists)
} catch (err) {
console.error("Failed to load worktree defaults:", err)
} finally {
setIsLoadingDefaults(false)
}
}, [])
useEffect(() => {
if (open) {
loadDefaults()
}
}, [open, loadDefaults])
// Reset form state when modal closes
useEffect(() => {
if (!open) {
setNewWorktreePath("")
setNewBranchName("")
setCreateError(null)
setHasWorktreeInclude(null)
}
}, [open])
const handleCreateWorktree = useCallback(async () => {
if (!newWorktreePath || !newBranchName) {
return
}
setIsCreating(true)
setCreateError(null)
try {
const result = await WorktreeServiceClient.createWorktree(
CreateWorktreeRequest.create({
path: newWorktreePath,
branch: newBranchName,
createNewBranch: true,
}),
)
if (!result.success) {
setCreateError(result.message)
} else {
// If openAfterCreate is true, open the worktree in a new window
if (openAfterCreate && result.worktree?.path) {
await WorktreeServiceClient.switchWorktree(
SwitchWorktreeRequest.create({
path: result.worktree.path,
newWindow: true,
}),
)
}
onSuccess?.()
onClose()
}
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create worktree")
} finally {
setIsCreating(false)
}
}, [newWorktreePath, newBranchName, openAfterCreate, onSuccess, onClose])
if (!open) {
return null
}
const title = openAfterCreate ? "New Worktree" : "Create New Worktree"
const buttonText = openAfterCreate ? "Create & Open" : "Create Worktree"
const creatingText = openAfterCreate ? "Creating & Opening..." : "Creating..."
const description = openAfterCreate
? "This will create a copy of your project on a new branch and open in a separate window."
: "This will create a copy of your project on a new branch."
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}>
<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)] cursor-pointer"
onClick={onClose}
type="button">
<X className="w-4 h-4" />
</button>
<h4 className="mt-0 mb-2 pr-6">{title}</h4>
<p className="text-sm text-[var(--vscode-descriptionForeground)] mt-0 mb-4">{description}</p>
{hasWorktreeInclude === false && (
<div
className="flex items-start gap-2 p-2 rounded mb-3"
style={{ backgroundColor: "var(--vscode-inputValidation-warningBackground)" }}>
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5 text-[var(--vscode-editorWarning-foreground)]" />
<p className="text-xs text-[var(--vscode-foreground)] m-0">
No .worktreeinclude detected.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees#worktreeinclude"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
</div>
)}
<div className="flex flex-col">
<div>
<label className="block text-sm font-medium mb-1">Branch Name *</label>
<VSCodeTextField
className="w-full"
onInput={(e) => setNewBranchName((e.target as HTMLInputElement).value)}
placeholder="feature/my-feature"
value={newBranchName}>
{newBranchName && (
<div
aria-label="Clear"
className="input-icon-button codicon codicon-close"
onClick={() => setNewBranchName("")}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
<p className="text-xs text-[var(--vscode-descriptionForeground)] mt-1">
Your new copy will be checked out to this branch.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Folder Path *</label>
<VSCodeTextField
className="w-full"
onInput={(e) => setNewWorktreePath((e.target as HTMLInputElement).value)}
placeholder="../my-feature-worktree"
value={newWorktreePath}>
{newWorktreePath && (
<div
aria-label="Clear"
className="input-icon-button codicon codicon-close"
onClick={() => setNewWorktreePath("")}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
<p className="text-xs text-[var(--vscode-descriptionForeground)] mt-1">
Where the project will be copied for the worktree.
</p>
</div>
{createError && (
<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">{createError}</p>
</div>
)}
<div className="flex justify-end gap-2">
<VSCodeButton
disabled={!newWorktreePath || !newBranchName || isCreating || isLoadingDefaults}
onClick={handleCreateWorktree}>
{isLoadingDefaults ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Loading...
</>
) : isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{creatingText}
</>
) : (
buttonText
)}
</VSCodeButton>
</div>
</div>
</div>
</div>
)
}
export default memo(CreateWorktreeModal)
@@ -0,0 +1,100 @@
import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { AlertTriangle, Loader2, X } from "lucide-react"
import { memo, useCallback, useState } from "react"
import { Button } from "@/components/ui/button"
interface DeleteWorktreeModalProps {
open: boolean
onClose: () => void
onConfirm: (deleteBranch: boolean) => Promise<void>
worktreePath: string
branchName: string
}
const DeleteWorktreeModal = ({ open, onClose, onConfirm, worktreePath, branchName }: DeleteWorktreeModalProps) => {
const [isDeleting, setIsDeleting] = useState(false)
const [deleteBranch, setDeleteBranch] = useState(false)
const handleDelete = useCallback(async () => {
setIsDeleting(true)
try {
await onConfirm(deleteBranch)
onClose()
} finally {
setIsDeleting(false)
setDeleteBranch(false)
}
}, [onConfirm, onClose, deleteBranch])
if (!open) {
return null
}
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget && !isDeleting) {
onClose()
}
}}>
<div className="bg-[var(--vscode-editor-background)] border border-[var(--vscode-panel-border)] rounded-lg p-5 w-[400px] 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)] cursor-pointer disabled:opacity-50"
disabled={isDeleting}
onClick={onClose}
type="button">
<X className="w-4 h-4" />
</button>
{/* Title row with icon */}
<div className="flex items-center gap-2 mb-3 pr-6">
<AlertTriangle className="w-5 h-5 text-[var(--vscode-errorForeground)]" />
<h4 className="m-0">Delete Worktree</h4>
</div>
{/* Content */}
<p className="text-sm text-[var(--vscode-descriptionForeground)] mt-0 mb-3">
This will delete the worktree directory at{" "}
<span className="font-semibold text-[var(--vscode-foreground)] break-all">{worktreePath}</span>
</p>
<label className="flex items-center gap-2 cursor-pointer mb-3">
<VSCodeCheckbox
checked={deleteBranch}
onChange={(e) => setDeleteBranch((e.target as HTMLInputElement).checked)}
/>
<span className="text-sm">
Also delete branch <span className="font-semibold">{branchName}</span>
</span>
</label>
{deleteBranch && (
<p className="text-sm text-[var(--vscode-inputValidation-warningForeground)] mt-0 mb-3">
Warning: Unpushed commits on this branch will be lost.
</p>
)}
{/* Buttons */}
<div className="flex justify-end gap-2">
<VSCodeButton appearance="secondary" disabled={isDeleting} onClick={onClose}>
Cancel
</VSCodeButton>
<Button disabled={isDeleting} onClick={handleDelete} variant="danger">
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</div>
</div>
</div>
)
}
export default memo(DeleteWorktreeModal)
@@ -0,0 +1,641 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { NewTaskRequest } from "@shared/proto/cline/task"
import type { MergeWorktreeResult, Worktree as WorktreeProto } from "@shared/proto/cline/worktree"
import {
CreateWorktreeIncludeRequest,
DeleteWorktreeRequest,
MergeWorktreeRequest,
SwitchWorktreeRequest,
} from "@shared/proto/cline/worktree"
import { VSCodeButton, VSCodeCheckbox } 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, TaskServiceClient, WorktreeServiceClient } from "@/services/grpc-client"
import { getEnvironmentColor } from "@/utils/environmentColors"
import CreateWorktreeModal from "./CreateWorktreeModal"
import DeleteWorktreeModal from "./DeleteWorktreeModal"
type WorktreesViewProps = {
onDone: () => void
}
const WorktreesView = ({ onDone }: WorktreesViewProps) => {
const { environment } = useExtensionState()
const [worktrees, setWorktrees] = useState<WorktreeProto[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isGitRepo, setIsGitRepo] = useState(true)
const [isMultiRoot, setIsMultiRoot] = useState(false)
const [isSubfolder, setIsSubfolder] = useState(false)
const [gitRootPath, setGitRootPath] = useState("")
const [showCreateForm, setShowCreateForm] = useState(false)
const [deleteWorktree, setDeleteWorktree] = useState<WorktreeProto | null>(null)
// 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)
const [gitignoreContent, setGitignoreContent] = useState("")
const [isCreatingWorktreeInclude, setIsCreatingWorktreeInclude] = useState(false)
// Check if a worktree is the main/primary worktree (first one, typically the original clone)
const isMainWorktree = useCallback(
(worktree: WorktreeProto) => {
// The main worktree is typically the first one listed and is where .git directory lives
// It's also usually the one that's marked as "bare" or is the original clone location
if (worktrees.length === 0) return false
return worktree.path === worktrees[0]?.path || worktree.isBare
},
[worktrees],
)
// Load worktrees - only updates state if data changed to prevent flickering
const loadWorktrees = useCallback(async () => {
try {
const response = await WorktreeServiceClient.listWorktrees(EmptyRequest.create({}))
// Only update state if data actually changed (prevents flickering)
setWorktrees((prev) => {
const newData = JSON.stringify(response.worktrees)
const oldData = JSON.stringify(prev)
return newData === oldData ? prev : response.worktrees
})
setIsGitRepo((prev) => (prev === response.isGitRepo ? prev : response.isGitRepo))
setIsMultiRoot((prev) => (prev === response.isMultiRoot ? prev : response.isMultiRoot))
setIsSubfolder((prev) => (prev === response.isSubfolder ? prev : response.isSubfolder))
setGitRootPath((prev) => (prev === response.gitRootPath ? prev : response.gitRootPath))
setError((prev) => (response.error ? response.error : prev === null ? null : prev))
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load worktrees")
} finally {
setIsLoading(false)
}
}, [])
// Load .worktreeinclude status
const loadWorktreeIncludeStatus = useCallback(async () => {
try {
const status = await WorktreeServiceClient.getWorktreeIncludeStatus(EmptyRequest.create({}))
setHasWorktreeInclude(status.exists)
setHasGitignore(status.hasGitignore)
setGitignoreContent(status.gitignoreContent)
} catch (err) {
console.error("Failed to load worktree include status:", err)
}
}, [])
// Create .worktreeinclude file and open it in editor
const handleCreateWorktreeInclude = useCallback(async () => {
setIsCreatingWorktreeInclude(true)
try {
const result = await WorktreeServiceClient.createWorktreeInclude(
CreateWorktreeIncludeRequest.create({
content: gitignoreContent,
}),
)
if (result.success) {
setHasWorktreeInclude(true)
// Open the file in the editor
await FileServiceClient.openFileRelativePath({ value: ".worktreeinclude" })
} else {
setError(result.message)
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create .worktreeinclude")
} finally {
setIsCreatingWorktreeInclude(false)
}
}, [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])
const handleDeleteWorktree = useCallback(
async (path: string, deleteBranch: boolean, branchName: string) => {
try {
const result = await WorktreeServiceClient.deleteWorktree(
DeleteWorktreeRequest.create({
path,
force: false,
deleteBranch,
branchName,
}),
)
if (!result.success) {
setError(result.message)
} else {
await loadWorktrees()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete worktree")
}
},
[loadWorktrees],
)
const handleSwitchWorktree = useCallback(async (path: string, newWindow: boolean) => {
try {
await WorktreeServiceClient.switchWorktree(
SwitchWorktreeRequest.create({
path,
newWindow,
}),
)
} catch (err) {
console.error("Failed to switch worktree:", err)
}
}, [])
// 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])
// 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()
// Close worktrees view to show the chat with the new task
onDone()
} catch (err) {
setMergeError(err instanceof Error ? err.message : "Failed to create task for Cline")
}
}, [mergeResult, mergeWorktree, closeMergeModal, onDone])
return (
<div className="fixed inset-0 flex flex-col overflow-hidden">
{/* Sticky Header with title and Done button */}
<div className="flex-none flex justify-between items-center px-5 py-3 border-b border-[var(--vscode-panel-border)]">
<h3 className="m-0" style={{ color: getEnvironmentColor(environment) }}>
Worktrees
</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5">
{/* Description */}
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0 mb-4">
Git worktrees let you work on multiple branches at the same time, each in its own folder. Open worktrees in
their own windows so Cline can work on multiple tasks in parallel.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
{/* .worktreeinclude status */}
{isGitRepo && !isMultiRoot && !isSubfolder && (
<div
className="p-3 rounded-md"
style={{
border: "1px solid var(--vscode-widget-border)",
backgroundColor: "var(--vscode-list-hoverBackground)",
}}>
{hasWorktreeInclude ? (
<p className="text-sm text-[var(--vscode-testing-iconPassed)] m-0">
<Check className="w-4 h-4 inline-block align-text-bottom mr-1" />
.worktreeinclude detected.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees#worktreeinclude"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
) : (
<div className="flex flex-col gap-2">
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0">
<strong>Tip:</strong> Create a{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
.worktreeinclude
</code>{" "}
file to automatically copy files like{" "}
<code className="bg-[var(--vscode-textCodeBlock-background)] px-1 rounded">
node_modules/
</code>{" "}
to new worktrees, so you don't have to reinstall dependencies.{" "}
<a
className="text-[var(--vscode-textLink-foreground)] hover:text-[var(--vscode-textLink-activeForeground)]"
href="https://docs.cline.bot/features/worktrees#worktreeinclude"
rel="noopener noreferrer"
style={{ fontSize: "inherit" }}
target="_blank">
Learn more
</a>
</p>
{hasGitignore && (
<VSCodeButton
appearance="secondary"
disabled={isCreatingWorktreeInclude}
onClick={handleCreateWorktreeInclude}>
{isCreatingWorktreeInclude ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Creating...
</>
) : (
"Create from .gitignore"
)}
</VSCodeButton>
)}
</div>
)}
</div>
)}
{/* Loading/Error States */}
{isLoading ? (
<div className="flex items-center justify-center min-h-32 py-8">
<Loader2 className="w-6 h-6 animate-spin text-[var(--vscode-descriptionForeground)]" />
<span className="ml-2 text-[var(--vscode-descriptionForeground)]">Loading...</span>
</div>
) : isMultiRoot ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-inputValidation-warningForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-foreground)] font-medium mb-1">Multi-folder workspace detected</p>
<p className="text-[var(--vscode-descriptionForeground)] text-sm">
Worktrees are not supported when multiple folders are open in the same workspace. Please open a single
repository folder to use this feature.
</p>
</div>
) : isSubfolder ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-inputValidation-warningForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-foreground)] font-medium mb-1">Subfolder of a git repository</p>
<p className="text-[var(--vscode-descriptionForeground)] text-sm">
You have a subfolder open instead of the repository root. Please open the root folder to use
worktrees:
</p>
<code className="mt-2 px-2 py-1 bg-[var(--vscode-textCodeBlock-background)] rounded text-sm break-all">
{gitRootPath}
</code>
</div>
) : !isGitRepo ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-descriptionForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-descriptionForeground)]">
Worktrees require a git repository. Please initialize git to use worktrees.
</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<AlertCircle className="w-8 h-8 text-[var(--vscode-errorForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-errorForeground)]">{error}</p>
<VSCodeButton appearance="secondary" className="mt-3" onClick={loadWorktrees}>
Retry
</VSCodeButton>
</div>
) : worktrees.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-32 py-8 text-center">
<GitBranch className="w-8 h-8 text-[var(--vscode-descriptionForeground)] mb-2 shrink-0" />
<p className="text-[var(--vscode-descriptionForeground)]">No worktrees found.</p>
</div>
) : (
<>
{/* Worktrees List - current worktree first, then others */}
<div className="mt-4 flex flex-col gap-2">
{worktrees.map((worktree) => (
<div
className={`p-4 rounded border ${
worktree.isCurrent
? "border-[var(--vscode-focusBorder)] bg-[var(--vscode-list-activeSelectionBackground)]"
: "border-[var(--vscode-panel-border)]"
}`}
key={worktree.path}>
{/* Branch name, badges, and action buttons - wraps on small screens */}
<div className="flex flex-wrap items-center justify-between gap-2 mb-1">
{/* Left side: branch name and badges */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 flex-shrink-0 text-[var(--vscode-button-background)]" />
<span className="font-medium break-all">
{worktree.branch || (worktree.isDetached ? "HEAD (detached)" : "unknown")}
</span>
</div>
{isMainWorktree(worktree) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--vscode-badge-background)] text-[var(--vscode-badge-foreground)] cursor-help">
Primary
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
The original worktree where your .git directory lives.
</TooltipContent>
</Tooltip>
)}
{worktree.isCurrent && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--vscode-button-background)] text-[var(--vscode-button-foreground)] cursor-help">
Current
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
This is the worktree currently open in this window.
</TooltipContent>
</Tooltip>
)}
{worktree.isLocked && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--vscode-inputValidation-warningBackground)] text-[var(--vscode-inputValidation-warningForeground)]">
Locked
</span>
)}
</div>
{/* Right side: action buttons */}
<div className="flex items-center gap-1">
{!worktree.isCurrent && (
<>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => handleSwitchWorktree(worktree.path, false)}>
<FolderOpen className="w-4 h-4" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">Open in current window</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => handleSwitchWorktree(worktree.path, true)}>
<ExternalLink className="w-4 h-4" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">Open in new window</TooltipContent>
</Tooltip>
</>
)}
{!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()}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<VSCodeButton
appearance="icon"
onClick={() => setDeleteWorktree(worktree)}>
<Trash2 className="w-4 h-4 text-[var(--vscode-errorForeground)]" />
</VSCodeButton>
</TooltipTrigger>
<TooltipContent side="bottom">Delete this worktree</TooltipContent>
</Tooltip>
</>
)}
</div>
</div>
{/* Path */}
<p className="text-sm text-[var(--vscode-descriptionForeground)] m-0 break-all">
{worktree.path}
</p>
</div>
))}
</div>
</>
)}
</div>
{/* Fixed Bottom - New Worktree Button */}
{isGitRepo && !isMultiRoot && !isSubfolder && (
<div
className="flex-none px-5 py-3"
style={{
borderTop: "1px solid var(--vscode-panel-border)",
}}>
<VSCodeButton disabled={isLoading} onClick={() => setShowCreateForm(true)} style={{ width: "100%" }}>
<Plus className="w-4 h-4 mr-1" />
New Worktree
</VSCodeButton>
</div>
)}
{/* Create Worktree Modal */}
<CreateWorktreeModal onClose={() => setShowCreateForm(false)} onSuccess={loadWorktrees} open={showCreateForm} />
{/* Delete Worktree Modal */}
<DeleteWorktreeModal
branchName={deleteWorktree?.branch || ""}
onClose={() => setDeleteWorktree(null)}
onConfirm={(deleteBranch) => handleDeleteWorktree(deleteWorktree!.path, deleteBranch, deleteWorktree!.branch)}
open={!!deleteWorktree}
worktreePath={deleteWorktree?.path || ""}
/>
{/* 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)] 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 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 mb-2">
The following files have conflicts:
</p>
<ul className="m-0 pl-4 text-sm font-mono text-[var(--vscode-descriptionForeground)]">
{mergeResult.conflictingFiles.slice(0, 3).map((file) => (
<li key={file}>{file}</li>
))}
{mergeResult.conflictingFiles.length > 3 && (
<li className="text-[var(--vscode-descriptionForeground)]">
...and {mergeResult.conflictingFiles.length - 3} more
</li>
)}
</ul>
</div>
</div>
<div className="flex flex-col gap-2">
<VSCodeButton onClick={handleAskClineToResolve} style={{ width: "100%" }}>
Ask Cline to Resolve
</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>
)
}
export default memo(WorktreesView)
@@ -56,6 +56,7 @@ export interface ExtensionStateContextType extends ExtensionState {
settingsTargetSection?: string
showHistory: boolean
showAccount: boolean
showWorktrees: boolean
showAnnouncement: boolean
showChatModelSelector: boolean
expandTaskHeader: boolean
@@ -103,12 +104,14 @@ export interface ExtensionStateContextType extends ExtensionState {
navigateToSettings: (targetSection?: string) => void
navigateToHistory: () => void
navigateToAccount: () => void
navigateToWorktrees: () => void
navigateToChat: () => void
// Hide functions
hideSettings: () => void
hideHistory: () => void
hideAccount: () => void
hideWorktrees: () => void
hideAnnouncement: () => void
hideChatModelSelector: () => void
closeMcpView: () => void
@@ -129,6 +132,7 @@ export const ExtensionStateContextProvider: React.FC<{
const [settingsTargetSection, setSettingsTargetSection] = useState<string | undefined>(undefined)
const [showHistory, setShowHistory] = useState(false)
const [showAccount, setShowAccount] = useState(false)
const [showWorktrees, setShowWorktrees] = useState(false)
const [showAnnouncement, setShowAnnouncement] = useState(false)
const [showChatModelSelector, setShowChatModelSelector] = useState(false)
@@ -145,6 +149,7 @@ export const ExtensionStateContextProvider: React.FC<{
}, [])
const hideHistory = useCallback(() => setShowHistory(false), [setShowHistory])
const hideAccount = useCallback(() => setShowAccount(false), [setShowAccount])
const hideWorktrees = useCallback(() => setShowWorktrees(false), [setShowWorktrees])
const hideAnnouncement = useCallback(() => setShowAnnouncement(false), [setShowAnnouncement])
const hideChatModelSelector = useCallback(() => setShowChatModelSelector(false), [setShowChatModelSelector])
@@ -154,12 +159,13 @@ export const ExtensionStateContextProvider: React.FC<{
setShowSettings(false)
setShowHistory(false)
setShowAccount(false)
setShowWorktrees(false)
if (tab) {
setMcpTab(tab)
}
setShowMcp(true)
},
[setShowMcp, setMcpTab, setShowSettings, setShowHistory, setShowAccount],
[setShowMcp, setMcpTab, setShowSettings, setShowHistory, setShowAccount, setShowWorktrees],
)
const navigateToSettings = useCallback(
@@ -167,6 +173,7 @@ export const ExtensionStateContextProvider: React.FC<{
setShowHistory(false)
closeMcpView()
setShowAccount(false)
setShowWorktrees(false)
setSettingsTargetSection(targetSection)
setShowSettings(true)
},
@@ -177,22 +184,33 @@ export const ExtensionStateContextProvider: React.FC<{
setShowSettings(false)
closeMcpView()
setShowAccount(false)
setShowWorktrees(false)
setShowHistory(true)
}, [setShowSettings, closeMcpView, setShowAccount, setShowHistory])
}, [setShowSettings, closeMcpView, setShowAccount, setShowWorktrees, setShowHistory])
const navigateToAccount = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowWorktrees(false)
setShowAccount(true)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount])
}, [setShowSettings, closeMcpView, setShowHistory, setShowWorktrees, setShowAccount])
const navigateToWorktrees = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowAccount(false)
setShowWorktrees(true)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount, setShowWorktrees])
const navigateToChat = useCallback(() => {
setShowSettings(false)
closeMcpView()
setShowHistory(false)
setShowAccount(false)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount])
setShowWorktrees(false)
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount, setShowWorktrees])
const [state, setState] = useState<ExtensionState>({
version: "",
@@ -236,6 +254,7 @@ export const ExtensionStateContextProvider: React.FC<{
customPrompt: undefined,
useAutoCondense: false,
clineWebToolsEnabled: { user: true, featureFlag: false },
worktreesEnabled: { user: true, featureFlag: false },
autoCondenseThreshold: undefined,
favoritedModelIds: [],
lastDismissedInfoBannerVersion: 0,
@@ -298,6 +317,7 @@ export const ExtensionStateContextProvider: React.FC<{
const chatButtonUnsubscribeRef = useRef<(() => void) | null>(null)
const accountButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
const settingsButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
const worktreesButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
const partialMessageUnsubscribeRef = useRef<(() => void) | null>(null)
const mcpMarketplaceUnsubscribeRef = useRef<(() => void) | null>(null)
const openRouterModelsUnsubscribeRef = useRef<(() => void) | null>(null)
@@ -453,6 +473,23 @@ export const ExtensionStateContextProvider: React.FC<{
},
})
// Set up worktrees button clicked subscription
worktreesButtonClickedSubscriptionRef.current = UiServiceClient.subscribeToWorktreesButtonClicked(
EmptyRequest.create({}),
{
onResponse: () => {
// When worktrees button is clicked, navigate to worktrees
navigateToWorktrees()
},
onError: (error) => {
console.error("Error in worktrees button clicked subscription:", error)
},
onComplete: () => {
console.log("Worktrees button clicked subscription completed")
},
},
)
// Subscribe to partial message events
partialMessageUnsubscribeRef.current = UiServiceClient.subscribeToPartialMessage(EmptyRequest.create({}), {
onResponse: (protoMessage) => {
@@ -604,6 +641,10 @@ export const ExtensionStateContextProvider: React.FC<{
settingsButtonClickedSubscriptionRef.current()
settingsButtonClickedSubscriptionRef.current = null
}
if (worktreesButtonClickedSubscriptionRef.current) {
worktreesButtonClickedSubscriptionRef.current()
worktreesButtonClickedSubscriptionRef.current = null
}
if (partialMessageUnsubscribeRef.current) {
partialMessageUnsubscribeRef.current()
partialMessageUnsubscribeRef.current = null
@@ -724,6 +765,7 @@ export const ExtensionStateContextProvider: React.FC<{
settingsTargetSection,
showHistory,
showAccount,
showWorktrees,
showAnnouncement,
showChatModelSelector,
globalClineRulesToggles: state.globalClineRulesToggles || {},
@@ -743,12 +785,14 @@ export const ExtensionStateContextProvider: React.FC<{
navigateToSettings,
navigateToHistory,
navigateToAccount,
navigateToWorktrees,
navigateToChat,
// Hide functions
hideSettings,
hideHistory,
hideAccount,
hideWorktrees,
hideAnnouncement,
setShowAnnouncement,
hideChatModelSelector,