mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 740317e147 | |||
| 8184d0376a | |||
| 19479a019c | |||
| 4d7646a3e1 | |||
| 2e81610db5 | |||
| f2a19928bb | |||
| 3a4efce342 | |||
| b93c908028 | |||
| ed52d936f8 | |||
| ab4cdfe7d4 | |||
| 266832ae69 | |||
| b42a39333f | |||
| cf71c32053 | |||
| 43b58cc734 | |||
| 6f80b3298b | |||
| 029bdd3c6d | |||
| dbbf8ef56f | |||
| 52b4a0879e | |||
| db50a262cf | |||
| 7497266ba8 | |||
| e08e75e9f7 | |||
| a623f2097e | |||
| 0d513b2df3 | |||
| 60026520a9 | |||
| d5630ca2b9 | |||
| c22c672370 | |||
| bb11715fd1 | |||
| 5aed40b18f | |||
| 3a90e4aeda | |||
| cfed851601 | |||
| db362c64a1 | |||
| 552169c7a0 | |||
| 1a484d7040 | |||
| b32d4ca21c | |||
| c4f2c8abf1 | |||
| ae564467e7 | |||
| 6e75cdb578 | |||
| f03ba8bfbb | |||
| ef2d04e1da | |||
| 6dba8a5a97 | |||
| 90ddc14ce6 | |||
| 8d5e081b6e | |||
| c745ba2898 | |||
| b342aeb4ca | |||
| d07ab5fba4 | |||
| 63d622d1f7 | |||
| 2ebecdb405 | |||
| afd8620def | |||
| bd4434a8ed | |||
| 9a9b7523f9 | |||
| f52ca3444e | |||
| 2e86c8afdc | |||
| abf20d196f | |||
| 1385b0d24c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
@@ -1,6 +1,8 @@
|
||||
# Default
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
.worktrees/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"features/tasks/task-management"
|
||||
]
|
||||
},
|
||||
"features/worktrees",
|
||||
"features/yolo-mode"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "" }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user