Key changes: - Phase A: Mechanical extract-class refactor (low risk, ship first) - Phase B: Enable second workspace, refactor stores as needed - Defer store complexity until we have test infrastructure - Focus on testability as the driving motivation
Unified · Split
| @@ -1,289 +0,0 @@ | ||
| 1 | −# Cross-Workspace Thread Handling Plan | |
| 2 | − | |
| 3 | −## Overview | |
| 4 | − | |
| 5 | −This document outlines the design for handling agent threads across multiple workspaces, turning "zombie threads" from a bug into a feature: **background threads**. | |
| 6 | − | |
| 7 | −## Problem Statement | |
| 8 | − | |
| 9 | −Opening the same thread in multiple workspaces currently has significant issues: | |
| 10 | − | |
| 11 | −1. **Flow disruption**: Opening a thread in two windows causes them to get out of sync | |
| 12 | −2. **Zombie threads**: Closing a window does NOT stop the thread from running - it continues processing in the background | |
| 13 | −4. **Split brain**: Each workspace creates its own `AcpThread` entity with no coordination | |
| 14 | − | |
| 15 | −### Current Architecture | |
| 16 | − | |
| 17 | −| Aspect | Current Behavior | | |
| 18 | −|--------|------------------| | |
| 19 | −| Thread data | Persisted to SQLite database, shared across workspaces | | |
| 20 | −| Running state | Per-workspace (no coordination) | | |
| 21 | −| Thread views | Each workspace creates its own `AcpThreadView` and `AcpThread` entity | | |
| 22 | −| Coordination | None between workspaces for the same thread | | |
| 23 | − | |
| 24 | −## Research Findings | |
| 25 | − | |
| 26 | −### AcpThread is Project-Scoped | |
| 27 | − | |
| 28 | −`AcpThread` takes an `Entity<Project>` and uses it for: | |
| 29 | −- **Language registry** - syntax highlighting in markdown/diffs | |
| 30 | −- **Path style** - Windows vs Unix path formatting | |
| 31 | −- **Git store** - checkpoints and restore functionality | |
| 32 | −- **Buffer operations** - `read_text_file`, `write_text_file` via `project.open_buffer()` | |
| 33 | −- **Terminal environment** - directory environment for spawned terminals | |
| 34 | −- **Path resolution** - `project_path_for_absolute_path()` | |
| 35 | − | |
| 36 | −**Implication**: Threads cannot be meaningfully shared across projects. A thread from Project A cannot operate on Project B's files. | |
| 37 | − | |
| 38 | −### No Cancellation on Drop | |
| 39 | − | |
| 40 | −- `AcpThread` has no `Drop` implementation | |
| 41 | −- When `AcpThreadView` is dropped, it only cleans up notification windows | |
| 42 | −- The `send_task` (background LLM work) continues running when entities are dropped | |
| 43 | −- `cancel()` must be called explicitly to stop a running thread | |
| 44 | − | |
| 45 | −### Existing Patterns in Zed | |
| 46 | − | |
| 47 | −- **`BufferStore`**: Per-project store that provides `Entity<Buffer>` instances. Multiple `Editor`s can display the same buffer. Uses `WeakEntity` and deduplicates concurrent loads. | |
| 48 | −- **`ActiveCall`**: Global singleton for call/room state across workspaces. Uses `impl Global` pattern. | |
| 49 | −- **`WorkspaceStore`**: Tracks all open workspaces via `HashSet<WindowHandle<Workspace>>`. | |
| 50 | − | |
| 51 | −## Proposed Solution: AgentThreadStore | |
| 52 | − | |
| 53 | −Create a new **global** `AgentThreadStore` in a new `agent_2` crate that: | |
| 54 | −1. **Owns running threads** - holds strong references to keep them alive | |
| 55 | −2. **Tracks view state** - which window (if any) is viewing each thread | |
| 56 | −3. **Enables background threads** - threads continue running when windows close | |
| 57 | − | |
| 58 | −### Design | |
| 59 | − | |
| 60 | −```rust | |
| 61 | −// crates/agent_2/src/agent_thread_store.rs | |
| 62 | − | |
| 63 | −pub struct AgentThreadStore { | |
| 64 | − /// Thread metadata from database (the "history") | |
| 65 | − threads: Vec<DbThreadMetadata>, | |
| 66 | − | |
| 67 | − /// Running threads - store holds strong reference to keep them alive | |
| 68 | − /// These continue running even if no window is viewing them | |
| 69 | − running_threads: HashMap<acp::SessionId, Entity<AcpThread>>, | |
| 70 | − | |
| 71 | − /// Which window (if any) is currently viewing each running thread | |
| 72 | − /// None = running in background, Some(window) = open in that window | |
| 73 | − thread_views: HashMap<acp::SessionId, WindowHandle<Workspace>>, | |
| 74 | − | |
| 75 | − /// Recently opened for quick-access UI | |
| 76 | − recently_opened: VecDeque<acp::SessionId>, | |
| 77 | −} | |
| 78 | − | |
| 79 | −/// State of a thread for UI display | |
| 80 | −pub enum ThreadState { | |
| 81 | − /// Not running (historical entry only) | |
| 82 | − Idle, | |
| 83 | − /// Running and visible in a window | |
| 84 | − Running { window: WindowHandle<Workspace> }, | |
| 85 | − /// Running in background (no window viewing it) | |
| 86 | − Background, | |
| 87 | −} | |
| 88 | − | |
| 89 | −pub enum AgentThreadStoreEvent { | |
| 90 | − /// History list changed (thread added/removed/updated) | |
| 91 | − HistoryChanged, | |
| 92 | − /// A thread started running | |
| 93 | − ThreadStarted(acp::SessionId), | |
| 94 | − /// A thread stopped running | |
| 95 | − ThreadStopped(acp::SessionId), | |
| 96 | − /// A thread's view state changed (attached/detached from window) | |
| 97 | − ThreadViewChanged(acp::SessionId), | |
| 98 | −} | |
| 99 | − | |
| 100 | −/// Result of trying to open a thread | |
| 101 | −pub enum OpenResult { | |
| 102 | − /// Thread opened/attached in this window | |
| 103 | − Opened(Entity<AcpThread>), | |
| 104 | − /// Thread is open in a different window - that window's handle | |
| 105 | − OpenElsewhere(WindowHandle<Workspace>), | |
| 106 | −} | |
| 107 | − | |
| 108 | −impl AgentThreadStore { | |
| 109 | − // --- Global access --- | |
| 110 | − pub fn global(cx: &mut App) -> Entity<Self>; | |
| 111 | − | |
| 112 | − // --- History (read from database) --- | |
| 113 | − pub fn history(&self) -> &[DbThreadMetadata]; | |
| 114 | − pub fn recently_opened(&self) -> impl Iterator<Item = &DbThreadMetadata>; | |
| 115 | − pub fn reload(&mut self, cx: &mut Context<Self>) -> Task<()>; | |
| 116 | − | |
| 117 | − // --- Thread state --- | |
| 118 | − | |
| 119 | − /// Get state of a thread for UI display | |
| 120 | − pub fn thread_state(&self, session_id: &acp::SessionId) -> ThreadState; | |
| 121 | − | |
| 122 | − /// Open/attach to a thread in this window. | |
| 123 | − /// - If idle: caller should create thread, then call `register_running` | |
| 124 | − /// - If background: attaches to this window | |
| 125 | − /// - If open elsewhere: returns that window handle | |
| 126 | − pub fn open_thread( | |
| 127 | − &mut self, | |
| 128 | − session_id: &acp::SessionId, | |
| 129 | − window: WindowHandle<Workspace>, | |
| 130 | − cx: &mut Context<Self>, | |
| 131 | − ) -> OpenResult; | |
| 132 | − | |
| 133 | − /// Register a newly created running thread | |
| 134 | − pub fn register_running( | |
| 135 | − &mut self, | |
| 136 | − session_id: acp::SessionId, | |
| 137 | − thread: Entity<AcpThread>, | |
| 138 | − window: WindowHandle<Workspace>, | |
| 139 | − cx: &mut Context<Self>, | |
| 140 | − ); | |
| 141 | − | |
| 142 | − /// Detach from a thread (window closing or user explicitly detaching) | |
| 143 | − /// Thread keeps running in background | |
| 144 | − pub fn detach_thread( | |
| 145 | − &mut self, | |
| 146 | − session_id: &acp::SessionId, | |
| 147 | − cx: &mut Context<Self>, | |
| 148 | − ); | |
| 149 | − | |
| 150 | − /// Stop a thread completely (user explicitly stopping) | |
| 151 | − pub fn stop_thread( | |
| 152 | − &mut self, | |
| 153 | − session_id: &acp::SessionId, | |
| 154 | − cx: &mut Context<Self>, | |
| 155 | − ); | |
| 156 | − | |
| 157 | − // --- Recently opened management --- | |
| 158 | − pub fn mark_recently_opened( | |
| 159 | − &mut self, | |
| 160 | − session_id: acp::SessionId, | |
| 161 | − cx: &mut Context<Self>, | |
| 162 | − ); | |
| 163 | −} | |
| 164 | −``` | |
| 165 | − | |
| 166 | −### Usage Flow | |
| 167 | − | |
| 168 | −**Opening a thread from history:** | |
| 169 | −```rust | |
| 170 | −fn open_thread(&mut self, session_id: acp::SessionId, window: &mut Window, cx: &mut Context<Self>) { | |
| 171 | − let store = AgentThreadStore::global(cx); | |
| 172 | − let this_window = window.window_handle().downcast::<Workspace>().unwrap(); | |
| 173 | − | |
| 174 | − let state = store.read(cx).thread_state(&session_id); | |
| 175 | − | |
| 176 | − match state { | |
| 177 | − ThreadState::Running { window } if window == this_window => { | |
| 178 | − // Already open here, just focus it | |
| 179 | − self.focus_thread_view(cx); | |
| 180 | − } | |
| 181 | − ThreadState::Running { window } => { | |
| 182 | − // Open in another window, activate that | |
| 183 | − cx.activate_window(window); | |
| 184 | − } | |
| 185 | − ThreadState::Background => { | |
| 186 | − // Running in background, attach to this window | |
| 187 | − store.update(cx, |store, cx| { | |
| 188 | − store.open_thread(&session_id, this_window, cx); | |
| 189 | − }); | |
| 190 | − self.show_thread_view(session_id, cx); | |
| 191 | − } | |
| 192 | − ThreadState::Idle => { | |
| 193 | − // Not running, need to create/resume | |
| 194 | − let thread = self.create_thread(session_id.clone(), cx); | |
| 195 | − store.update(cx, |store, cx| { | |
| 196 | − store.register_running(session_id, thread, this_window, cx); | |
| 197 | − }); | |
| 198 | − } | |
| 199 | − } | |
| 200 | −} | |
| 201 | −``` | |
| 202 | − | |
| 203 | −**Window close handling:** | |
| 204 | −```rust | |
| 205 | −fn on_window_close(&mut self, window: WindowHandle<Workspace>, cx: &mut App) { | |
| 206 | − let store = AgentThreadStore::global(cx); | |
| 207 | − store.update(cx, |store, cx| { | |
| 208 | − // Detach all threads viewed in this window - they become background threads | |
| 209 | − for (session_id, view_window) in store.thread_views.iter() { | |
| 210 | − if *view_window == window { | |
| 211 | − store.detach_thread(session_id, cx); | |
| 212 | − } | |
| 213 | − } | |
| 214 | − }); | |
| 215 | −} | |
| 216 | −``` | |
| 217 | − | |
| 218 | −**History view rendering:** | |
| 219 | −```rust | |
| 220 | −fn render_history_entry(&self, entry: &DbThreadMetadata, cx: &App) -> impl IntoElement { | |
| 221 | − let store = AgentThreadStore::global(cx); | |
| 222 | − let state = store.read(cx).thread_state(&entry.id); | |
| 223 | − | |
| 224 | − let status_indicator = match state { | |
| 225 | − ThreadState::Running { window } if window == self.this_window => { | |
| 226 | − Icon::new(IconName::Play).color(Color::Success) // 🟢 Running here | |
| 227 | − } | |
| 228 | − ThreadState::Running { .. } => { | |
| 229 | − Icon::new(IconName::Play).color(Color::Info) // 🔵 Running elsewhere | |
| 230 | − } | |
| 231 | − ThreadState::Background => { | |
| 232 | − Icon::new(IconName::CloudUpload).color(Color::Warning) // 🟡 Background | |
| 233 | − } | |
| 234 | − ThreadState::Idle => { | |
| 235 | − Icon::new(IconName::Circle).color(Color::Muted) // ⚪ Idle | |
| 236 | − } | |
| 237 | − }; | |
| 238 | − | |
| 239 | − div() | |
| 240 | − .child(status_indicator) | |
| 241 | − .child(Label::new(&entry.title)) | |
| 242 | −} | |
| 243 | −``` | |
| 244 | − | |
| 245 | −### Key Design Decisions | |
| 246 | − | |
| 247 | −1. **Global store owns running threads**: Strong `Entity<AcpThread>` references keep threads alive | |
| 248 | −2. **Background threads are a feature**: Closing a window detaches, doesn't kill | |
| 249 | −3. **Single view per thread**: Only one window can view a thread at a time | |
| 250 | −4. **Redirect, don't duplicate**: If a thread is open elsewhere, redirect to that window | |
| 251 | − | |
| 252 | −## Future Vision | |
| 253 | − | |
| 254 | −This refactor sets the foundation for a deeper architectural change: **threads as the primary object**. | |
| 255 | − | |
| 256 | −In the future, threads will become richer objects that the editor UI is downstream of: | |
| 257 | −- Each thread has its own worktrees | |
| 258 | −- Each thread has its own workspace state | |
| 259 | −- The editor becomes a view into a thread's state | |
| 260 | −- Multiple threads can be running simultaneously, each with their own context | |
| 261 | − | |
| 262 | −This is similar to how modern AI coding tools treat "sessions" or "conversations" as first-class workspace containers. | |
| 263 | − | |
| 264 | −The current refactor provides immediate value by: | |
| 265 | −1. ✅ Fixing the split-brain problem | |
| 266 | −2. ✅ Enabling background threads as a feature | |
| 267 | −3. ✅ Establishing the `AgentThreadStore` as the single source of truth | |
| 268 | −4. ✅ Setting up the ownership model for the future vision | |
| 269 | − | |
| 270 | −## Implementation Plan | |
| 271 | − | |
| 272 | −1. [ ] Create new `agent_2` crate | |
| 273 | −2. [ ] Implement `AgentThreadStore` with global access pattern | |
| 274 | −3. [ ] Implement thread state tracking (`running_threads`, `thread_views`) | |
| 275 | −4. [ ] Add `open_thread` / `detach_thread` / `stop_thread` APIs | |
| 276 | −5. [ ] Wire up `AgentsPanel` (v2) to use `AgentThreadStore` | |
| 277 | −6. [ ] Add window close handling to detach threads | |
| 278 | −7. [ ] Update history view to show thread state (running/background/idle) | |
| 279 | −8. [ ] Add tests for cross-workspace scenarios | |
| 280 | −9. [ ] Add UI affordances for background threads (attach, stop, etc.) | |
| 281 | − | |
| 282 | −## Related Files | |
| 283 | − | |
| 284 | −- `crates/agent_ui_v2/src/agents_panel.rs` - Will consume AgentThreadStore | |
| 285 | −- `crates/agent_ui_v2/src/agent_thread_pane.rs` - Thread pane management | |
| 286 | −- `crates/acp_thread/src/acp_thread.rs` - Thread entity and lifecycle | |
| 287 | −- `crates/agent/src/history_store.rs` - Existing history store (for reference) | |
| 288 | −- `crates/agent/src/db.rs` - Thread database types (DbThreadMetadata, etc.) | |
| 289 | −- `crates/workspace/src/workspace.rs` - Workspace lifecycle, WorkspaceStore pattern | |
| @@ -6,7 +6,7 @@ We are refactoring Zed to support multiple Workspaces that share worktrees. The · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | ### Current Architecture |
| 8 | 8 | ``` |
| 9 | −Workspace | |
| 9 | +Workspace (being renamed to MultiWorkspace) | |
| 10 | 10 | └── Entity<Project> |
| 11 | 11 | └── Entity<WorktreeStore> // Project creates and owns this |
| 12 | 12 | └── Entity<Worktree>... |
| @@ -16,10 +16,10 @@ Workspace · 3 unmodified lines | ||
| 16 | 16 | └── ... other stores |
| 17 | 17 | ``` |
| 18 | 18 | |
| 19 | −### New Architecture | |
| 19 | +### Target Architecture | |
| 20 | 20 | ``` |
| 21 | −MultiWorkspace (new top-level container) | |
| 22 | −├── Entity<WorktreeStore> // Shared, contains mix of local AND remote worktrees | |
| 21 | +MultiWorkspace (window-level container) | |
| 22 | +├── Entity<WorktreeStore> // Shared, eventually contains mix of local AND remote worktrees | |
| 23 | 23 | ├── Entity<BufferStore> // Shared - same file = same buffer |
| 24 | 24 | ├── Entity<GitStore> // Shared - git state is per-repo |
| 25 | 25 | ├── Entity<LspStore> // Shared - one LSP instance per worktree |
| @@ -33,6 +33,7 @@ MultiWorkspace (new top-level container) · 7 unmodified lines | ||
| 33 | 33 | │ ├── Entity<DapStore> // Per-workspace (debugging sessions) |
| 34 | 34 | │ ├── Entity<TaskStore> // Per-workspace (task execution context) |
| 35 | 35 | │ └── (references to shared stores) |
| 36 | +│ └── Panes, Panels, Docks (UI) | |
| 36 | 37 | │ |
| 37 | 38 | └── Workspace B |
| 38 | 39 | └── Entity<Project> |
| @@ -40,40 +41,115 @@ MultiWorkspace (new top-level container) · 1 unmodified line | ||
| 40 | 41 | ├── client_state: ProjectClientState |
| 41 | 42 | ├── Entity<DapStore> |
| 42 | 43 | └── Entity<TaskStore> |
| 44 | + └── Panes, Panels, Docks (UI) | |
| 43 | 45 | ``` |
| 44 | 46 | |
| 47 | +## Implementation Strategy | |
| 48 | + | |
| 49 | +The key insight is: **defer store complexity until we actually have two workspaces trying to share them**. This gives us concrete test cases and clearer requirements. | |
| 50 | + | |
| 51 | +### Phase A: Structural Rename (Ship First) | |
| 52 | + | |
| 53 | +**Goal**: Extract `Workspace` from `MultiWorkspace` as a mechanical refactor with no behavior change. | |
| 54 | + | |
| 55 | +**Current State**: | |
| 56 | +- `Workspace` has been renamed to `MultiWorkspace` ✅ | |
| 57 | +- Stub `single_workspace::Workspace` module created ✅ | |
| 58 | + | |
| 59 | +**What Moves to `Workspace`** (the new inner struct): | |
| 60 | +- `Entity<Project>` - workspace owns its project | |
| 61 | +- `center: PaneGroup` - pane layout | |
| 62 | +- `panes: Vec<Entity<Pane>>` - all panes | |
| 63 | +- `active_pane: Entity<Pane>` | |
| 64 | +- `left_dock`, `right_dock`, `bottom_dock` - dock UI | |
| 65 | +- `status_bar` - per-workspace status | |
| 66 | +- `modal_layer`, `toast_layer` - per-workspace overlays | |
| 67 | +- `notifications` - per-workspace notifications | |
| 68 | +- Most UI rendering logic | |
| 69 | + | |
| 70 | +**What Stays in `MultiWorkspace`**: | |
| 71 | +- `Entity<WorktreeStore>` - shared (passed down to Projects) | |
| 72 | +- `Entity<Workspace>` - just one for now | |
| 73 | +- `weak_self`, `app_state` - window-level concerns | |
| 74 | +- `workspace_actions` - action registration | |
| 75 | +- `database_id`, serialization concerns | |
| 76 | +- `WorkspaceStore` registration | |
| 77 | +- Window title management | |
| 78 | + | |
| 79 | +**This is "extract class" refactoring** - moving fields/methods down without changing behavior. Risk is low because it's mechanical. | |
| 80 | + | |
| 81 | +**Merge Point**: Ship this rename, get it tested in the wild. | |
| 82 | + | |
| 83 | +### Phase B: Enable Second Workspace (Real Refactor) | |
| 84 | + | |
| 85 | +**Goal**: Actually support multiple Workspace instances, refactoring stores as needed. | |
| 86 | + | |
| 87 | +**Why This Order?**: You can't write integration tests for shared state without two workspaces. Once you have: | |
| 88 | + | |
| 89 | +```rust | |
| 90 | +struct MultiWorkspace { | |
| 91 | + worktree_store: Entity<WorktreeStore>, | |
| 92 | + workspaces: Vec<Entity<Workspace>>, | |
| 93 | +} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +Then you can write tests like: | |
| 97 | + | |
| 98 | +```rust | |
| 99 | +#[gpui::test] | |
| 100 | +async fn test_two_workspaces_share_buffer(cx: &mut TestAppContext) { | |
| 101 | + let multi = cx.new(|cx| MultiWorkspace::new(...)); | |
| 102 | + | |
| 103 | + // Add a worktree to the shared store | |
| 104 | + let worktree_id = multi.update(cx, |m, cx| m.add_worktree("/path", cx)).await; | |
| 105 | + | |
| 106 | + // Create two workspaces viewing the same worktree | |
| 107 | + let ws_a = multi.update(cx, |m, cx| m.add_workspace(cx)); | |
| 108 | + let ws_b = multi.update(cx, |m, cx| m.add_workspace(cx)); | |
| 109 | + | |
| 110 | + // Open same file in both, verify it's the same buffer | |
| 111 | + // Edit in one, verify it appears in the other | |
| 112 | +} | |
| 113 | +``` | |
| 114 | + | |
| 115 | +**Store Refactors** (driven by test failures): | |
| 116 | +1. `WorktreeStore` - Remove `WorktreeStoreState` enum, push local/remote distinction into individual worktrees | |
| 117 | +2. `BufferStore` - Lift to MultiWorkspace so same file = same buffer | |
| 118 | +3. `GitStore` - Lift to MultiWorkspace (git state is per-repo) | |
| 119 | +4. `LspStore` - Lift to MultiWorkspace (one LSP per worktree) | |
| 120 | +5. Keep `DapStore`, `TaskStore` per-workspace | |
| 121 | + | |
| 122 | +**Project Changes**: | |
| 123 | +- Add `worktree_ids: HashSet<WorktreeId>` to filter which worktrees it "views" | |
| 124 | +- `Project::worktrees()` filters by this set | |
| 125 | +- Project receives shared store references from MultiWorkspace | |
| 126 | + | |
| 45 | 127 | ## Key Design Decisions |
| 46 | 128 | |
| 47 | −### 1. WorktreeStore Contains Mixed Local/Remote Worktrees | |
| 129 | +### 1. WorktreeStore Will Contain Mixed Local/Remote Worktrees | |
| 48 | 130 | |
| 49 | −The shared WorktreeStore can contain both local and remote worktrees simultaneously: | |
| 131 | +Eventually the shared WorktreeStore can contain both local and remote worktrees: | |
| 50 | 132 | - `LocalWorktree` for `~/projects/foo` (local filesystem) |
| 51 | 133 | - `RemoteWorktree` for `server-a:/home/user/bar` (SSH connection) |
| 52 | −- `RemoteWorktree` for `server-b:/work/baz` (different SSH connection) | |
| 53 | 134 | |
| 54 | −This works because **transport lives at the Worktree level**, not WorktreeStore level: | |
| 135 | +This works because **transport lives at the Worktree level**: | |
| 55 | 136 | - `LocalWorktree` has `fs: Arc<dyn Fs>` |
| 56 | 137 | - `RemoteWorktree` has `client: AnyProtoClient` and `project_id: u64` |
| 57 | 138 | |
| 58 | −When BufferStore opens a file, it delegates to the Worktree via `worktree.load_file()`, so the Worktree handles its own transport. | |
| 59 | − | |
| 60 | −**Required change**: Remove `WorktreeStoreState` enum (currently Local vs Remote). Instead, provide explicit methods for adding local vs remote worktrees: | |
| 139 | +**Required change** (Phase B): Remove `WorktreeStoreState` enum. Add explicit methods: | |
| 61 | 140 | ```rust |
| 62 | 141 | impl WorktreeStore { |
| 63 | − pub fn add_local_worktree(&mut self, path: &Path, fs: Arc<dyn Fs>, cx: ...) -> Task<...> | |
| 142 | + pub fn add_local_worktree(&mut self, path: &Path, fs: Arc<dyn Fs>, ...) -> Task<...> | |
| 64 | 143 | pub fn add_remote_worktree(&mut self, client: AnyProtoClient, project_id: u64, ...) -> Task<...> |
| 65 | 144 | } |
| 66 | 145 | ``` |
| 67 | 146 | |
| 68 | 147 | ### 2. Project Becomes a "View" Over Shared Worktrees |
| 69 | 148 | |
| 70 | −Project tracks which worktrees it contains via `worktree_ids: HashSet<WorktreeId>`: | |
| 71 | − | |
| 72 | 149 | ```rust |
| 73 | 150 | struct Project { |
| 74 | 151 | worktree_ids: HashSet<WorktreeId>, |
| 75 | 152 | worktree_store: Entity<WorktreeStore>, // Reference to shared store |
| 76 | − // ... | |
| 77 | 153 | } |
| 78 | 154 | |
| 79 | 155 | impl Project { |
| @@ -85,38 +161,28 @@ impl Project { · 5 unmodified lines | ||
| 85 | 161 | } |
| 86 | 162 | ``` |
| 87 | 163 | |
| 88 | −Worktrees are **oblivious to Projects** - they don't track which Projects reference them. This keeps the relationship clean and unidirectional. | |
| 164 | +Worktrees are **oblivious to Projects** - they don't track which Projects reference them. | |
| 89 | 165 | |
| 90 | 166 | ### 3. Shared Stores vs Per-Workspace Stores |
| 91 | 167 | |
| 92 | −**Shared at MultiWorkspace level** (same file = same entity everywhere): | |
| 93 | −- `BufferStore` - Edits in Workspace A are immediately visible in Workspace B | |
| 168 | +**Eventually Shared** (same file = same entity everywhere): | |
| 169 | +- `BufferStore` - Edits in Workspace A visible in Workspace B | |
| 94 | 170 | - `GitStore` - Git state is per-repository |
| 95 | −- `LspStore` - One language server instance per worktree, shared across workspaces | |
| 171 | +- `LspStore` - One language server instance per worktree | |
| 96 | 172 | - `ImageStore` - Same as BufferStore |
| 97 | 173 | - `BreakpointStore` - Breakpoints are on files |
| 98 | 174 | |
| 99 | 175 | **Per-Workspace** (workspace-specific context): |
| 100 | 176 | - `DapStore` - Debugging sessions are focused activities |
| 101 | 177 | - `TaskStore` - Tasks run in a specific workspace context |
| 102 | −- `ContextServerStore` - Context servers may be workspace-scoped | |
| 103 | − | |
| 104 | −### 4. Collaboration Model (Option C: Share at Project Level) | |
| 105 | 178 | |
| 106 | −Collaboration happens at the **Project level**, not WorktreeStore level: | |
| 107 | −- A Project can be shared (gets a `remote_id` in `ProjectClientState::Shared`) | |
| 108 | −- Collaborators join a specific Project and see its worktrees | |
| 109 | −- The same worktree can be in multiple Projects, but... | |
| 179 | +### 4. Collaboration Constraint | |
| 110 | 180 | |
| 111 | −**Constraint**: A worktree can only be in ONE shared Project at a time. | |
| 112 | − | |
| 113 | − | |
| 114 | −When sharing, validate: | |
| 181 | +A worktree can only be in ONE shared Project at a time. Validate when sharing: | |
| 115 | 182 | ```rust |
| 116 | 183 | impl Project { |
| 117 | 184 | pub fn share(&mut self, cx: &mut Context<Self>) -> Task<Result<u64>> { |
| 118 | 185 | // Check if any of our worktrees are already in a shared project |
| 119 | − let multi_workspace = MultiWorkspace::global(cx); | |
| 120 | 186 | for worktree_id in &self.worktree_ids { |
| 121 | 187 | if let Some(other_project) = multi_workspace.find_sharing_project_for_worktree(*worktree_id, cx) { |
| 122 | 188 | if other_project != cx.entity() { |
| @@ -132,100 +198,19 @@ impl Project { · 9 unmodified lines | ||
| 132 | 198 | } |
| 133 | 199 | ``` |
| 134 | 200 | |
| 135 | −### 5. RemoteWorktree.project_id Clarification | |
| 136 | − | |
| 137 | −There are two different IDs to understand: | |
| 138 | −1. **`RemoteWorktree.project_id`** - The upstream source ID (e.g., SSH server's project ID). This is for RPC with the server hosting the files. | |
| 139 | −2. **`Project.client_state.remote_id`** - The collaboration ID when this Project is shared with others. | |
| 140 | − | |
| 141 | −These are orthogonal. A RemoteWorktree connected via SSH has a fixed `project_id` for that connection. That worktree can be part of multiple local Projects with different collaboration states. | |
| 142 | − | |
| 143 | −### 6. Worktree Lifecycle | |
| 144 | − | |
| 145 | −Worktrees are reference-counted in the shared WorktreeStore: | |
| 146 | −- When a Workspace closes, its Project removes its worktree references | |
| 147 | −- The worktree stays alive if another Project still references it | |
| 148 | −- When the last reference is removed, the worktree is dropped | |
| 149 | − | |
| 150 | −## Implementation Plan | |
| 151 | − | |
| 152 | −### Phase 1: Create MultiWorkspace Container | |
| 153 | − | |
| 154 | −1. Create new `MultiWorkspace` struct that holds: | |
| 155 | − - `Entity<WorktreeStore>` | |
| 156 | − - `Entity<BufferStore>` | |
| 157 | − - `Entity<GitStore>` | |
| 158 | − - `Entity<LspStore>` | |
| 159 | − - `Entity<ImageStore>` | |
| 160 | − - `Entity<BreakpointStore>` | |
| 161 | − - `Vec<Entity<Workspace>>` or similar | |
| 162 | − | |
| 163 | −2. Make MultiWorkspace global or per-window (TBD based on window model) | |
| 164 | − | |
| 165 | −3. Update Workspace creation to: | |
| 166 | − - Get/create MultiWorkspace | |
| 167 | − - Create Project with reference to shared WorktreeStore | |
| 168 | − - Pass shared stores to Project | |
| 169 | − | |
| 170 | −### Phase 2: Refactor WorktreeStore | |
| 171 | − | |
| 172 | −1. Remove `WorktreeStoreState` enum | |
| 173 | −2. Add explicit `add_local_worktree` and `add_remote_worktree` methods | |
| 174 | −3. Update `find_or_create_worktree` to take connection info parameter | |
| 175 | − | |
| 176 | −### Phase 3: Refactor Project | |
| 177 | − | |
| 178 | −1. Add `worktree_ids: HashSet<WorktreeId>` field | |
| 179 | −2. Change `worktree_store` from owned to shared reference | |
| 180 | −3. Update all methods that iterate worktrees to filter by `worktree_ids` | |
| 181 | −4. Move DapStore and TaskStore creation into Project (keep them per-workspace) | |
| 182 | −5. Add references to shared stores (BufferStore, GitStore, etc.) | |
| 183 | − | |
| 184 | −### Phase 4: Refactor Store Creation | |
| 185 | − | |
| 186 | −1. Move BufferStore, GitStore, LspStore, ImageStore, BreakpointStore creation to MultiWorkspace | |
| 187 | −2. Update these stores to work with the shared WorktreeStore | |
| 188 | −3. Ensure stores handle worktrees being added/removed dynamically | |
| 189 | − | |
| 190 | −### Phase 5: Update Collaboration | |
| 191 | − | |
| 192 | −1. Add sharing validation (prevent double-sharing of worktrees) | |
| 193 | −2. Update `share_project` to work with the new architecture | |
| 194 | −3. Test collaboration scenarios with multiple workspaces | |
| 195 | − | |
| 196 | −### Phase 6: Update Consumers | |
| 197 | − | |
| 198 | −Many places call `workspace.project()` and expect to access stores. Audit and update: | |
| 199 | −- Panels (AgentPanel, GitPanel, etc.) | |
| 200 | −- Activity indicator | |
| 201 | −- File opening code paths | |
| 202 | −- Search functionality | |
| 203 | − | |
| 204 | −## Key Files to Modify | |
| 205 | − | |
| 206 | −- `crates/project/src/project.rs` - Major refactor | |
| 207 | −- `crates/project/src/worktree_store.rs` - Remove state enum, add explicit methods | |
| 208 | −- `crates/project/src/buffer_store.rs` - May need state simplification | |
| 209 | −- `crates/workspace/src/workspace.rs` - Update to use MultiWorkspace | |
| 210 | −- `crates/call/src/call_impl/room.rs` - Update sharing logic | |
| 211 | −- New file: `crates/workspace/src/multi_workspace.rs` or similar | |
| 212 | − | |
| 213 | −## Testing Considerations | |
| 214 | − | |
| 215 | −1. **Multi-workspace scenarios**: Create tests where two workspaces share worktrees | |
| 216 | −2. **Edit propagation**: Verify edits in one workspace appear in another | |
| 217 | −3. **Collaboration with shared worktrees**: Test sharing when worktrees overlap | |
| 218 | −4. **Mixed local/remote**: Test WorktreeStore with both local and SSH worktrees | |
| 219 | −5. **Worktree lifecycle**: Test closing workspaces and worktree cleanup | |
| 220 | − | |
| 221 | −## Open Questions for Implementation | |
| 201 | +## Key Files | |
| 222 | 202 | |
| 223 | −1. Should MultiWorkspace be global (one per app) or per-window? | |
| 224 | −2. How should we handle the transition for existing serialized workspaces? | |
| 225 | −3. Should there be UI for managing worktrees across workspaces? | |
| 203 | +- `crates/workspace/src/workspace.rs` - Contains `MultiWorkspace`, will contain `Workspace` | |
| 204 | +- `crates/workspace/src/single_workspace.rs` - Stub module (created) | |
| 205 | +- `crates/project/src/project.rs` - Will add `worktree_ids` filter | |
| 206 | +- `crates/project/src/worktree_store.rs` - Will remove `WorktreeStoreState` enum | |
| 226 | 207 | |
| 227 | −## References | |
| 208 | +## Current Status | |
| 228 | 209 | |
| 229 | −- Original detailed plan: `threads-sidebar-plan.md` (some concepts superseded) | |
| 230 | −- Comment in `crates/project/src/project.rs` lines 162-172 showing target architecture | |
| 231 | −- Current Project::local() and Project::remote() constructors show store creation patterns | |
| 210 | +- [x] Rename `Workspace` → `MultiWorkspace` (done) | |
| 211 | +- [x] Create stub `single_workspace::Workspace` module (done) | |
| 212 | +- [ ] Extract UI/pane/panel ownership from `MultiWorkspace` into `Workspace` | |
| 213 | +- [ ] Pass `WorktreeStore` from `MultiWorkspace` down to `Project` | |
| 214 | +- [ ] **MERGE POINT** | |
| 215 | +- [ ] Add second `Workspace` support | |
| 216 | +- [ ] Refactor stores as needed for sharing | |
| @@ -1,1067 +0,0 @@ | ||
| 1 | −# Phase 2: MultiWorkspace Implementation Plan | |
| 2 | − | |
| 3 | −This document details Phase 2 of the MultiWorkspace Agent V2 Refactor. Phase 1 (GlobalThreadStore Infrastructure) is a prerequisite. | |
| 4 | − | |
| 5 | −## Overview | |
| 6 | − | |
| 7 | −Enable the workspace to swap between different ThreadWorktrees contexts while keeping state in memory. **Key insight:** Project stays singular per window; we swap which ThreadWorktrees group is active. | |
| 8 | − | |
| 9 | −### Goals | |
| 10 | − | |
| 11 | −1. Create `ThreadWorktrees` as a scoped lens into `Project` for panels | |
| 12 | −2. Implement `WorkspaceState` to store per-thread UI state (panes, panels) | |
| 13 | −3. Enable switching between thread contexts without losing state | |
| 14 | −4. Migrate existing panels from `Entity<Project>` to `Entity<ThreadWorktrees>` | |
| 15 | − | |
| 16 | −### Dependencies | |
| 17 | − | |
| 18 | −- **Phase 1 Complete**: `GlobalThreadStore` with project paths stored per thread | |
| 19 | −- **Feature Flag**: All changes gated behind `agent-v2` feature flag | |
| 20 | − | |
| 21 | −--- | |
| 22 | − | |
| 23 | −## 2.1 New Concept: ThreadWorktrees | |
| 24 | − | |
| 25 | −### ThreadWorktreesId | |
| 26 | − | |
| 27 | −The identity that ties a thread to workspace-level state: | |
| 28 | − | |
| 29 | −```rust | |
| 30 | −// New file: crates/project/src/thread_worktrees.rs | |
| 31 | − | |
| 32 | −use gpui::{App, Context, Entity, EventEmitter, Task, WeakEntity}; | |
| 33 | −use collections::HashSet; | |
| 34 | −use std::path::PathBuf; | |
| 35 | −use anyhow::{Result, anyhow}; | |
| 36 | − | |
| 37 | −/// Identifies a ThreadWorktrees group - glues thread identity to workspace identity | |
| 38 | −#[derive(Clone, Hash, Eq, PartialEq, Debug)] | |
| 39 | −pub struct ThreadWorktreesId { | |
| 40 | − /// None for "default" (no thread active) | |
| 41 | − pub session_id: Option<acp::SessionId>, | |
| 42 | − pub workspace_db_id: WorkspaceId, | |
| 43 | −} | |
| 44 | − | |
| 45 | −impl ThreadWorktreesId { | |
| 46 | − pub fn default_for_workspace(workspace_db_id: WorkspaceId) -> Self { | |
| 47 | − Self { | |
| 48 | − session_id: None, | |
| 49 | − workspace_db_id, | |
| 50 | − } | |
| 51 | − } | |
| 52 | − | |
| 53 | − pub fn for_thread(session_id: acp::SessionId, workspace_db_id: WorkspaceId) -> Self { | |
| 54 | − Self { | |
| 55 | − session_id: Some(session_id), | |
| 56 | − workspace_db_id, | |
| 57 | − } | |
| 58 | − } | |
| 59 | −} | |
| 60 | −``` | |
| 61 | − | |
| 62 | −### ThreadWorktrees Struct | |
| 63 | − | |
| 64 | −```rust | |
| 65 | −/// Groups worktrees that belong to a specific thread context. | |
| 66 | −/// Acts as a filtered lens into Project for panels. | |
| 67 | −pub struct ThreadWorktrees { | |
| 68 | − id: ThreadWorktreesId, | |
| 69 | − project: Entity<Project>, | |
| 70 | − worktree_ids: HashSet<WorktreeId>, | |
| 71 | − folder_paths: Vec<PathBuf>, // Absolute paths for persistence | |
| 72 | − _subscriptions: Vec<gpui::Subscription>, | |
| 73 | −} | |
| 74 | −``` | |
| 75 | − | |
| 76 | −### Project Additions | |
| 77 | − | |
| 78 | −Modify `crates/project/src/project.rs`: | |
| 79 | − | |
| 80 | −```rust | |
| 81 | −pub struct Project { | |
| 82 | − // EXISTING: all worktrees for all live threads in this window | |
| 83 | − worktree_store: Entity<WorktreeStore>, | |
| 84 | − | |
| 85 | − // NEW: Groups worktrees by thread | |
| 86 | − thread_worktrees: HashMap<ThreadWorktreesId, Entity<ThreadWorktrees>>, | |
| 87 | − | |
| 88 | − // NEW: Currently active thread context | |
| 89 | − active_thread_worktrees_id: Option<ThreadWorktreesId>, | |
| 90 | − | |
| 91 | − // ... existing fields ... | |
| 92 | −} | |
| 93 | − | |
| 94 | −impl Project { | |
| 95 | − /// Get or create ThreadWorktrees for a given ID | |
| 96 | − pub fn thread_worktrees( | |
| 97 | − &mut self, | |
| 98 | − id: &ThreadWorktreesId, | |
| 99 | − cx: &mut Context<Self>, | |
| 100 | − ) -> Entity<ThreadWorktrees> { | |
| 101 | − if let Some(tw) = self.thread_worktrees.get(id) { | |
| 102 | − return tw.clone(); | |
| 103 | − } | |
| 104 | − | |
| 105 | − // Create new ThreadWorktrees with worktrees based on thread's folder_paths | |
| 106 | − let worktree_ids = self.worktree_ids_for_thread(id, cx); | |
| 107 | − let thread_worktrees = cx.new(|cx| { | |
| 108 | − ThreadWorktrees::new(id.clone(), cx.entity().clone(), worktree_ids, cx) | |
| 109 | − }); | |
| 110 | − self.thread_worktrees.insert(id.clone(), thread_worktrees.clone()); | |
| 111 | − thread_worktrees | |
| 112 | − } | |
| 113 | − | |
| 114 | − /// Set the active thread context | |
| 115 | − pub fn set_active_thread_worktrees( | |
| 116 | − &mut self, | |
| 117 | − id: Option<ThreadWorktreesId>, | |
| 118 | − cx: &mut Context<Self>, | |
| 119 | − ) { | |
| 120 | − self.active_thread_worktrees_id = id; | |
| 121 | − cx.notify(); | |
| 122 | − } | |
| 123 | − | |
| 124 | − /// Get the currently active ThreadWorktrees | |
| 125 | − pub fn active_thread_worktrees(&self, cx: &App) -> Option<Entity<ThreadWorktrees>> { | |
| 126 | − self.active_thread_worktrees_id | |
| 127 | − .as_ref() | |
| 128 | − .and_then(|id| self.thread_worktrees.get(id).cloned()) | |
| 129 | − } | |
| 130 | −} | |
| 131 | −``` | |
| 132 | − | |
| 133 | −### Worktree Lifecycle | |
| 134 | − | |
| 135 | −- Worktrees are automatically created/loaded when opening a thread | |
| 136 | −- Live threads (running in background) keep their worktrees loaded | |
| 137 | −- Worktrees shared between threads (e.g., /cloud in threads A and B) exist once in Project but are referenced by multiple ThreadWorktrees | |
| 138 | − | |
| 139 | −--- | |
| 140 | − | |
| 141 | −## 2.2 ThreadWorktrees as Panel API Surface | |
| 142 | − | |
| 143 | −Panels currently hold `Entity<Project>` and call methods like `project.read(cx).git_store()`. In multi-workspace, panels need **scoped access** - GitPanel for ThreadWorktrees A shouldn't see repos from ThreadWorktrees B. | |
| 144 | − | |
| 145 | −### Current Panel Pattern (to migrate from) | |
| 146 | − | |
| 147 | −Based on existing code analysis: | |
| 148 | − | |
| 149 | −```rust | |
| 150 | −// crates/git_ui/src/git_panel.rs - Current | |
| 151 | −pub struct GitPanel { | |
| 152 | − pub(crate) project: Entity<Project>, | |
| 153 | − pub(crate) active_repository: Option<Entity<Repository>>, | |
| 154 | − // ... | |
| 155 | −} | |
| 156 | − | |
| 157 | −// crates/project_panel/src/project_panel.rs - Current | |
| 158 | −pub struct ProjectPanel { | |
| 159 | − project: Entity<Project>, | |
| 160 | − fs: Arc<dyn Fs>, | |
| 161 | − // ... | |
| 162 | −} | |
| 163 | −``` | |
| 164 | − | |
| 165 | −### ThreadWorktrees API Implementation | |
| 166 | − | |
| 167 | −```rust | |
| 168 | −// crates/project/src/thread_worktrees.rs | |
| 169 | − | |
| 170 | −impl ThreadWorktrees { | |
| 171 | − pub fn new( | |
| 172 | − id: ThreadWorktreesId, | |
| 173 | − project: Entity<Project>, | |
| 174 | − worktree_ids: HashSet<WorktreeId>, | |
| 175 | − cx: &mut Context<Self>, | |
| 176 | − ) -> Self { | |
| 177 | − let git_store = project.read(cx).git_store().clone(); | |
| 178 | − let worktree_store = project.read(cx).worktree_store.clone(); | |
| 179 | − let mut subscriptions = Vec::new(); | |
| 180 | − | |
| 181 | − // Subscribe to git_store and filter events | |
| 182 | − subscriptions.push(cx.subscribe(&git_store, { | |
| 183 | − let worktree_ids = worktree_ids.clone(); | |
| 184 | − move |this, _store, event, cx| { | |
| 185 | − this.handle_git_store_event(event, &worktree_ids, cx); | |
| 186 | − } | |
| 187 | − })); | |
| 188 | − | |
| 189 | − // Subscribe to worktree_store for worktree changes | |
| 190 | − subscriptions.push(cx.subscribe(&worktree_store, { | |
| 191 | − let worktree_ids = worktree_ids.clone(); | |
| 192 | − move |this, _store, event, cx| { | |
| 193 | − this.handle_worktree_store_event(event, &worktree_ids, cx); | |
| 194 | − } | |
| 195 | − })); | |
| 196 | − | |
| 197 | − Self { | |
| 198 | − id, | |
| 199 | − project, | |
| 200 | − worktree_ids, | |
| 201 | − folder_paths: Vec::new(), | |
| 202 | − _subscriptions: subscriptions, | |
| 203 | − } | |
| 204 | − } | |
| 205 | − | |
| 206 | − // === Identity === | |
| 207 | − | |
| 208 | − pub fn id(&self) -> &ThreadWorktreesId { | |
| 209 | − &self.id | |
| 210 | − } | |
| 211 | − | |
| 212 | − pub fn project(&self) -> &Entity<Project> { | |
| 213 | − &self.project | |
| 214 | − } | |
| 215 | − | |
| 216 | − // === Scoped Worktree Access === | |
| 217 | − | |
| 218 | − pub fn visible_worktrees<'a>(&self, cx: &'a App) -> impl Iterator<Item = Entity<Worktree>> + 'a { | |
| 219 | − let owned_ids = self.worktree_ids.clone(); | |
| 220 | − self.project | |
| 221 | − .read(cx) | |
| 222 | − .visible_worktrees(cx) | |
| 223 | − .filter(move |wt| owned_ids.contains(&wt.read(cx).id())) | |
| 224 | − } | |
| 225 | − | |
| 226 | − pub fn worktree_for_id(&self, id: WorktreeId, cx: &App) -> Option<Entity<Worktree>> { | |
| 227 | − if self.worktree_ids.contains(&id) { | |
| 228 | − self.project.read(cx).worktree_for_id(id, cx) | |
| 229 | − } else { | |
| 230 | − None | |
| 231 | − } | |
| 232 | − } | |
| 233 | − | |
| 234 | − pub fn worktree_count(&self) -> usize { | |
| 235 | − self.worktree_ids.len() | |
| 236 | − } | |
| 237 | − | |
| 238 | − // === Scoped Git Access === | |
| 239 | − | |
| 240 | − pub fn active_repository(&self, cx: &App) -> Option<Entity<Repository>> { | |
| 241 | − let repo = self.project.read(cx).active_repository(cx)?; | |
| 242 | − let worktree_id = repo.read(cx).worktree_id(); | |
| 243 | − self.worktree_ids.contains(&worktree_id).then_some(repo) | |
| 244 | − } | |
| 245 | − | |
| 246 | − /// Returns the shared git_store - events are filtered via subscriptions | |
| 247 | − pub fn git_store(&self, cx: &App) -> Entity<GitStore> { | |
| 248 | − self.project.read(cx).git_store().clone() | |
| 249 | − } | |
| 250 | − | |
| 251 | − fn repo_in_scope(&self, repo_id: RepositoryId, cx: &App) -> bool { | |
| 252 | − let git_store = self.project.read(cx).git_store(); | |
| 253 | − git_store | |
| 254 | − .read(cx) | |
| 255 | − .repository(repo_id) | |
| 256 | − .map(|repo| self.worktree_ids.contains(&repo.read(cx).worktree_id())) | |
| 257 | − .unwrap_or(false) | |
| 258 | − } | |
| 259 | − | |
| 260 | − // === Pass-through for Global State === | |
| 261 | − | |
| 262 | − pub fn is_via_collab(&self, cx: &App) -> bool { | |
| 263 | − self.project.read(cx).is_via_collab() | |
| 264 | − } | |
| 265 | − | |
| 266 | − pub fn is_read_only(&self, cx: &App) -> bool { | |
| 267 | − self.project.read(cx).is_read_only(cx) | |
| 268 | − } | |
| 269 | − | |
| 270 | − pub fn languages(&self, cx: &App) -> Arc<LanguageRegistry> { | |
| 271 | − self.project.read(cx).languages().clone() | |
| 272 | − } | |
| 273 | − | |
| 274 | − pub fn path_style(&self, cx: &App) -> PathStyle { | |
| 275 | − self.project.read(cx).path_style(cx) | |
| 276 | − } | |
| 277 | − | |
| 278 | − pub fn fs(&self, cx: &App) -> Arc<dyn Fs> { | |
| 279 | − self.project.read(cx).fs.clone() | |
| 280 | − } | |
| 281 | − | |
| 282 | − // === Scoped Buffer Operations === | |
| 283 | − | |
| 284 | − /// Shared buffer_store - buffers are deduplicated across threads | |
| 285 | − pub fn buffer_store(&self, cx: &App) -> Entity<BufferStore> { | |
| 286 | − self.project.read(cx).buffer_store().clone() | |
| 287 | − } | |
| 288 | − | |
| 289 | − /// Open buffer with scope validation (workflow guard, not isolation) | |
| 290 | − pub fn open_buffer( | |
| 291 | − &self, | |
| 292 | − path: ProjectPath, | |
| 293 | − cx: &mut Context<Self>, | |
| 294 | − ) -> Task<Result<Entity<Buffer>>> { | |
| 295 | − if !self.worktree_ids.contains(&path.worktree_id) { | |
| 296 | − return Task::ready(Err(anyhow!("Path not in this ThreadWorktrees scope"))); | |
| 297 | − } | |
| 298 | − self.project | |
| 299 | − .update(cx, |project, cx| project.open_buffer(path, cx)) | |
| 300 | − } | |
| 301 | − | |
| 302 | − // === Scoped File Operations === | |
| 303 | − | |
| 304 | − pub fn delete_file( | |
| 305 | − &self, | |
| 306 | − path: ProjectPath, | |
| 307 | − trash: bool, | |
| 308 | − cx: &mut Context<Self>, | |
| 309 | − ) -> Option<Task<Result<()>>> { | |
| 310 | − if !self.worktree_ids.contains(&path.worktree_id) { | |
| 311 | − return None; | |
| 312 | − } | |
| 313 | − self.project | |
| 314 | − .update(cx, |project, cx| project.delete_file(path, trash, cx)) | |
| 315 | − } | |
| 316 | − | |
| 317 | − pub fn create_file( | |
| 318 | − &self, | |
| 319 | − path: ProjectPath, | |
| 320 | − is_dir: bool, | |
| 321 | − cx: &mut Context<Self>, | |
| 322 | − ) -> Option<Task<Result<CreatedEntry>>> { | |
| 323 | − if !self.worktree_ids.contains(&path.worktree_id) { | |
| 324 | − return None; | |
| 325 | − } | |
| 326 | − self.project | |
| 327 | − .update(cx, |project, cx| project.create_file(path, is_dir, cx)) | |
| 328 | − } | |
| 329 | − | |
| 330 | − pub fn rename_entry( | |
| 331 | − &self, | |
| 332 | − entry_id: ProjectEntryId, | |
| 333 | − new_path: impl Into<Arc<Path>>, | |
| 334 | − cx: &mut Context<Self>, | |
| 335 | − ) -> Option<Task<Result<CreatedEntry>>> { | |
| 336 | − // Verify entry belongs to our worktrees | |
| 337 | − let worktree_id = self.project.read(cx).entry_worktree_id(entry_id)?; | |
| 338 | − if !self.worktree_ids.contains(&worktree_id) { | |
| 339 | − return None; | |
| 340 | − } | |
| 341 | − self.project.update(cx, |project, cx| { | |
| 342 | − project.rename_entry(entry_id, new_path, cx) | |
| 343 | − }) | |
| 344 | − } | |
| 345 | −} | |
| 346 | −``` | |
| 347 | − | |
| 348 | −### ThreadWorktrees Events | |
| 349 | − | |
| 350 | −```rust | |
| 351 | −#[derive(Clone, Debug)] | |
| 352 | −pub enum ThreadWorktreesEvent { | |
| 353 | − /// Active repository changed (filtered to this scope) | |
| 354 | − ActiveRepositoryChanged(Option<Entity<Repository>>), | |
| 355 | − /// A repository in this scope was updated | |
| 356 | − RepositoryUpdated(RepositoryId, RepositoryEvent), | |
| 357 | − /// A worktree in this scope was added | |
| 358 | − WorktreeAdded(WorktreeId), | |
| 359 | − /// A worktree in this scope was removed | |
| 360 | − WorktreeRemoved(WorktreeId), | |
| 361 | − /// Entries updated in a worktree in this scope | |
| 362 | − WorktreeEntriesUpdated(WorktreeId, UpdatedEntriesSet), | |
| 363 | −} | |
| 364 | − | |
| 365 | −impl EventEmitter<ThreadWorktreesEvent> for ThreadWorktrees {} | |
| 366 | − | |
| 367 | −impl ThreadWorktrees { | |
| 368 | − fn handle_git_store_event( | |
| 369 | − &mut self, | |
| 370 | − event: &GitStoreEvent, | |
| 371 | − worktree_ids: &HashSet<WorktreeId>, | |
| 372 | − cx: &mut Context<Self>, | |
| 373 | − ) { | |
| 374 | − match event { | |
| 375 | − GitStoreEvent::ActiveRepositoryChanged(repo_id) => { | |
| 376 | − // Only emit if repo is in our worktrees | |
| 377 | − let filtered_repo = repo_id.as_ref().and_then(|id| { | |
| 378 | − let git_store = self.project.read(cx).git_store(); | |
| 379 | − let repo = git_store.read(cx).repository(*id)?; | |
| 380 | − worktree_ids | |
| 381 | − .contains(&repo.read(cx).worktree_id()) | |
| 382 | − .then_some(repo) | |
| 383 | − }); | |
| 384 | − cx.emit(ThreadWorktreesEvent::ActiveRepositoryChanged(filtered_repo)); | |
| 385 | − } | |
| 386 | − GitStoreEvent::RepositoryUpdated(repo_id, event, _) => { | |
| 387 | − if self.repo_in_scope(*repo_id, cx) { | |
| 388 | − cx.emit(ThreadWorktreesEvent::RepositoryUpdated(*repo_id, event.clone())); | |
| 389 | − } | |
| 390 | − } | |
| 391 | − _ => {} | |
| 392 | − } | |
| 393 | − } | |
| 394 | − | |
| 395 | − fn handle_worktree_store_event( | |
| 396 | − &mut self, | |
| 397 | − event: &WorktreeStoreEvent, | |
| 398 | − worktree_ids: &HashSet<WorktreeId>, | |
| 399 | − cx: &mut Context<Self>, | |
| 400 | − ) { | |
| 401 | − match event { | |
| 402 | − WorktreeStoreEvent::WorktreeAdded(wt) => { | |
| 403 | − let id = wt.read(cx).id(); | |
| 404 | − if worktree_ids.contains(&id) { | |
| 405 | − cx.emit(ThreadWorktreesEvent::WorktreeAdded(id)); | |
| 406 | − } | |
| 407 | − } | |
| 408 | − WorktreeStoreEvent::WorktreeRemoved(_, id) => { | |
| 409 | − if worktree_ids.contains(id) { | |
| 410 | − cx.emit(ThreadWorktreesEvent::WorktreeRemoved(*id)); | |
| 411 | − } | |
| 412 | − } | |
| 413 | − WorktreeStoreEvent::WorktreeEntriesUpdated { worktree_id, entries } => { | |
| 414 | − if worktree_ids.contains(worktree_id) { | |
| 415 | − cx.emit(ThreadWorktreesEvent::WorktreeEntriesUpdated( | |
| 416 | − *worktree_id, | |
| 417 | − entries.clone(), | |
| 418 | − )); | |
| 419 | − } | |
| 420 | − } | |
| 421 | − _ => {} | |
| 422 | − } | |
| 423 | − } | |
| 424 | −} | |
| 425 | −``` | |
| 426 | − | |
| 427 | −### API Categories Reference | |
| 428 | − | |
| 429 | −| Category | Examples | ThreadWorktrees Behavior | | |
| 430 | −|----------|----------|-------------------------| | |
| 431 | −| Worktree-scoped | `visible_worktrees()`, `worktree_for_id()` | Filtered to owned worktrees | | |
| 432 | −| Git-scoped | `active_repository()`, `git_init()` | Filtered by worktree ownership | | |
| 433 | −| File operations | `open_buffer()`, `delete_file()` | Validated against scope | | |
| 434 | −| Global/shared | `is_via_collab()`, `languages()`, `path_style()`, `fs()` | Pass-through to Project | | |
| 435 | −| Buffer store | `buffer_store()`, `open_buffer()` | Shared (deduped by ProjectPath) | | |
| 436 | − | |
| 437 | −--- | |
| 438 | − | |
| 439 | −## 2.3 WorkspaceState per ThreadWorktrees | |
| 440 | − | |
| 441 | −Modify `crates/workspace/src/workspace.rs`: | |
| 442 | − | |
| 443 | −### WorkspaceState Struct | |
| 444 | − | |
| 445 | −```rust | |
| 446 | −/// State for a specific ThreadWorktrees context. | |
| 447 | −/// Each thread gets its own set of panes and panel instances. | |
| 448 | −pub struct WorkspaceState { | |
| 449 | − thread_worktrees_id: ThreadWorktreesId, | |
| 450 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 451 | − | |
| 452 | − // Pane state (swapped when switching threads) | |
| 453 | − center: PaneGroup, | |
| 454 | − panes: Vec<Entity<Pane>>, | |
| 455 | − panes_by_item: HashMap<EntityId, WeakEntity<Pane>>, | |
| 456 | − active_pane: Entity<Pane>, | |
| 457 | − last_active_center_pane: Option<WeakEntity<Pane>>, | |
| 458 | − | |
| 459 | − // Panel instances - each ThreadWorktrees gets its own | |
| 460 | − panel_instances: HashMap<TypeId, Box<dyn PanelHandle>>, | |
| 461 | − | |
| 462 | − // Serialization | |
| 463 | − last_serialized: Option<Instant>, | |
| 464 | −} | |
| 465 | − | |
| 466 | −impl WorkspaceState { | |
| 467 | − pub fn new( | |
| 468 | − thread_worktrees_id: ThreadWorktreesId, | |
| 469 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 470 | − window: &mut Window, | |
| 471 | − cx: &mut Context<Workspace>, | |
| 472 | − ) -> Self { | |
| 473 | − let pane = cx.new(|cx| { | |
| 474 | − Pane::new( | |
| 475 | − cx.entity().downgrade(), | |
| 476 | − /* project */ thread_worktrees.read(cx).project().clone(), | |
| 477 | − Default::default(), | |
| 478 | − None, | |
| 479 | − NewFile.boxed_clone(), | |
| 480 | − window, | |
| 481 | − cx, | |
| 482 | − ) | |
| 483 | − }); | |
| 484 | − | |
| 485 | − Self { | |
| 486 | − thread_worktrees_id, | |
| 487 | − thread_worktrees, | |
| 488 | − center: PaneGroup::new(pane.clone()), | |
| 489 | − panes: vec![pane.clone()], | |
| 490 | − panes_by_item: HashMap::default(), | |
| 491 | − active_pane: pane, | |
| 492 | − last_active_center_pane: None, | |
| 493 | − panel_instances: HashMap::default(), | |
| 494 | − last_serialized: None, | |
| 495 | − } | |
| 496 | − } | |
| 497 | − | |
| 498 | − pub fn thread_worktrees(&self) -> &Entity<ThreadWorktrees> { | |
| 499 | − &self.thread_worktrees | |
| 500 | − } | |
| 501 | −} | |
| 502 | −``` | |
| 503 | − | |
| 504 | −### Modified Workspace Struct | |
| 505 | − | |
| 506 | −```rust | |
| 507 | −pub struct Workspace { | |
| 508 | − project: Entity<Project>, // Still singular per window | |
| 509 | − | |
| 510 | − // NEW: State per ThreadWorktrees | |
| 511 | − workspace_states: HashMap<ThreadWorktreesId, WorkspaceState>, | |
| 512 | − active_workspace_state_id: Option<ThreadWorktreesId>, | |
| 513 | − | |
| 514 | − // Docks stay constant - panel contents come from active WorkspaceState | |
| 515 | − left_dock: Entity<Dock>, | |
| 516 | − bottom_dock: Entity<Dock>, | |
| 517 | − right_dock: Entity<Dock>, | |
| 518 | − | |
| 519 | − // These stay constant across thread switches | |
| 520 | − status_bar: Entity<StatusBar>, | |
| 521 | − modal_layer: Entity<ModalLayer>, | |
| 522 | − toast_layer: Entity<ToastLayer>, | |
| 523 | − titlebar_item: Option<AnyView>, | |
| 524 | − notifications: Notifications, | |
| 525 | − | |
| 526 | − // Global panels (not per-thread) | |
| 527 | − global_panel_instances: HashMap<TypeId, Box<dyn PanelHandle>>, | |
| 528 | − | |
| 529 | − // ... existing fields (weak_self, app_state, etc.) ... | |
| 530 | −} | |
| 531 | −``` | |
| 532 | − | |
| 533 | −--- | |
| 534 | − | |
| 535 | −## 2.4 Switching Active ThreadWorktrees | |
| 536 | − | |
| 537 | −### Implementation | |
| 538 | − | |
| 539 | −```rust | |
| 540 | −impl Workspace { | |
| 541 | − /// Switch to a different thread context | |
| 542 | − pub fn switch_to_thread( | |
| 543 | − &mut self, | |
| 544 | − thread_worktrees_id: ThreadWorktreesId, | |
| 545 | − window: &mut Window, | |
| 546 | − cx: &mut Context<Self>, | |
| 547 | − ) { | |
| 548 | − // 1. Save current state (implicit - already in workspace_states) | |
| 549 | − if let Some(current_id) = &self.active_workspace_state_id { | |
| 550 | − if *current_id == thread_worktrees_id { | |
| 551 | − return; // Already active | |
| 552 | − } | |
| 553 | − } | |
| 554 | − | |
| 555 | − // 2. Ensure ThreadWorktrees exists in Project | |
| 556 | − let thread_worktrees = self.project.update(cx, |project, cx| { | |
| 557 | − project.thread_worktrees(&thread_worktrees_id, cx) | |
| 558 | − }); | |
| 559 | − | |
| 560 | − // 3. Update Project's active context | |
| 561 | − self.project.update(cx, |project, cx| { | |
| 562 | − project.set_active_thread_worktrees(Some(thread_worktrees_id.clone()), cx); | |
| 563 | − }); | |
| 564 | − | |
| 565 | − // 4. Load or create WorkspaceState | |
| 566 | − if !self.workspace_states.contains_key(&thread_worktrees_id) { | |
| 567 | − let state = WorkspaceState::new( | |
| 568 | − thread_worktrees_id.clone(), | |
| 569 | − thread_worktrees.clone(), | |
| 570 | − window, | |
| 571 | − cx, | |
| 572 | − ); | |
| 573 | − self.workspace_states.insert(thread_worktrees_id.clone(), state); | |
| 574 | − | |
| 575 | − // Initialize scoped panels for new state | |
| 576 | − self.initialize_scoped_panels_for_state(&thread_worktrees_id, window, cx); | |
| 577 | − } | |
| 578 | − | |
| 579 | − // 5. Update active state | |
| 580 | − self.active_workspace_state_id = Some(thread_worktrees_id); | |
| 581 | − | |
| 582 | − // 6. Update UI | |
| 583 | − self.update_docks_for_active_state(window, cx); | |
| 584 | − self.update_window_title(window, cx); | |
| 585 | − cx.notify(); | |
| 586 | − } | |
| 587 | − | |
| 588 | − /// Get the active WorkspaceState | |
| 589 | − pub fn active_workspace_state(&self) -> Option<&WorkspaceState> { | |
| 590 | − self.active_workspace_state_id | |
| 591 | − .as_ref() | |
| 592 | − .and_then(|id| self.workspace_states.get(id)) | |
| 593 | − } | |
| 594 | − | |
| 595 | − pub fn active_workspace_state_mut(&mut self) -> Option<&mut WorkspaceState> { | |
| 596 | − self.active_workspace_state_id | |
| 597 | − .as_ref() | |
| 598 | − .cloned() | |
| 599 | − .and_then(move |id| self.workspace_states.get_mut(&id)) | |
| 600 | − } | |
| 601 | − | |
| 602 | − /// Close a thread context and clean up its state | |
| 603 | − pub fn close_thread( | |
| 604 | − &mut self, | |
| 605 | − thread_worktrees_id: &ThreadWorktreesId, | |
| 606 | − window: &mut Window, | |
| 607 | − cx: &mut Context<Self>, | |
| 608 | − ) { | |
| 609 | − // Remove from workspace_states - GPUI reference counting handles cleanup | |
| 610 | − self.workspace_states.remove(thread_worktrees_id); | |
| 611 | − | |
| 612 | − // If this was the active state, switch to default or another thread | |
| 613 | − if self.active_workspace_state_id.as_ref() == Some(thread_worktrees_id) { | |
| 614 | − let fallback = self.workspace_states.keys().next().cloned(); | |
| 615 | − if let Some(fallback_id) = fallback { | |
| 616 | − self.switch_to_thread(fallback_id, window, cx); | |
| 617 | − } else { | |
| 618 | − self.active_workspace_state_id = None; | |
| 619 | − cx.notify(); | |
| 620 | − } | |
| 621 | − } | |
| 622 | − } | |
| 623 | − | |
| 624 | − fn update_docks_for_active_state(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 625 | − let Some(state) = self.active_workspace_state() else { | |
| 626 | − return; | |
| 627 | − }; | |
| 628 | − | |
| 629 | − // Update each dock to show panels from the active WorkspaceState | |
| 630 | − for (type_id, panel) in &state.panel_instances { | |
| 631 | − // Find which dock this panel belongs to and update it | |
| 632 | − let position = panel.position(window, cx); | |
| 633 | − let dock = match position { | |
| 634 | − DockPosition::Left => &self.left_dock, | |
| 635 | − DockPosition::Bottom => &self.bottom_dock, | |
| 636 | − DockPosition::Right => &self.right_dock, | |
| 637 | − }; | |
| 638 | − // Dock will need API to swap panel instance | |
| 639 | − // dock.update(cx, |dock, cx| dock.set_panel_instance(*type_id, panel.clone(), cx)); | |
| 640 | − } | |
| 641 | − } | |
| 642 | −} | |
| 643 | −``` | |
| 644 | − | |
| 645 | −--- | |
| 646 | − | |
| 647 | −## 2.5 Panel Instance Management | |
| 648 | − | |
| 649 | −### Scoped vs Global Panels | |
| 650 | − | |
| 651 | −**SCOPED (per-ThreadWorktrees)** - stored in `WorkspaceState.panel_instances`: | |
| 652 | − | |
| 653 | −| Panel | Reason | | |
| 654 | −|-------|--------| | |
| 655 | −| `ProjectPanel` | Shows worktree file trees | | |
| 656 | −| `GitPanel` | Shows repos tied to worktrees | | |
| 657 | −| `OutlinePanel` | Shows context for files in worktrees | | |
| 658 | −| `DebugPanel` | Debug sessions are project-specific | | |
| 659 | −| `TerminalPanel` | Terminals have working directories in worktrees | | |
| 660 | −| `AgentPanel` | Thread-specific conversation | | |
| 661 | − | |
| 662 | −**GLOBAL (shared)** - stored on `Workspace.global_panel_instances`: | |
| 663 | − | |
| 664 | −| Panel | Reason | | |
| 665 | −|-------|--------| | |
| 666 | −| `CollabPanel` | Collaborators, channels, contacts - user's social state | | |
| 667 | −| `NotificationPanel` | User's notifications across all projects | | |
| 668 | − | |
| 669 | −### Panel Creation | |
| 670 | − | |
| 671 | −```rust | |
| 672 | −impl WorkspaceState { | |
| 673 | − /// Get or create a scoped panel for this WorkspaceState | |
| 674 | − pub fn get_or_create_panel<P: Panel>( | |
| 675 | − &mut self, | |
| 676 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 677 | − window: &mut Window, | |
| 678 | − cx: &mut App, | |
| 679 | − ) -> Entity<P> | |
| 680 | − where | |
| 681 | − P: Panel + PanelFromThreadWorktrees, | |
| 682 | − { | |
| 683 | − let type_id = TypeId::of::<P>(); | |
| 684 | − if let Some(panel) = self.panel_instances.get(&type_id) { | |
| 685 | − return panel | |
| 686 | − .to_any() | |
| 687 | − .downcast::<P>() | |
| 688 | − .expect("Panel type mismatch"); | |
| 689 | − } | |
| 690 | − | |
| 691 | − let panel = cx.new(|cx| P::new(thread_worktrees, window, cx)); | |
| 692 | − self.panel_instances.insert(type_id, Box::new(panel.clone())); | |
| 693 | − panel | |
| 694 | − } | |
| 695 | −} | |
| 696 | − | |
| 697 | −/// Trait for panels that can be constructed from ThreadWorktrees | |
| 698 | −pub trait PanelFromThreadWorktrees: Panel { | |
| 699 | − fn new( | |
| 700 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 701 | − window: &mut Window, | |
| 702 | − cx: &mut Context<Self>, | |
| 703 | − ) -> Self; | |
| 704 | −} | |
| 705 | −``` | |
| 706 | − | |
| 707 | −### Initialize Scoped Panels | |
| 708 | − | |
| 709 | −```rust | |
| 710 | −impl Workspace { | |
| 711 | − fn initialize_scoped_panels_for_state( | |
| 712 | − &mut self, | |
| 713 | − thread_worktrees_id: &ThreadWorktreesId, | |
| 714 | − window: &mut Window, | |
| 715 | − cx: &mut Context<Self>, | |
| 716 | − ) { | |
| 717 | − let Some(state) = self.workspace_states.get_mut(thread_worktrees_id) else { | |
| 718 | − return; | |
| 719 | − }; | |
| 720 | − let thread_worktrees = state.thread_worktrees.clone(); | |
| 721 | − | |
| 722 | − // Initialize all scoped panels | |
| 723 | − // Note: This follows the pattern in zed.rs initialize_panels but per-state | |
| 724 | − | |
| 725 | − // ProjectPanel | |
| 726 | − let project_panel = cx.new(|cx| { | |
| 727 | − ProjectPanel::new_from_thread_worktrees(thread_worktrees.clone(), window, cx) | |
| 728 | − }); | |
| 729 | − state.panel_instances.insert( | |
| 730 | − TypeId::of::<ProjectPanel>(), | |
| 731 | − Box::new(project_panel), | |
| 732 | − ); | |
| 733 | − | |
| 734 | − // GitPanel | |
| 735 | − let git_panel = cx.new(|cx| { | |
| 736 | − GitPanel::new_from_thread_worktrees(thread_worktrees.clone(), window, cx) | |
| 737 | − }); | |
| 738 | − state.panel_instances.insert( | |
| 739 | − TypeId::of::<GitPanel>(), | |
| 740 | − Box::new(git_panel), | |
| 741 | − ); | |
| 742 | − | |
| 743 | − // OutlinePanel, DebugPanel, TerminalPanel, AgentPanel... | |
| 744 | − } | |
| 745 | −} | |
| 746 | −``` | |
| 747 | − | |
| 748 | −--- | |
| 749 | − | |
| 750 | −## 2.6 Panel Migration Pattern | |
| 751 | − | |
| 752 | −### Before/After Comparison | |
| 753 | − | |
| 754 | −```rust | |
| 755 | −// BEFORE: Panel holds Project directly | |
| 756 | −pub struct GitPanel { | |
| 757 | − project: Entity<Project>, | |
| 758 | − active_repository: Option<Entity<Repository>>, | |
| 759 | − // ... | |
| 760 | −} | |
| 761 | − | |
| 762 | −impl GitPanel { | |
| 763 | − fn new(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) -> Entity<Self> { | |
| 764 | − let project = workspace.project().clone(); | |
| 765 | − let git_store = project.read(cx).git_store().clone(); | |
| 766 | − let active_repository = project.read(cx).active_repository(cx); | |
| 767 | − | |
| 768 | − cx.new(|cx| { | |
| 769 | − // Subscribe to git_store directly | |
| 770 | − cx.subscribe(&git_store, |this, _, event, cx| { | |
| 771 | − // Handle ALL events, no filtering | |
| 772 | − match event { | |
| 773 | − GitStoreEvent::ActiveRepositoryChanged(repo) => { | |
| 774 | − this.active_repository = repo.clone(); | |
| 775 | − cx.notify(); | |
| 776 | − } | |
| 777 | − // ... | |
| 778 | − } | |
| 779 | − }).detach(); | |
| 780 | − | |
| 781 | − Self { project, active_repository, /* ... */ } | |
| 782 | − }) | |
| 783 | − } | |
| 784 | −} | |
| 785 | −``` | |
| 786 | − | |
| 787 | −```rust | |
| 788 | −// AFTER: Panel holds ThreadWorktrees | |
| 789 | −pub struct GitPanel { | |
| 790 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 791 | − active_repository: Option<Entity<Repository>>, | |
| 792 | − // ... | |
| 793 | −} | |
| 794 | − | |
| 795 | −impl GitPanel { | |
| 796 | − fn new_from_thread_worktrees( | |
| 797 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 798 | − window: &mut Window, | |
| 799 | − cx: &mut Context<Self>, | |
| 800 | − ) -> Self { | |
| 801 | − let active_repository = thread_worktrees.read(cx).active_repository(cx); | |
| 802 | − | |
| 803 | − // Subscribe to ThreadWorktrees events (pre-filtered) | |
| 804 | − cx.subscribe(&thread_worktrees, |this, _, event, cx| { | |
| 805 | − match event { | |
| 806 | − ThreadWorktreesEvent::ActiveRepositoryChanged(repo) => { | |
| 807 | − this.active_repository = repo.clone(); | |
| 808 | − cx.notify(); | |
| 809 | − } | |
| 810 | − ThreadWorktreesEvent::RepositoryUpdated(repo_id, event) => { | |
| 811 | − // Already filtered to our scope | |
| 812 | − this.handle_repo_update(*repo_id, event, cx); | |
| 813 | − } | |
| 814 | − // ... | |
| 815 | − } | |
| 816 | − }).detach(); | |
| 817 | − | |
| 818 | − Self { thread_worktrees, active_repository, /* ... */ } | |
| 819 | − } | |
| 820 | −} | |
| 821 | − | |
| 822 | −// Method migration example | |
| 823 | −impl GitPanel { | |
| 824 | − // BEFORE | |
| 825 | − fn old_get_repos(&self, cx: &App) -> Vec<Entity<Repository>> { | |
| 826 | − self.project.read(cx).visible_worktrees(cx) | |
| 827 | − .filter_map(|wt| /* get repo */) | |
| 828 | − .collect() | |
| 829 | − } | |
| 830 | − | |
| 831 | − // AFTER - same API, scoped results | |
| 832 | − fn get_repos(&self, cx: &App) -> Vec<Entity<Repository>> { | |
| 833 | − self.thread_worktrees.read(cx).visible_worktrees(cx) | |
| 834 | − .filter_map(|wt| /* get repo */) | |
| 835 | − .collect() | |
| 836 | − } | |
| 837 | −} | |
| 838 | −``` | |
| 839 | − | |
| 840 | −### Migration Checklist Per Panel | |
| 841 | − | |
| 842 | −For each panel migrating from `Entity<Project>` to `Entity<ThreadWorktrees>`: | |
| 843 | − | |
| 844 | −1. [ ] Replace `project: Entity<Project>` field with `thread_worktrees: Entity<ThreadWorktrees>` | |
| 845 | −2. [ ] Update constructor to take `Entity<ThreadWorktrees>` parameter | |
| 846 | −3. [ ] Replace `cx.subscribe(&git_store, ...)` with `cx.subscribe(&thread_worktrees, ...)` | |
| 847 | −4. [ ] Update event handlers to use `ThreadWorktreesEvent` variants | |
| 848 | −5. [ ] Replace `self.project.read(cx).visible_worktrees(cx)` with `self.thread_worktrees.read(cx).visible_worktrees(cx)` | |
| 849 | −6. [ ] Replace `self.project.read(cx).active_repository(cx)` with `self.thread_worktrees.read(cx).active_repository(cx)` | |
| 850 | −7. [ ] Keep pass-through methods: `is_via_collab()`, `languages()`, `path_style()`, `fs()` | |
| 851 | −8. [ ] Add `impl PanelFromThreadWorktrees for Panel` if needed | |
| 852 | − | |
| 853 | −--- | |
| 854 | − | |
| 855 | −## 2.7 Feature-Flagged Actions | |
| 856 | − | |
| 857 | −All Phase 2 functionality is gated behind the `agent-v2` feature flag. | |
| 858 | − | |
| 859 | −### New Actions | |
| 860 | − | |
| 861 | −```rust | |
| 862 | −// crates/workspace/src/workspace.rs | |
| 863 | − | |
| 864 | −actions!( | |
| 865 | − workspace, | |
| 866 | − [ | |
| 867 | − /// Switch to a specific thread's workspace state | |
| 868 | − SwitchToThread, | |
| 869 | − /// Open a thread (loads worktrees, creates WorkspaceState if needed) | |
| 870 | − OpenThread, | |
| 871 | − /// Close a thread's workspace state | |
| 872 | − CloseThread, | |
| 873 | − ] | |
| 874 | −); | |
| 875 | − | |
| 876 | −#[derive(Clone, Debug, Deserialize, PartialEq)] | |
| 877 | −pub struct SwitchToThread { | |
| 878 | − pub session_id: String, | |
| 879 | −} | |
| 880 | − | |
| 881 | −impl_actions!(workspace, [SwitchToThread, OpenThread, CloseThread]); | |
| 882 | −``` | |
| 883 | − | |
| 884 | −### Action Handlers | |
| 885 | − | |
| 886 | −```rust | |
| 887 | −impl Workspace { | |
| 888 | − pub fn register_thread_actions(cx: &mut App) { | |
| 889 | − if !cx.has_flag::<AgentV2FeatureFlag>() { | |
| 890 | − return; | |
| 891 | − } | |
| 892 | − | |
| 893 | − cx.on_action(|workspace: &mut Workspace, action: &SwitchToThread, window, cx| { | |
| 894 | − let session_id = acp::SessionId::from(action.session_id.clone()); | |
| 895 | − let workspace_db_id = workspace.database_id().unwrap_or_default(); | |
| 896 | − let thread_worktrees_id = ThreadWorktreesId::for_thread(session_id, workspace_db_id); | |
| 897 | − workspace.switch_to_thread(thread_worktrees_id, window, cx); | |
| 898 | − }); | |
| 899 | − } | |
| 900 | −} | |
| 901 | −``` | |
| 902 | − | |
| 903 | −--- | |
| 904 | − | |
| 905 | −## Cross-Thread File References | |
| 906 | − | |
| 907 | −**Resolution: Center panes are NOT scoped, only panels are** | |
| 908 | − | |
| 909 | −When "Go to Definition" (or similar navigation) targets a file outside the current ThreadWorktrees: | |
| 910 | − | |
| 911 | −- **If the worktree is loaded** (another thread is live) → **Just works**. The file opens in a center tab. ProjectPanel won't show it in its tree (filtered by ThreadWorktrees), but the editor works normally. | |
| 912 | − | |
| 913 | −- **If the worktree is NOT loaded** → **File not available**. Show message: "Definition is in `/path` which is not currently loaded." | |
| 914 | − | |
| 915 | −### Architecture | |
| 916 | − | |
| 917 | −``` | |
| 918 | −┌─────────────────────────────────────────────────────────┐ | |
| 919 | −│ Window │ | |
| 920 | −├──────────┬──────────────────────────────────────────────┤ | |
| 921 | −│ Agents │ ┌─────────┬──────────────────┬────────┐ │ | |
| 922 | −│ Sidebar │ │ Left │ │ Right │ │ | |
| 923 | −│ │ │ Dock │ Center Panes │ Dock │ │ | |
| 924 | −│ │ │ │ │ │ │ | |
| 925 | −│ │ │ SCOPED │ NOT SCOPED │ SCOPED │ │ | |
| 926 | −│ │ │ (shows │ (can open any │ │ │ | |
| 927 | −│ │ │ only │ buffer from │ │ │ | |
| 928 | −│ │ │ Thread │ any loaded │ │ │ | |
| 929 | −│ │ │ A's │ worktree) │ │ │ | |
| 930 | −│ │ │ trees) │ │ │ │ | |
| 931 | −│ │ └─────────┴──────────────────┴────────┘ │ | |
| 932 | −└──────────┴──────────────────────────────────────────────┘ | |
| 933 | −``` | |
| 934 | − | |
| 935 | −This matches current Zed behavior - you can have open files that aren't in the project panel (via drag-drop, external open, etc.). | |
| 936 | − | |
| 937 | −--- | |
| 938 | − | |
| 939 | −## Key Files to Modify | |
| 940 | − | |
| 941 | −| File | Changes | | |
| 942 | −|------|---------| | |
| 943 | −| `crates/project/src/thread_worktrees.rs` | **NEW**: ThreadWorktrees struct, ThreadWorktreesId, event filtering | | |
| 944 | −| `crates/project/src/project.rs` | Add `thread_worktrees` HashMap, `active_thread_worktrees_id`, helper methods | | |
| 945 | −| `crates/workspace/src/workspace.rs` | Add `WorkspaceState`, `workspace_states` HashMap, switch_to_thread logic | | |
| 946 | −| `crates/git_ui/src/git_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 947 | −| `crates/project_panel/src/project_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 948 | −| `crates/outline_panel/src/outline_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 949 | −| `crates/debugger_ui/src/debugger_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 950 | −| `crates/terminal_view/src/terminal_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 951 | −| `crates/agent_ui/src/agent_panel.rs` | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 952 | − | |
| 953 | −--- | |
| 954 | − | |
| 955 | −## Implementation Order | |
| 956 | − | |
| 957 | −### Step 1: Core Infrastructure | |
| 958 | −1. Create `crates/project/src/thread_worktrees.rs` with `ThreadWorktreesId` and `ThreadWorktrees` struct | |
| 959 | −2. Add basic API methods (scoped worktree access, pass-throughs) | |
| 960 | −3. Add `thread_worktrees` HashMap to `Project` | |
| 961 | −4. Wire up module in `crates/project/src/project.rs` | |
| 962 | − | |
| 963 | −### Step 2: Event Filtering | |
| 964 | −1. Implement `ThreadWorktreesEvent` enum | |
| 965 | −2. Add subscriptions to git_store and worktree_store in `ThreadWorktrees::new` | |
| 966 | −3. Implement `handle_git_store_event` and `handle_worktree_store_event` | |
| 967 | − | |
| 968 | −### Step 3: WorkspaceState | |
| 969 | −1. Create `WorkspaceState` struct in workspace.rs | |
| 970 | −2. Add `workspace_states` HashMap to `Workspace` | |
| 971 | −3. Implement `switch_to_thread` method | |
| 972 | −4. Add feature-flagged actions | |
| 973 | − | |
| 974 | −### Step 4: Panel Migration (one at a time) | |
| 975 | −1. Start with `ProjectPanel` (simplest, most isolated) | |
| 976 | −2. Then `GitPanel` (tests event filtering) | |
| 977 | −3. Then remaining panels | |
| 978 | − | |
| 979 | −### Step 5: Integration Testing | |
| 980 | −1. Test switching between threads | |
| 981 | −2. Test panel state preservation | |
| 982 | −3. Test cross-thread file navigation | |
| 983 | − | |
| 984 | −--- | |
| 985 | − | |
| 986 | −## Testing Strategy | |
| 987 | − | |
| 988 | −### Unit Tests | |
| 989 | − | |
| 990 | −```rust | |
| 991 | −#[gpui::test] | |
| 992 | −async fn test_thread_worktrees_scopes_worktrees(cx: &mut TestAppContext) { | |
| 993 | − let fs = FakeFs::new(cx.background_executor.clone()); | |
| 994 | − fs.insert_tree("/project_a", json!({"src": {"main.rs": ""}})).await; | |
| 995 | − fs.insert_tree("/project_b", json!({"lib": {"mod.rs": ""}})).await; | |
| 996 | − | |
| 997 | − let project = Project::test(fs.clone(), ["/project_a", "/project_b"], cx).await; | |
| 998 | − | |
| 999 | − // Create ThreadWorktrees with only project_a | |
| 1000 | − let worktree_a_id = project.read_with(cx, |p, cx| { | |
| 1001 | − p.worktrees(cx).next().unwrap().read(cx).id() | |
| 1002 | − }); | |
| 1003 | − | |
| 1004 | − let thread_worktrees = cx.new(|cx| { | |
| 1005 | − ThreadWorktrees::new( | |
| 1006 | − ThreadWorktreesId::default_for_workspace(WorkspaceId(1)), | |
| 1007 | − project.clone(), | |
| 1008 | − HashSet::from([worktree_a_id]), | |
| 1009 | − cx, | |
| 1010 | − ) | |
| 1011 | − }); | |
| 1012 | − | |
| 1013 | − // Should only see project_a worktree | |
| 1014 | − let visible: Vec<_> = thread_worktrees.read_with(cx, |tw, cx| { | |
| 1015 | − tw.visible_worktrees(cx).collect() | |
| 1016 | − }); | |
| 1017 | − assert_eq!(visible.len(), 1); | |
| 1018 | −} | |
| 1019 | −``` | |
| 1020 | − | |
| 1021 | −### Integration Tests | |
| 1022 | − | |
| 1023 | −```rust | |
| 1024 | −#[gpui::test] | |
| 1025 | −async fn test_switch_between_threads_preserves_state(cx: &mut TestAppContext) { | |
| 1026 | − // Setup workspace with two threads | |
| 1027 | − // Switch to thread A, make changes | |
| 1028 | − // Switch to thread B | |
| 1029 | − // Switch back to thread A | |
| 1030 | − // Verify state is preserved | |
| 1031 | −} | |
| 1032 | − | |
| 1033 | −#[gpui::test] | |
| 1034 | −async fn test_panel_receives_filtered_events(cx: &mut TestAppContext) { | |
| 1035 | − // Setup ThreadWorktrees with subset of worktrees | |
| 1036 | − // Trigger git event on worktree outside scope | |
| 1037 | − // Verify panel doesn't receive event | |
| 1038 | − // Trigger git event on worktree inside scope | |
| 1039 | − // Verify panel receives event | |
| 1040 | −} | |
| 1041 | −``` | |
| 1042 | − | |
| 1043 | −--- | |
| 1044 | − | |
| 1045 | −## Design Decisions Summary | |
| 1046 | − | |
| 1047 | −1. **Project remains singular** - One `Entity<Project>` per window holds all worktrees. ThreadWorktrees is a filtered lens, not a separate data store. | |
| 1048 | − | |
| 1049 | −2. **ThreadWorktrees as panel API surface** - Provides type safety, centralized event filtering, and clear API boundary. | |
| 1050 | − | |
| 1051 | −3. **Buffer store is shared** - Buffers represent files which are singular. Edits in shared worktrees are visible across all threads immediately. | |
| 1052 | − | |
| 1053 | −4. **Center panes are not scoped** - Only panels are filtered. Users can open any file from any loaded worktree in editor tabs. | |
| 1054 | − | |
| 1055 | −5. **Panel state preserved by design** - Each ThreadWorktrees has its own panel instances stored in WorkspaceState. Switching threads swaps which instances are displayed. | |
| 1056 | − | |
| 1057 | −6. **Reference counting handles cleanup** - No explicit cleanup logic needed. When WorkspaceState is removed, GPUI drops panel instances automatically. | |
| 1058 | − | |
| 1059 | −--- | |
| 1060 | − | |
| 1061 | −## Open Questions for Phase 2 | |
| 1062 | − | |
| 1063 | −1. **Dock panel swapping mechanism** - How exactly do docks swap panel instances? May need new Dock API. | |
| 1064 | − | |
| 1065 | −2. **Panel initialization timing** - Should panels be lazily initialized on first access or eagerly on thread switch? | |
| 1066 | − | |
| 1067 | −3. **Serialization** - Should WorkspaceState be serialized per-thread for restore on restart? (Likely Phase 3) | |
| @@ -1,408 +0,0 @@ | ||
| 1 | −# Pluggable Shaders for GPUI | |
| 2 | − | |
| 3 | −## Overview | |
| 4 | − | |
| 5 | −This document outlines a plan for adding pluggable shader support to GPUI, allowing users to define custom shaders, pipelines, and primitives that integrate with GPUI's rendering system. | |
| 6 | − | |
| 7 | −## Goals | |
| 8 | − | |
| 9 | −- Allow users to define custom WGSL shaders for the Blade backend | |
| 10 | −- Allow users to register custom render pipelines with GPUI | |
| 11 | −- Allow users to define custom primitive types with `#[repr(C)]` structs for GPU data | |
| 12 | −- Integrate custom primitives into GPUI's batching and draw-order system | |
| 13 | −- Provide a clean, type-safe API for painting custom primitives during element rendering | |
| 14 | − | |
| 15 | −## Non-Goals | |
| 16 | − | |
| 17 | −- Supporting Metal or DirectX shader backends initially (focus on Blade/WGSL) | |
| 18 | −- Automatic shader hot-reloading | |
| 19 | −- Providing a shader authoring GUI | |
| 20 | −- Multi-pass rendering for custom primitives (can be added later) | |
| 21 | − | |
| 22 | −## Current State | |
| 23 | − | |
| 24 | −### Rendering Architecture | |
| 25 | − | |
| 26 | −GPUI uses a multi-backend rendering system: | |
| 27 | −- **Blade** (Linux, FreeBSD, macOS with feature flag) - uses WGSL shaders | |
| 28 | −- **Metal** (macOS default) - uses Metal shaders | |
| 29 | −- **DirectX** (Windows) - uses HLSL shaders | |
| 30 | − | |
| 31 | −### Key Components | |
| 32 | − | |
| 33 | −1. **Scene** (`crates/gpui/src/scene.rs:24-36`): Holds all primitives to render | |
| 34 | − - Contains typed vectors for each primitive type (quads, shadows, paths, etc.) | |
| 35 | − - Manages draw ordering via `DrawOrder` (u32) | |
| 36 | − - Provides `batches()` iterator for efficient GPU rendering | |
| 37 | − | |
| 38 | −2. **Primitive Types** (`scene.rs:211-220`): 8 built-in types | |
| 39 | − - Shadow, Quad, Path, Underline | |
| 40 | − - MonochromeSprite, SubpixelSprite, PolychromeSprite | |
| 41 | − - Surface (video surfaces, macOS only) | |
| 42 | − | |
| 43 | −3. **BladeRenderer** (`blade_renderer.rs:369-386`): | |
| 44 | − - Owns pipelines, instance buffers, atlas, textures | |
| 45 | − - `draw()` method iterates batches and dispatches to appropriate pipelines | |
| 46 | − | |
| 47 | −4. **BladePipelines** (`blade_renderer.rs:140-150`): | |
| 48 | − - Struct holding all render pipelines | |
| 49 | − - Created at window initialization with shader source | |
| 50 | − | |
| 51 | −5. **Shader Data** (`blade_renderer.rs:56-123`): | |
| 52 | − - Uses `#[derive(blade_macros::ShaderData)]` for GPU bindings | |
| 53 | − - GlobalParams uniform, storage buffers for instance data | |
| 54 | − | |
| 55 | −### Data Flow | |
| 56 | − | |
| 57 | −``` | |
| 58 | −Window::paint_*() | |
| 59 | − → Scene::insert_primitive() | |
| 60 | − → Scene::finish() sorts by order | |
| 61 | − → BladeRenderer::draw() | |
| 62 | − → scene.batches() iterator | |
| 63 | − → For each batch: bind pipeline, upload data, draw | |
| 64 | −``` | |
| 65 | − | |
| 66 | −## Proposed Design | |
| 67 | − | |
| 68 | −### Architecture | |
| 69 | − | |
| 70 | −The design adds an extensible primitive system alongside the existing built-in primitives: | |
| 71 | − | |
| 72 | −``` | |
| 73 | −┌─────────────────────────────────────────────────────────────┐ | |
| 74 | −│ User Code │ | |
| 75 | −│ - Define CustomPrimitive struct (#[repr(C)]) │ | |
| 76 | −│ - Provide WGSL shader source │ | |
| 77 | −│ - Register with GPUI via CustomShaderDescriptor │ | |
| 78 | −└─────────────────────────────────────────────────────────────┘ | |
| 79 | − │ | |
| 80 | − ▼ | |
| 81 | −┌─────────────────────────────────────────────────────────────┐ | |
| 82 | −│ CustomShaderRegistry │ | |
| 83 | −│ - Stores registered shader descriptors │ | |
| 84 | −│ - Creates pipelines on renderer initialization │ | |
| 85 | −│ - Maps CustomShaderId → pipeline + metadata │ | |
| 86 | −└─────────────────────────────────────────────────────────────┘ | |
| 87 | − │ | |
| 88 | − ▼ | |
| 89 | −┌─────────────────────────────────────────────────────────────┐ | |
| 90 | −│ Scene (Extended) │ | |
| 91 | −│ - Existing primitive vectors │ | |
| 92 | −│ - New: custom_primitives: HashMap<CustomShaderId, Vec<u8>> │ | |
| 93 | −│ - BatchIterator yields CustomBatch alongside built-ins │ | |
| 94 | −└─────────────────────────────────────────────────────────────┘ | |
| 95 | − │ | |
| 96 | − ▼ | |
| 97 | −┌─────────────────────────────────────────────────────────────┐ | |
| 98 | −│ BladeRenderer (Extended) │ | |
| 99 | −│ - Existing pipelines │ | |
| 100 | −│ - New: custom_pipelines: HashMap<CustomShaderId, Pipeline> │ | |
| 101 | −│ - draw() handles PrimitiveBatch::Custom │ | |
| 102 | −└─────────────────────────────────────────────────────────────┘ | |
| 103 | −``` | |
| 104 | − | |
| 105 | −### API Surface | |
| 106 | − | |
| 107 | −#### 1. Custom Shader Descriptor | |
| 108 | − | |
| 109 | −```rust | |
| 110 | −/// Describes a custom shader and its pipeline configuration. | |
| 111 | −pub struct CustomShaderDescriptor { | |
| 112 | − /// Unique identifier for this shader | |
| 113 | − pub name: &'static str, | |
| 114 | − | |
| 115 | − /// WGSL shader source code | |
| 116 | − pub shader_source: &'static str, | |
| 117 | − | |
| 118 | − /// Vertex shader entry point name | |
| 119 | − pub vertex_entry: &'static str, | |
| 120 | − | |
| 121 | − /// Fragment shader entry point name | |
| 122 | − pub fragment_entry: &'static str, | |
| 123 | − | |
| 124 | − /// Size of each instance in bytes (must match #[repr(C)] struct) | |
| 125 | − pub instance_size: usize, | |
| 126 | − | |
| 127 | − /// Blend mode for this shader | |
| 128 | − pub blend_mode: CustomBlendMode, | |
| 129 | − | |
| 130 | − /// Primitive topology (typically TriangleStrip for instanced quads) | |
| 131 | − pub topology: PrimitiveTopology, | |
| 132 | −} | |
| 133 | − | |
| 134 | −pub enum CustomBlendMode { | |
| 135 | − /// Standard alpha blending | |
| 136 | − Alpha, | |
| 137 | − /// Premultiplied alpha | |
| 138 | − PremultipliedAlpha, | |
| 139 | − /// Additive blending | |
| 140 | − Additive, | |
| 141 | − /// No blending (opaque) | |
| 142 | − Opaque, | |
| 143 | −} | |
| 144 | − | |
| 145 | −pub enum PrimitiveTopology { | |
| 146 | − TriangleStrip, | |
| 147 | − TriangleList, | |
| 148 | −} | |
| 149 | −``` | |
| 150 | − | |
| 151 | −#### 2. Custom Shader Handle | |
| 152 | − | |
| 153 | −```rust | |
| 154 | −/// Opaque handle to a registered custom shader. | |
| 155 | −#[derive(Clone, Copy, PartialEq, Eq, Hash)] | |
| 156 | −pub struct CustomShaderId(u32); | |
| 157 | −``` | |
| 158 | − | |
| 159 | −#### 3. Custom Primitive Trait | |
| 160 | − | |
| 161 | −```rust | |
| 162 | −/// Trait for types that can be rendered as custom primitives. | |
| 163 | −/// | |
| 164 | −/// # Safety | |
| 165 | −/// The implementing type must be `#[repr(C)]` and match the expected | |
| 166 | −/// layout in the WGSL shader. | |
| 167 | −pub unsafe trait CustomPrimitive: Pod + Zeroable + Clone { | |
| 168 | − /// Returns the bounds of this primitive for clipping/culling. | |
| 169 | − fn bounds(&self) -> Bounds<ScaledPixels>; | |
| 170 | − | |
| 171 | − /// Returns the content mask for clipping. | |
| 172 | − fn content_mask(&self) -> ContentMask<ScaledPixels>; | |
| 173 | − | |
| 174 | − /// Returns the draw order for sorting. | |
| 175 | − fn order(&self) -> DrawOrder; | |
| 176 | − | |
| 177 | − /// Sets the draw order. | |
| 178 | − fn set_order(&mut self, order: DrawOrder); | |
| 179 | −} | |
| 180 | −``` | |
| 181 | − | |
| 182 | −#### 4. Registration API (on App) | |
| 183 | − | |
| 184 | −```rust | |
| 185 | −impl App { | |
| 186 | − /// Register a custom shader with GPUI. | |
| 187 | − /// | |
| 188 | − /// This should be called during app initialization before any windows | |
| 189 | − /// are created. The shader will be compiled when windows are opened. | |
| 190 | − pub fn register_custom_shader( | |
| 191 | − &mut self, | |
| 192 | − descriptor: CustomShaderDescriptor, | |
| 193 | − ) -> CustomShaderId; | |
| 194 | −} | |
| 195 | −``` | |
| 196 | − | |
| 197 | −#### 5. Painting API (on Window) | |
| 198 | − | |
| 199 | −```rust | |
| 200 | −impl Window { | |
| 201 | − /// Paint a custom primitive into the scene. | |
| 202 | − /// | |
| 203 | − /// The primitive type must match the shader it was registered with. | |
| 204 | − /// This method should only be called during the paint phase. | |
| 205 | − pub fn paint_custom<P: CustomPrimitive>( | |
| 206 | − &mut self, | |
| 207 | − shader_id: CustomShaderId, | |
| 208 | − primitive: P, | |
| 209 | − ); | |
| 210 | − | |
| 211 | − /// Paint multiple custom primitives of the same type. | |
| 212 | − /// | |
| 213 | − /// More efficient than calling paint_custom repeatedly. | |
| 214 | − pub fn paint_custom_batch<P: CustomPrimitive>( | |
| 215 | − &mut self, | |
| 216 | − shader_id: CustomShaderId, | |
| 217 | − primitives: &[P], | |
| 218 | − ); | |
| 219 | −} | |
| 220 | −``` | |
| 221 | − | |
| 222 | −### Example Usage | |
| 223 | − | |
| 224 | −```rust | |
| 225 | −// 1. Define the primitive struct (must match shader layout) | |
| 226 | −#[repr(C)] | |
| 227 | −#[derive(Clone, Copy, Pod, Zeroable)] | |
| 228 | −struct GlowQuad { | |
| 229 | − order: u32, | |
| 230 | − _pad: u32, | |
| 231 | − bounds: Bounds<ScaledPixels>, | |
| 232 | − content_mask: ContentMask<ScaledPixels>, | |
| 233 | − color: Hsla, | |
| 234 | − glow_radius: f32, | |
| 235 | − glow_intensity: f32, | |
| 236 | −} | |
| 237 | − | |
| 238 | −unsafe impl CustomPrimitive for GlowQuad { | |
| 239 | − fn bounds(&self) -> Bounds<ScaledPixels> { self.bounds } | |
| 240 | − fn content_mask(&self) -> ContentMask<ScaledPixels> { self.content_mask } | |
| 241 | − fn order(&self) -> DrawOrder { self.order } | |
| 242 | − fn set_order(&mut self, order: DrawOrder) { self.order = order; } | |
| 243 | −} | |
| 244 | − | |
| 245 | −// 2. Define the shader | |
| 246 | −const GLOW_SHADER: &str = r#" | |
| 247 | −struct GlowQuad { | |
| 248 | − order: u32, | |
| 249 | − pad: u32, | |
| 250 | − bounds: Bounds, | |
| 251 | − content_mask: Bounds, | |
| 252 | − color: Hsla, | |
| 253 | − glow_radius: f32, | |
| 254 | − glow_intensity: f32, | |
| 255 | −} | |
| 256 | − | |
| 257 | −var<storage, read> b_glow_quads: array<GlowQuad>; | |
| 258 | − | |
| 259 | −struct GlowVarying { | |
| 260 | − @builtin(position) position: vec4<f32>, | |
| 261 | − @location(0) @interpolate(flat) color: vec4<f32>, | |
| 262 | − // ... more fields | |
| 263 | −} | |
| 264 | − | |
| 265 | −@vertex | |
| 266 | −fn vs_glow(@builtin(vertex_index) vertex_id: u32, | |
| 267 | − @builtin(instance_index) instance_id: u32) -> GlowVarying { | |
| 268 | − let unit_vertex = vec2<f32>(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); | |
| 269 | − let quad = b_glow_quads[instance_id]; | |
| 270 | − // ... transform and output | |
| 271 | −} | |
| 272 | − | |
| 273 | −@fragment | |
| 274 | −fn fs_glow(input: GlowVarying) -> @location(0) vec4<f32> { | |
| 275 | − // ... glow effect calculation | |
| 276 | −} | |
| 277 | −"#; | |
| 278 | − | |
| 279 | −// 3. Register at app startup | |
| 280 | −fn main() { | |
| 281 | − App::new().run(|cx| { | |
| 282 | − let glow_shader = cx.register_custom_shader(CustomShaderDescriptor { | |
| 283 | − name: "glow_quad", | |
| 284 | − shader_source: GLOW_SHADER, | |
| 285 | − vertex_entry: "vs_glow", | |
| 286 | − fragment_entry: "fs_glow", | |
| 287 | − instance_size: std::mem::size_of::<GlowQuad>(), | |
| 288 | − blend_mode: CustomBlendMode::PremultipliedAlpha, | |
| 289 | − topology: PrimitiveTopology::TriangleStrip, | |
| 290 | − }); | |
| 291 | − | |
| 292 | − // Store shader_id somewhere accessible (e.g., Global) | |
| 293 | − cx.set_global(GlowShader(glow_shader)); | |
| 294 | − | |
| 295 | − // ... rest of app setup | |
| 296 | − }); | |
| 297 | −} | |
| 298 | − | |
| 299 | −// 4. Use during element painting | |
| 300 | −impl Render for MyElement { | |
| 301 | − fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { | |
| 302 | − div() | |
| 303 | − .child("Hello") | |
| 304 | − .on_paint(cx.listener(|this, _bounds, window, cx| { | |
| 305 | − let shader_id = cx.global::<GlowShader>().0; | |
| 306 | − window.paint_custom(shader_id, GlowQuad { | |
| 307 | − order: 0, | |
| 308 | − _pad: 0, | |
| 309 | − bounds: /* ... */, | |
| 310 | − content_mask: window.content_mask().scale(window.scale_factor()), | |
| 311 | − color: hsla(0.5, 1.0, 0.5, 1.0), | |
| 312 | − glow_radius: 10.0, | |
| 313 | − glow_intensity: 0.8, | |
| 314 | − }); | |
| 315 | − })) | |
| 316 | − } | |
| 317 | −} | |
| 318 | −``` | |
| 319 | − | |
| 320 | −### Implementation Steps | |
| 321 | − | |
| 322 | −1. **[DONE] Add CustomShaderDescriptor and CustomShaderId types** (`crates/gpui/src/custom_shader.rs`) | |
| 323 | − - Define the descriptor struct | |
| 324 | − - Define the ID type | |
| 325 | − - Define CustomPrimitive trait | |
| 326 | − - Added `GPUI_SHADER_HEADER` constant with common shader utilities | |
| 327 | − | |
| 328 | −2. **[DONE] Add CustomShaderRegistry** (`crates/gpui/src/custom_shader.rs`) | |
| 329 | − - Store registered descriptors | |
| 330 | − - Provide lookup by ID | |
| 331 | − - Track which shaders need pipelines created | |
| 332 | − | |
| 333 | −3. **[DONE] Extend App with registration API** | |
| 334 | − - Add `register_custom_shader()` method | |
| 335 | − - Store registry in App state (`custom_shader_registry` field) | |
| 336 | − | |
| 337 | −4. **[DONE] Extend Scene with custom primitive storage** (`crates/gpui/src/scene.rs`) | |
| 338 | − - Add `custom_primitives: HashMap<CustomShaderId, CustomPrimitiveBuffer>` | |
| 339 | − - Add `insert_custom_primitive()` method | |
| 340 | − - Add `custom_batches()` method to yield custom batches separately | |
| 341 | − | |
| 342 | −5. **[DONE] Add PrimitiveBatch::Custom variant** (`crates/gpui/src/scene.rs`) | |
| 343 | − ```rust | |
| 344 | − Custom { | |
| 345 | − shader_id: CustomShaderId, | |
| 346 | − data: &'a [u8], | |
| 347 | − instance_count: u32, | |
| 348 | − vertices_per_instance: u32, | |
| 349 | − } | |
| 350 | − ``` | |
| 351 | − | |
| 352 | −6. **[DONE] Extend BladeRenderer** (`crates/gpui/src/platform/blade/blade_renderer.rs`) | |
| 353 | − - Add `custom_pipelines: HashMap<CustomShaderId, CustomPipelineEntry>` | |
| 354 | − - Add `register_custom_shader()` method to create pipelines | |
| 355 | − - Handle `PrimitiveBatch::Custom` in `draw()` via `custom_batches()` iterator | |
| 356 | − - Add `ShaderCustomData` struct for GPU bindings | |
| 357 | − | |
| 358 | −7. **[DONE] Add Window painting API** (`crates/gpui/src/window.rs`) | |
| 359 | − - Add `paint_custom()` method | |
| 360 | − | |
| 361 | −8. **[DONE] Create helper shader functions module** | |
| 362 | − - Export common WGSL functions via `GPUI_SHADER_HEADER` constant | |
| 363 | − - Includes: GlobalParams, Bounds, Hsla, to_device_position, hsla_to_rgba, blend_color, etc. | |
| 364 | − | |
| 365 | −9. **[TODO] Add tests and examples** | |
| 366 | − - Unit tests for registration and painting | |
| 367 | − - Example custom shader (e.g., glow effect, noise pattern) | |
| 368 | − | |
| 369 | −10. **[TODO] Wire up shader registration from App to Renderer** | |
| 370 | − - Currently renderer has `register_custom_shader()` but needs to be called | |
| 371 | − - Need to plumb shader registry to window creation | |
| 372 | − | |
| 373 | −## Open Questions | |
| 374 | − | |
| 375 | −1. **Global vs Per-Window Shader Registration?** | |
| 376 | − - Current design: Global registration, per-window pipeline creation | |
| 377 | − - Alternative: Per-window registration (more flexible, more complex) | |
| 378 | − - **Decision**: Global registration is simpler and matches most use cases | |
| 379 | − | |
| 380 | −2. **How to handle shader compilation errors?** | |
| 381 | − - Option A: Panic on invalid shader (current built-in behavior) | |
| 382 | − - Option B: Return Result from registration | |
| 383 | − - Option C: Log error and skip rendering | |
| 384 | − - **Recommendation**: Return Result, let users handle errors | |
| 385 | − | |
| 386 | −3. **Should custom primitives participate in layer ordering?** | |
| 387 | − - Built-in primitives use `layer_stack` for z-ordering | |
| 388 | − - Custom primitives should integrate with this | |
| 389 | − - **Decision**: Yes, use the same DrawOrder system | |
| 390 | − | |
| 391 | −4. **Texture access for custom shaders?** | |
| 392 | − - Some custom effects need textures (e.g., noise, patterns) | |
| 393 | − - Could expose atlas access or allow custom texture uploads | |
| 394 | − - **Recommendation**: Add optional texture binding support in v2 | |
| 395 | − | |
| 396 | −5. **Metal/DirectX support?** | |
| 397 | − - Initial implementation is Blade-only | |
| 398 | − - Could add abstractions for cross-platform shaders later | |
| 399 | − - **Recommendation**: Start with Blade, add others based on demand | |
| 400 | − | |
| 401 | −## References | |
| 402 | − | |
| 403 | −- `crates/gpui/src/scene.rs` - Scene and primitive definitions | |
| 404 | −- `crates/gpui/src/platform/blade/blade_renderer.rs` - Blade rendering implementation | |
| 405 | −- `crates/gpui/src/platform/blade/shaders.wgsl` - Built-in WGSL shaders | |
| 406 | −- `crates/gpui/src/window.rs` - Window painting API | |
| 407 | −- [blade_graphics crate](https://docs.rs/blade-graphics) - GPU abstraction layer | |
| 408 | −- [WGSL specification](https://www.w3.org/TR/WGSL/) - WebGPU Shading Language | |
| @@ -1,921 +0,0 @@ | ||
| 1 | −# MultiWorkspace Agent V2 Refactor | |
| 2 | − | |
| 3 | − | |
| 4 | − | |
| 5 | − | |
| 6 | − | |
| 7 | − | |
| 8 | − | |
| 9 | −This is a multi-phase project to enable Agent V2's vision: a threads sidebar that can switch between projects within a single window, with global thread management and folder ownership. | |
| 10 | − | |
| 11 | −--- | |
| 12 | − | |
| 13 | −## Phase 1: GlobalThreadStore Infrastructure | |
| 14 | − | |
| 15 | −Adapt the existing `ThreadStore` to be global and add the data structures for project path associations and folder ownership tracking. **Note:** Association UI is deferred to Phase 3 - all threads will have empty paths initially. | |
| 16 | − | |
| 17 | −### 1.1 Make ThreadStore Global | |
| 18 | − | |
| 19 | −Add Global wrapper pattern (similar to `HistoryManager`): | |
| 20 | − | |
| 21 | −```rust | |
| 22 | −// In crates/agent/src/thread_store.rs | |
| 23 | −struct GlobalThreadStore(Entity<ThreadStore>); | |
| 24 | −impl Global for GlobalThreadStore {} | |
| 25 | − | |
| 26 | −impl ThreadStore { | |
| 27 | − pub fn init(cx: &mut App) { ... } | |
| 28 | − pub fn global(cx: &App) -> Entity<Self> { ... } | |
| 29 | −} | |
| 30 | −``` | |
| 31 | − | |
| 32 | −Initialize in [`crates/zed/src/main.rs`](crates/zed/src/main.rs) near other global inits. | |
| 33 | − | |
| 34 | −### 1.2 Add Project Paths to Thread Storage | |
| 35 | − | |
| 36 | −**Database migration** - Add `project_paths` column to threads table in [`crates/agent/src/db.rs`](crates/agent/src/db.rs): | |
| 37 | − | |
| 38 | −```rust | |
| 39 | −// DbThreadMetadata - add project_paths for quick access | |
| 40 | −pub struct DbThreadMetadata { | |
| 41 | − pub id: acp::SessionId, | |
| 42 | − pub title: SharedString, | |
| 43 | − pub updated_at: DateTime<Utc>, | |
| 44 | − pub project_paths: Vec<PathBuf>, // NEW | |
| 45 | −} | |
| 46 | − | |
| 47 | −// DbThread - add with serde default for backwards compat | |
| 48 | −pub struct DbThread { | |
| 49 | − // ... existing fields ... | |
| 50 | − #[serde(default)] | |
| 51 | − pub project_paths: Vec<PathBuf>, // NEW | |
| 52 | −} | |
| 53 | −``` | |
| 54 | − | |
| 55 | −### 1.3 Folder Ownership Tracking | |
| 56 | − | |
| 57 | −Add ownership tracking to `ThreadStore`: | |
| 58 | − | |
| 59 | −```rust | |
| 60 | −pub struct ThreadStore { | |
| 61 | − threads: Vec<DbThreadMetadata>, | |
| 62 | − // Tracks which folders are owned by which window | |
| 63 | − folder_owners: HashMap<PathBuf, WeakWindowHandle<Workspace>>, | |
| 64 | − // Tracks which thread is actively open in which window | |
| 65 | − active_threads: HashMap<acp::SessionId, WeakWindowHandle<Workspace>>, | |
| 66 | −} | |
| 67 | −``` | |
| 68 | − | |
| 69 | −Key methods: | |
| 70 | − | |
| 71 | −- `claim_thread(session_id, window, project_paths)` - Claims ownership of thread's folders | |
| 72 | −- `release_thread(session_id)` - Releases folder ownership | |
| 73 | −- `can_open_thread(session_id, window)` - Checks if any folders overlap with another window | |
| 74 | −- `thread_available_in_window(session_id, window)` - Returns availability status for UI | |
| 75 | − | |
| 76 | −### 1.4 Update Consumers | |
| 77 | − | |
| 78 | −Update [`crates/agent_ui_v2/src/agents_panel.rs`](crates/agent_ui_v2/src/agents_panel.rs): | |
| 79 | − | |
| 80 | −- Replace `cx.new(|cx| ThreadStore::new(cx))` with `ThreadStore::global(cx)` | |
| 81 | − | |
| 82 | −**Note:** Folder overlap UI indicators are deferred to Phase 3. Since all threads have empty paths initially, there will be no conflicts to show. | |
| 83 | − | |
| 84 | −--- | |
| 85 | − | |
| 86 | −## Phase 2: MultiWorkspace | |
| 87 | − | |
| 88 | −Enable the workspace to swap between different ThreadWorktrees contexts while keeping state in memory. **Key insight:** Project stays singular per window; we swap which ThreadWorktrees group is active. | |
| 89 | − | |
| 90 | −### 2.1 New Concept: ThreadWorktrees | |
| 91 | − | |
| 92 | −Add to [`crates/project/src/project.rs`](crates/project/src/project.rs): | |
| 93 | − | |
| 94 | −```rust | |
| 95 | −/// Identifies a ThreadWorktrees group - glues thread identity to workspace identity | |
| 96 | −#[derive(Clone, Hash, Eq, PartialEq)] | |
| 97 | −pub struct ThreadWorktreesId { | |
| 98 | − pub session_id: Option<acp::SessionId>, // None for "default" (no thread) | |
| 99 | − pub workspace_db_id: WorkspaceId, | |
| 100 | −} | |
| 101 | − | |
| 102 | −/// Groups worktrees that belong to a specific thread | |
| 103 | −pub struct ThreadWorktrees { | |
| 104 | − pub id: ThreadWorktreesId, | |
| 105 | − pub worktree_ids: Vec<WorktreeId>, | |
| 106 | − pub folder_paths: Vec<PathBuf>, // Absolute paths for the folders | |
| 107 | −} | |
| 108 | − | |
| 109 | −// Project additions | |
| 110 | −pub struct Project { | |
| 111 | − // Existing: all worktrees for all live threads in this window | |
| 112 | − worktree_store: WorktreeStore, | |
| 113 | − | |
| 114 | − // NEW: Groups worktrees by thread | |
| 115 | − thread_worktrees: HashMap<ThreadWorktreesId, ThreadWorktrees>, | |
| 116 | − | |
| 117 | − // NEW: Currently active thread context | |
| 118 | − active_thread_worktrees: ThreadWorktreesId, | |
| 119 | − // ... | |
| 120 | −} | |
| 121 | −``` | |
| 122 | − | |
| 123 | −**Worktree lifecycle:** | |
| 124 | − | |
| 125 | −- Worktrees are automatically created/loaded when opening a thread | |
| 126 | −- Live threads (running in background) keep their worktrees loaded | |
| 127 | −- Worktrees shared between threads (e.g., /cloud in threads A and B) exist once in Project but are referenced by multiple ThreadWorktrees | |
| 128 | − | |
| 129 | −### 2.2 ThreadWorktrees as Panel API Surface | |
| 130 | − | |
| 131 | −Panels currently hold `Entity<Project>` and call methods like `project.read(cx).git_store()`. In multi-workspace, panels need **scoped access** - GitPanel for ThreadWorktrees A shouldn't see repos from ThreadWorktrees B. | |
| 132 | − | |
| 133 | −**ThreadWorktrees becomes the primary API surface for panels**, acting as a filtered lens into Project: | |
| 134 | − | |
| 135 | −```rust | |
| 136 | −pub struct ThreadWorktrees { | |
| 137 | − id: ThreadWorktreesId, | |
| 138 | − project: Entity<Project>, | |
| 139 | − worktree_ids: HashSet<WorktreeId>, | |
| 140 | −} | |
| 141 | − | |
| 142 | −impl ThreadWorktrees { | |
| 143 | − // === Scoped worktree access === | |
| 144 | − pub fn visible_worktrees<'a>(&self, cx: &'a App) -> impl Iterator<Item = Entity<Worktree>> + 'a { | |
| 145 | − let dominated_ids = self.worktree_ids.clone(); | |
| 146 | − self.project.read(cx) | |
| 147 | − .visible_worktrees(cx) | |
| 148 | − .filter(move |wt| dominated_ids.contains(&wt.read(cx).id())) | |
| 149 | − } | |
| 150 | − | |
| 151 | − pub fn worktree_for_id(&self, id: WorktreeId, cx: &App) -> Option<Entity<Worktree>> { | |
| 152 | − if self.worktree_ids.contains(&id) { | |
| 153 | − self.project.read(cx).worktree_for_id(id, cx) | |
| 154 | − } else { | |
| 155 | − None | |
| 156 | − } | |
| 157 | − } | |
| 158 | − | |
| 159 | − // === Scoped git access === | |
| 160 | − pub fn active_repository(&self, cx: &App) -> Option<Entity<Repository>> { | |
| 161 | − let repo = self.project.read(cx).active_repository(cx)?; | |
| 162 | − let worktree_id = repo.read(cx).worktree_id(); | |
| 163 | − self.worktree_ids.contains(&worktree_id).then_some(repo) | |
| 164 | − } | |
| 165 | − | |
| 166 | − pub fn git_store(&self, cx: &App) -> Entity<GitStore> { | |
| 167 | − // Returns the shared git_store - events are filtered separately | |
| 168 | − self.project.read(cx).git_store().clone() | |
| 169 | − } | |
| 170 | − | |
| 171 | − // === Pass-through for global state === | |
| 172 | − pub fn is_via_collab(&self, cx: &App) -> bool { | |
| 173 | − self.project.read(cx).is_via_collab() | |
| 174 | − } | |
| 175 | − | |
| 176 | − pub fn is_read_only(&self, cx: &App) -> bool { | |
| 177 | − self.project.read(cx).is_read_only(cx) | |
| 178 | − } | |
| 179 | − | |
| 180 | − pub fn languages(&self, cx: &App) -> Arc<LanguageRegistry> { | |
| 181 | − self.project.read(cx).languages().clone() | |
| 182 | − } | |
| 183 | − | |
| 184 | − pub fn path_style(&self, cx: &App) -> PathStyle { | |
| 185 | − self.project.read(cx).path_style(cx) | |
| 186 | − } | |
| 187 | − | |
| 188 | − // === Scoped file operations === | |
| 189 | − pub fn open_buffer(&self, path: ProjectPath, cx: &mut Context<Self>) -> Task<Result<Entity<Buffer>>> { | |
| 190 | − if !self.worktree_ids.contains(&path.worktree_id) { | |
| 191 | − return Task::ready(Err(anyhow!("Path not in this ThreadWorktrees scope"))); | |
| 192 | − } | |
| 193 | − self.project.update(cx, |project, cx| project.open_buffer(path, cx)) | |
| 194 | − } | |
| 195 | − | |
| 196 | − pub fn delete_file(&self, path: ProjectPath, trash: bool, cx: &mut Context<Self>) -> Option<Task<Result<()>>> { | |
| 197 | − if !self.worktree_ids.contains(&path.worktree_id) { | |
| 198 | − return None; | |
| 199 | − } | |
| 200 | − self.project.update(cx, |project, cx| project.delete_file(path, trash, cx)) | |
| 201 | − } | |
| 202 | −} | |
| 203 | −``` | |
| 204 | − | |
| 205 | −**Project API usage categories:** | |
| 206 | − | |
| 207 | −| Category | Examples | ThreadWorktrees behavior | | |
| 208 | −|----------|----------|-------------------------| | |
| 209 | −| Worktree-scoped | `visible_worktrees()`, `worktree_for_id()` | Filtered to owned worktrees | | |
| 210 | −| Git-scoped | `active_repository()`, `git_init()` | Filtered by worktree ownership | | |
| 211 | −| File operations | `open_buffer()`, `delete_file()` | Validated against scope | | |
| 212 | −| Global/shared | `is_via_collab()`, `languages()`, `path_style()` | Pass-through to Project | | |
| 213 | − | |
| 214 | −**Event filtering via ThreadWorktrees:** | |
| 215 | − | |
| 216 | −ThreadWorktrees re-emits filtered events so panels don't need to filter themselves: | |
| 217 | − | |
| 218 | −```rust | |
| 219 | −pub enum ThreadWorktreesEvent { | |
| 220 | − ActiveRepositoryChanged(Option<Entity<Repository>>), | |
| 221 | − RepositoryUpdated(RepositoryId), | |
| 222 | − WorktreeAdded(WorktreeId), | |
| 223 | − WorktreeRemoved(WorktreeId), | |
| 224 | −} | |
| 225 | − | |
| 226 | −impl ThreadWorktrees { | |
| 227 | − fn new( | |
| 228 | − id: ThreadWorktreesId, | |
| 229 | − project: Entity<Project>, | |
| 230 | − worktree_ids: HashSet<WorktreeId>, | |
| 231 | − cx: &mut Context<Self>, | |
| 232 | − ) -> Self { | |
| 233 | − let git_store = project.read(cx).git_store().clone(); | |
| 234 | − | |
| 235 | − // Subscribe to git_store and filter events | |
| 236 | − cx.subscribe(&git_store, |this, _store, event, cx| { | |
| 237 | − match event { | |
| 238 | − GitStoreEvent::ActiveRepositoryChanged(repo) => { | |
| 239 | − // Only emit if repo is in our worktrees | |
| 240 | − let dominated_repo = repo.as_ref().and_then(|r| { | |
| 241 | − this.worktree_ids.contains(&r.read(cx).worktree_id()) | |
| 242 | − .then(|| r.clone()) | |
| 243 | − }); | |
| 244 | − cx.emit(ThreadWorktreesEvent::ActiveRepositoryChanged(dominated_repo)); | |
| 245 | − } | |
| 246 | − GitStoreEvent::RepositoryUpdated(repo_id, _, _) => { | |
| 247 | − // Check if this repo belongs to our worktrees | |
| 248 | − if this.repo_in_scope(*repo_id, cx) { | |
| 249 | − cx.emit(ThreadWorktreesEvent::RepositoryUpdated(*repo_id)); | |
| 250 | − } | |
| 251 | − } | |
| 252 | − // ... filter other events similarly | |
| 253 | − } | |
| 254 | − }).detach(); | |
| 255 | − | |
| 256 | − Self { id, project, worktree_ids } | |
| 257 | − } | |
| 258 | −} | |
| 259 | −``` | |
| 260 | − | |
| 261 | −**Panel migration pattern:** | |
| 262 | − | |
| 263 | −```rust | |
| 264 | −// Before: Panel holds Project | |
| 265 | −pub struct GitPanel { | |
| 266 | − project: Entity<Project>, | |
| 267 | − // ... | |
| 268 | −} | |
| 269 | − | |
| 270 | −// After: Panel holds ThreadWorktrees | |
| 271 | −pub struct GitPanel { | |
| 272 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 273 | − // ... | |
| 274 | −} | |
| 275 | − | |
| 276 | −// Before: Direct project access | |
| 277 | −let repos = self.project.read(cx).visible_worktrees(cx); | |
| 278 | − | |
| 279 | −// After: Scoped access via ThreadWorktrees | |
| 280 | −let repos = self.thread_worktrees.read(cx).visible_worktrees(cx); | |
| 281 | − | |
| 282 | −// Before: Subscribe to git_store directly | |
| 283 | −cx.subscribe(&git_store, |this, _, event, cx| { ... }); | |
| 284 | − | |
| 285 | −// After: Subscribe to ThreadWorktrees events (pre-filtered) | |
| 286 | −cx.subscribe(&self.thread_worktrees, |this, _, event, cx| { | |
| 287 | − match event { | |
| 288 | − ThreadWorktreesEvent::ActiveRepositoryChanged(repo) => { | |
| 289 | − this.active_repository = repo.clone(); | |
| 290 | − this.schedule_update(window, cx); | |
| 291 | − } | |
| 292 | − // ... | |
| 293 | − } | |
| 294 | −}); | |
| 295 | −``` | |
| 296 | − | |
| 297 | −**Benefits of this approach:** | |
| 298 | − | |
| 299 | −1. **Type safety** - Panels can't accidentally access wrong scope; they only have `ThreadWorktrees` | |
| 300 | −2. **Centralized filtering** - Event filtering happens once in `ThreadWorktrees`, not in every panel | |
| 301 | −3. **Clear API boundary** - Easy to see which operations are scoped vs global | |
| 302 | −4. **Testability** - Can create `ThreadWorktrees` with specific worktree sets for testing | |
| 303 | − | |
| 304 | −### 2.3 WorkspaceState per ThreadWorktrees | |
| 305 | − | |
| 306 | −Modify [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs): | |
| 307 | − | |
| 308 | −```rust | |
| 309 | −/// State for a specific ThreadWorktrees context | |
| 310 | −struct WorkspaceState { | |
| 311 | − thread_worktrees_id: ThreadWorktreesId, | |
| 312 | − center: PaneGroup, | |
| 313 | − panes: Vec<Entity<Pane>>, | |
| 314 | − panes_by_item: HashMap<EntityId, WeakEntity<Pane>>, | |
| 315 | − active_pane: Entity<Pane>, | |
| 316 | − // Panel instances - each ThreadWorktrees gets its own panel entities | |
| 317 | − panel_instances: HashMap<TypeId, Box<dyn PanelHandle>>, | |
| 318 | −} | |
| 319 | − | |
| 320 | −pub struct Workspace { | |
| 321 | − project: Entity<Project>, // Still singular | |
| 322 | − | |
| 323 | − // NEW: State per ThreadWorktrees | |
| 324 | − workspace_states: HashMap<ThreadWorktreesId, WorkspaceState>, | |
| 325 | − active_workspace_state_id: ThreadWorktreesId, | |
| 326 | − | |
| 327 | − // Docks stay constant - but panel contents come from active WorkspaceState | |
| 328 | − left_dock: Entity<Dock>, | |
| 329 | − bottom_dock: Entity<Dock>, | |
| 330 | − right_dock: Entity<Dock>, | |
| 331 | − | |
| 332 | − // These stay constant across switches | |
| 333 | − status_bar: Entity<StatusBar>, | |
| 334 | − modal_layer: Entity<ModalLayer>, | |
| 335 | − toast_layer: Entity<ToastLayer>, | |
| 336 | − titlebar_item: Option<AnyView>, | |
| 337 | − notifications: Notifications, | |
| 338 | − // ... | |
| 339 | −} | |
| 340 | −``` | |
| 341 | − | |
| 342 | −### 2.4 Switching Active ThreadWorktrees | |
| 343 | − | |
| 344 | −When switching threads: | |
| 345 | − | |
| 346 | −1. **Save current state** - Store current center/panes in `workspace_states` | |
| 347 | −2. **Update Project** - Set `active_thread_worktrees` to new ThreadWorktreesId | |
| 348 | −3. **Load/create WorkspaceState** - Either restore existing or create new | |
| 349 | −4. **Swap dock panels** - Update docks to show panels from new WorkspaceState | |
| 350 | −5. **Update status bar** - Refresh to reflect new active worktrees | |
| 351 | −```rust | |
| 352 | −impl Workspace { | |
| 353 | − pub fn switch_to_thread( | |
| 354 | − &mut self, | |
| 355 | − thread_worktrees_id: ThreadWorktreesId, | |
| 356 | − window: &mut Window, | |
| 357 | − cx: &mut Context<Self>, | |
| 358 | − ) { | |
| 359 | − // 1. Save current state | |
| 360 | − self.save_active_workspace_state(cx); | |
| 361 | − | |
| 362 | − // 2. Ensure worktrees are loaded in Project | |
| 363 | − self.project.update(cx, |project, cx| { | |
| 364 | − project.ensure_thread_worktrees_loaded(&thread_worktrees_id, cx); | |
| 365 | − }); | |
| 366 | − | |
| 367 | − // 3. Load or create WorkspaceState | |
| 368 | − let state = self.workspace_states | |
| 369 | − .entry(thread_worktrees_id.clone()) | |
| 370 | − .or_insert_with(|| WorkspaceState::new(thread_worktrees_id.clone(), cx)); | |
| 371 | − | |
| 372 | − // 4. Update active state | |
| 373 | − self.active_workspace_state_id = thread_worktrees_id; | |
| 374 | − | |
| 375 | − // 5. Update UI | |
| 376 | − self.update_docks_for_active_state(window, cx); | |
| 377 | − self.update_window_title(window, cx); | |
| 378 | − cx.notify(); | |
| 379 | − } | |
| 380 | −} | |
| 381 | −``` | |
| 382 | − | |
| 383 | − | |
| 384 | −### 2.5 Panel Instance Management | |
| 385 | − | |
| 386 | −Each ThreadWorktrees gets its own panel instances: | |
| 387 | − | |
| 388 | −```rust | |
| 389 | −impl WorkspaceState { | |
| 390 | − fn get_or_create_panel<P: Panel>(&mut self, cx: &mut App) -> Entity<P> { | |
| 391 | − let type_id = TypeId::of::<P>(); | |
| 392 | − if let Some(panel) = self.panel_instances.get(&type_id) { | |
| 393 | − panel.downcast::<P>().unwrap() | |
| 394 | − } else { | |
| 395 | − let panel = cx.new(|cx| P::new(/* ... */)); | |
| 396 | − self.panel_instances.insert(type_id, Box::new(panel.clone())); | |
| 397 | − panel | |
| 398 | − } | |
| 399 | − } | |
| 400 | −} | |
| 401 | −``` | |
| 402 | − | |
| 403 | −### 2.6 Feature-Flagged Action | |
| 404 | − | |
| 405 | −Add action under `agent-v2` feature flag: | |
| 406 | − | |
| 407 | −- `SwitchToThread(SessionId)` - Switches to a thread's ThreadWorktrees | |
| 408 | −- `OpenThread(SessionId)` - Opens a thread (loads its worktrees, creates WorkspaceState) | |
| 409 | −- Initially test with hardcoded thread data | |
| 410 | − | |
| 411 | −--- | |
| 412 | − | |
| 413 | −## Phase 3: Agents Sidebar | |
| 414 | − | |
| 415 | −Create a new sidebar on the far left (outside the workspace/docks), replace AgentsPanel, and remove utility pane concept. | |
| 416 | − | |
| 417 | −### 3.1 Window Layout | |
| 418 | − | |
| 419 | −``` | |
| 420 | −┌──────────┬──────────────────────────────────────────────────┐ | |
| 421 | −│ │ │ | |
| 422 | −│ Agents │ ┌─────────┬──────────────────┬────────┐ │ | |
| 423 | −│ Sidebar │ │ Left │ │ Right │ │ | |
| 424 | −│ │ │ Dock │ Center Panes │ Dock │ │ | |
| 425 | −│ ──────── │ │ │ │ │ │ | |
| 426 | −│ Thread │ │ ─────── │ │ │ │ | |
| 427 | −│ List │ │ Agent │ │ │ │ | |
| 428 | −│ │ │ Panel │ │ │ │ | |
| 429 | −│ ──────── │ │ (thread │ │ │ │ | |
| 430 | −│ Running │ │ view) │ │ │ │ | |
| 431 | −│ Threads │ │ │ │ │ │ | |
| 432 | −│ │ └─────────┴──────────────────┴────────┘ │ | |
| 433 | −└──────────┴──────────────────────────────────────────────────┘ | |
| 434 | −``` | |
| 435 | − | |
| 436 | −**AgentsSidebar** (constant, outside workspace): | |
| 437 | − | |
| 438 | −- Far left, before left dock | |
| 439 | −- Resizable (like docks) | |
| 440 | −- Contains thread list/history and running threads section | |
| 441 | −- Does NOT swap with ThreadWorktrees | |
| 442 | − | |
| 443 | −**AgentPanel** (in left dock, swaps with ThreadWorktrees): | |
| 444 | − | |
| 445 | −- Shows single thread view (the thread associated with active ThreadWorktrees) | |
| 446 | −- Each ThreadWorktrees has its own AgentPanel instance | |
| 447 | −- Simpler than current AgentsPanel (no history, just thread view) | |
| 448 | − | |
| 449 | −### 3.2 AgentsSidebar Component | |
| 450 | − | |
| 451 | −Create new [`crates/agent_ui_v2/src/agents_sidebar.rs`](crates/agent_ui_v2/src/agents_sidebar.rs): | |
| 452 | − | |
| 453 | −```rust | |
| 454 | −pub struct AgentsSidebar { | |
| 455 | − width: Pixels, | |
| 456 | − thread_history: Entity<AcpThreadHistory>, // Reuse existing | |
| 457 | − running_threads: Vec<RunningThread>, | |
| 458 | − focus_handle: FocusHandle, | |
| 459 | −} | |
| 460 | − | |
| 461 | −struct RunningThread { | |
| 462 | − session_id: acp::SessionId, | |
| 463 | − thread_worktrees_id: ThreadWorktreesId, | |
| 464 | − thread: Entity<Thread>, // Keeps running in background | |
| 465 | − title: SharedString, | |
| 466 | − status: ThreadStatus, // Running, Idle, Error, etc. | |
| 467 | −} | |
| 468 | − | |
| 469 | −impl AgentsSidebar { | |
| 470 | − /// Called when user clicks a thread in the list | |
| 471 | − fn open_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) { | |
| 472 | − // 1. Check folder availability via GlobalThreadStore | |
| 473 | − // 2. If available, switch to thread's ThreadWorktrees | |
| 474 | − // 3. Show thread in AgentPanel | |
| 475 | − } | |
| 476 | − | |
| 477 | − /// Called when user clicks a running thread | |
| 478 | − fn switch_to_running_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) { | |
| 479 | − // Switch to that thread's ThreadWorktrees AND show in AgentPanel | |
| 480 | − // (Multiple threads can share the same ThreadWorktrees) | |
| 481 | − } | |
| 482 | −} | |
| 483 | −``` | |
| 484 | − | |
| 485 | −### 3.3 AgentPanel (Simplified) | |
| 486 | − | |
| 487 | −Replace current AgentsPanel with simpler AgentPanel in [`crates/agent_ui_v2/src/agent_panel.rs`](crates/agent_ui_v2/src/agent_panel.rs): | |
| 488 | − | |
| 489 | −```rust | |
| 490 | −/// Panel in left dock that shows the thread view for active ThreadWorktrees | |
| 491 | −pub struct AgentPanel { | |
| 492 | − thread_view: Option<Entity<AcpThreadView>>, | |
| 493 | − thread_worktrees_id: ThreadWorktreesId, | |
| 494 | − // No history - that's in the sidebar now | |
| 495 | −} | |
| 496 | − | |
| 497 | −impl AgentPanel { | |
| 498 | − /// Shows thread associated with the current ThreadWorktrees | |
| 499 | − fn show_thread(&mut self, thread: &AgentSessionInfo, cx: &mut Context<Self>) { | |
| 500 | − // Create/update AcpThreadView | |
| 501 | − } | |
| 502 | −} | |
| 503 | −``` | |
| 504 | − | |
| 505 | −**Key difference from current AgentsPanel:** | |
| 506 | − | |
| 507 | −- No thread history (moved to sidebar) | |
| 508 | −- No utility pane management (removed) | |
| 509 | −- One instance per ThreadWorktrees (swaps with workspace state) | |
| 510 | − | |
| 511 | −### 3.4 Remove Utility Pane Concept | |
| 512 | − | |
| 513 | −Delete or deprecate: | |
| 514 | − | |
| 515 | −- `AgentThreadPane` (`crates/agent_ui_v2/src/agent_thread_pane.rs`) | |
| 516 | −- Utility pane infrastructure in workspace | |
| 517 | − | |
| 518 | −### 3.5 Running Threads Section | |
| 519 | − | |
| 520 | −```rust | |
| 521 | −impl AgentsSidebar { | |
| 522 | − fn render_running_threads(&self, cx: &Context<Self>) -> impl IntoElement { | |
| 523 | − v_flex() | |
| 524 | − .child(Label::new("Running Threads").size(LabelSize::Small)) | |
| 525 | − .children(self.running_threads.iter().map(|rt| { | |
| 526 | − ListItem::new(rt.session_id.clone()) | |
| 527 | − .child(Label::new(rt.title.clone())) | |
| 528 | − .child(ThreadStatusIndicator::new(rt.status)) | |
| 529 | − .on_click(cx.listener(move |this, _, _, cx| { | |
| 530 | − this.switch_to_running_thread(&rt.session_id, cx); | |
| 531 | − })) | |
| 532 | − })) | |
| 533 | − } | |
| 534 | −} | |
| 535 | −``` | |
| 536 | − | |
| 537 | −### 3.6 Integration with Workspace | |
| 538 | − | |
| 539 | −Modify [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs) to add sidebar: | |
| 540 | − | |
| 541 | −```rust | |
| 542 | −pub struct Workspace { | |
| 543 | − // NEW: Sidebar lives outside the swappable content | |
| 544 | − agents_sidebar: Option<Entity<AgentsSidebar>>, | |
| 545 | − agents_sidebar_width: Pixels, | |
| 546 | − | |
| 547 | − // Existing (docks swap their panel contents) | |
| 548 | − left_dock: Entity<Dock>, | |
| 549 | − // ... | |
| 550 | −} | |
| 551 | − | |
| 552 | −impl Workspace { | |
| 553 | − fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { | |
| 554 | − h_flex() | |
| 555 | − .size_full() | |
| 556 | − // Sidebar first (far left) | |
| 557 | − .when_some(self.agents_sidebar.as_ref(), |this, sidebar| { | |
| 558 | − this.child(sidebar.clone()) | |
| 559 | − }) | |
| 560 | − // Then the rest of workspace (docks + center) | |
| 561 | − .child(self.render_workspace_content(window, cx)) | |
| 562 | − } | |
| 563 | −} | |
| 564 | −``` | |
| 565 | − | |
| 566 | −### 3.7 Thread Association UI | |
| 567 | − | |
| 568 | −Add UI for associating threads with project folders (deferred from Phase 1): | |
| 569 | − | |
| 570 | −- **Thread context menu** - Right-click thread in sidebar → "Associate with current project" | |
| 571 | −- **Sidebar action** - Button in thread list header | |
| 572 | −- **New thread prompt** - When creating thread, prompt to associate | |
| 573 | −```rust | |
| 574 | −impl AgentsSidebar { | |
| 575 | − fn associate_thread_with_project( | |
| 576 | − &mut self, | |
| 577 | − session_id: &acp::SessionId, | |
| 578 | − cx: &mut Context<Self>, | |
| 579 | − ) { | |
| 580 | − // Get current window's worktree paths from Project | |
| 581 | − // Update ThreadStore with association | |
| 582 | − // Refresh thread availability indicators | |
| 583 | − } | |
| 584 | −} | |
| 585 | −``` | |
| 586 | − | |
| 587 | − | |
| 588 | −--- | |
| 589 | − | |
| 590 | −## Architecture Diagram | |
| 591 | − | |
| 592 | −```mermaid | |
| 593 | −graph TB | |
| 594 | − subgraph app [Application] | |
| 595 | − GTS[GlobalThreadStore] | |
| 596 | − DB[(ThreadsDatabase)] | |
| 597 | − end | |
| 598 | − | |
| 599 | − subgraph window [Window - MultiWorkspace] | |
| 600 | − subgraph constant [Constant - Outside Workspace] | |
| 601 | − Sidebar[AgentsSidebar] | |
| 602 | − SidebarHistory[Thread History] | |
| 603 | − SidebarRunning[Running Threads] | |
| 604 | − end | |
| 605 | − | |
| 606 | − subgraph swappable [Swappable - Per ThreadWorktrees] | |
| 607 | − subgraph wsA [WorkspaceState A] | |
| 608 | − CenterA[Center Panes A] | |
| 609 | − AgentPanelA[AgentPanel A] | |
| 610 | − ProjectPanelA[ProjectPanel A] | |
| 611 | − end | |
| 612 | − subgraph wsB [WorkspaceState B] | |
| 613 | − CenterB[Center Panes B] | |
| 614 | − AgentPanelB[AgentPanel B] | |
| 615 | − ProjectPanelB[ProjectPanel B] | |
| 616 | − end | |
| 617 | − end | |
| 618 | − | |
| 619 | − Project[Project - all worktrees] | |
| 620 | − end | |
| 621 | − | |
| 622 | − GTS --> DB | |
| 623 | − Sidebar --> GTS | |
| 624 | − Sidebar --> SidebarHistory | |
| 625 | − Sidebar --> SidebarRunning | |
| 626 | − SidebarRunning --> wsA | |
| 627 | − SidebarRunning --> wsB | |
| 628 | − Project --> swappable | |
| 629 | −``` | |
| 630 | − | |
| 631 | −## Window Layout | |
| 632 | − | |
| 633 | −``` | |
| 634 | −┌──────────┬──────────────────────────────────────────────────┐ | |
| 635 | −│ Agents │ ┌─────────┬──────────────────┬────────┐ │ | |
| 636 | −│ Sidebar │ │ Left │ │ Right │ │ | |
| 637 | −│ │ │ Dock │ Center Panes │ Dock │ │ | |
| 638 | −│ ──────── │ │ │ (swappable) │ │ │ | |
| 639 | −│ Thread │ │ Agent │ │ │ │ | |
| 640 | −│ History │ │ Panel │ │ │ │ | |
| 641 | −│ │ │ (swap) │ │ │ │ | |
| 642 | −│ ──────── │ │ │ │ │ │ | |
| 643 | −│ Running │ │ Project │ │ │ │ | |
| 644 | −│ Threads │ │ Panel │ │ │ │ | |
| 645 | −│ │ │ (swap) │ │ │ │ | |
| 646 | −│(constant)│ └─────────┴──────────────────┴────────┘ │ | |
| 647 | −└──────────┴──────────────────────────────────────────────────┘ | |
| 648 | − ↑ ↑ | |
| 649 | − Outside Inside Workspace | |
| 650 | − Workspace (swaps with ThreadWorktrees) | |
| 651 | −``` | |
| 652 | − | |
| 653 | −## Key Concepts | |
| 654 | − | |
| 655 | −``` | |
| 656 | −Window (MultiWorkspace) | |
| 657 | −└── Project (singular) | |
| 658 | − ├── Worktrees: [/zed, /cloud, /zed.dev] ← All loaded worktrees | |
| 659 | − ├── ThreadWorktrees A: [/zed, /cloud] ← Thread A's folders | |
| 660 | − ├── ThreadWorktrees B: [/cloud, /zed.dev] ← Thread B's folders | |
| 661 | − └── active_thread_worktrees: A | |
| 662 | − | |
| 663 | −└── Workspace | |
| 664 | − ├── workspace_states: | |
| 665 | − │ ├── A → WorkspaceState (center panes, panels for Thread A) | |
| 666 | − │ └── B → WorkspaceState (center panes, panels for Thread B) | |
| 667 | − ├── active_workspace_state_id: A | |
| 668 | − └── Docks (constant, panels swapped from active WorkspaceState) | |
| 669 | −``` | |
| 670 | − | |
| 671 | −## Folder Ownership Model | |
| 672 | − | |
| 673 | −``` | |
| 674 | −Thread A: folders {zed, cloud} | |
| 675 | −Thread B: folders {cloud, zed.dev} | |
| 676 | − | |
| 677 | −Window 1 opens Thread A: | |
| 678 | − → Window 1 owns: {zed, cloud} | |
| 679 | − → Thread B unavailable in Window 2 (shares "cloud") | |
| 680 | − → Thread B available in Window 1 (same window, can open both) | |
| 681 | −``` | |
| 682 | − | |
| 683 | −--- | |
| 684 | − | |
| 685 | −## Key Files | |
| 686 | − | |
| 687 | −| File | Phase | Changes | | |
| 688 | −|------|-------|---------| | |
| 689 | −| [`crates/agent/src/thread_store.rs`](crates/agent/src/thread_store.rs) | 1 | Global wrapper, ownership tracking | | |
| 690 | −| [`crates/agent/src/db.rs`](crates/agent/src/db.rs) | 1 | Add project_paths to schema | | |
| 691 | −| [`crates/zed/src/main.rs`](crates/zed/src/main.rs) | 1 | ThreadStore::init() | | |
| 692 | −| [`crates/project/src/project.rs`](crates/project/src/project.rs) | 2 | ThreadWorktrees struct, ThreadWorktreesId | | |
| 693 | −| [`crates/project/src/thread_worktrees.rs`](crates/project/src/thread_worktrees.rs) | 2 | NEW: ThreadWorktrees API surface, event filtering | | |
| 694 | −| [`crates/workspace/src/workspace.rs`](crates/workspace/src/workspace.rs) | 2, 3 | WorkspaceState, AgentsSidebar integration | | |
| 695 | −| [`crates/git_ui/src/git_panel.rs`](crates/git_ui/src/git_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 696 | −| [`crates/outline_panel/src/outline_panel.rs`](crates/outline_panel/src/outline_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 697 | −| [`crates/project_panel/src/project_panel.rs`](crates/project_panel/src/project_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 698 | −| [`crates/agent_ui/src/agent_panel.rs`](crates/agent_ui/src/agent_panel.rs) | 2 | Migrate from `Entity<Project>` to `Entity<ThreadWorktrees>` | | |
| 699 | −| [`crates/agent_ui_v2/src/agents_sidebar.rs`](crates/agent_ui_v2/src/agents_sidebar.rs) | 3 | NEW: AgentsSidebar component | | |
| 700 | −| [`crates/agent_ui_v2/src/agent_panel.rs`](crates/agent_ui_v2/src/agent_panel.rs) | 3 | Simplified thread view panel | | |
| 701 | −| [`crates/agent_ui_v2/src/agents_panel.rs`](crates/agent_ui_v2/src/agents_panel.rs) | 3 | DELETE or heavily refactor | | |
| 702 | −| [`crates/agent_ui_v2/src/agent_thread_pane.rs`](crates/agent_ui_v2/src/agent_thread_pane.rs) | 3 | DELETE (utility pane removed) | | |
| 703 | − | |
| 704 | −--- | |
| 705 | − | |
| 706 | −## Design Decisions | |
| 707 | − | |
| 708 | −1. **Thread-project association is explicit** - Users must explicitly associate threads with projects (UI in Phase 3). No automatic association on thread creation. | |
| 709 | − | |
| 710 | −2. **Existing threads have empty paths** - Backwards compatible: threads without `project_paths` are treated as available everywhere (no folder ownership conflicts). | |
| 711 | − | |
| 712 | −3. **Missing paths prompt reassociation** - If a thread's associated folders no longer exist on disk, prompt user to re-associate when opening. | |
| 713 | − | |
| 714 | −4. **Association scope** - When associating, all worktrees in the current project are associated. | |
| 715 | − | |
| 716 | −5. **Association UI entry points** (Phase 3): | |
| 717 | − - Thread context menu | |
| 718 | − - Sidebar action | |
| 719 | − - Auto-prompt on new thread creation | |
| 720 | − | |
| 721 | −6. **ThreadWorktrees as panel API surface** - Panels hold `Entity<ThreadWorktrees>` instead of `Entity<Project>`. This provides: | |
| 722 | − - **Type safety**: Panels can't accidentally access wrong scope | |
| 723 | − - **Centralized event filtering**: ThreadWorktrees filters git/worktree events before re-emitting | |
| 724 | − - **Clear API boundary**: Scoped operations (worktrees, git, files) vs pass-through (is_via_collab, languages) | |
| 725 | − | |
| 726 | −7. **Project remains singular** - One `Entity<Project>` per window holds all worktrees. ThreadWorktrees is a filtered lens, not a separate data store. Worktrees shared between threads exist once in Project. | |
| 727 | − | |
| 728 | −8. **Panel migration strategy** - Panels migrate incrementally: | |
| 729 | − - Replace `project: Entity<Project>` with `thread_worktrees: Entity<ThreadWorktrees>` | |
| 730 | − - Replace `cx.subscribe(&git_store, ...)` with `cx.subscribe(&thread_worktrees, ...)` | |
| 731 | − - Scoped methods have same signatures, making migration mechanical | |
| 732 | − | |
| 733 | −--- | |
| 734 | − | |
| 735 | −## Open Questions | |
| 736 | − | |
| 737 | −*All questions resolved!* | |
| 738 | − | |
| 739 | −--- | |
| 740 | − | |
| 741 | −## Resolved Questions | |
| 742 | − | |
| 743 | −### R1. ThreadWorktrees and buffer_store | |
| 744 | − | |
| 745 | −**Resolution: SHARED (pass-through)** | |
| 746 | − | |
| 747 | −`buffer_store()` is shared across all ThreadWorktrees, not scoped. Rationale: | |
| 748 | − | |
| 749 | −- **Buffers represent files, which are singular** - BufferStore already deduplicates by `ProjectPath`. Opening the same file twice returns the same `Entity<Buffer>`. | |
| 750 | −- **Shared worktrees mean shared files** - If Thread A and B both have `/cloud`, edits to `/cloud/src/main.rs` should be visible in both immediately. | |
| 751 | −- **Scoping is about visibility, not isolation** - ThreadWorktrees controls which worktrees appear in UI, not parallel editing universes. | |
| 752 | −- **LSP and collab depend on shared buffers** - Language servers work per-file, remote collaboration shares buffers by ID. | |
| 753 | − | |
| 754 | −Implementation: | |
| 755 | −```rust | |
| 756 | −impl ThreadWorktrees { | |
| 757 | − // Pass-through - buffers are shared | |
| 758 | − pub fn buffer_store(&self, cx: &App) -> Entity<BufferStore> { | |
| 759 | − self.project.read(cx).buffer_store().clone() | |
| 760 | − } | |
| 761 | − | |
| 762 | − // Convenience with scope validation (workflow guard, not isolation) | |
| 763 | − pub fn open_buffer(&self, path: ProjectPath, cx: &mut Context<Self>) -> Task<Result<Entity<Buffer>>> { | |
| 764 | − if !self.worktree_ids.contains(&path.worktree_id) { | |
| 765 | − return Task::ready(Err(anyhow!("Path not in this ThreadWorktrees scope"))); | |
| 766 | − } | |
| 767 | − self.project.update(cx, |project, cx| project.open_buffer(path, cx)) | |
| 768 | − } | |
| 769 | −} | |
| 770 | −``` | |
| 771 | − | |
| 772 | −### R2. Cross-thread file references | |
| 773 | − | |
| 774 | −**Resolution: Center panes are NOT scoped, only panels are** | |
| 775 | − | |
| 776 | −When "Go to Definition" (or similar navigation) targets a file outside the current ThreadWorktrees: | |
| 777 | − | |
| 778 | −- **If the worktree is loaded** (another thread is live) → **Just works**. The file opens in a center tab. ProjectPanel won't show it in its tree (filtered by ThreadWorktrees), but the editor works normally. | |
| 779 | − | |
| 780 | −- **If the worktree is NOT loaded** → **File not available**. Show message: "Definition is in `/path` which is not currently loaded." | |
| 781 | − | |
| 782 | −Rationale: | |
| 783 | −- **Project** holds ALL worktrees from ALL live threads | |
| 784 | −- **ThreadWorktrees** is a filtered *view* for panels only | |
| 785 | −- **Center panes** (editors, tabs) can show any buffer from any loaded worktree | |
| 786 | −- This matches current Zed behavior - you can have open files that aren't in the project panel (via drag-drop, external open, etc.) | |
| 787 | − | |
| 788 | −Architecture: | |
| 789 | −``` | |
| 790 | −┌─────────────────────────────────────────────────────────┐ | |
| 791 | −│ Window │ | |
| 792 | −├──────────┬──────────────────────────────────────────────┤ | |
| 793 | −│ Agents │ ┌─────────┬──────────────────┬────────┐ │ | |
| 794 | −│ Sidebar │ │ Left │ │ Right │ │ | |
| 795 | −│ │ │ Dock │ Center Panes │ Dock │ │ | |
| 796 | −│ │ │ │ │ │ │ | |
| 797 | −│ │ │ SCOPED │ NOT SCOPED │ SCOPED │ │ | |
| 798 | −│ │ │ (shows │ (can open any │ │ │ | |
| 799 | −│ │ │ only │ buffer from │ │ │ | |
| 800 | −│ │ │ Thread │ any loaded │ │ │ | |
| 801 | −│ │ │ A's │ worktree) │ │ │ | |
| 802 | −│ │ │ trees) │ │ │ │ | |
| 803 | −│ │ └─────────┴──────────────────┴────────┘ │ | |
| 804 | −└──────────┴──────────────────────────────────────────────┘ | |
| 805 | −``` | |
| 806 | − | |
| 807 | −### R3. ThreadWorktrees lifecycle | |
| 808 | − | |
| 809 | −**Resolution: Reference counting handles cleanup automatically** | |
| 810 | − | |
| 811 | −GPUI entities are reference counted. No special "closed" state or explicit cleanup logic needed. | |
| 812 | − | |
| 813 | −**Thread states:** | |
| 814 | −- **Active**: Currently shown in UI | |
| 815 | −- **Backgrounded**: Still alive (in `workspace_states` hashmap), not currently shown | |
| 816 | − | |
| 817 | −**Cleanup flow when closing a thread:** | |
| 818 | −1. Remove `WorkspaceState` from `workspace_states` hashmap | |
| 819 | −2. WorkspaceState is dropped, which drops panel instances | |
| 820 | −3. Panels drop their `Entity<ThreadWorktrees>` references | |
| 821 | −4. ThreadWorktrees is automatically cleaned up by GPUI (refcount → 0) | |
| 822 | −5. Worktrees may stay in Project (if referenced by other threads) or get cleaned up similarly | |
| 823 | − | |
| 824 | −No explicit `remove_thread_worktrees()` call needed - just drop the WorkspaceState and reference counting handles the rest. | |
| 825 | − | |
| 826 | −### R4. Panel state preservation | |
| 827 | − | |
| 828 | −**Resolution: Preserved by design** | |
| 829 | − | |
| 830 | −Each ThreadWorktrees has its own panel instances stored in `WorkspaceState.panel_instances`. When switching threads: | |
| 831 | −- Current WorkspaceState (with all panel state) stays in the `workspace_states` hashmap | |
| 832 | −- New ThreadWorktrees' WorkspaceState is restored (or created) | |
| 833 | −- Switch back → original panel state is intact (scroll positions, selections, etc.) | |
| 834 | − | |
| 835 | −No extra work needed - the architecture inherently preserves panel state. | |
| 836 | − | |
| 837 | −### R5. Global vs scoped panels | |
| 838 | − | |
| 839 | −**Resolution: Most panels are scoped, a few stay global** | |
| 840 | − | |
| 841 | −**SCOPED (per-ThreadWorktrees)** - stored in `WorkspaceState.panel_instances`: | |
| 842 | −| Panel | Reason | | |
| 843 | −|-------|--------| | |
| 844 | −| ProjectPanel | Shows worktree file trees | | |
| 845 | −| GitPanel | Shows repos tied to worktrees | | |
| 846 | −| OutlinePanel | Shows context for files in worktrees | | |
| 847 | −| DebugPanel | Debug sessions are project-specific | | |
| 848 | −| TerminalPanel | Terminals have working directories in worktrees | | |
| 849 | −| AgentPanel | Thread-specific conversation | | |
| 850 | − | |
| 851 | −**GLOBAL (shared)** - stored on `Workspace` directly (current behavior): | |
| 852 | −| Panel | Reason | | |
| 853 | −|-------|--------| | |
| 854 | −| CollabPanel | Collaborators, channels, contacts - user's social state | | |
| 855 | −| NotificationPanel | User's notifications across all projects | | |
| 856 | − | |
| 857 | −**SPECIAL:** | |
| 858 | −| Panel | Reason | | |
| 859 | −|-------|--------| | |
| 860 | −| AgentsSidebar | Lives outside workspace/dock system entirely | | |
| 861 | − | |
| 862 | −**Implementation note:** `WorkspaceState` should be an `Entity<WorkspaceState>` so that `zed.rs` can initialize scoped panels using the existing `initialize_panels` pattern. This: | |
| 863 | −- Keeps the initialization flow in one place (zed.rs) | |
| 864 | −- Solves cyclic reference problems (panels reference workspace, workspace references panels) | |
| 865 | −- Matches the existing pattern that already works | |
| 866 | − | |
| 867 | −```rust | |
| 868 | −// In zed.rs - similar to current initialize_panels but per-WorkspaceState | |
| 869 | −fn initialize_scoped_panels( | |
| 870 | − workspace_state: WeakEntity<WorkspaceState>, | |
| 871 | − thread_worktrees: Entity<ThreadWorktrees>, | |
| 872 | − cx: AsyncWindowContext, | |
| 873 | −) { | |
| 874 | − cx.spawn(async move |cx| { | |
| 875 | − let project_panel = ProjectPanel::load(thread_worktrees.clone(), cx.clone()); | |
| 876 | − let git_panel = GitPanel::load(thread_worktrees.clone(), cx.clone()); | |
| 877 | − // ... other scoped panels | |
| 878 | − | |
| 879 | − futures::join!( | |
| 880 | − add_panel_when_ready(project_panel, workspace_state.clone(), cx.clone()), | |
| 881 | − add_panel_when_ready(git_panel, workspace_state.clone(), cx.clone()), | |
| 882 | − // ... | |
| 883 | − ); | |
| 884 | − }).detach(); | |
| 885 | −} | |
| 886 | −``` | |
| 887 | − | |
| 888 | −Global panels (CollabPanel, NotificationPanel) continue to be initialized once per Workspace as they are today. | |
| 889 | − | |
| 890 | −### R6. Sidebar persistence | |
| 891 | − | |
| 892 | −**Resolution: Serialize alongside MultiWorkspace state** | |
| 893 | − | |
| 894 | −Sidebar state should be persisted to the database alongside the MultiWorkspace state. Rationale: | |
| 895 | −- One MultiWorkspace per Sidebar (per window) - their lifecycles are tied | |
| 896 | −- State should be co-located for consistency | |
| 897 | −- Enables full restore of window state on restart | |
| 898 | − | |
| 899 | −**What to persist:** | |
| 900 | −- Sidebar width | |
| 901 | −- Expanded/collapsed sections (thread history, running threads) | |
| 902 | −- Scroll position in thread list | |
| 903 | −- Any filter/search state | |
| 904 | − | |
| 905 | −**Where to store:** | |
| 906 | −Alongside the existing workspace serialization in the DB, keyed by `WorkspaceId`. The `SerializedWorkspace` struct (or a new sibling) would include sidebar state. | |
| 907 | − | |
| 908 | −```rust | |
| 909 | −struct SerializedMultiWorkspace { | |
| 910 | − // Existing workspace fields... | |
| 911 | − | |
| 912 | − // Sidebar state | |
| 913 | − sidebar_width: Option<Pixels>, | |
| 914 | − sidebar_expanded_sections: Vec<String>, | |
| 915 | − sidebar_scroll_position: Option<f32>, | |
| 916 | − | |
| 917 | − // Per-ThreadWorktrees state | |
| 918 | − thread_workspace_states: HashMap<ThreadWorktreesId, SerializedWorkspaceState>, | |
| 919 | − active_thread_worktrees_id: Option<ThreadWorktreesId>, | |
| 920 | −} | |
| 921 | −``` | |
| @@ -1,154 +0,0 @@ | ||
| 1 | −# Utility Pane Removal Plan | |
| 2 | − | |
| 3 | −## Overview | |
| 4 | − | |
| 5 | −The utility pane is a secondary pane system that sits alongside the main editor panes. It allows panels (specifically `AgentThreadPane`) to "pop out" a detail view next to the center editor area. It supports two slots: | |
| 6 | − | |
| 7 | −- **Left slot**: Adjacent to the left dock | |
| 8 | −- **Right slot**: Adjacent to the right dock | |
| 9 | − | |
| 10 | −According to `threads-sidebar-plan.md`, this removal is part of **Phase 3** of the MultiWorkspace Agent V2 Refactor. The utility pane concept is being replaced by an "Agents Sidebar" that will live outside the workspace/docks structure. | |
| 11 | − | |
| 12 | −--- | |
| 13 | − | |
| 14 | −## Files to Modify/Delete | |
| 15 | − | |
| 16 | −### 1. Core Infrastructure (workspace crate) | |
| 17 | − | |
| 18 | −- `crates/workspace/src/utility_pane.rs` — **DELETE** | |
| 19 | − - Contains all utility pane state, types, and impl methods on Workspace | |
| 20 | − | |
| 21 | −- `crates/workspace/src/dock.rs` — **EDIT** | |
| 22 | − - Remove `UtilityPane`, `UtilityPaneHandle`, `UtilityPanePosition`, `MinimizePane`, `ClosePane` | |
| 23 | − | |
| 24 | −- `crates/workspace/src/workspace.rs` — **EDIT** | |
| 25 | − - Remove: `utility_panes` field, imports, `clamp_utility_pane_widths()`, `max_utility_pane_width()`, `UtilityPaneFrame` renders in `fn render()` | |
| 26 | − | |
| 27 | −- `crates/workspace/src/pane.rs` — **EDIT** | |
| 28 | − - Remove utility pane toggle buttons from tab bar rendering | |
| 29 | − | |
| 30 | −### 2. Agent UI V2 (consumer of utility panes) | |
| 31 | − | |
| 32 | −- `crates/agent_ui_v2/src/agent_thread_pane.rs` — **DELETE** | |
| 33 | − - The only implementer of `UtilityPane` trait | |
| 34 | − | |
| 35 | −- `crates/agent_ui_v2/src/agents_panel.rs` — **EDIT** | |
| 36 | − - Remove all utility pane management (`register_utility_pane`, `restore_utility_pane`, subscriptions, etc.) | |
| 37 | − | |
| 38 | −### 3. Settings | |
| 39 | − | |
| 40 | −- `crates/settings/src/settings_content/agent.rs` — **EDIT** | |
| 41 | − - Remove "utility pane" dock setting comment/option | |
| 42 | − | |
| 43 | −--- | |
| 44 | − | |
| 45 | −## Types and Traits to Remove | |
| 46 | − | |
| 47 | −### From `crates/workspace/src/dock.rs` | |
| 48 | − | |
| 49 | −- `MinimizePane` (struct) | |
| 50 | −- `ClosePane` (struct) | |
| 51 | −- `UtilityPane` (trait) | |
| 52 | −- `UtilityPaneHandle` (trait) | |
| 53 | −- `UtilityPanePosition` (enum) | |
| 54 | − | |
| 55 | −### From `crates/workspace/src/utility_pane.rs` (entire file) | |
| 56 | − | |
| 57 | −- `UtilityPaneSlot` (enum) | |
| 58 | −- `UtilityPaneSlotState` (struct) | |
| 59 | −- `UtilityPaneState` (struct) | |
| 60 | −- `DraggedUtilityPane` (struct) | |
| 61 | −- `UtilityPaneFrame` (struct) | |
| 62 | −- `utility_slot_for_dock_position()` (function) | |
| 63 | −- `UTILITY_PANE_RESIZE_HANDLE_SIZE` (const) | |
| 64 | −- `UTILITY_PANE_MIN_WIDTH` (const) | |
| 65 | −- All `impl Workspace` methods for utility panes | |
| 66 | − | |
| 67 | −--- | |
| 68 | − | |
| 69 | −## Methods on `Workspace` to Remove | |
| 70 | − | |
| 71 | −- `utility_pane()` — Get a utility pane by slot | |
| 72 | −- `toggle_utility_pane()` — Toggle expanded state | |
| 73 | −- `register_utility_pane()` — Register a new utility pane | |
| 74 | −- `clear_utility_pane()` — Clear a utility pane slot | |
| 75 | −- `clear_utility_pane_if_provider()` — Conditionally clear if owned by panel | |
| 76 | −- `resize_utility_pane()` — Resize a utility pane | |
| 77 | −- `reset_utility_pane_width()` — Reset to default width | |
| 78 | −- `clamp_utility_pane_widths()` — Clamp widths when docks resize | |
| 79 | −- `max_utility_pane_width()` — Calculate max allowed width | |
| 80 | − | |
| 81 | −--- | |
| 82 | − | |
| 83 | −## Render Changes in `workspace.rs` | |
| 84 | − | |
| 85 | −The `render()` method has **8 places** where `UtilityPaneFrame::new()` is conditionally rendered based on dock positions. All of these need to be removed: | |
| 86 | − | |
| 87 | −- Line ~7244: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | |
| 88 | −- Line ~7294: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | |
| 89 | −- Line ~7333: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | |
| 90 | −- Line ~7368: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | |
| 91 | −- Line ~7400: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | |
| 92 | −- Line ~7447: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | |
| 93 | −- Line ~7474: `UtilityPaneFrame::new(UtilityPaneSlot::Left, ...)` | |
| 94 | −- Line ~7519: `UtilityPaneFrame::new(UtilityPaneSlot::Right, ...)` | |
| 95 | − | |
| 96 | −Also remove: | |
| 97 | −- The `DragMoveEvent<DraggedUtilityPane>` handler (~line 7195) | |
| 98 | −- The `DraggedUtilityPane` import | |
| 99 | − | |
| 100 | −--- | |
| 101 | − | |
| 102 | −## Implementation Order | |
| 103 | − | |
| 104 | −### Phase 1: Remove Consumer (agent_ui_v2) | |
| 105 | − | |
| 106 | −1. Delete `crates/agent_ui_v2/src/agent_thread_pane.rs` | |
| 107 | −2. Edit `crates/agent_ui_v2/src/agents_panel.rs`: | |
| 108 | − - Remove imports from `workspace::dock` and `workspace::utility_pane` | |
| 109 | − - Remove `agent_thread_pane` field and related fields | |
| 110 | − - Remove `restore_utility_pane()` method | |
| 111 | − - Remove `handle_utility_pane_event()` method | |
| 112 | − - Remove `handle_close_pane_event()` method | |
| 113 | − - Remove utility pane registration in `open_thread()` | |
| 114 | − - Remove `utility_slot()` method | |
| 115 | − - Update `serialize()` to not include pane state | |
| 116 | − | |
| 117 | −### Phase 2: Remove Infrastructure (workspace) | |
| 118 | − | |
| 119 | −1. Delete `crates/workspace/src/utility_pane.rs` | |
| 120 | −2. Edit `crates/workspace/src/dock.rs`: | |
| 121 | − - Remove `MinimizePane`, `ClosePane` structs | |
| 122 | − - Remove `UtilityPane` trait | |
| 123 | − - Remove `UtilityPaneHandle` trait and impl | |
| 124 | − - Remove `UtilityPanePosition` enum | |
| 125 | −3. Edit `crates/workspace/src/workspace.rs`: | |
| 126 | − - Remove `pub mod utility_pane;` declaration | |
| 127 | − - Remove utility_pane imports | |
| 128 | − - Remove `utility_panes: UtilityPaneState` field from `Workspace` struct | |
| 129 | − - Remove initialization in `Workspace::new()` | |
| 130 | − - Remove `clamp_utility_pane_widths()` calls in dock resize methods | |
| 131 | − - Remove `max_utility_pane_width()` method | |
| 132 | − - Remove `clamp_utility_pane_widths()` method | |
| 133 | − - Remove all `UtilityPaneFrame` renders in `fn render()` | |
| 134 | − - Remove `DragMoveEvent<DraggedUtilityPane>` handler | |
| 135 | − - Remove `clear_utility_pane_if_provider()` call in `remove_panel()` | |
| 136 | −4. Edit `crates/workspace/src/pane.rs`: | |
| 137 | − - Remove `utility_pane::UtilityPaneSlot` import | |
| 138 | − - Remove `open_aside_left` rendering block in `render_tab_bar()` | |
| 139 | − - Remove `open_aside_right` rendering block in `render_tab_bar()` | |
| 140 | − | |
| 141 | −### Phase 3: Clean Up Settings | |
| 142 | − | |
| 143 | −1. Edit `crates/settings/src/settings_content/agent.rs`: | |
| 144 | − - Remove or update the "utility pane" dock setting documentation | |
| 145 | − | |
| 146 | −--- | |
| 147 | − | |
| 148 | −## Testing | |
| 149 | − | |
| 150 | −After removal: | |
| 151 | −1. Run `cargo check -p workspace -p agent_ui_v2` | |
| 152 | −2. Run `./script/clippy` | |
| 153 | −3. Verify the workspace renders correctly without utility panes | |
| 154 | −4. Verify the agents panel still functions (without the thread pane popup) | |