Merge remote-tracking branch 'origin/main' into zmd

Nathan Sobo created

Change summary

.github/pull_request_template.md                      |   10 
Cargo.lock                                            |   13 
Cargo.toml                                            |    2 
Untitled                                              |    0 
assets/keymaps/atom.json                              |    8 
assets/keymaps/default.json                           |   61 
assets/keymaps/jetbrains.json                         |   11 
assets/keymaps/sublime_text.json                      |    9 
assets/keymaps/textmate.json                          |    6 
assets/settings/default.json                          |   55 
crates/activity_indicator/Cargo.toml                  |    3 
crates/client/src/client.rs                           |    2 
crates/client/src/telemetry.rs                        |   11 
crates/collab/src/tests.rs                            |    3 
crates/collab/src/tests/integration_tests.rs          |   19 
crates/collab_ui/src/incoming_call_notification.rs    |    1 
crates/collab_ui/src/project_shared_notification.rs   |    1 
crates/copilot/src/sign_in.rs                         |    1 
crates/copilot_button/Cargo.toml                      |    3 
crates/copilot_button/src/copilot_button.rs           |    9 
crates/diagnostics/src/diagnostics.rs                 |   25 
crates/editor/Cargo.toml                              |    4 
crates/editor/src/editor.rs                           |  215 +
crates/editor/src/editor_settings.rs                  |   23 
crates/editor/src/editor_tests.rs                     |  112 +
crates/editor/src/element.rs                          |  115 
crates/editor/src/git.rs                              |   16 
crates/editor/src/items.rs                            |   12 
crates/editor/src/movement.rs                         |   38 
crates/editor/src/multi_buffer.rs                     |  105 
crates/editor/src/test/editor_test_context.rs         |    4 
crates/feedback/Cargo.toml                            |    3 
crates/feedback/src/deploy_feedback_button.rs         |    4 
crates/file_finder/Cargo.toml                         |    1 
crates/file_finder/src/file_finder.rs                 |   12 
crates/git/src/diff.rs                                |   65 
crates/go_to_line/Cargo.toml                          |    3 
crates/gpui/src/app.rs                                |  115 
crates/gpui/src/app/test_app_context.rs               |    2 
crates/gpui/src/app/window.rs                         |    7 
crates/gpui/src/elements.rs                           |   26 
crates/gpui/src/elements/list.rs                      |    4 
crates/gpui/src/elements/mouse_event_handler.rs       |   21 
crates/gpui/src/elements/resizable.rs                 |  133 
crates/gpui/src/platform.rs                           |   10 
crates/gpui/src/platform/mac/window.rs                |    2 
crates/journal/Cargo.toml                             |    3 
crates/language/Cargo.toml                            |    2 
crates/language/src/buffer.rs                         |   29 
crates/language/src/diagnostic_set.rs                 |    4 
crates/language/src/language_settings.rs              |    9 
crates/language_selector/Cargo.toml                   |    3 
crates/live_kit_client/LiveKitBridge/Package.resolved |   20 
crates/live_kit_client/LiveKitBridge/Package.swift    |    2 
crates/lsp_log/Cargo.toml                             |    1 
crates/outline/Cargo.toml                             |    3 
crates/picker/Cargo.toml                              |    1 
crates/project/Cargo.toml                             |    2 
crates/project/src/lsp_glob_set.rs                    |  121 -
crates/project/src/project.rs                         |  218 +
crates/project/src/project_settings.rs                |    2 
crates/project/src/project_tests.rs                   |  144 +
crates/project/src/search.rs                          |   70 
crates/project/src/worktree.rs                        | 1055 ++++------
crates/project_panel/Cargo.toml                       |    6 
crates/project_panel/src/project_panel.rs             |  425 ++++
crates/project_panel/src/project_panel_settings.rs    |   39 
crates/project_symbols/Cargo.toml                     |    1 
crates/recent_projects/Cargo.toml                     |    3 
crates/search/Cargo.toml                              |    2 
crates/search/src/project_search.rs                   |   88 
crates/settings/Cargo.toml                            |    1 
crates/settings/src/settings_store.rs                 |   73 
crates/sqlez/src/bindable.rs                          |    2 
crates/sqlez/src/statement.rs                         |    6 
crates/sqlez/src/typed_statements.rs                  |    6 
crates/terminal/src/terminal.rs                       |   14 
crates/terminal_view/Cargo.toml                       |    1 
crates/terminal_view/src/terminal_button.rs           |  173 -
crates/terminal_view/src/terminal_panel.rs            |  408 ++++
crates/terminal_view/src/terminal_view.rs             |    3 
crates/text/src/text.rs                               |   21 
crates/theme/src/theme.rs                             |   53 
crates/theme/src/ui.rs                                |   57 
crates/theme_selector/Cargo.toml                      |    3 
crates/util/src/channel.rs                            |   15 
crates/welcome/Cargo.toml                             |    3 
crates/welcome/src/welcome.rs                         |    4 
crates/workspace/src/dock.rs                          | 1277 +++++-------
crates/workspace/src/dock/toggle_dock_button.rs       |  125 -
crates/workspace/src/item.rs                          |    6 
crates/workspace/src/pane.rs                          |  618 ++---
crates/workspace/src/pane/dragged_item_receiver.rs    |  101 
crates/workspace/src/pane_group.rs                    |   16 
crates/workspace/src/persistence.rs                   |  221 -
crates/workspace/src/persistence/model.rs             |  134 
crates/workspace/src/sidebar.rs                       |  321 ---
crates/workspace/src/workspace.rs                     |  662 ++++--
crates/workspace/src/workspace_settings.rs            |   47 
crates/zed/src/main.rs                                |    5 
crates/zed/src/menus.rs                               |   13 
crates/zed/src/zed.rs                                 |  250 +-
script/get-preview-channel-changes                    |   80 
styles/src/styleTree/editor.ts                        |   41 
styles/src/styleTree/projectPanel.ts                  |   18 
styles/src/styleTree/statusBar.ts                     |    5 
styles/src/styleTree/workspace.ts                     |   35 
107 files changed, 4,408 insertions(+), 3,968 deletions(-)

Detailed changes

.github/pull_request_template.md 🔗

@@ -2,4 +2,12 @@
 
 Release Notes:
 
-* [[Added foo / Fixed bar / No notes]]
+Use `N/A` in this section if this item should be skipped in the release notes.
+
+Add release note lines here:
+
+* (Added|Fixed|Improved) ... ([#<public_issue_number_if_exists>](https://github.com/zed-industries/community/issues/<public_issue_number_if_exists>)).
+* ...
+
+If the release notes are only intended for a specific release channel only, add `(<release_channel>-only)` to the end of the release note line.
+These will be removed by the person making the release.

Cargo.lock 🔗

@@ -2054,7 +2054,6 @@ dependencies = [
  "futures 0.3.28",
  "fuzzy",
  "git",
- "glob",
  "gpui",
  "indoc",
  "itertools",
@@ -3458,7 +3457,7 @@ dependencies = [
  "futures 0.3.28",
  "fuzzy",
  "git",
- "glob",
+ "globset",
  "gpui",
  "indoc",
  "lazy_static",
@@ -4867,7 +4866,7 @@ dependencies = [
  "fuzzy",
  "git",
  "git2",
- "glob",
+ "globset",
  "gpui",
  "ignore",
  "itertools",
@@ -4903,8 +4902,10 @@ dependencies = [
 name = "project_panel"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "client",
  "context_menu",
+ "db",
  "drag_and_drop",
  "editor",
  "futures 0.3.28",
@@ -4913,6 +4914,9 @@ dependencies = [
  "menu",
  "postage",
  "project",
+ "schemars",
+ "serde",
+ "serde_derive",
  "serde_json",
  "settings",
  "theme",
@@ -5965,7 +5969,7 @@ dependencies = [
  "collections",
  "editor",
  "futures 0.3.28",
- "glob",
+ "globset",
  "gpui",
  "language",
  "log",
@@ -6127,7 +6131,6 @@ dependencies = [
  "collections",
  "fs",
  "futures 0.3.28",
- "glob",
  "gpui",
  "json_comments",
  "lazy_static",

Cargo.toml 🔗

@@ -78,7 +78,7 @@ async-trait = { version = "0.1" }
 ctor = { version = "0.1" }
 env_logger = { version = "0.9" }
 futures = { version = "0.3" }
-glob = { version = "0.3.1" }
+globset = { version = "0.4" }
 indoc = "1"
 isahc = "1.7.2"
 lazy_static = { version = "1.4.0" }

assets/keymaps/atom.json 🔗

@@ -39,8 +39,8 @@
   {
     "context": "Workspace",
     "bindings": {
-      "cmd-\\": "workspace::ToggleLeftSidebar",
-      "cmd-k cmd-b": "workspace::ToggleLeftSidebar",
+      "cmd-\\": "workspace::ToggleLeftDock",
+      "cmd-k cmd-b": "workspace::ToggleLeftDock",
       "cmd-t": "file_finder::Toggle",
       "cmd-shift-r": "project_symbols::Toggle"
     }
@@ -62,9 +62,5 @@
       "ctrl-f": "project_panel::ExpandSelectedEntry",
       "ctrl-shift-c": "project_panel::CopyPath"
     }
-  },
-  {
-    "context": "Dock",
-    "bindings": {}
   }
 ]

assets/keymaps/default.json 🔗

@@ -39,7 +39,8 @@
       "cmd-shift-n": "workspace::NewWindow",
       "cmd-o": "workspace::Open",
       "alt-cmd-o": "projects::OpenRecent",
-      "ctrl-`": "workspace::NewTerminal"
+      "ctrl-~": "workspace::NewTerminal",
+      "ctrl-`": "terminal_panel::ToggleFocus"
     }
   },
   {
@@ -67,10 +68,12 @@
       "cmd-z": "editor::Undo",
       "cmd-shift-z": "editor::Redo",
       "up": "editor::MoveUp",
+      "ctrl-up": "editor::MoveToStartOfParagraph",
       "pageup": "editor::PageUp",
       "shift-pageup": "editor::MovePageUp",
       "home": "editor::MoveToBeginningOfLine",
       "down": "editor::MoveDown",
+      "ctrl-down": "editor::MoveToEndOfParagraph",
       "pagedown": "editor::PageDown",
       "shift-pagedown": "editor::MovePageDown",
       "end": "editor::MoveToEndOfLine",
@@ -103,6 +106,8 @@
       "alt-shift-b": "editor::SelectToPreviousWordStart",
       "alt-shift-right": "editor::SelectToNextWordEnd",
       "alt-shift-f": "editor::SelectToNextWordEnd",
+      "ctrl-shift-up": "editor::SelectToStartOfParagraph",
+      "ctrl-shift-down": "editor::SelectToEndOfParagraph",
       "cmd-shift-up": "editor::SelectToBeginning",
       "cmd-shift-down": "editor::SelectToEnd",
       "cmd-a": "editor::SelectAll",
@@ -225,7 +230,8 @@
       "cmd-shift-g": "search::SelectPrevMatch",
       "alt-cmd-c": "search::ToggleCaseSensitive",
       "alt-cmd-w": "search::ToggleWholeWord",
-      "alt-cmd-r": "search::ToggleRegex"
+      "alt-cmd-r": "search::ToggleRegex",
+      "shift-escape": "workspace::ToggleZoom"
     }
   },
   // Bindings from VS Code
@@ -367,7 +373,30 @@
         "workspace::ActivatePane",
         8
       ],
-      "cmd-b": "workspace::ToggleLeftSidebar",
+      "cmd-b": [
+        "workspace::ToggleLeftDock",
+        { "focus": true }
+      ],
+      "cmd-shift-b": [
+        "workspace::ToggleLeftDock",
+        { "focus": false }
+      ],
+      "cmd-r": [
+        "workspace::ToggleRightDock",
+        { "focus": true }
+      ],
+      "cmd-shift-r": [
+        "workspace::ToggleRightDock",
+        { "focus": false }
+      ],
+      "cmd-j": [
+        "workspace::ToggleBottomDock",
+        { "focus": true }
+      ],
+      "cmd-shift-j": [
+        "workspace::ToggleBottomDock",
+        { "focus": false }
+      ],
       "cmd-shift-f": "workspace::NewSearch",
       "cmd-k cmd-t": "theme_selector::Toggle",
       "cmd-k cmd-s": "zed::OpenKeymap",
@@ -461,32 +490,6 @@
       "cmd-enter": "project_search::SearchInNew"
     }
   },
-  {
-    "context": "Workspace",
-    "bindings": {
-      "shift-escape": "dock::FocusDock"
-    }
-  },
-  {
-    "bindings": {
-      "cmd-shift-k cmd-shift-right": "dock::AnchorDockRight",
-      "cmd-shift-k cmd-shift-down": "dock::AnchorDockBottom",
-      "cmd-shift-k cmd-shift-up": "dock::ExpandDock"
-    }
-  },
-  {
-    "context": "Pane",
-    "bindings": {
-      "cmd-escape": "dock::AddTabToDock"
-    }
-  },
-  {
-    "context": "Pane && docked",
-    "bindings": {
-      "shift-escape": "dock::HideDock",
-      "cmd-escape": "dock::RemoveTabFromDock"
-    }
-  },
   {
     "context": "ProjectPanel",
     "bindings": {

assets/keymaps/jetbrains.json 🔗

@@ -68,15 +68,8 @@
       "cmd-shift-o": "file_finder::Toggle",
       "cmd-shift-a": "command_palette::Toggle",
       "cmd-alt-o": "project_symbols::Toggle",
-      "cmd-1": "workspace::ToggleLeftSidebar",
-      "cmd-6": "diagnostics::Deploy",
-      "alt-f12": "dock::FocusDock"
-    }
-  },
-  {
-    "context": "Dock",
-    "bindings": {
-      "alt-f12": "dock::HideDock"
+      "cmd-1": "workspace::ToggleLeftDock",
+      "cmd-6": "diagnostics::Deploy"
     }
   }
 ]

assets/keymaps/sublime_text.json 🔗

@@ -45,18 +45,11 @@
   {
     "context": "Workspace",
     "bindings": {
-      "ctrl-`": "dock::FocusDock",
-      "cmd-k cmd-b": "workspace::ToggleLeftSidebar",
+      "cmd-k cmd-b": "workspace::ToggleLeftDock",
       "cmd-t": "file_finder::Toggle",
       "shift-cmd-r": "project_symbols::Toggle",
       // Currently busted: https://github.com/zed-industries/feedback/issues/898
       "ctrl-0": "project_panel::ToggleFocus"
     }
-  },
-  {
-    "context": "Dock",
-    "bindings": {
-      "ctrl-`": "dock::HideDock"
-    }
   }
 ]

assets/keymaps/textmate.json 🔗

@@ -68,7 +68,7 @@
   {
     "context": "Workspace",
     "bindings": {
-      "cmd-alt-ctrl-d": "workspace::ToggleLeftSidebar",
+      "cmd-alt-ctrl-d": "workspace::ToggleLeftDock",
       "cmd-t": "file_finder::Toggle",
       "cmd-shift-t": "project_symbols::Toggle"
     }
@@ -83,9 +83,5 @@
   {
     "context": "ProjectPanel",
     "bindings": {}
-  },
-  {
-    "context": "Dock",
-    "bindings": {}
   }
 ]

assets/settings/default.json 🔗

@@ -52,19 +52,32 @@
   // 3. Draw all invisible symbols:
   //   "all"
   "show_whitespaces": "selection",
-  // Whether to show the scrollbar in the editor.
-  // This setting can take four values:
-  //
-  // 1. Show the scrollbar if there's important information or
-  //    follow the system's configured behavior (default):
-  //   "auto"
-  // 2. Match the system's configured behavior:
-  //    "system"
-  // 3. Always show the scrollbar:
-  //    "always"
-  // 4. Never show the scrollbar:
-  //    "never"
-  "show_scrollbars": "auto",
+  // Scrollbar related settings
+  "scrollbar": {
+      // When to show the scrollbar in the editor.
+      // This setting can take four values:
+      //
+      // 1. Show the scrollbar if there's important information or
+      //    follow the system's configured behavior (default):
+      //   "auto"
+      // 2. Match the system's configured behavior:
+      //    "system"
+      // 3. Always show the scrollbar:
+      //    "always"
+      // 4. Never show the scrollbar:
+      //    "never"
+      "show": "auto",
+      // Whether to show git diff indicators in the scrollbar.
+      "git_diff": true
+  },
+  "project_panel": {
+      // Whether to show the git status in the project panel.
+      "git_status": true,
+      // Where to dock project panel. Can be 'left' or 'right'.
+      "dock": "left",
+      // Default width of the project panel.
+      "default_width": 240
+  },
   // Whether the screen sharing icon is shown in the os status bar.
   "show_call_status_icon": true,
   // Whether to use language servers to provide code intelligence.
@@ -81,16 +94,6 @@
   // 4. Save when idle for a certain amount of time:
   //     "autosave": { "after_delay": {"milliseconds": 500} },
   "autosave": "off",
-  // Where to place the dock by default. This setting can take three
-  // values:
-  //
-  // 1. Position the dock attached to the bottom of the workspace
-  //     "default_dock_anchor": "bottom"
-  // 2. Position the dock to the right of the workspace like a side panel
-  //     "default_dock_anchor": "right"
-  // 3. Position the dock full screen over the entire workspace"
-  //     "default_dock_anchor": "expanded"
-  "default_dock_anchor": "bottom",
   // Whether or not to remove any trailing whitespace from lines of a buffer
   // before saving it.
   "remove_trailing_whitespace_on_save": true,
@@ -181,6 +184,12 @@
     //         }
     //     }
     "shell": "system",
+    // Where to dock terminals panel. Can be 'left', 'right', 'bottom'.
+    "dock": "bottom",
+    // Default width when the terminal is docked to the left or right.
+    "default_width": 640,
+    // Default height when the terminal is docked to the bottom.
+    "default_height": 320,
     // What working directory to use when launching the terminal.
     // May take 4 values:
     // 1. Use the current file's project directory.  Will Fallback to the

crates/activity_indicator/Cargo.toml 🔗

@@ -21,3 +21,6 @@ workspace = { path = "../workspace" }
 
 futures.workspace = true
 smallvec.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/client/src/client.rs 🔗

@@ -339,7 +339,7 @@ pub struct TelemetrySettings {
     pub metrics: bool,
 }
 
-#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
 pub struct TelemetrySettingsContent {
     pub diagnostics: Option<bool>,
     pub metrics: Option<bool>,

crates/client/src/telemetry.rs 🔗

@@ -10,6 +10,7 @@ use parking_lot::Mutex;
 use serde::Serialize;
 use serde_json::json;
 use std::{
+    env,
     io::Write,
     mem,
     path::PathBuf,
@@ -33,8 +34,9 @@ struct TelemetryState {
     installation_id: Option<Arc<str>>, // Per app installation
     app_version: Option<Arc<str>>,
     release_channel: Option<&'static str>,
-    os_version: Option<Arc<str>>,
     os_name: &'static str,
+    os_version: Option<Arc<str>>,
+    architecture: &'static str,
     mixpanel_events_queue: Vec<MixpanelEvent>,
     clickhouse_events_queue: Vec<ClickhouseEventWrapper>,
     next_mixpanel_event_id: usize,
@@ -63,6 +65,7 @@ struct ClickhouseEventRequestBody {
     app_version: Option<Arc<str>>,
     os_name: &'static str,
     os_version: Option<Arc<str>>,
+    architecture: &'static str,
     release_channel: Option<&'static str>,
     events: Vec<ClickhouseEventWrapper>,
 }
@@ -153,12 +156,14 @@ impl Telemetry {
         } else {
             None
         };
+        // TODO: Replace all hardware stuff with nested SystemSpecs json
         let this = Arc::new(Self {
             http_client: client,
             executor: cx.background().clone(),
             state: Mutex::new(TelemetryState {
-                os_version: platform.os_version().ok().map(|v| v.to_string().into()),
                 os_name: platform.os_name().into(),
+                os_version: platform.os_version().ok().map(|v| v.to_string().into()),
+                architecture: env::consts::ARCH,
                 app_version: platform.app_version().ok().map(|v| v.to_string().into()),
                 release_channel,
                 installation_id: None,
@@ -451,6 +456,8 @@ impl Telemetry {
                                 app_version: state.app_version.clone(),
                                 os_name: state.os_name,
                                 os_version: state.os_version.clone(),
+                                architecture: state.architecture,
+
                                 release_channel: state.release_channel,
                                 events,
                             },

crates/collab/src/tests.rs 🔗

@@ -192,8 +192,7 @@ impl TestServer {
             languages: Arc::new(LanguageRegistry::test()),
             fs: fs.clone(),
             build_window_options: |_, _, _| Default::default(),
-            initialize_workspace: |_, _, _| unimplemented!(),
-            dock_default_item_factory: |_, _| None,
+            initialize_workspace: |_, _, _, _| unimplemented!(),
             background_actions: || &[],
         });
 

crates/collab/src/tests/integration_tests.rs 🔗

@@ -2437,7 +2437,7 @@ async fn test_git_diff_base_change(
     buffer_local_a.read_with(cx_a, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2457,7 +2457,7 @@ async fn test_git_diff_base_change(
     buffer_remote_a.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2481,7 +2481,7 @@ async fn test_git_diff_base_change(
         assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
 
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2492,7 +2492,7 @@ async fn test_git_diff_base_change(
     buffer_remote_a.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2535,7 +2535,7 @@ async fn test_git_diff_base_change(
     buffer_local_b.read_with(cx_a, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2555,7 +2555,7 @@ async fn test_git_diff_base_change(
     buffer_remote_b.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2583,12 +2583,12 @@ async fn test_git_diff_base_change(
             "{:?}",
             buffer
                 .snapshot()
-                .git_diff_hunks_in_row_range(0..4, false)
+                .git_diff_hunks_in_row_range(0..4)
                 .collect::<Vec<_>>()
         );
 
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2599,7 +2599,7 @@ async fn test_git_diff_base_change(
     buffer_remote_b.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2688,6 +2688,7 @@ async fn test_git_branch_name(
     });
 
     let project_remote_c = client_c.build_remote_project(project_id, cx_c).await;
+    deterministic.run_until_parked();
     project_remote_c.read_with(cx_c, |project, cx| {
         assert_branch(Some("branch-2"), project, cx)
     });

crates/copilot/src/sign_in.rs 🔗

@@ -73,6 +73,7 @@ fn create_copilot_auth_window(
         titlebar: None,
         center: true,
         focus: true,
+        show: true,
         kind: WindowKind::Normal,
         is_movable: true,
         screen: None,

crates/copilot_button/Cargo.toml 🔗

@@ -23,3 +23,6 @@ workspace = { path = "../workspace" }
 anyhow.workspace = true
 smol.workspace = true
 futures.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/copilot_button/src/copilot_button.rs 🔗

@@ -66,8 +66,8 @@ impl View for CopilotButton {
                         let style = theme
                             .workspace
                             .status_bar
-                            .sidebar_buttons
-                            .item
+                            .panel_buttons
+                            .button
                             .style_for(state, active);
 
                         Flex::row()
@@ -335,10 +335,9 @@ async fn configure_disabled_globs(
                     .get::<AllLanguageSettings>(None)
                     .copilot
                     .disabled_globs
-                    .clone()
                     .iter()
-                    .map(|glob| glob.as_str().to_string())
-                    .collect::<Vec<_>>()
+                    .map(|glob| glob.glob().to_string())
+                    .collect()
             });
 
             if let Some(path_to_disable) = &path_to_disable {

crates/diagnostics/src/diagnostics.rs 🔗

@@ -33,7 +33,7 @@ use theme::ThemeSettings;
 use util::TryFutureExt;
 use workspace::{
     item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
-    ItemNavHistory, Pane, ToolbarItemLocation, Workspace,
+    ItemNavHistory, Pane, PaneBackdrop, ToolbarItemLocation, Workspace,
 };
 
 actions!(diagnostics, [Deploy]);
@@ -90,11 +90,15 @@ impl View for ProjectDiagnosticsEditor {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         if self.path_states.is_empty() {
             let theme = &theme::current(cx).project_diagnostics;
-            Label::new("No problems in workspace", theme.empty_message.clone())
-                .aligned()
-                .contained()
-                .with_style(theme.container)
-                .into_any()
+            PaneBackdrop::new(
+                cx.view_id(),
+                Label::new("No problems in workspace", theme.empty_message.clone())
+                    .aligned()
+                    .contained()
+                    .with_style(theme.container)
+                    .into_any(),
+            )
+            .into_any()
         } else {
             ChildView::new(&self.editor, cx).into_any()
         }
@@ -161,8 +165,13 @@ impl ProjectDiagnosticsEditor {
             editor.set_vertical_scroll_margin(5, cx);
             editor
         });
-        cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()))
-            .detach();
+        cx.subscribe(&editor, |this, _, event, cx| {
+            cx.emit(event.clone());
+            if event == &editor::Event::Focused && this.path_states.is_empty() {
+                cx.focus_self()
+            }
+        })
+        .detach();
 
         let project = project_handle.read(cx);
         let paths_to_update = project

crates/editor/Cargo.toml 🔗

@@ -49,8 +49,7 @@ workspace = { path = "../workspace" }
 aho-corasick = "0.7"
 anyhow.workspace = true
 futures.workspace = true
-glob.workspace = true
-indoc.workspace = true
+indoc = "1.0.4"
 itertools = "0.10"
 lazy_static.workspace = true
 log.workspace = true
@@ -82,7 +81,6 @@ workspace = { path = "../workspace", features = ["test-support"] }
 
 ctor.workspace = true
 env_logger.workspace = true
-glob.workspace = true
 rand.workspace = true
 unindent.workspace = true
 tree-sitter = "0.20"

crates/editor/src/editor.rs 🔗

@@ -20,6 +20,7 @@ mod editor_tests;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
+use ::git::diff::DiffHunk;
 use aho_corasick::AhoCorasick;
 use anyhow::{anyhow, Result};
 use blink_manager::BlinkManager;
@@ -215,6 +216,8 @@ actions!(
         MoveToNextSubwordEnd,
         MoveToBeginningOfLine,
         MoveToEndOfLine,
+        MoveToStartOfParagraph,
+        MoveToEndOfParagraph,
         MoveToBeginning,
         MoveToEnd,
         SelectUp,
@@ -225,6 +228,8 @@ actions!(
         SelectToPreviousSubwordStart,
         SelectToNextWordEnd,
         SelectToNextSubwordEnd,
+        SelectToStartOfParagraph,
+        SelectToEndOfParagraph,
         SelectToBeginning,
         SelectToEnd,
         SelectAll,
@@ -336,6 +341,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(Editor::move_to_next_subword_end);
     cx.add_action(Editor::move_to_beginning_of_line);
     cx.add_action(Editor::move_to_end_of_line);
+    cx.add_action(Editor::move_to_start_of_paragraph);
+    cx.add_action(Editor::move_to_end_of_paragraph);
     cx.add_action(Editor::move_to_beginning);
     cx.add_action(Editor::move_to_end);
     cx.add_action(Editor::select_up);
@@ -348,6 +355,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(Editor::select_to_next_subword_end);
     cx.add_action(Editor::select_to_beginning_of_line);
     cx.add_action(Editor::select_to_end_of_line);
+    cx.add_action(Editor::select_to_start_of_paragraph);
+    cx.add_action(Editor::select_to_end_of_paragraph);
     cx.add_action(Editor::select_to_beginning);
     cx.add_action(Editor::select_to_end);
     cx.add_action(Editor::select_all);
@@ -524,15 +533,6 @@ pub struct EditorSnapshot {
     ongoing_scroll: OngoingScroll,
 }
 
-impl EditorSnapshot {
-    fn has_scrollbar_info(&self) -> bool {
-        self.buffer_snapshot
-            .git_diff_hunks_in_range(0..self.max_point().row(), false)
-            .next()
-            .is_some()
-    }
-}
-
 #[derive(Clone, Debug)]
 struct SelectionHistoryEntry {
     selections: Arc<[Selection<Anchor>]>,
@@ -4761,6 +4761,80 @@ impl Editor {
         });
     }
 
+    pub fn move_to_start_of_paragraph(
+        &mut self,
+        _: &MoveToStartOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_with(|map, selection| {
+                selection.collapse_to(
+                    movement::start_of_paragraph(map, selection.head()),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
+    pub fn move_to_end_of_paragraph(
+        &mut self,
+        _: &MoveToEndOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_with(|map, selection| {
+                selection.collapse_to(
+                    movement::end_of_paragraph(map, selection.head()),
+                    SelectionGoal::None,
+                )
+            });
+        })
+    }
+
+    pub fn select_to_start_of_paragraph(
+        &mut self,
+        _: &SelectToStartOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_heads_with(|map, head, _| {
+                (movement::start_of_paragraph(map, head), SelectionGoal::None)
+            });
+        })
+    }
+
+    pub fn select_to_end_of_paragraph(
+        &mut self,
+        _: &SelectToEndOfParagraph,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.move_heads_with(|map, head, _| {
+                (movement::end_of_paragraph(map, head), SelectionGoal::None)
+            });
+        })
+    }
+
     pub fn move_to_beginning(&mut self, _: &MoveToBeginning, cx: &mut ViewContext<Self>) {
         if matches!(self.mode, EditorMode::SingleLine) {
             cx.propagate_action();
@@ -5569,68 +5643,91 @@ impl Editor {
     }
 
     fn go_to_hunk(&mut self, _: &GoToHunk, cx: &mut ViewContext<Self>) {
-        self.go_to_hunk_impl(Direction::Next, cx)
-    }
+        let snapshot = self
+            .display_map
+            .update(cx, |display_map, cx| display_map.snapshot(cx));
+        let selection = self.selections.newest::<Point>(cx);
 
-    fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, cx: &mut ViewContext<Self>) {
-        self.go_to_hunk_impl(Direction::Prev, cx)
+        if !self.seek_in_direction(
+            &snapshot,
+            selection.head(),
+            false,
+            snapshot
+                .buffer_snapshot
+                .git_diff_hunks_in_range((selection.head().row + 1)..u32::MAX),
+            cx,
+        ) {
+            let wrapped_point = Point::zero();
+            self.seek_in_direction(
+                &snapshot,
+                wrapped_point,
+                true,
+                snapshot
+                    .buffer_snapshot
+                    .git_diff_hunks_in_range((wrapped_point.row + 1)..u32::MAX),
+                cx,
+            );
+        }
     }
 
-    pub fn go_to_hunk_impl(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
+    fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, cx: &mut ViewContext<Self>) {
         let snapshot = self
             .display_map
             .update(cx, |display_map, cx| display_map.snapshot(cx));
         let selection = self.selections.newest::<Point>(cx);
 
-        fn seek_in_direction(
-            this: &mut Editor,
-            snapshot: &DisplaySnapshot,
-            initial_point: Point,
-            is_wrapped: bool,
-            direction: Direction,
-            cx: &mut ViewContext<Editor>,
-        ) -> bool {
-            let hunks = if direction == Direction::Next {
-                snapshot
-                    .buffer_snapshot
-                    .git_diff_hunks_in_range(initial_point.row..u32::MAX, false)
-            } else {
+        if !self.seek_in_direction(
+            &snapshot,
+            selection.head(),
+            false,
+            snapshot
+                .buffer_snapshot
+                .git_diff_hunks_in_range_rev(0..selection.head().row),
+            cx,
+        ) {
+            let wrapped_point = snapshot.buffer_snapshot.max_point();
+            self.seek_in_direction(
+                &snapshot,
+                wrapped_point,
+                true,
                 snapshot
                     .buffer_snapshot
-                    .git_diff_hunks_in_range(0..initial_point.row, true)
-            };
-
-            let display_point = initial_point.to_display_point(snapshot);
-            let mut hunks = hunks
-                .map(|hunk| diff_hunk_to_display(hunk, &snapshot))
-                .skip_while(|hunk| {
-                    if is_wrapped {
-                        false
-                    } else {
-                        hunk.contains_display_row(display_point.row())
-                    }
-                })
-                .dedup();
+                    .git_diff_hunks_in_range_rev(0..wrapped_point.row),
+                cx,
+            );
+        }
+    }
 
-            if let Some(hunk) = hunks.next() {
-                this.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                    let row = hunk.start_display_row();
-                    let point = DisplayPoint::new(row, 0);
-                    s.select_display_ranges([point..point]);
-                });
+    fn seek_in_direction(
+        &mut self,
+        snapshot: &DisplaySnapshot,
+        initial_point: Point,
+        is_wrapped: bool,
+        hunks: impl Iterator<Item = DiffHunk<u32>>,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        let display_point = initial_point.to_display_point(snapshot);
+        let mut hunks = hunks
+            .map(|hunk| diff_hunk_to_display(hunk, &snapshot))
+            .skip_while(|hunk| {
+                if is_wrapped {
+                    false
+                } else {
+                    hunk.contains_display_row(display_point.row())
+                }
+            })
+            .dedup();
 
-                true
-            } else {
-                false
-            }
-        }
+        if let Some(hunk) = hunks.next() {
+            self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                let row = hunk.start_display_row();
+                let point = DisplayPoint::new(row, 0);
+                s.select_display_ranges([point..point]);
+            });
 
-        if !seek_in_direction(self, &snapshot, selection.head(), false, direction, cx) {
-            let wrapped_point = match direction {
-                Direction::Next => Point::zero(),
-                Direction::Prev => snapshot.buffer_snapshot.max_point(),
-            };
-            seek_in_direction(self, &snapshot, wrapped_point, true, direction, cx);
+            true
+        } else {
+            false
         }
     }
 
@@ -7104,6 +7201,7 @@ pub enum Event {
     BufferEdited,
     Edited,
     Reparsed,
+    Focused,
     Blurred,
     DirtyChanged,
     Saved,
@@ -7157,6 +7255,7 @@ impl View for Editor {
     fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
         if cx.is_self_focused() {
             let focused_event = EditorFocused(cx.handle());
+            cx.emit(Event::Focused);
             cx.emit_global(focused_event);
         }
         if let Some(rename) = self.pending_rename.as_ref() {

crates/editor/src/editor_settings.rs 🔗

@@ -7,25 +7,36 @@ pub struct EditorSettings {
     pub cursor_blink: bool,
     pub hover_popover_enabled: bool,
     pub show_completions_on_input: bool,
-    pub show_scrollbars: ShowScrollbars,
+    pub scrollbar: Scrollbar,
 }
 
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct Scrollbar {
+    pub show: ShowScrollbar,
+    pub git_diff: bool,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
 #[serde(rename_all = "snake_case")]
-pub enum ShowScrollbars {
-    #[default]
+pub enum ShowScrollbar {
     Auto,
     System,
     Always,
     Never,
 }
 
-#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct EditorSettingsContent {
     pub cursor_blink: Option<bool>,
     pub hover_popover_enabled: Option<bool>,
     pub show_completions_on_input: Option<bool>,
-    pub show_scrollbars: Option<ShowScrollbars>,
+    pub scrollbar: Option<ScrollbarContent>,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct ScrollbarContent {
+    pub show: Option<ShowScrollbar>,
+    pub git_diff: Option<bool>,
 }
 
 impl Setting for EditorSettings {

crates/editor/src/editor_tests.rs 🔗

@@ -1243,6 +1243,118 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorTestContext::new(cx);
+
+    let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache()));
+    cx.simulate_window_resize(cx.window_id, vec2f(100., 4. * line_height));
+
+    cx.set_state(
+        &r#"ˇone
+        two
+
+        three
+        fourˇ
+        five
+
+        six"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+        ˇ
+        three
+        four
+        five
+        ˇ
+        six"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+
+        three
+        four
+        five
+        ˇ
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"ˇone
+        two
+
+        three
+        four
+        five
+
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"ˇone
+        two
+        ˇ
+        three
+        four
+        five
+
+        six"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"ˇone
+        two
+
+        three
+        four
+        five
+
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+
+        three
+        four
+        five
+        ˇ
+        sixˇ"#
+            .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
+    cx.assert_editor_state(
+        &r#"one
+        two
+        ˇ
+        three
+        four
+        five
+        ˇ
+        six"#
+            .unindent(),
+    );
+}
+
 #[gpui::test]
 async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/element.rs 🔗

@@ -5,7 +5,7 @@ use super::{
 };
 use crate::{
     display_map::{BlockStyle, DisplaySnapshot, FoldStatus, TransformBlock},
-    editor_settings::ShowScrollbars,
+    editor_settings::ShowScrollbar,
     git::{diff_hunk_to_display, DisplayDiffHunk},
     hover_popover::{
         hide_hover, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH,
@@ -50,6 +50,7 @@ use std::{
     ops::Range,
     sync::Arc,
 };
+use text::Point;
 use workspace::{item::Item, GitGutterSetting, WorkspaceSettings};
 
 enum FoldMarkers {}
@@ -651,7 +652,7 @@ impl EditorElement {
 
                 //TODO: This rendering is entirely a horrible hack
                 DiffHunkStatus::Removed => {
-                    let row = *display_row_range.start();
+                    let row = display_row_range.start;
 
                     let offset = line_height / 2.;
                     let start_y = row as f32 * line_height - offset - scroll_top;
@@ -673,11 +674,11 @@ impl EditorElement {
                 }
             };
 
-            let start_row = *display_row_range.start();
-            let end_row = *display_row_range.end();
+            let start_row = display_row_range.start;
+            let end_row = display_row_range.end;
 
             let start_y = start_row as f32 * line_height - scroll_top;
-            let end_y = end_row as f32 * line_height - scroll_top + line_height;
+            let end_y = end_row as f32 * line_height - scroll_top;
 
             let width = diff_style.width_em * line_height;
             let highlight_origin = bounds.origin() + vec2f(-width, start_y);
@@ -1051,47 +1052,53 @@ impl EditorElement {
                 ..Default::default()
             });
 
-            let diff_style = theme::current(cx).editor.diff.clone();
-            for hunk in layout
-                .position_map
-                .snapshot
-                .buffer_snapshot
-                .git_diff_hunks_in_range(0..(max_row.floor() as u32), false)
-            {
-                let start_y = y_for_row(hunk.buffer_range.start as f32);
-                let mut end_y = if hunk.buffer_range.start == hunk.buffer_range.end {
-                    y_for_row((hunk.buffer_range.end + 1) as f32)
-                } else {
-                    y_for_row((hunk.buffer_range.end) as f32)
-                };
+            if layout.is_singleton && settings::get::<EditorSettings>(cx).scrollbar.git_diff {
+                let diff_style = theme::current(cx).editor.scrollbar.git.clone();
+                for hunk in layout
+                    .position_map
+                    .snapshot
+                    .buffer_snapshot
+                    .git_diff_hunks_in_range(0..(max_row.floor() as u32))
+                {
+                    let start_display = Point::new(hunk.buffer_range.start, 0)
+                        .to_display_point(&layout.position_map.snapshot.display_snapshot);
+                    let end_display = Point::new(hunk.buffer_range.end, 0)
+                        .to_display_point(&layout.position_map.snapshot.display_snapshot);
+                    let start_y = y_for_row(start_display.row() as f32);
+                    let mut end_y = if hunk.buffer_range.start == hunk.buffer_range.end {
+                        y_for_row((end_display.row() + 1) as f32)
+                    } else {
+                        y_for_row((end_display.row()) as f32)
+                    };
 
-                if end_y - start_y < 1. {
-                    end_y = start_y + 1.;
-                }
-                let bounds = RectF::from_points(vec2f(left, start_y), vec2f(right, end_y));
-
-                let color = match hunk.status() {
-                    DiffHunkStatus::Added => diff_style.inserted,
-                    DiffHunkStatus::Modified => diff_style.modified,
-                    DiffHunkStatus::Removed => diff_style.deleted,
-                };
-
-                let border = Border {
-                    width: 1.,
-                    color: style.thumb.border.color,
-                    overlay: false,
-                    top: false,
-                    right: true,
-                    bottom: false,
-                    left: true,
-                };
+                    if end_y - start_y < 1. {
+                        end_y = start_y + 1.;
+                    }
+                    let bounds = RectF::from_points(vec2f(left, start_y), vec2f(right, end_y));
 
-                scene.push_quad(Quad {
-                    bounds,
-                    background: Some(color),
-                    border,
-                    corner_radius: style.thumb.corner_radius,
-                })
+                    let color = match hunk.status() {
+                        DiffHunkStatus::Added => diff_style.inserted,
+                        DiffHunkStatus::Modified => diff_style.modified,
+                        DiffHunkStatus::Removed => diff_style.deleted,
+                    };
+
+                    let border = Border {
+                        width: 1.,
+                        color: style.thumb.border.color,
+                        overlay: false,
+                        top: false,
+                        right: true,
+                        bottom: false,
+                        left: true,
+                    };
+
+                    scene.push_quad(Quad {
+                        bounds,
+                        background: Some(color),
+                        border,
+                        corner_radius: style.thumb.corner_radius,
+                    })
+                }
             }
 
             scene.push_quad(Quad {
@@ -1269,7 +1276,7 @@ impl EditorElement {
             .row;
 
         buffer_snapshot
-            .git_diff_hunks_in_range(buffer_start_row..buffer_end_row, false)
+            .git_diff_hunks_in_range(buffer_start_row..buffer_end_row)
             .map(|hunk| diff_hunk_to_display(hunk, snapshot))
             .dedup()
             .collect()
@@ -2060,13 +2067,17 @@ impl Element<Editor> for EditorElement {
             ));
         }
 
-        let show_scrollbars = match settings::get::<EditorSettings>(cx).show_scrollbars {
-            ShowScrollbars::Auto => {
-                snapshot.has_scrollbar_info() || editor.scroll_manager.scrollbars_visible()
+        let scrollbar_settings = &settings::get::<EditorSettings>(cx).scrollbar;
+        let show_scrollbars = match scrollbar_settings.show {
+            ShowScrollbar::Auto => {
+                // Git
+                (is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot.has_git_diffs())
+                // Scrollmanager
+                || editor.scroll_manager.scrollbars_visible()
             }
-            ShowScrollbars::System => editor.scroll_manager.scrollbars_visible(),
-            ShowScrollbars::Always => true,
-            ShowScrollbars::Never => false,
+            ShowScrollbar::System => editor.scroll_manager.scrollbars_visible(),
+            ShowScrollbar::Always => true,
+            ShowScrollbar::Never => false,
         };
 
         let include_root = editor
@@ -2285,6 +2296,7 @@ impl Element<Editor> for EditorElement {
                 text_size,
                 scrollbar_row_range,
                 show_scrollbars,
+                is_singleton,
                 max_row,
                 gutter_margin,
                 active_rows,
@@ -2440,6 +2452,7 @@ pub struct LayoutState {
     selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
     scrollbar_row_range: Range<f32>,
     show_scrollbars: bool,
+    is_singleton: bool,
     max_row: u32,
     context_menu: Option<(DisplayPoint, AnyElement<Editor>)>,
     code_actions_indicator: Option<(u32, AnyElement<Editor>)>,

crates/editor/src/git.rs 🔗

@@ -1,4 +1,4 @@
-use std::ops::RangeInclusive;
+use std::ops::Range;
 
 use git::diff::{DiffHunk, DiffHunkStatus};
 use language::Point;
@@ -15,7 +15,7 @@ pub enum DisplayDiffHunk {
     },
 
     Unfolded {
-        display_row_range: RangeInclusive<u32>,
+        display_row_range: Range<u32>,
         status: DiffHunkStatus,
     },
 }
@@ -26,7 +26,7 @@ impl DisplayDiffHunk {
             &DisplayDiffHunk::Folded { display_row } => display_row,
             DisplayDiffHunk::Unfolded {
                 display_row_range, ..
-            } => *display_row_range.start(),
+            } => display_row_range.start,
         }
     }
 
@@ -36,7 +36,7 @@ impl DisplayDiffHunk {
 
             DisplayDiffHunk::Unfolded {
                 display_row_range, ..
-            } => display_row_range.clone(),
+            } => display_row_range.start..=display_row_range.end - 1,
         };
 
         range.contains(&display_row)
@@ -77,16 +77,12 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
     } else {
         let start = hunk_start_point.to_display_point(snapshot).row();
 
-        let hunk_end_row_inclusive = hunk
-            .buffer_range
-            .end
-            .saturating_sub(1)
-            .max(hunk.buffer_range.start);
+        let hunk_end_row_inclusive = hunk.buffer_range.end.max(hunk.buffer_range.start);
         let hunk_end_point = Point::new(hunk_end_row_inclusive, 0);
         let end = hunk_end_point.to_display_point(snapshot).row();
 
         DisplayDiffHunk::Unfolded {
-            display_row_range: start..=end,
+            display_row_range: start..end,
             status: hunk.status(),
         }
     }

crates/editor/src/items.rs 🔗

@@ -1231,27 +1231,27 @@ mod tests {
         }
 
         fn as_local(&self) -> Option<&dyn language::LocalFile> {
-            todo!()
+            unimplemented!()
         }
 
         fn mtime(&self) -> SystemTime {
-            todo!()
+            unimplemented!()
         }
 
         fn file_name<'a>(&'a self, _: &'a gpui::AppContext) -> &'a std::ffi::OsStr {
-            todo!()
+            unimplemented!()
         }
 
         fn is_deleted(&self) -> bool {
-            todo!()
+            unimplemented!()
         }
 
         fn as_any(&self) -> &dyn std::any::Any {
-            todo!()
+            unimplemented!()
         }
 
         fn to_proto(&self) -> rpc::proto::File {
-            todo!()
+            unimplemented!()
         }
     }
 }

crates/editor/src/movement.rs 🔗

@@ -193,6 +193,44 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     })
 }
 
+pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    if point.row == 0 {
+        return map.max_point();
+    }
+
+    let mut found_non_blank_line = false;
+    for row in (0..point.row + 1).rev() {
+        let blank = map.buffer_snapshot.is_line_blank(row);
+        if found_non_blank_line && blank {
+            return Point::new(row, 0).to_display_point(map);
+        }
+
+        found_non_blank_line |= !blank;
+    }
+
+    DisplayPoint::zero()
+}
+
+pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    if point.row == map.max_buffer_row() {
+        return DisplayPoint::zero();
+    }
+
+    let mut found_non_blank_line = false;
+    for row in point.row..map.max_buffer_row() + 1 {
+        let blank = map.buffer_snapshot.is_line_blank(row);
+        if found_non_blank_line && blank {
+            return Point::new(row, 0).to_display_point(map);
+        }
+
+        found_non_blank_line |= !blank;
+    }
+
+    map.max_point()
+}
+
 /// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
 /// given predicate returning true. The predicate is called with the character to the left and right
 /// of the candidate boundary location, and will be called with `\n` characters indicating the start

crates/editor/src/multi_buffer.rs 🔗

@@ -1140,6 +1140,10 @@ impl MultiBuffer {
         let mut result = Vec::new();
         let mut cursor = snapshot.excerpts.cursor::<usize>();
         cursor.seek(&start, Bias::Right, &());
+        if cursor.item().is_none() {
+            cursor.prev(&());
+        }
+
         while let Some(excerpt) = cursor.item() {
             if *cursor.start() > end {
                 break;
@@ -2841,20 +2845,24 @@ impl MultiBufferSnapshot {
             })
     }
 
-    pub fn git_diff_hunks_in_range<'a>(
+    pub fn has_git_diffs(&self) -> bool {
+        for excerpt in self.excerpts.iter() {
+            if !excerpt.buffer.git_diff.is_empty() {
+                return true;
+            }
+        }
+        false
+    }
+
+    pub fn git_diff_hunks_in_range_rev<'a>(
         &'a self,
         row_range: Range<u32>,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let mut cursor = self.excerpts.cursor::<Point>();
 
-        if reversed {
-            cursor.seek(&Point::new(row_range.end, 0), Bias::Left, &());
-            if cursor.item().is_none() {
-                cursor.prev(&());
-            }
-        } else {
-            cursor.seek(&Point::new(row_range.start, 0), Bias::Right, &());
+        cursor.seek(&Point::new(row_range.end, 0), Bias::Left, &());
+        if cursor.item().is_none() {
+            cursor.prev(&());
         }
 
         std::iter::from_fn(move || {
@@ -2884,7 +2892,7 @@ impl MultiBufferSnapshot {
 
             let buffer_hunks = excerpt
                 .buffer
-                .git_diff_hunks_intersecting_range(buffer_start..buffer_end, reversed)
+                .git_diff_hunks_intersecting_range_rev(buffer_start..buffer_end)
                 .filter_map(move |hunk| {
                     let start = multibuffer_start.row
                         + hunk
@@ -2904,12 +2912,70 @@ impl MultiBufferSnapshot {
                     })
                 });
 
-            if reversed {
-                cursor.prev(&());
-            } else {
-                cursor.next(&());
+            cursor.prev(&());
+
+            Some(buffer_hunks)
+        })
+        .flatten()
+    }
+
+    pub fn git_diff_hunks_in_range<'a>(
+        &'a self,
+        row_range: Range<u32>,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        let mut cursor = self.excerpts.cursor::<Point>();
+
+        cursor.seek(&Point::new(row_range.start, 0), Bias::Right, &());
+
+        std::iter::from_fn(move || {
+            let excerpt = cursor.item()?;
+            let multibuffer_start = *cursor.start();
+            let multibuffer_end = multibuffer_start + excerpt.text_summary.lines;
+            if multibuffer_start.row >= row_range.end {
+                return None;
+            }
+
+            let mut buffer_start = excerpt.range.context.start;
+            let mut buffer_end = excerpt.range.context.end;
+            let excerpt_start_point = buffer_start.to_point(&excerpt.buffer);
+            let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines;
+
+            if row_range.start > multibuffer_start.row {
+                let buffer_start_point =
+                    excerpt_start_point + Point::new(row_range.start - multibuffer_start.row, 0);
+                buffer_start = excerpt.buffer.anchor_before(buffer_start_point);
             }
 
+            if row_range.end < multibuffer_end.row {
+                let buffer_end_point =
+                    excerpt_start_point + Point::new(row_range.end - multibuffer_start.row, 0);
+                buffer_end = excerpt.buffer.anchor_before(buffer_end_point);
+            }
+
+            let buffer_hunks = excerpt
+                .buffer
+                .git_diff_hunks_intersecting_range(buffer_start..buffer_end)
+                .filter_map(move |hunk| {
+                    let start = multibuffer_start.row
+                        + hunk
+                            .buffer_range
+                            .start
+                            .saturating_sub(excerpt_start_point.row);
+                    let end = multibuffer_start.row
+                        + hunk
+                            .buffer_range
+                            .end
+                            .min(excerpt_end_point.row + 1)
+                            .saturating_sub(excerpt_start_point.row);
+
+                    Some(DiffHunk {
+                        buffer_range: start..end,
+                        diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+                    })
+                });
+
+            cursor.next(&());
+
             Some(buffer_hunks)
         })
         .flatten()
@@ -4647,7 +4713,7 @@ mod tests {
 
         assert_eq!(
             snapshot
-                .git_diff_hunks_in_range(0..12, false)
+                .git_diff_hunks_in_range(0..12)
                 .map(|hunk| (hunk.status(), hunk.buffer_range))
                 .collect::<Vec<_>>(),
             &expected,
@@ -4655,7 +4721,7 @@ mod tests {
 
         assert_eq!(
             snapshot
-                .git_diff_hunks_in_range(0..12, true)
+                .git_diff_hunks_in_range_rev(0..12)
                 .map(|hunk| (hunk.status(), hunk.buffer_range))
                 .collect::<Vec<_>>(),
             expected
@@ -5010,16 +5076,19 @@ mod tests {
                     .read(cx)
                     .range_to_buffer_ranges(start_ix..end_ix, cx);
                 let excerpted_buffers_text = excerpted_buffer_ranges
-                    .into_iter()
+                    .iter()
                     .map(|(buffer, buffer_range)| {
                         buffer
                             .read(cx)
-                            .text_for_range(buffer_range)
+                            .text_for_range(buffer_range.clone())
                             .collect::<String>()
                     })
                     .collect::<Vec<_>>()
                     .join("\n");
                 assert_eq!(excerpted_buffers_text, text_for_range);
+                if !expected_excerpts.is_empty() {
+                    assert!(!excerpted_buffer_ranges.is_empty());
+                }
 
                 let expected_summary = TextSummary::from(&expected_text[start_ix..end_ix]);
                 assert_eq!(

crates/editor/src/test/editor_test_context.rs 🔗

@@ -204,6 +204,7 @@ impl<'a> EditorTestContext<'a> {
         self.assert_selections(expected_selections, marked_text.to_string())
     }
 
+    #[track_caller]
     pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
         let expected_ranges = self.ranges(marked_text);
         let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
@@ -220,6 +221,7 @@ impl<'a> EditorTestContext<'a> {
         assert_set_eq!(actual_ranges, expected_ranges);
     }
 
+    #[track_caller]
     pub fn assert_editor_text_highlights<Tag: ?Sized + 'static>(&mut self, marked_text: &str) {
         let expected_ranges = self.ranges(marked_text);
         let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
@@ -233,12 +235,14 @@ impl<'a> EditorTestContext<'a> {
         assert_set_eq!(actual_ranges, expected_ranges);
     }
 
+    #[track_caller]
     pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
         let expected_marked_text =
             generate_marked_text(&self.buffer_text(), &expected_selections, true);
         self.assert_selections(expected_selections, expected_marked_text)
     }
 
+    #[track_caller]
     fn assert_selections(
         &mut self,
         expected_selections: Vec<Range<usize>>,

crates/feedback/Cargo.toml 🔗

@@ -35,3 +35,6 @@ serde_derive.workspace = true
 sysinfo = "0.27.1"
 tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" }
 urlencoding = "2.1.2"
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/feedback/src/deploy_feedback_button.rs 🔗

@@ -39,8 +39,8 @@ impl View for DeployFeedbackButton {
                     let style = &theme
                         .workspace
                         .status_bar
-                        .sidebar_buttons
-                        .item
+                        .panel_buttons
+                        .button
                         .style_for(state, active);
 
                     Svg::new("icons/feedback_16.svg")

crates/file_finder/Cargo.toml 🔗

@@ -23,6 +23,7 @@ workspace = { path = "../workspace" }
 postage.workspace = true
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }

crates/file_finder/src/file_finder.rs 🔗

@@ -380,7 +380,7 @@ mod tests {
     use gpui::{TestAppContext, ViewHandle};
     use menu::{Confirm, SelectNext};
     use serde_json::json;
-    use workspace::{AppState, Pane, Workspace};
+    use workspace::{AppState, Workspace};
 
     #[ctor::ctor]
     fn init_logger() {
@@ -1161,9 +1161,13 @@ mod tests {
                 assert!(insertion_result.is_none(), "Pane id {pane_id} collision");
             }
         });
-        workspace.update(cx, |workspace, cx| {
-            Pane::close_active_item(workspace, &workspace::CloseActiveItem, cx);
-        });
+        active_pane
+            .update(cx, |pane, cx| {
+                pane.close_active_item(&workspace::CloseActiveItem, cx)
+                    .unwrap()
+            })
+            .await
+            .unwrap();
         deterministic.run_until_parked();
         cx.read(|cx| {
             for pane in workspace.read(cx).panes() {

crates/git/src/diff.rs 🔗

@@ -1,4 +1,4 @@
-use std::ops::Range;
+use std::{iter, ops::Range};
 use sum_tree::SumTree;
 use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
 
@@ -71,22 +71,25 @@ impl BufferDiff {
         }
     }
 
+    pub fn is_empty(&self) -> bool {
+        self.tree.is_empty()
+    }
+
     pub fn hunks_in_row_range<'a>(
         &'a self,
         range: Range<u32>,
         buffer: &'a BufferSnapshot,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let start = buffer.anchor_before(Point::new(range.start, 0));
         let end = buffer.anchor_after(Point::new(range.end, 0));
-        self.hunks_intersecting_range(start..end, buffer, reversed)
+
+        self.hunks_intersecting_range(start..end, buffer)
     }
 
     pub fn hunks_intersecting_range<'a>(
         &'a self,
         range: Range<Anchor>,
         buffer: &'a BufferSnapshot,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| {
             let before_start = summary.buffer_range.end.cmp(&range.start, buffer).is_lt();
@@ -94,15 +97,51 @@ impl BufferDiff {
             !before_start && !after_end
         });
 
-        std::iter::from_fn(move || {
-            if reversed {
-                cursor.prev(buffer);
+        let anchor_iter = std::iter::from_fn(move || {
+            cursor.next(buffer);
+            cursor.item()
+        })
+        .flat_map(move |hunk| {
+            [
+                (&hunk.buffer_range.start, hunk.diff_base_byte_range.start),
+                (&hunk.buffer_range.end, hunk.diff_base_byte_range.end),
+            ]
+            .into_iter()
+        });
+
+        let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(anchor_iter);
+        iter::from_fn(move || {
+            let (start_point, start_base) = summaries.next()?;
+            let (end_point, end_base) = summaries.next()?;
+
+            let end_row = if end_point.column > 0 {
+                end_point.row + 1
             } else {
-                cursor.next(buffer);
-            }
+                end_point.row
+            };
 
-            let hunk = cursor.item()?;
+            Some(DiffHunk {
+                buffer_range: start_point.row..end_row,
+                diff_base_byte_range: start_base..end_base,
+            })
+        })
+    }
 
+    pub fn hunks_intersecting_range_rev<'a>(
+        &'a self,
+        range: Range<Anchor>,
+        buffer: &'a BufferSnapshot,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| {
+            let before_start = summary.buffer_range.end.cmp(&range.start, buffer).is_lt();
+            let after_end = summary.buffer_range.start.cmp(&range.end, buffer).is_gt();
+            !before_start && !after_end
+        });
+
+        std::iter::from_fn(move || {
+            cursor.prev(buffer);
+
+            let hunk = cursor.item()?;
             let range = hunk.buffer_range.to_point(buffer);
             let end_row = if range.end.column > 0 {
                 range.end.row + 1
@@ -151,7 +190,7 @@ impl BufferDiff {
     fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let start = text.anchor_before(Point::new(0, 0));
         let end = text.anchor_after(Point::new(u32::MAX, u32::MAX));
-        self.hunks_intersecting_range(start..end, text, false)
+        self.hunks_intersecting_range(start..end, text)
     }
 
     fn diff<'a>(head: &'a str, current: &'a str) -> Option<GitPatch<'a>> {
@@ -279,6 +318,8 @@ pub fn assert_hunks<Iter>(
 
 #[cfg(test)]
 mod tests {
+    use std::assert_eq;
+
     use super::*;
     use text::Buffer;
     use unindent::Unindent as _;
@@ -365,7 +406,7 @@ mod tests {
         assert_eq!(diff.hunks(&buffer).count(), 8);
 
         assert_hunks(
-            diff.hunks_in_row_range(7..12, &buffer, false),
+            diff.hunks_in_row_range(7..12, &buffer),
             &buffer,
             &diff_base,
             &[

crates/go_to_line/Cargo.toml 🔗

@@ -18,3 +18,6 @@ workspace = { path = "../workspace" }
 postage.workspace = true
 theme = { path = "../theme" }
 util = { path = "../util" }
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/gpui/src/app.rs 🔗

@@ -1460,27 +1460,13 @@ impl AppContext {
                 self.views_metadata.remove(&(window_id, view_id));
                 let mut view = self.views.remove(&(window_id, view_id)).unwrap();
                 view.release(self);
-                let change_focus_to = self.windows.get_mut(&window_id).and_then(|window| {
+                if let Some(window) = self.windows.get_mut(&window_id) {
                     window.parents.remove(&view_id);
                     window
                         .invalidation
                         .get_or_insert_with(Default::default)
                         .removed
                         .push(view_id);
-                    if window.focused_view_id == Some(view_id) {
-                        Some(window.root_view().id())
-                    } else {
-                        None
-                    }
-                });
-
-                if let Some(view_id) = change_focus_to {
-                    self.pending_effects
-                        .push_back(Effect::Focus(FocusEffect::View {
-                            window_id,
-                            view_id: Some(view_id),
-                            is_forced: false,
-                        }));
                 }
 
                 self.pending_effects
@@ -1717,8 +1703,69 @@ impl AppContext {
                             if let Some(invalidation) = invalidation {
                                 let appearance = cx.window.platform_window.appearance();
                                 cx.invalidate(invalidation, appearance);
-                                if cx.layout(refreshing).log_err().is_some() {
+                                if let Some(old_parents) = cx.layout(refreshing).log_err() {
                                     updated_windows.insert(window_id);
+
+                                    if let Some(focused_view_id) = cx.focused_view_id() {
+                                        let old_ancestors = std::iter::successors(
+                                            Some(focused_view_id),
+                                            |&view_id| old_parents.get(&view_id).copied(),
+                                        )
+                                        .collect::<HashSet<_>>();
+                                        let new_ancestors =
+                                            cx.ancestors(focused_view_id).collect::<HashSet<_>>();
+
+                                        // Notify the old ancestors of the focused view when they don't contain it anymore.
+                                        for old_ancestor in old_ancestors.iter().copied() {
+                                            if !new_ancestors.contains(&old_ancestor) {
+                                                if let Some(mut view) =
+                                                    cx.views.remove(&(window_id, old_ancestor))
+                                                {
+                                                    view.focus_out(
+                                                        focused_view_id,
+                                                        cx,
+                                                        old_ancestor,
+                                                    );
+                                                    cx.views
+                                                        .insert((window_id, old_ancestor), view);
+                                                }
+                                            }
+                                        }
+
+                                        // Notify the new ancestors of the focused view if they contain it now.
+                                        for new_ancestor in new_ancestors.iter().copied() {
+                                            if !old_ancestors.contains(&new_ancestor) {
+                                                if let Some(mut view) =
+                                                    cx.views.remove(&(window_id, new_ancestor))
+                                                {
+                                                    view.focus_in(
+                                                        focused_view_id,
+                                                        cx,
+                                                        new_ancestor,
+                                                    );
+                                                    cx.views
+                                                        .insert((window_id, new_ancestor), view);
+                                                }
+                                            }
+                                        }
+
+                                        // When the previously-focused view has been dropped and
+                                        // there isn't any pending focus, focus the root view.
+                                        let root_view_id = cx.window.root_view().id();
+                                        if focused_view_id != root_view_id
+                                            && !cx.views.contains_key(&(window_id, focused_view_id))
+                                            && !focus_effects.contains_key(&window_id)
+                                        {
+                                            focus_effects.insert(
+                                                window_id,
+                                                FocusEffect::View {
+                                                    window_id,
+                                                    view_id: Some(root_view_id),
+                                                    is_forced: false,
+                                                },
+                                            );
+                                        }
+                                    }
                                 }
                             }
                         });
@@ -1895,9 +1942,27 @@ impl AppContext {
     fn handle_focus_effect(&mut self, effect: FocusEffect) {
         let window_id = effect.window_id();
         self.update_window(window_id, |cx| {
+            // Ensure the newly-focused view still exists, otherwise focus
+            // the root view instead.
             let focused_id = match effect {
-                FocusEffect::View { view_id, .. } => view_id,
-                FocusEffect::ViewParent { view_id, .. } => cx.ancestors(view_id).skip(1).next(),
+                FocusEffect::View { view_id, .. } => {
+                    if let Some(view_id) = view_id {
+                        if cx.views.contains_key(&(window_id, view_id)) {
+                            Some(view_id)
+                        } else {
+                            Some(cx.root_view().id())
+                        }
+                    } else {
+                        None
+                    }
+                }
+                FocusEffect::ViewParent { view_id, .. } => Some(
+                    cx.window
+                        .parents
+                        .get(&view_id)
+                        .copied()
+                        .unwrap_or(cx.root_view().id()),
+                ),
             };
 
             let focus_changed = cx.window.focused_view_id != focused_id;
@@ -3802,6 +3867,12 @@ impl<T> PartialEq for ViewHandle<T> {
     }
 }
 
+impl<T> PartialEq<AnyViewHandle> for ViewHandle<T> {
+    fn eq(&self, other: &AnyViewHandle) -> bool {
+        self.window_id == other.window_id && self.view_id == other.view_id
+    }
+}
+
 impl<T> PartialEq<WeakViewHandle<T>> for ViewHandle<T> {
     fn eq(&self, other: &WeakViewHandle<T>) -> bool {
         self.window_id == other.window_id && self.view_id == other.view_id
@@ -3952,6 +4023,12 @@ impl Clone for AnyViewHandle {
     }
 }
 
+impl PartialEq for AnyViewHandle {
+    fn eq(&self, other: &Self) -> bool {
+        self.window_id == other.window_id && self.view_id == other.view_id
+    }
+}
+
 impl<T> PartialEq<ViewHandle<T>> for AnyViewHandle {
     fn eq(&self, other: &ViewHandle<T>) -> bool {
         self.window_id == other.window_id && self.view_id == other.view_id
@@ -4198,7 +4275,7 @@ impl<T> Hash for WeakViewHandle<T> {
     }
 }
 
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
 pub struct AnyWeakViewHandle {
     window_id: usize,
     view_id: usize,

crates/gpui/src/app/test_app_context.rs 🔗

@@ -270,7 +270,7 @@ impl TestAppContext {
             .borrow_mut()
             .pop_front()
             .expect("prompt was not called");
-        let _ = done_tx.try_send(answer);
+        done_tx.try_send(answer).ok();
     }
 
     pub fn has_pending_prompt(&self, window_id: usize) -> bool {

crates/gpui/src/app/window.rs 🔗

@@ -29,6 +29,7 @@ use sqlez::{
 };
 use std::{
     any::TypeId,
+    mem,
     ops::{Deref, DerefMut, Range},
 };
 use util::ResultExt;
@@ -890,7 +891,7 @@ impl<'a> WindowContext<'a> {
         Ok(element)
     }
 
-    pub(crate) fn layout(&mut self, refreshing: bool) -> Result<()> {
+    pub(crate) fn layout(&mut self, refreshing: bool) -> Result<HashMap<usize, usize>> {
         let window_size = self.window.platform_window.content_size();
         let root_view_id = self.window.root_view().id();
         let mut rendered_root = self.window.rendered_views.remove(&root_view_id).unwrap();
@@ -923,11 +924,11 @@ impl<'a> WindowContext<'a> {
             }
         }
 
-        self.window.parents = new_parents;
+        let old_parents = mem::replace(&mut self.window.parents, new_parents);
         self.window
             .rendered_views
             .insert(root_view_id, rendered_root);
-        Ok(())
+        Ok(old_parents)
     }
 
     pub(crate) fn paint(&mut self) -> Result<Scene> {

crates/gpui/src/elements.rs 🔗

@@ -187,25 +187,23 @@ pub trait Element<V: View>: 'static {
         Tooltip::new::<Tag, V>(id, text, action, style, self.into_any(), cx)
     }
 
-    fn with_resize_handle<Tag: 'static>(
+    fn resizable(
         self,
-        element_id: usize,
-        side: Side,
-        handle_size: f32,
-        initial_size: f32,
-        cx: &mut ViewContext<V>,
+        side: HandleSide,
+        size: f32,
+        on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext<V>),
     ) -> Resizable<V>
     where
         Self: 'static + Sized,
     {
-        Resizable::new::<Tag, V>(
-            self.into_any(),
-            element_id,
-            side,
-            handle_size,
-            initial_size,
-            cx,
-        )
+        Resizable::new(self.into_any(), side, size, on_resize)
+    }
+
+    fn mouse<Tag>(self, region_id: usize) -> MouseEventHandler<Tag, V>
+    where
+        Self: Sized,
+    {
+        MouseEventHandler::for_child(self.into_any(), region_id)
     }
 }
 

crates/gpui/src/elements/list.rs 🔗

@@ -990,7 +990,7 @@ mod tests {
             _: &mut V,
             _: &mut ViewContext<V>,
         ) {
-            todo!()
+            unimplemented!()
         }
 
         fn rect_for_text_range(
@@ -1003,7 +1003,7 @@ mod tests {
             _: &V,
             _: &ViewContext<V>,
         ) -> Option<RectF> {
-            todo!()
+            unimplemented!()
         }
 
         fn debug(&self, _: RectF, _: &(), _: &(), _: &V, _: &ViewContext<V>) -> serde_json::Value {

crates/gpui/src/elements/mouse_event_handler.rs 🔗

@@ -32,10 +32,25 @@ pub struct MouseEventHandler<Tag: 'static, V: View> {
 /// Element which provides a render_child callback with a MouseState and paints a mouse
 /// region under (or above) it for easy mouse event handling.
 impl<Tag, V: View> MouseEventHandler<Tag, V> {
-    pub fn new<D, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
+    pub fn for_child(child: impl Element<V>, region_id: usize) -> Self {
+        Self {
+            child: child.into_any(),
+            region_id,
+            cursor_style: None,
+            handlers: Default::default(),
+            notify_on_hover: false,
+            notify_on_click: false,
+            hoverable: false,
+            above: false,
+            padding: Default::default(),
+            _tag: PhantomData,
+        }
+    }
+
+    pub fn new<E, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
     where
-        D: Element<V>,
-        F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> D,
+        E: Element<V>,
+        F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> E,
     {
         let mut mouse_state = cx.mouse_state::<Tag>(region_id);
         let child = render_child(&mut mouse_state, cx).into_any();

crates/gpui/src/elements/resizable.rs 🔗

@@ -1,4 +1,4 @@
-use std::{cell::Cell, rc::Rc};
+use std::{cell::RefCell, rc::Rc};
 
 use pathfinder_geometry::vector::{vec2f, Vector2F};
 use serde_json::json;
@@ -7,25 +7,23 @@ use crate::{
     geometry::rect::RectF,
     platform::{CursorStyle, MouseButton},
     scene::MouseDrag,
-    AnyElement, Axis, Element, ElementStateHandle, LayoutContext, MouseRegion, SceneBuilder, View,
+    AnyElement, Axis, Element, LayoutContext, MouseRegion, SceneBuilder, SizeConstraint, View,
     ViewContext,
 };
 
-use super::{ConstrainedBox, Hook};
-
 #[derive(Copy, Clone, Debug)]
-pub enum Side {
+pub enum HandleSide {
     Top,
     Bottom,
     Left,
     Right,
 }
 
-impl Side {
+impl HandleSide {
     fn axis(&self) -> Axis {
         match self {
-            Side::Left | Side::Right => Axis::Horizontal,
-            Side::Top | Side::Bottom => Axis::Vertical,
+            HandleSide::Left | HandleSide::Right => Axis::Horizontal,
+            HandleSide::Top | HandleSide::Bottom => Axis::Vertical,
         }
     }
 
@@ -33,8 +31,8 @@ impl Side {
     /// then top-to-bottom
     fn before_content(self) -> bool {
         match self {
-            Side::Left | Side::Top => true,
-            Side::Right | Side::Bottom => false,
+            HandleSide::Left | HandleSide::Top => true,
+            HandleSide::Right | HandleSide::Bottom => false,
         }
     }
 
@@ -55,14 +53,14 @@ impl Side {
 
     fn of_rect(&self, bounds: RectF, handle_size: f32) -> RectF {
         match self {
-            Side::Top => RectF::new(bounds.origin(), vec2f(bounds.width(), handle_size)),
-            Side::Left => RectF::new(bounds.origin(), vec2f(handle_size, bounds.height())),
-            Side::Bottom => {
+            HandleSide::Top => RectF::new(bounds.origin(), vec2f(bounds.width(), handle_size)),
+            HandleSide::Left => RectF::new(bounds.origin(), vec2f(handle_size, bounds.height())),
+            HandleSide::Bottom => {
                 let mut origin = bounds.lower_left();
                 origin.set_y(origin.y() - handle_size);
                 RectF::new(origin, vec2f(bounds.width(), handle_size))
             }
-            Side::Right => {
+            HandleSide::Right => {
                 let mut origin = bounds.upper_right();
                 origin.set_x(origin.x() - handle_size);
                 RectF::new(origin, vec2f(handle_size, bounds.height()))
@@ -71,69 +69,44 @@ impl Side {
     }
 }
 
-struct ResizeHandleState {
-    actual_dimension: Cell<f32>,
-    custom_dimension: Cell<f32>,
-}
-
 pub struct Resizable<V: View> {
-    side: Side,
-    handle_size: f32,
     child: AnyElement<V>,
-    state: Rc<ResizeHandleState>,
-    _state_handle: ElementStateHandle<Rc<ResizeHandleState>>,
+    handle_side: HandleSide,
+    handle_size: f32,
+    on_resize: Rc<RefCell<dyn FnMut(&mut V, f32, &mut ViewContext<V>)>>,
 }
 
+const DEFAULT_HANDLE_SIZE: f32 = 4.0;
+
 impl<V: View> Resizable<V> {
-    pub fn new<Tag: 'static, T: View>(
+    pub fn new(
         child: AnyElement<V>,
-        element_id: usize,
-        side: Side,
-        handle_size: f32,
-        initial_size: f32,
-        cx: &mut ViewContext<V>,
+        handle_side: HandleSide,
+        size: f32,
+        on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext<V>),
     ) -> Self {
-        let state_handle = cx.element_state::<Tag, Rc<ResizeHandleState>>(
-            element_id,
-            Rc::new(ResizeHandleState {
-                actual_dimension: Cell::new(initial_size),
-                custom_dimension: Cell::new(initial_size),
-            }),
-        );
-
-        let state = state_handle.read(cx).clone();
-
-        let child = Hook::new({
-            let constrained = ConstrainedBox::new(child);
-            match side.axis() {
-                Axis::Horizontal => constrained.with_max_width(state.custom_dimension.get()),
-                Axis::Vertical => constrained.with_max_height(state.custom_dimension.get()),
-            }
-        })
-        .on_after_layout({
-            let state = state.clone();
-            move |size, _| {
-                state.actual_dimension.set(side.relevant_component(size));
-            }
-        })
+        let child = match handle_side.axis() {
+            Axis::Horizontal => child.constrained().with_max_width(size),
+            Axis::Vertical => child.constrained().with_max_height(size),
+        }
         .into_any();
 
         Self {
-            side,
             child,
-            handle_size,
-            state,
-            _state_handle: state_handle,
+            handle_side,
+            handle_size: DEFAULT_HANDLE_SIZE,
+            on_resize: Rc::new(RefCell::new(on_resize)),
         }
     }
 
-    pub fn current_size(&self) -> f32 {
-        self.state.actual_dimension.get()
+    pub fn with_handle_size(mut self, handle_size: f32) -> Self {
+        self.handle_size = handle_size;
+        self
     }
 }
 
 impl<V: View> Element<V> for Resizable<V> {
-    type LayoutState = ();
+    type LayoutState = SizeConstraint;
     type PaintState = ();
 
     fn layout(
@@ -142,7 +115,7 @@ impl<V: View> Element<V> for Resizable<V> {
         view: &mut V,
         cx: &mut LayoutContext<V>,
     ) -> (Vector2F, Self::LayoutState) {
-        (self.child.layout(constraint, view, cx), ())
+        (self.child.layout(constraint, view, cx), constraint)
     }
 
     fn paint(
@@ -150,34 +123,44 @@ impl<V: View> Element<V> for Resizable<V> {
         scene: &mut SceneBuilder,
         bounds: pathfinder_geometry::rect::RectF,
         visible_bounds: pathfinder_geometry::rect::RectF,
-        _child_size: &mut Self::LayoutState,
+        constraint: &mut SizeConstraint,
         view: &mut V,
         cx: &mut ViewContext<V>,
     ) -> Self::PaintState {
         scene.push_stacking_context(None, None);
 
-        let handle_region = self.side.of_rect(bounds, self.handle_size);
+        let handle_region = self.handle_side.of_rect(bounds, self.handle_size);
 
         enum ResizeHandle {}
         scene.push_mouse_region(
-            MouseRegion::new::<ResizeHandle>(cx.view_id(), self.side as usize, handle_region)
-                .on_down(MouseButton::Left, |_, _: &mut V, _| {}) // This prevents the mouse down event from being propagated elsewhere
-                .on_drag(MouseButton::Left, {
-                    let state = self.state.clone();
-                    let side = self.side;
-                    move |e, _: &mut V, cx| {
-                        let prev_width = state.actual_dimension.get();
-                        state
-                            .custom_dimension
-                            .set(0f32.max(prev_width + side.compute_delta(e)).round());
-                        cx.notify();
+            MouseRegion::new::<ResizeHandle>(
+                cx.view_id(),
+                self.handle_side as usize,
+                handle_region,
+            )
+            .on_down(MouseButton::Left, |_, _: &mut V, _| {}) // This prevents the mouse down event from being propagated elsewhere
+            .on_drag(MouseButton::Left, {
+                let bounds = bounds.clone();
+                let side = self.handle_side;
+                let prev_size = side.relevant_component(bounds.size());
+                let min_size = side.relevant_component(constraint.min);
+                let max_size = side.relevant_component(constraint.max);
+                let on_resize = self.on_resize.clone();
+                move |event, view: &mut V, cx| {
+                    let new_size = min_size
+                        .max(prev_size + side.compute_delta(event))
+                        .min(max_size)
+                        .round();
+                    if new_size != prev_size {
+                        on_resize.borrow_mut()(view, new_size, cx);
                     }
-                }),
+                }
+            }),
         );
 
         scene.push_cursor_region(crate::CursorRegion {
             bounds: handle_region,
-            style: match self.side.axis() {
+            style: match self.handle_side.axis() {
                 Axis::Horizontal => CursorStyle::ResizeLeftRight,
                 Axis::Vertical => CursorStyle::ResizeUpDown,
             },

crates/gpui/src/platform.rs 🔗

@@ -173,6 +173,7 @@ pub struct WindowOptions<'a> {
     pub titlebar: Option<TitlebarOptions<'a>>,
     pub center: bool,
     pub focus: bool,
+    pub show: bool,
     pub kind: WindowKind,
     pub is_movable: bool,
     pub screen: Option<Rc<dyn Screen>>,
@@ -222,21 +223,21 @@ impl Bind for WindowBounds {
     fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
         let (region, next_index) = match self {
             WindowBounds::Fullscreen => {
-                let next_index = statement.bind("Fullscreen", start_index)?;
+                let next_index = statement.bind(&"Fullscreen", start_index)?;
                 (None, next_index)
             }
             WindowBounds::Maximized => {
-                let next_index = statement.bind("Maximized", start_index)?;
+                let next_index = statement.bind(&"Maximized", start_index)?;
                 (None, next_index)
             }
             WindowBounds::Fixed(region) => {
-                let next_index = statement.bind("Fixed", start_index)?;
+                let next_index = statement.bind(&"Fixed", start_index)?;
                 (Some(*region), next_index)
             }
         };
 
         statement.bind(
-            region.map(|region| {
+            &region.map(|region| {
                 (
                     region.min_x(),
                     region.min_y(),
@@ -376,6 +377,7 @@ impl<'a> Default for WindowOptions<'a> {
             }),
             center: false,
             focus: true,
+            show: true,
             kind: WindowKind::Normal,
             is_movable: true,
             screen: None,

crates/gpui/src/platform/mac/window.rs 🔗

@@ -614,7 +614,7 @@ impl Window {
             }
             if options.focus {
                 native_window.makeKeyAndOrderFront_(nil);
-            } else {
+            } else if options.show {
                 native_window.orderFront_(nil);
             }
 

crates/journal/Cargo.toml 🔗

@@ -22,3 +22,6 @@ serde.workspace = true
 schemars.workspace = true
 log.workspace = true
 shellexpand = "2.1.0"
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/language/Cargo.toml 🔗

@@ -41,7 +41,7 @@ anyhow.workspace = true
 async-broadcast = "0.4"
 async-trait.workspace = true
 futures.workspace = true
-glob.workspace = true
+globset.workspace = true
 lazy_static.workspace = true
 log.workspace = true
 parking_lot.workspace = true

crates/language/src/buffer.rs 🔗

@@ -1644,10 +1644,17 @@ impl Buffer {
         cx: &mut ModelContext<Self>,
     ) {
         if lamport_timestamp > self.diagnostics_timestamp {
-            match self.diagnostics.binary_search_by_key(&server_id, |e| e.0) {
-                Err(ix) => self.diagnostics.insert(ix, (server_id, diagnostics)),
-                Ok(ix) => self.diagnostics[ix].1 = diagnostics,
-            };
+            let ix = self.diagnostics.binary_search_by_key(&server_id, |e| e.0);
+            if diagnostics.len() == 0 {
+                if let Ok(ix) = ix {
+                    self.diagnostics.remove(ix);
+                }
+            } else {
+                match ix {
+                    Err(ix) => self.diagnostics.insert(ix, (server_id, diagnostics)),
+                    Ok(ix) => self.diagnostics[ix].1 = diagnostics,
+                };
+            }
             self.diagnostics_timestamp = lamport_timestamp;
             self.diagnostics_update_count += 1;
             self.text.lamport_clock.observe(lamport_timestamp);
@@ -2509,18 +2516,22 @@ impl BufferSnapshot {
     pub fn git_diff_hunks_in_row_range<'a>(
         &'a self,
         range: Range<u32>,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
-        self.git_diff.hunks_in_row_range(range, self, reversed)
+        self.git_diff.hunks_in_row_range(range, self)
     }
 
     pub fn git_diff_hunks_intersecting_range<'a>(
         &'a self,
         range: Range<Anchor>,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
-        self.git_diff
-            .hunks_intersecting_range(range, self, reversed)
+        self.git_diff.hunks_intersecting_range(range, self)
+    }
+
+    pub fn git_diff_hunks_intersecting_range_rev<'a>(
+        &'a self,
+        range: Range<Anchor>,
+    ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
+        self.git_diff.hunks_intersecting_range_rev(range, self)
     }
 
     pub fn diagnostics_in_range<'a, T, O>(

crates/language/src/diagnostic_set.rs 🔗

@@ -80,6 +80,10 @@ impl DiagnosticSet {
         }
     }
 
+    pub fn len(&self) -> usize {
+        self.diagnostics.summary().count
+    }
+
     pub fn iter(&self) -> impl Iterator<Item = &DiagnosticEntry<Anchor>> {
         self.diagnostics.iter()
     }

crates/language/src/language_settings.rs 🔗

@@ -1,5 +1,6 @@
 use anyhow::Result;
 use collections::HashMap;
+use globset::GlobMatcher;
 use gpui::AppContext;
 use schemars::{
     schema::{InstanceType, ObjectValidation, Schema, SchemaObject},
@@ -45,10 +46,10 @@ pub struct LanguageSettings {
 #[derive(Clone, Debug, Default)]
 pub struct CopilotSettings {
     pub feature_enabled: bool,
-    pub disabled_globs: Vec<glob::Pattern>,
+    pub disabled_globs: Vec<GlobMatcher>,
 }
 
-#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct AllLanguageSettingsContent {
     #[serde(default)]
     pub features: Option<FeaturesContent>,
@@ -151,7 +152,7 @@ impl AllLanguageSettings {
             .copilot
             .disabled_globs
             .iter()
-            .any(|glob| glob.matches_path(path))
+            .any(|glob| glob.is_match(path))
     }
 
     pub fn copilot_enabled(&self, language_name: Option<&str>, path: Option<&Path>) -> bool {
@@ -236,7 +237,7 @@ impl settings::Setting for AllLanguageSettings {
                 feature_enabled: copilot_enabled,
                 disabled_globs: copilot_globs
                     .iter()
-                    .filter_map(|pattern| glob::Pattern::new(pattern).ok())
+                    .filter_map(|g| Some(globset::Glob::new(g).ok()?.compile_matcher()))
                     .collect(),
             },
             defaults,

crates/language_selector/Cargo.toml 🔗

@@ -20,3 +20,6 @@ settings = { path = "../settings" }
 util = { path = "../util" }
 workspace = { path = "../workspace" }
 anyhow.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/live_kit_client/LiveKitBridge/Package.resolved 🔗

@@ -6,8 +6,8 @@
         "repositoryURL": "https://github.com/livekit/client-sdk-swift.git",
         "state": {
           "branch": null,
-          "revision": "f6ca534eb334e99acb8e82cc99b491717df28d8a",
-          "version": null
+          "revision": "7331b813a5ab8a95cfb81fb2b4ed10519428b9ff",
+          "version": "1.0.12"
         }
       },
       {
@@ -15,8 +15,8 @@
         "repositoryURL": "https://github.com/google/promises.git",
         "state": {
           "branch": null,
-          "revision": "3e4e743631e86c8c70dbc6efdc7beaa6e90fd3bb",
-          "version": "2.1.1"
+          "revision": "ec957ccddbcc710ccc64c9dcbd4c7006fcf8b73a",
+          "version": "2.2.0"
         }
       },
       {
@@ -24,8 +24,8 @@
         "repositoryURL": "https://github.com/webrtc-sdk/Specs.git",
         "state": {
           "branch": null,
-          "revision": "38ac06261e62f980652278c69b70284324c769e0",
-          "version": "104.5112.5"
+          "revision": "2f6bab30c8df0fe59ab3e58bc99097f757f85f65",
+          "version": "104.5112.17"
         }
       },
       {
@@ -33,8 +33,8 @@
         "repositoryURL": "https://github.com/apple/swift-log.git",
         "state": {
           "branch": null,
-          "revision": "6fe203dc33195667ce1759bf0182975e4653ba1c",
-          "version": "1.4.4"
+          "revision": "32e8d724467f8fe623624570367e3d50c5638e46",
+          "version": "1.5.2"
         }
       },
       {
@@ -42,8 +42,8 @@
         "repositoryURL": "https://github.com/apple/swift-protobuf.git",
         "state": {
           "branch": null,
-          "revision": "88c7d15e1242fdb6ecbafbc7926426a19be1e98a",
-          "version": "1.20.2"
+          "revision": "0af9125c4eae12a4973fb66574c53a54962a9e1e",
+          "version": "1.21.0"
         }
       }
     ]

crates/live_kit_client/LiveKitBridge/Package.swift 🔗

@@ -15,7 +15,7 @@ let package = Package(
             targets: ["LiveKitBridge"]),
     ],
     dependencies: [
-        .package(url: "https://github.com/livekit/client-sdk-swift.git", revision: "f6ca534eb334e99acb8e82cc99b491717df28d8a"),
+        .package(url: "https://github.com/livekit/client-sdk-swift.git", .exact("1.0.12")),
     ],
     targets: [
         // Targets are the basic building blocks of a package. A target can define a module or a test suite.

crates/lsp_log/Cargo.toml 🔗

@@ -24,6 +24,7 @@ serde.workspace = true
 anyhow.workspace = true
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 unindent.workspace = true

crates/outline/Cargo.toml 🔗

@@ -22,3 +22,6 @@ workspace = { path = "../workspace" }
 ordered-float.workspace = true
 postage.workspace = true
 smol.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/picker/Cargo.toml 🔗

@@ -20,6 +20,7 @@ workspace = { path = "../workspace" }
 parking_lot.workspace = true
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 serde_json.workspace = true
 workspace = { path = "../workspace", features = ["test-support"] }

crates/project/Cargo.toml 🔗

@@ -42,7 +42,7 @@ anyhow.workspace = true
 async-trait.workspace = true
 backtrace = "0.3"
 futures.workspace = true
-glob.workspace = true
+globset.workspace = true
 ignore = "0.4"
 lazy_static.workspace = true
 log.workspace = true

crates/project/src/lsp_glob_set.rs 🔗

@@ -1,121 +0,0 @@
-use anyhow::{anyhow, Result};
-use std::path::Path;
-
-#[derive(Default)]
-pub struct LspGlobSet {
-    patterns: Vec<glob::Pattern>,
-}
-
-impl LspGlobSet {
-    pub fn clear(&mut self) {
-        self.patterns.clear();
-    }
-
-    /// Add a pattern to the glob set.
-    ///
-    /// LSP's glob syntax supports bash-style brace expansion. For example,
-    /// the pattern '*.{js,ts}' would match all JavaScript or TypeScript files.
-    /// This is not a part of the standard libc glob syntax, and isn't supported
-    /// by the `glob` crate. So we pre-process the glob patterns, producing a
-    /// separate glob `Pattern` object for each part of a brace expansion.
-    pub fn add_pattern(&mut self, pattern: &str) -> Result<()> {
-        // Find all of the ranges of `pattern` that contain matched curly braces.
-        let mut expansion_ranges = Vec::new();
-        let mut expansion_start_ix = None;
-        for (ix, c) in pattern.match_indices(|c| ['{', '}'].contains(&c)) {
-            match c {
-                "{" => {
-                    if expansion_start_ix.is_some() {
-                        return Err(anyhow!("nested braces in glob patterns aren't supported"));
-                    }
-                    expansion_start_ix = Some(ix);
-                }
-                "}" => {
-                    if let Some(start_ix) = expansion_start_ix {
-                        expansion_ranges.push(start_ix..ix + 1);
-                    }
-                    expansion_start_ix = None;
-                }
-                _ => {}
-            }
-        }
-
-        // Starting with a single pattern, process each brace expansion by cloning
-        // the pattern once per element of the expansion.
-        let mut unexpanded_patterns = vec![];
-        let mut expanded_patterns = vec![pattern.to_string()];
-
-        for outer_range in expansion_ranges.into_iter().rev() {
-            let inner_range = (outer_range.start + 1)..(outer_range.end - 1);
-            std::mem::swap(&mut unexpanded_patterns, &mut expanded_patterns);
-            for unexpanded_pattern in unexpanded_patterns.drain(..) {
-                for part in unexpanded_pattern[inner_range.clone()].split(',') {
-                    let mut expanded_pattern = unexpanded_pattern.clone();
-                    expanded_pattern.replace_range(outer_range.clone(), part);
-                    expanded_patterns.push(expanded_pattern);
-                }
-            }
-        }
-
-        // Parse the final glob patterns and add them to the set.
-        for pattern in expanded_patterns {
-            let pattern = glob::Pattern::new(&pattern)?;
-            self.patterns.push(pattern);
-        }
-
-        Ok(())
-    }
-
-    pub fn matches(&self, path: &Path) -> bool {
-        self.patterns
-            .iter()
-            .any(|pattern| pattern.matches_path(path))
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_glob_set() {
-        let mut watch = LspGlobSet::default();
-        watch.add_pattern("/a/**/*.rs").unwrap();
-        watch.add_pattern("/a/**/Cargo.toml").unwrap();
-
-        assert!(watch.matches("/a/b.rs".as_ref()));
-        assert!(watch.matches("/a/b/c.rs".as_ref()));
-
-        assert!(!watch.matches("/b/c.rs".as_ref()));
-        assert!(!watch.matches("/a/b.ts".as_ref()));
-    }
-
-    #[test]
-    fn test_brace_expansion() {
-        let mut watch = LspGlobSet::default();
-        watch.add_pattern("/a/*.{ts,js,tsx}").unwrap();
-
-        assert!(watch.matches("/a/one.js".as_ref()));
-        assert!(watch.matches("/a/two.ts".as_ref()));
-        assert!(watch.matches("/a/three.tsx".as_ref()));
-
-        assert!(!watch.matches("/a/one.j".as_ref()));
-        assert!(!watch.matches("/a/two.s".as_ref()));
-        assert!(!watch.matches("/a/three.t".as_ref()));
-        assert!(!watch.matches("/a/four.t".as_ref()));
-        assert!(!watch.matches("/a/five.xt".as_ref()));
-    }
-
-    #[test]
-    fn test_multiple_brace_expansion() {
-        let mut watch = LspGlobSet::default();
-        watch.add_pattern("/a/{one,two,three}.{b*c,d*e}").unwrap();
-
-        assert!(watch.matches("/a/one.bic".as_ref()));
-        assert!(watch.matches("/a/two.dole".as_ref()));
-        assert!(watch.matches("/a/three.deeee".as_ref()));
-
-        assert!(!watch.matches("/a/four.bic".as_ref()));
-        assert!(!watch.matches("/a/one.be".as_ref()));
-    }
-}

crates/project/src/project.rs 🔗

@@ -1,6 +1,5 @@
 mod ignore;
 mod lsp_command;
-mod lsp_glob_set;
 mod project_settings;
 pub mod search;
 pub mod terminals;
@@ -17,8 +16,10 @@ use copilot::Copilot;
 use futures::{
     channel::mpsc::{self, UnboundedReceiver},
     future::{try_join_all, Shared},
+    stream::FuturesUnordered,
     AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt,
 };
+use globset::{Glob, GlobSet, GlobSetBuilder};
 use gpui::{
     AnyModelHandle, AppContext, AsyncAppContext, BorrowAppContext, Entity, ModelContext,
     ModelHandle, Task, WeakModelHandle,
@@ -41,7 +42,6 @@ use lsp::{
     DocumentHighlightKind, LanguageServer, LanguageServerId,
 };
 use lsp_command::*;
-use lsp_glob_set::LspGlobSet;
 use postage::watch;
 use project_settings::ProjectSettings;
 use rand::prelude::*;
@@ -213,6 +213,7 @@ pub enum Event {
     RemoteIdChanged(Option<u64>),
     DisconnectedFromHost,
     Closed,
+    DeletedEntry(ProjectEntryId),
     CollaboratorUpdated {
         old_peer_id: proto::PeerId,
         new_peer_id: proto::PeerId,
@@ -226,7 +227,7 @@ pub enum LanguageServerState {
         language: Arc<Language>,
         adapter: Arc<CachedLspAdapter>,
         server: Arc<LanguageServer>,
-        watched_paths: LspGlobSet,
+        watched_paths: HashMap<WorktreeId, GlobSet>,
         simulate_disk_based_diagnostics_completion: Option<Task<()>>,
     },
 }
@@ -977,6 +978,9 @@ impl Project {
         cx: &mut ModelContext<Self>,
     ) -> Option<Task<Result<()>>> {
         let worktree = self.worktree_for_entry(entry_id, cx)?;
+
+        cx.emit(Event::DeletedEntry(entry_id));
+
         if self.is_local() {
             worktree.update(cx, |worktree, cx| {
                 worktree.as_local_mut().unwrap().delete_entry(entry_id, cx)
@@ -1371,7 +1375,7 @@ impl Project {
             return Task::ready(Ok(existing_buffer));
         }
 
-        let mut loading_watch = match self.loading_buffers_by_path.entry(project_path.clone()) {
+        let loading_watch = match self.loading_buffers_by_path.entry(project_path.clone()) {
             // If the given path is already being loaded, then wait for that existing
             // task to complete and return the same buffer.
             hash_map::Entry::Occupied(e) => e.get().clone(),
@@ -1402,15 +1406,9 @@ impl Project {
         };
 
         cx.foreground().spawn(async move {
-            loop {
-                if let Some(result) = loading_watch.borrow().as_ref() {
-                    match result {
-                        Ok(buffer) => return Ok(buffer.clone()),
-                        Err(error) => return Err(anyhow!("{}", error)),
-                    }
-                }
-                loading_watch.next().await;
-            }
+            pump_loading_buffer_reciever(loading_watch)
+                .await
+                .map_err(|error| anyhow!("{}", error))
         })
     }
 
@@ -2562,6 +2560,23 @@ impl Project {
                 }
             }
 
+            for buffer in self.opened_buffers.values() {
+                if let Some(buffer) = buffer.upgrade(cx) {
+                    buffer.update(cx, |buffer, cx| {
+                        buffer.update_diagnostics(server_id, Default::default(), cx);
+                    });
+                }
+            }
+            for worktree in &self.worktrees {
+                if let Some(worktree) = worktree.upgrade(cx) {
+                    worktree.update(cx, |worktree, cx| {
+                        if let Some(worktree) = worktree.as_local_mut() {
+                            worktree.clear_diagnostics_for_language_server(server_id, cx);
+                        }
+                    });
+                }
+            }
+
             self.language_server_statuses.remove(&server_id);
             cx.notify();
 
@@ -2867,10 +2882,37 @@ impl Project {
         if let Some(LanguageServerState::Running { watched_paths, .. }) =
             self.language_servers.get_mut(&language_server_id)
         {
-            watched_paths.clear();
+            let mut builders = HashMap::default();
             for watcher in params.watchers {
-                watched_paths.add_pattern(&watcher.glob_pattern).log_err();
+                for worktree in &self.worktrees {
+                    if let Some(worktree) = worktree.upgrade(cx) {
+                        let worktree = worktree.read(cx);
+                        if let Some(abs_path) = worktree.abs_path().to_str() {
+                            if let Some(suffix) = watcher
+                                .glob_pattern
+                                .strip_prefix(abs_path)
+                                .and_then(|s| s.strip_prefix(std::path::MAIN_SEPARATOR))
+                            {
+                                if let Some(glob) = Glob::new(suffix).log_err() {
+                                    builders
+                                        .entry(worktree.id())
+                                        .or_insert_with(|| GlobSetBuilder::new())
+                                        .add(glob);
+                                }
+                                break;
+                            }
+                        }
+                    }
+                }
             }
+
+            watched_paths.clear();
+            for (worktree_id, builder) in builders {
+                if let Ok(globset) = builder.build() {
+                    watched_paths.insert(worktree_id, globset);
+                }
+            }
+
             cx.notify();
         }
     }
@@ -4707,25 +4749,39 @@ impl Project {
         changes: &HashMap<(Arc<Path>, ProjectEntryId), PathChange>,
         cx: &mut ModelContext<Self>,
     ) {
+        if changes.is_empty() {
+            return;
+        }
+
         let worktree_id = worktree_handle.read(cx).id();
+        let mut language_server_ids = self
+            .language_server_ids
+            .iter()
+            .filter_map(|((server_worktree_id, _), server_id)| {
+                (*server_worktree_id == worktree_id).then_some(*server_id)
+            })
+            .collect::<Vec<_>>();
+        language_server_ids.sort();
+        language_server_ids.dedup();
+
         let abs_path = worktree_handle.read(cx).abs_path();
-        for ((server_worktree_id, _), server_id) in &self.language_server_ids {
-            if *server_worktree_id == worktree_id {
-                if let Some(server) = self.language_servers.get(server_id) {
-                    if let LanguageServerState::Running {
-                        server,
-                        watched_paths,
-                        ..
-                    } = server
-                    {
+        for server_id in &language_server_ids {
+            if let Some(server) = self.language_servers.get(server_id) {
+                if let LanguageServerState::Running {
+                    server,
+                    watched_paths,
+                    ..
+                } = server
+                {
+                    if let Some(watched_paths) = watched_paths.get(&worktree_id) {
                         let params = lsp::DidChangeWatchedFilesParams {
                             changes: changes
                                 .iter()
                                 .filter_map(|((path, _), change)| {
-                                    let path = abs_path.join(path);
-                                    if watched_paths.matches(&path) {
+                                    if watched_paths.is_match(&path) {
                                         Some(lsp::FileEvent {
-                                            uri: lsp::Url::from_file_path(path).unwrap(),
+                                            uri: lsp::Url::from_file_path(abs_path.join(path))
+                                                .unwrap(),
                                             typ: match change {
                                                 PathChange::Added => lsp::FileChangeType::CREATED,
                                                 PathChange::Removed => lsp::FileChangeType::DELETED,
@@ -4761,6 +4817,51 @@ impl Project {
     ) {
         debug_assert!(worktree_handle.read(cx).is_local());
 
+        // Setup the pending buffers
+        let future_buffers = self
+            .loading_buffers_by_path
+            .iter()
+            .filter_map(|(path, receiver)| {
+                let path = &path.path;
+                let (work_directory, repo) = repos
+                    .iter()
+                    .find(|(work_directory, _)| path.starts_with(work_directory))?;
+
+                let repo_relative_path = path.strip_prefix(work_directory).log_err()?;
+
+                let receiver = receiver.clone();
+                let repo_ptr = repo.repo_ptr.clone();
+                let repo_relative_path = repo_relative_path.to_owned();
+                Some(async move {
+                    pump_loading_buffer_reciever(receiver)
+                        .await
+                        .ok()
+                        .map(|buffer| (buffer, repo_relative_path, repo_ptr))
+                })
+            })
+            .collect::<FuturesUnordered<_>>()
+            .filter_map(|result| async move {
+                let (buffer_handle, repo_relative_path, repo_ptr) = result?;
+
+                let lock = repo_ptr.lock();
+                lock.load_index_text(&repo_relative_path)
+                    .map(|diff_base| (diff_base, buffer_handle))
+            });
+
+        let update_diff_base_fn = update_diff_base(self);
+        cx.spawn(|_, mut cx| async move {
+            let diff_base_tasks = cx
+                .background()
+                .spawn(future_buffers.collect::<Vec<_>>())
+                .await;
+
+            for (diff_base, buffer) in diff_base_tasks.into_iter() {
+                update_diff_base_fn(Some(diff_base), buffer, &mut cx);
+            }
+        })
+        .detach();
+
+        // And the current buffers
         for (_, buffer) in &self.opened_buffers {
             if let Some(buffer) = buffer.upgrade(cx) {
                 let file = match File::from_dyn(buffer.read(cx).file()) {
@@ -4780,18 +4881,17 @@ impl Project {
                     .find(|(work_directory, _)| path.starts_with(work_directory))
                 {
                     Some(repo) => repo.clone(),
-                    None => return,
+                    None => continue,
                 };
 
                 let relative_repo = match path.strip_prefix(work_directory).log_err() {
                     Some(relative_repo) => relative_repo.to_owned(),
-                    None => return,
+                    None => continue,
                 };
 
                 drop(worktree);
 
-                let remote_id = self.remote_id();
-                let client = self.client.clone();
+                let update_diff_base_fn = update_diff_base(self);
                 let git_ptr = repo.repo_ptr.clone();
                 let diff_base_task = cx
                     .background()
@@ -4799,21 +4899,7 @@ impl Project {
 
                 cx.spawn(|_, mut cx| async move {
                     let diff_base = diff_base_task.await;
-
-                    let buffer_id = buffer.update(&mut cx, |buffer, cx| {
-                        buffer.set_diff_base(diff_base.clone(), cx);
-                        buffer.remote_id()
-                    });
-
-                    if let Some(project_id) = remote_id {
-                        client
-                            .send(proto::UpdateDiffBase {
-                                project_id,
-                                buffer_id: buffer_id as u64,
-                                diff_base,
-                            })
-                            .log_err();
-                    }
+                    update_diff_base_fn(diff_base, buffer, &mut cx);
                 })
                 .detach();
             }
@@ -5146,6 +5232,9 @@ impl Project {
         mut cx: AsyncAppContext,
     ) -> Result<proto::ProjectEntryResponse> {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+
+        this.update(&mut cx, |_, cx| cx.emit(Event::DeletedEntry(entry_id)));
+
         let worktree = this.read_with(&cx, |this, cx| {
             this.worktree_for_entry(entry_id, cx)
                 .ok_or_else(|| anyhow!("worktree not found"))
@@ -6700,3 +6789,40 @@ impl Item for Buffer {
         })
     }
 }
+
+async fn pump_loading_buffer_reciever(
+    mut receiver: postage::watch::Receiver<Option<Result<ModelHandle<Buffer>, Arc<anyhow::Error>>>>,
+) -> Result<ModelHandle<Buffer>, Arc<anyhow::Error>> {
+    loop {
+        if let Some(result) = receiver.borrow().as_ref() {
+            match result {
+                Ok(buffer) => return Ok(buffer.to_owned()),
+                Err(e) => return Err(e.to_owned()),
+            }
+        }
+        receiver.next().await;
+    }
+}
+
+fn update_diff_base(
+    project: &Project,
+) -> impl Fn(Option<String>, ModelHandle<Buffer>, &mut AsyncAppContext) {
+    let remote_id = project.remote_id();
+    let client = project.client().clone();
+    move |diff_base, buffer, cx| {
+        let buffer_id = buffer.update(cx, |buffer, cx| {
+            buffer.set_diff_base(diff_base.clone(), cx);
+            buffer.remote_id()
+        });
+
+        if let Some(project_id) = remote_id {
+            client
+                .send(proto::UpdateDiffBase {
+                    project_id,
+                    buffer_id: buffer_id as u64,
+                    diff_base,
+                })
+                .log_err();
+        }
+    }
+}

crates/project/src/project_settings.rs 🔗

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
 use settings::Setting;
 use std::sync::Arc;
 
-#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct ProjectSettings {
     #[serde(default)]
     pub lsp: HashMap<Arc<str>, LspSettings>,

crates/project/src/project_tests.rs 🔗

@@ -1,6 +1,7 @@
 use crate::{worktree::WorktreeHandle, Event, *};
 use fs::{FakeFs, LineEnding, RealFs};
 use futures::{future, StreamExt};
+use globset::Glob;
 use gpui::{executor::Deterministic, test::subscribe, AppContext};
 use language::{
     language_settings::{AllLanguageSettings, LanguageSettingsContent},
@@ -505,7 +506,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
                 register_options: serde_json::to_value(
                     lsp::DidChangeWatchedFilesRegistrationOptions {
                         watchers: vec![lsp::FileSystemWatcher {
-                            glob_pattern: "*.{rs,c}".to_string(),
+                            glob_pattern: "/the-root/*.{rs,c}".to_string(),
                             kind: None,
                         }],
                     },
@@ -925,6 +926,95 @@ async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppC
     });
 }
 
+#[gpui::test]
+async fn test_restarting_server_with_diagnostics_published(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let mut language = Language::new(
+        LanguageConfig {
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        None,
+    );
+    let mut fake_servers = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            ..Default::default()
+        }))
+        .await;
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree("/dir", json!({ "a.rs": "x" })).await;
+
+    let project = Project::test(fs, ["/dir".as_ref()], cx).await;
+    project.update(cx, |project, _| project.languages.add(Arc::new(language)));
+
+    let buffer = project
+        .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
+        .await
+        .unwrap();
+
+    // Publish diagnostics
+    let fake_server = fake_servers.next().await.unwrap();
+    fake_server.notify::<lsp::notification::PublishDiagnostics>(lsp::PublishDiagnosticsParams {
+        uri: Url::from_file_path("/dir/a.rs").unwrap(),
+        version: None,
+        diagnostics: vec![lsp::Diagnostic {
+            range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
+            severity: Some(lsp::DiagnosticSeverity::ERROR),
+            message: "the message".to_string(),
+            ..Default::default()
+        }],
+    });
+
+    cx.foreground().run_until_parked();
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer
+                .snapshot()
+                .diagnostics_in_range::<_, usize>(0..1, false)
+                .map(|entry| entry.diagnostic.message.clone())
+                .collect::<Vec<_>>(),
+            ["the message".to_string()]
+        );
+    });
+    project.read_with(cx, |project, cx| {
+        assert_eq!(
+            project.diagnostic_summary(cx),
+            DiagnosticSummary {
+                error_count: 1,
+                warning_count: 0,
+            }
+        );
+    });
+
+    project.update(cx, |project, cx| {
+        project.restart_language_servers_for_buffers([buffer.clone()], cx);
+    });
+
+    // The diagnostics are cleared.
+    cx.foreground().run_until_parked();
+    buffer.read_with(cx, |buffer, _| {
+        assert_eq!(
+            buffer
+                .snapshot()
+                .diagnostics_in_range::<_, usize>(0..1, false)
+                .map(|entry| entry.diagnostic.message.clone())
+                .collect::<Vec<_>>(),
+            Vec::<String>::new(),
+        );
+    });
+    project.read_with(cx, |project, cx| {
+        assert_eq!(
+            project.diagnostic_summary(cx),
+            DiagnosticSummary {
+                error_count: 0,
+                warning_count: 0,
+            }
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -3393,7 +3483,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.odd").unwrap()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
                 Vec::new()
             ),
             cx
@@ -3411,7 +3501,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.rs").unwrap()],
+                vec![Glob::new("*.rs").unwrap().compile_matcher()],
                 Vec::new()
             ),
             cx
@@ -3433,8 +3523,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
                 Vec::new()
             ),
@@ -3457,9 +3547,9 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.rs").unwrap(),
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.rs").unwrap().compile_matcher(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
                 Vec::new()
             ),
@@ -3504,7 +3594,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 Vec::new(),
-                vec![glob::Pattern::new("*.odd").unwrap()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3527,7 +3617,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 Vec::new(),
-                vec![glob::Pattern::new("*.rs").unwrap()],
+                vec![Glob::new("*.rs").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3549,8 +3639,8 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 true,
                 Vec::new(),
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
             ),
             cx
@@ -3573,9 +3663,9 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 true,
                 Vec::new(),
                 vec![
-                    glob::Pattern::new("*.rs").unwrap(),
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.rs").unwrap().compile_matcher(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
             ),
             cx
@@ -3612,8 +3702,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.odd").unwrap()],
-                vec![glob::Pattern::new("*.odd").unwrap()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3630,8 +3720,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.ts").unwrap()],
-                vec![glob::Pattern::new("*.ts").unwrap()],
+                vec![Glob::new("*.ts").unwrap().compile_matcher()],
+                vec![Glob::new("*.ts").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3649,12 +3739,12 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
             ),
             cx
@@ -3673,12 +3763,12 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
                 vec![
-                    glob::Pattern::new("*.rs").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.rs").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
             ),
             cx

crates/project/src/search.rs 🔗

@@ -1,6 +1,7 @@
 use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
 use anyhow::Result;
 use client::proto;
+use globset::{Glob, GlobMatcher};
 use itertools::Itertools;
 use language::{char_kind, Rope};
 use regex::{Regex, RegexBuilder};
@@ -19,8 +20,8 @@ pub enum SearchQuery {
         query: Arc<str>,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     },
     Regex {
         regex: Regex,
@@ -28,8 +29,8 @@ pub enum SearchQuery {
         multiline: bool,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     },
 }
 
@@ -38,8 +39,8 @@ impl SearchQuery {
         query: impl ToString,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     ) -> Self {
         let query = query.to_string();
         let search = AhoCorasickBuilder::new()
@@ -60,8 +61,8 @@ impl SearchQuery {
         query: impl ToString,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     ) -> Result<Self> {
         let mut query = query.to_string();
         let initial_query = Arc::from(query.as_str());
@@ -95,40 +96,16 @@ impl SearchQuery {
                 message.query,
                 message.whole_word,
                 message.case_sensitive,
-                message
-                    .files_to_include
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
-                message
-                    .files_to_exclude
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
+                deserialize_globs(&message.files_to_include)?,
+                deserialize_globs(&message.files_to_exclude)?,
             )
         } else {
             Ok(Self::text(
                 message.query,
                 message.whole_word,
                 message.case_sensitive,
-                message
-                    .files_to_include
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
-                message
-                    .files_to_exclude
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
+                deserialize_globs(&message.files_to_include)?,
+                deserialize_globs(&message.files_to_exclude)?,
             ))
         }
     }
@@ -143,12 +120,12 @@ impl SearchQuery {
             files_to_include: self
                 .files_to_include()
                 .iter()
-                .map(ToString::to_string)
+                .map(|g| g.glob().to_string())
                 .join(","),
             files_to_exclude: self
                 .files_to_exclude()
                 .iter()
-                .map(ToString::to_string)
+                .map(|g| g.glob().to_string())
                 .join(","),
         }
     }
@@ -289,7 +266,7 @@ impl SearchQuery {
         matches!(self, Self::Regex { .. })
     }
 
-    pub fn files_to_include(&self) -> &[glob::Pattern] {
+    pub fn files_to_include(&self) -> &[GlobMatcher] {
         match self {
             Self::Text {
                 files_to_include, ..
@@ -300,7 +277,7 @@ impl SearchQuery {
         }
     }
 
-    pub fn files_to_exclude(&self) -> &[glob::Pattern] {
+    pub fn files_to_exclude(&self) -> &[GlobMatcher] {
         match self {
             Self::Text {
                 files_to_exclude, ..
@@ -317,14 +294,23 @@ impl SearchQuery {
                 !self
                     .files_to_exclude()
                     .iter()
-                    .any(|exclude_glob| exclude_glob.matches_path(file_path))
+                    .any(|exclude_glob| exclude_glob.is_match(file_path))
                     && (self.files_to_include().is_empty()
                         || self
                             .files_to_include()
                             .iter()
-                            .any(|include_glob| include_glob.matches_path(file_path)))
+                            .any(|include_glob| include_glob.is_match(file_path)))
             }
             None => self.files_to_include().is_empty(),
         }
     }
 }
+
+fn deserialize_globs(glob_set: &str) -> Result<Vec<GlobMatcher>> {
+    glob_set
+        .split(',')
+        .map(str::trim)
+        .filter(|glob_str| !glob_str.is_empty())
+        .map(|glob_str| Ok(Glob::new(glob_str)?.compile_matcher()))
+        .collect()
+}

crates/project/src/worktree.rs 🔗

@@ -120,25 +120,6 @@ pub struct Snapshot {
     completed_scan_id: usize,
 }
 
-impl Snapshot {
-    pub fn repo_for(&self, path: &Path) -> Option<RepositoryEntry> {
-        let mut max_len = 0;
-        let mut current_candidate = None;
-        for (work_directory, repo) in (&self.repository_entries).iter() {
-            if repo.contains(self, path) {
-                if work_directory.0.as_os_str().len() >= max_len {
-                    current_candidate = Some(repo);
-                    max_len = work_directory.0.as_os_str().len();
-                } else {
-                    break;
-                }
-            }
-        }
-
-        current_candidate.map(|entry| entry.to_owned())
-    }
-}
-
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct RepositoryEntry {
     pub(crate) work_directory: WorkDirectoryEntry,
@@ -169,17 +150,6 @@ impl RepositoryEntry {
             .map(|entry| RepositoryWorkDirectory(entry.path.clone()))
     }
 
-    pub(crate) fn contains(&self, snapshot: &Snapshot, path: &Path) -> bool {
-        self.work_directory.contains(snapshot, path)
-    }
-
-    pub fn status_for_file(&self, snapshot: &Snapshot, path: &Path) -> Option<GitFileStatus> {
-        self.work_directory
-            .relativize(snapshot, path)
-            .and_then(|repo_path| self.statuses.get(&repo_path))
-            .cloned()
-    }
-
     pub fn status_for_path(&self, snapshot: &Snapshot, path: &Path) -> Option<GitFileStatus> {
         self.work_directory
             .relativize(snapshot, path)
@@ -205,6 +175,14 @@ impl RepositoryEntry {
             })
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn status_for_file(&self, snapshot: &Snapshot, path: &Path) -> Option<GitFileStatus> {
+        self.work_directory
+            .relativize(snapshot, path)
+            .and_then(|repo_path| (&self.statuses).get(&repo_path))
+            .cloned()
+    }
+
     pub fn build_update(&self, other: &Self) -> proto::RepositoryEntry {
         let mut updated_statuses: Vec<proto::StatusEntry> = Vec::new();
         let mut removed_statuses: Vec<String> = Vec::new();
@@ -250,7 +228,7 @@ impl RepositoryEntry {
             work_directory_id: self.work_directory_id().to_proto(),
             branch: self.branch.as_ref().map(|str| str.to_string()),
             removed_repo_paths: removed_statuses,
-            updated_statuses: updated_statuses,
+            updated_statuses,
         }
     }
 }
@@ -305,14 +283,6 @@ impl AsRef<Path> for RepositoryWorkDirectory {
 pub struct WorkDirectoryEntry(ProjectEntryId);
 
 impl WorkDirectoryEntry {
-    // Note that these paths should be relative to the worktree root.
-    pub(crate) fn contains(&self, snapshot: &Snapshot, path: &Path) -> bool {
-        snapshot
-            .entry_for_id(self.0)
-            .map(|entry| path.starts_with(&entry.path))
-            .unwrap_or(false)
-    }
-
     pub(crate) fn relativize(&self, worktree: &Snapshot, path: &Path) -> Option<RepoPath> {
         worktree.entry_for_id(self.0).and_then(|entry| {
             path.strip_prefix(&entry.path)
@@ -338,19 +308,28 @@ impl<'a> From<ProjectEntryId> for WorkDirectoryEntry {
 
 #[derive(Debug, Clone)]
 pub struct LocalSnapshot {
-    ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>, // (gitignore, needs_update)
-    // The ProjectEntryId corresponds to the entry for the .git dir
-    // work_directory_id
+    snapshot: Snapshot,
+    /// All of the gitignore files in the worktree, indexed by their relative path.
+    /// The boolean indicates whether the gitignore needs to be updated.
+    ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
+    /// All of the git repositories in the worktree, indexed by the project entry
+    /// id of their parent directory.
     git_repositories: TreeMap<ProjectEntryId, LocalRepositoryEntry>,
+}
+
+pub struct LocalMutableSnapshot {
+    snapshot: LocalSnapshot,
+    /// The ids of all of the entries that were removed from the snapshot
+    /// as part of the current update. These entry ids may be re-used
+    /// if the same inode is discovered at a new path, or if the given
+    /// path is re-created after being deleted.
     removed_entry_ids: HashMap<u64, ProjectEntryId>,
-    next_entry_id: Arc<AtomicUsize>,
-    snapshot: Snapshot,
 }
 
 #[derive(Debug, Clone)]
 pub struct LocalRepositoryEntry {
     pub(crate) scan_id: usize,
-    pub(crate) full_scan_id: usize,
+    pub(crate) git_dir_scan_id: usize,
     pub(crate) repo_ptr: Arc<Mutex<dyn GitRepository>>,
     /// Path to the actual .git folder.
     /// Note: if .git is a file, this points to the folder indicated by the .git file
@@ -378,6 +357,20 @@ impl DerefMut for LocalSnapshot {
     }
 }
 
+impl Deref for LocalMutableSnapshot {
+    type Target = LocalSnapshot;
+
+    fn deref(&self) -> &Self::Target {
+        &self.snapshot
+    }
+}
+
+impl DerefMut for LocalMutableSnapshot {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.snapshot
+    }
+}
+
 enum ScanState {
     Started,
     Updated {
@@ -428,9 +421,7 @@ impl Worktree {
 
             let mut snapshot = LocalSnapshot {
                 ignores_by_parent_abs_path: Default::default(),
-                removed_entry_ids: Default::default(),
                 git_repositories: Default::default(),
-                next_entry_id,
                 snapshot: Snapshot {
                     id: WorktreeId::from_usize(cx.model_id()),
                     abs_path: abs_path.clone(),
@@ -449,7 +440,7 @@ impl Worktree {
                     Entry::new(
                         Arc::from(Path::new("")),
                         &metadata,
-                        &snapshot.next_entry_id,
+                        &next_entry_id,
                         snapshot.root_char_bag,
                     ),
                     fs.as_ref(),
@@ -493,6 +484,7 @@ impl Worktree {
                     let events = fs.watch(&abs_path, Duration::from_millis(100)).await;
                     BackgroundScanner::new(
                         snapshot,
+                        next_entry_id,
                         fs,
                         scan_states_tx,
                         background,
@@ -745,6 +737,45 @@ impl LocalWorktree {
         self.diagnostics.get(path).cloned().unwrap_or_default()
     }
 
+    pub fn clear_diagnostics_for_language_server(
+        &mut self,
+        server_id: LanguageServerId,
+        _: &mut ModelContext<Worktree>,
+    ) {
+        let worktree_id = self.id().to_proto();
+        self.diagnostic_summaries
+            .retain(|path, summaries_by_server_id| {
+                if summaries_by_server_id.remove(&server_id).is_some() {
+                    if let Some(share) = self.share.as_ref() {
+                        self.client
+                            .send(proto::UpdateDiagnosticSummary {
+                                project_id: share.project_id,
+                                worktree_id,
+                                summary: Some(proto::DiagnosticSummary {
+                                    path: path.to_string_lossy().to_string(),
+                                    language_server_id: server_id.0 as u64,
+                                    error_count: 0,
+                                    warning_count: 0,
+                                }),
+                            })
+                            .log_err();
+                    }
+                    !summaries_by_server_id.is_empty()
+                } else {
+                    true
+                }
+            });
+
+        self.diagnostics.retain(|_, diagnostics_by_server_id| {
+            if let Ok(ix) = diagnostics_by_server_id.binary_search_by_key(&server_id, |e| e.0) {
+                diagnostics_by_server_id.remove(ix);
+                !diagnostics_by_server_id.is_empty()
+            } else {
+                true
+            }
+        });
+    }
+
     pub fn update_diagnostics(
         &mut self,
         server_id: LanguageServerId,
@@ -808,6 +839,7 @@ impl LocalWorktree {
     fn set_snapshot(&mut self, new_snapshot: LocalSnapshot, cx: &mut ModelContext<Worktree>) {
         let updated_repos =
             self.changed_repos(&self.git_repositories, &new_snapshot.git_repositories);
+
         self.snapshot = new_snapshot;
 
         if let Some(share) = self.share.as_mut() {
@@ -838,7 +870,7 @@ impl LocalWorktree {
                             old_repos.next();
                         }
                         Ordering::Equal => {
-                            if old_repo.scan_id != new_repo.scan_id {
+                            if old_repo.git_dir_scan_id != new_repo.git_dir_scan_id {
                                 if let Some(entry) = self.entry_for_id(**new_entry_id) {
                                     diff.insert(entry.path.clone(), (*new_repo).clone());
                                 }
@@ -913,7 +945,7 @@ impl LocalWorktree {
 
         let mut index_task = None;
 
-        if let Some(repo) = snapshot.repo_for(&path) {
+        if let Some(repo) = snapshot.repository_for_path(&path) {
             let repo_path = repo.work_directory.relativize(self, &path).unwrap();
             if let Some(repo) = self.git_repositories.get(&*repo.work_directory) {
                 let repo = repo.repo_ptr.to_owned();
@@ -1240,8 +1272,6 @@ impl LocalWorktree {
                     let mut share_tx = Some(share_tx);
                     let mut prev_snapshot = LocalSnapshot {
                         ignores_by_parent_abs_path: Default::default(),
-                        removed_entry_ids: Default::default(),
-                        next_entry_id: Default::default(),
                         git_repositories: Default::default(),
                         snapshot: Snapshot {
                             id: WorktreeId(worktree_id as usize),
@@ -1643,8 +1673,63 @@ impl Snapshot {
         self.traverse_from_offset(true, include_ignored, 0)
     }
 
-    pub fn repositories(&self) -> impl Iterator<Item = &RepositoryEntry> {
-        self.repository_entries.values()
+    pub fn repositories(&self) -> impl Iterator<Item = (&Arc<Path>, &RepositoryEntry)> {
+        self.repository_entries
+            .iter()
+            .map(|(path, entry)| (&path.0, entry))
+    }
+
+    /// Get the repository whose work directory contains the given path.
+    pub fn repository_for_work_directory(&self, path: &Path) -> Option<RepositoryEntry> {
+        self.repository_entries
+            .get(&RepositoryWorkDirectory(path.into()))
+            .cloned()
+    }
+
+    /// Get the repository whose work directory contains the given path.
+    pub fn repository_for_path(&self, path: &Path) -> Option<RepositoryEntry> {
+        let mut max_len = 0;
+        let mut current_candidate = None;
+        for (work_directory, repo) in (&self.repository_entries).iter() {
+            if path.starts_with(&work_directory.0) {
+                if work_directory.0.as_os_str().len() >= max_len {
+                    current_candidate = Some(repo);
+                    max_len = work_directory.0.as_os_str().len();
+                } else {
+                    break;
+                }
+            }
+        }
+
+        current_candidate.cloned()
+    }
+
+    /// Given an ordered iterator of entries, returns an iterator of those entries,
+    /// along with their containing git repository.
+    pub fn entries_with_repositories<'a>(
+        &'a self,
+        entries: impl 'a + Iterator<Item = &'a Entry>,
+    ) -> impl 'a + Iterator<Item = (&'a Entry, Option<&'a RepositoryEntry>)> {
+        let mut containing_repos = Vec::<(&Arc<Path>, &RepositoryEntry)>::new();
+        let mut repositories = self.repositories().peekable();
+        entries.map(move |entry| {
+            while let Some((repo_path, _)) = containing_repos.last() {
+                if !entry.path.starts_with(repo_path) {
+                    containing_repos.pop();
+                } else {
+                    break;
+                }
+            }
+            while let Some((repo_path, _)) = repositories.peek() {
+                if entry.path.starts_with(repo_path) {
+                    containing_repos.push(repositories.next().unwrap());
+                } else {
+                    break;
+                }
+            }
+            let repo = containing_repos.last().map(|(_, repo)| *repo);
+            (entry, repo)
+        })
     }
 
     pub fn paths(&self) -> impl Iterator<Item = &Arc<Path>> {
@@ -1895,8 +1980,6 @@ impl LocalSnapshot {
             }
         }
 
-        self.reuse_entry_id(&mut entry);
-
         if entry.kind == EntryKind::PendingDir {
             if let Some(existing_entry) =
                 self.entries_by_path.get(&PathKey(entry.path.clone()), &())
@@ -1925,60 +2008,6 @@ impl LocalSnapshot {
         entry
     }
 
-    fn populate_dir(
-        &mut self,
-        parent_path: Arc<Path>,
-        entries: impl IntoIterator<Item = Entry>,
-        ignore: Option<Arc<Gitignore>>,
-        fs: &dyn Fs,
-    ) {
-        let mut parent_entry = if let Some(parent_entry) =
-            self.entries_by_path.get(&PathKey(parent_path.clone()), &())
-        {
-            parent_entry.clone()
-        } else {
-            log::warn!(
-                "populating a directory {:?} that has been removed",
-                parent_path
-            );
-            return;
-        };
-
-        match parent_entry.kind {
-            EntryKind::PendingDir => {
-                parent_entry.kind = EntryKind::Dir;
-            }
-            EntryKind::Dir => {}
-            _ => return,
-        }
-
-        if let Some(ignore) = ignore {
-            self.ignores_by_parent_abs_path
-                .insert(self.abs_path.join(&parent_path).into(), (ignore, false));
-        }
-
-        if parent_path.file_name() == Some(&DOT_GIT) {
-            self.build_repo(parent_path, fs);
-        }
-
-        let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)];
-        let mut entries_by_id_edits = Vec::new();
-
-        for mut entry in entries {
-            self.reuse_entry_id(&mut entry);
-            entries_by_id_edits.push(Edit::Insert(PathEntry {
-                id: entry.id,
-                path: entry.path.clone(),
-                is_ignored: entry.is_ignored,
-                scan_id: self.scan_id,
-            }));
-            entries_by_path_edits.push(Edit::Insert(entry));
-        }
-
-        self.entries_by_path.edit(entries_by_path_edits, &());
-        self.entries_by_id.edit(entries_by_id_edits, &());
-    }
-
     fn build_repo(&mut self, parent_path: Arc<Path>, fs: &dyn Fs) -> Option<()> {
         let abs_path = self.abs_path.join(&parent_path);
         let work_dir: Arc<Path> = parent_path.parent().unwrap().into();
@@ -2017,7 +2046,7 @@ impl LocalSnapshot {
                 work_dir_id,
                 LocalRepositoryEntry {
                     scan_id,
-                    full_scan_id: scan_id,
+                    git_dir_scan_id: scan_id,
                     repo_ptr: repo,
                     git_dir_path: parent_path.clone(),
                 },
@@ -2026,46 +2055,6 @@ impl LocalSnapshot {
 
         Some(())
     }
-    fn reuse_entry_id(&mut self, entry: &mut Entry) {
-        if let Some(removed_entry_id) = self.removed_entry_ids.remove(&entry.inode) {
-            entry.id = removed_entry_id;
-        } else if let Some(existing_entry) = self.entry_for_path(&entry.path) {
-            entry.id = existing_entry.id;
-        }
-    }
-
-    fn remove_path(&mut self, path: &Path) {
-        let mut new_entries;
-        let removed_entries;
-        {
-            let mut cursor = self.entries_by_path.cursor::<TraversalProgress>();
-            new_entries = cursor.slice(&TraversalTarget::Path(path), Bias::Left, &());
-            removed_entries = cursor.slice(&TraversalTarget::PathSuccessor(path), Bias::Left, &());
-            new_entries.push_tree(cursor.suffix(&()), &());
-        }
-        self.entries_by_path = new_entries;
-
-        let mut entries_by_id_edits = Vec::new();
-        for entry in removed_entries.cursor::<()>() {
-            let removed_entry_id = self
-                .removed_entry_ids
-                .entry(entry.inode)
-                .or_insert(entry.id);
-            *removed_entry_id = cmp::max(*removed_entry_id, entry.id);
-            entries_by_id_edits.push(Edit::Remove(entry.id));
-        }
-        self.entries_by_id.edit(entries_by_id_edits, &());
-
-        if path.file_name() == Some(&GITIGNORE) {
-            let abs_parent_path = self.abs_path.join(path.parent().unwrap());
-            if let Some((_, needs_update)) = self
-                .ignores_by_parent_abs_path
-                .get_mut(abs_parent_path.as_path())
-            {
-                *needs_update = true;
-            }
-        }
-    }
 
     fn ancestor_inodes_for_path(&self, path: &Path) -> TreeSet<u64> {
         let mut inodes = TreeSet::default();
@@ -2105,36 +2094,139 @@ impl LocalSnapshot {
     }
 }
 
-async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
-    let contents = fs.load(abs_path).await?;
-    let parent = abs_path.parent().unwrap_or_else(|| Path::new("/"));
-    let mut builder = GitignoreBuilder::new(parent);
-    for line in contents.lines() {
-        builder.add_line(Some(abs_path.into()), line)?;
+impl LocalMutableSnapshot {
+    fn reuse_entry_id(&mut self, entry: &mut Entry) {
+        if let Some(removed_entry_id) = self.removed_entry_ids.remove(&entry.inode) {
+            entry.id = removed_entry_id;
+        } else if let Some(existing_entry) = self.entry_for_path(&entry.path) {
+            entry.id = existing_entry.id;
+        }
     }
-    Ok(builder.build()?)
-}
 
-impl WorktreeId {
-    pub fn from_usize(handle_id: usize) -> Self {
-        Self(handle_id)
+    fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry {
+        self.reuse_entry_id(&mut entry);
+        self.snapshot.insert_entry(entry, fs)
     }
 
-    pub(crate) fn from_proto(id: u64) -> Self {
-        Self(id as usize)
-    }
+    fn populate_dir(
+        &mut self,
+        parent_path: Arc<Path>,
+        entries: impl IntoIterator<Item = Entry>,
+        ignore: Option<Arc<Gitignore>>,
+        fs: &dyn Fs,
+    ) {
+        let mut parent_entry = if let Some(parent_entry) =
+            self.entries_by_path.get(&PathKey(parent_path.clone()), &())
+        {
+            parent_entry.clone()
+        } else {
+            log::warn!(
+                "populating a directory {:?} that has been removed",
+                parent_path
+            );
+            return;
+        };
 
-    pub fn to_proto(&self) -> u64 {
-        self.0 as u64
-    }
+        match parent_entry.kind {
+            EntryKind::PendingDir => {
+                parent_entry.kind = EntryKind::Dir;
+            }
+            EntryKind::Dir => {}
+            _ => return,
+        }
 
-    pub fn to_usize(&self) -> usize {
-        self.0
-    }
-}
+        if let Some(ignore) = ignore {
+            let abs_parent_path = self.abs_path.join(&parent_path).into();
+            self.ignores_by_parent_abs_path
+                .insert(abs_parent_path, (ignore, false));
+        }
 
-impl fmt::Display for WorktreeId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if parent_path.file_name() == Some(&DOT_GIT) {
+            self.build_repo(parent_path, fs);
+        }
+
+        let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)];
+        let mut entries_by_id_edits = Vec::new();
+
+        for mut entry in entries {
+            self.reuse_entry_id(&mut entry);
+            entries_by_id_edits.push(Edit::Insert(PathEntry {
+                id: entry.id,
+                path: entry.path.clone(),
+                is_ignored: entry.is_ignored,
+                scan_id: self.scan_id,
+            }));
+            entries_by_path_edits.push(Edit::Insert(entry));
+        }
+
+        self.entries_by_path.edit(entries_by_path_edits, &());
+        self.entries_by_id.edit(entries_by_id_edits, &());
+    }
+
+    fn remove_path(&mut self, path: &Path) {
+        let mut new_entries;
+        let removed_entries;
+        {
+            let mut cursor = self.entries_by_path.cursor::<TraversalProgress>();
+            new_entries = cursor.slice(&TraversalTarget::Path(path), Bias::Left, &());
+            removed_entries = cursor.slice(&TraversalTarget::PathSuccessor(path), Bias::Left, &());
+            new_entries.push_tree(cursor.suffix(&()), &());
+        }
+        self.entries_by_path = new_entries;
+
+        let mut entries_by_id_edits = Vec::new();
+        for entry in removed_entries.cursor::<()>() {
+            let removed_entry_id = self
+                .removed_entry_ids
+                .entry(entry.inode)
+                .or_insert(entry.id);
+            *removed_entry_id = cmp::max(*removed_entry_id, entry.id);
+            entries_by_id_edits.push(Edit::Remove(entry.id));
+        }
+        self.entries_by_id.edit(entries_by_id_edits, &());
+
+        if path.file_name() == Some(&GITIGNORE) {
+            let abs_parent_path = self.abs_path.join(path.parent().unwrap());
+            if let Some((_, needs_update)) = self
+                .ignores_by_parent_abs_path
+                .get_mut(abs_parent_path.as_path())
+            {
+                *needs_update = true;
+            }
+        }
+    }
+}
+
+async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
+    let contents = fs.load(abs_path).await?;
+    let parent = abs_path.parent().unwrap_or_else(|| Path::new("/"));
+    let mut builder = GitignoreBuilder::new(parent);
+    for line in contents.lines() {
+        builder.add_line(Some(abs_path.into()), line)?;
+    }
+    Ok(builder.build()?)
+}
+
+impl WorktreeId {
+    pub fn from_usize(handle_id: usize) -> Self {
+        Self(handle_id)
+    }
+
+    pub(crate) fn from_proto(id: u64) -> Self {
+        Self(id as usize)
+    }
+
+    pub fn to_proto(&self) -> u64 {
+        self.0 as u64
+    }
+
+    pub fn to_usize(&self) -> usize {
+        self.0
+    }
+}
+
+impl fmt::Display for WorktreeId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         self.0.fmt(f)
     }
 }
@@ -2547,12 +2639,13 @@ impl<'a> sum_tree::Dimension<'a, EntrySummary> for PathKey {
 }
 
 struct BackgroundScanner {
-    snapshot: Mutex<LocalSnapshot>,
+    snapshot: Mutex<LocalMutableSnapshot>,
     fs: Arc<dyn Fs>,
     status_updates_tx: UnboundedSender<ScanState>,
     executor: Arc<executor::Background>,
     refresh_requests_rx: channel::Receiver<(Vec<PathBuf>, barrier::Sender)>,
     prev_state: Mutex<BackgroundScannerState>,
+    next_entry_id: Arc<AtomicUsize>,
     finished_initial_scan: bool,
 }
 
@@ -2564,6 +2657,7 @@ struct BackgroundScannerState {
 impl BackgroundScanner {
     fn new(
         snapshot: LocalSnapshot,
+        next_entry_id: Arc<AtomicUsize>,
         fs: Arc<dyn Fs>,
         status_updates_tx: UnboundedSender<ScanState>,
         executor: Arc<executor::Background>,
@@ -2574,11 +2668,15 @@ impl BackgroundScanner {
             status_updates_tx,
             executor,
             refresh_requests_rx,
+            next_entry_id,
             prev_state: Mutex::new(BackgroundScannerState {
                 snapshot: snapshot.snapshot.clone(),
                 event_paths: Default::default(),
             }),
-            snapshot: Mutex::new(snapshot),
+            snapshot: Mutex::new(LocalMutableSnapshot {
+                snapshot,
+                removed_entry_ids: Default::default(),
+            }),
             finished_initial_scan: false,
         }
     }
@@ -2732,10 +2830,7 @@ impl BackgroundScanner {
                 .is_some()
         });
         snapshot.snapshot.repository_entries = git_repository_entries;
-
-        snapshot.removed_entry_ids.clear();
         snapshot.completed_scan_id = snapshot.scan_id;
-
         drop(snapshot);
 
         self.send_status_update(false, None);
@@ -2846,7 +2941,7 @@ impl BackgroundScanner {
             (
                 snapshot.abs_path().clone(),
                 snapshot.root_char_bag,
-                snapshot.next_entry_id.clone(),
+                self.next_entry_id.clone(),
             )
         };
         let mut child_paths = self.fs.read_dir(&job.abs_path).await?;
@@ -3018,7 +3113,7 @@ impl BackgroundScanner {
                     let mut fs_entry = Entry::new(
                         path.clone(),
                         &metadata,
-                        snapshot.next_entry_id.as_ref(),
+                        self.next_entry_id.as_ref(),
                         snapshot.root_char_bag,
                     );
                     fs_entry.is_ignored = ignore_stack.is_all();
@@ -3058,7 +3153,18 @@ impl BackgroundScanner {
             .any(|component| component.as_os_str() == *DOT_GIT)
         {
             let scan_id = snapshot.scan_id;
-            let repo = snapshot.repo_for(&path)?;
+
+            if let Some(repository) = snapshot.repository_for_work_directory(path) {
+                let entry = repository.work_directory.0;
+                snapshot.git_repositories.remove(&entry);
+                snapshot
+                    .snapshot
+                    .repository_entries
+                    .remove(&RepositoryWorkDirectory(path.into()));
+                return Some(());
+            }
+
+            let repo = snapshot.repository_for_path(&path)?;
 
             let repo_path = repo.work_directory.relativize(&snapshot, &path)?;
 
@@ -3100,7 +3206,7 @@ impl BackgroundScanner {
                     snapshot.build_repo(dot_git_dir.into(), fs);
                     return None;
                 };
-                if repo.full_scan_id == scan_id {
+                if repo.git_dir_scan_id == scan_id {
                     return None;
                 }
                 (*entry_id, repo.repo_ptr.to_owned())
@@ -3117,7 +3223,7 @@ impl BackgroundScanner {
 
             snapshot.git_repositories.update(&entry_id, |entry| {
                 entry.scan_id = scan_id;
-                entry.full_scan_id = scan_id;
+                entry.git_dir_scan_id = scan_id;
             });
 
             snapshot.repository_entries.update(&work_dir, |entry| {
@@ -3134,7 +3240,7 @@ impl BackgroundScanner {
                 return None;
             }
 
-            let repo = snapshot.repo_for(&path)?;
+            let repo = snapshot.repository_for_path(&path)?;
 
             let work_dir = repo.work_directory(snapshot)?;
             let work_dir_id = repo.work_directory.clone();
@@ -3146,7 +3252,7 @@ impl BackgroundScanner {
             let local_repo = snapshot.get_local_repo(&repo)?.to_owned();
 
             // Short circuit if we've already scanned everything
-            if local_repo.full_scan_id == scan_id {
+            if local_repo.git_dir_scan_id == scan_id {
                 return None;
             }
 
@@ -3928,6 +4034,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
+        // .gitignores are handled explicitly by Zed and do not use the git
+        // machinery that the git_tests module checks
         let parent_dir = temp_tree(json!({
             ".gitignore": "ancestor-ignored-file1\nancestor-ignored-file2\n",
             "tree": {
@@ -4006,30 +4114,19 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_git_repository_for_path(cx: &mut TestAppContext) {
-        let root = temp_tree(json!({
-            "dir1": {
-                ".git": {},
-                "deps": {
-                    "dep1": {
-                        ".git": {},
-                        "src": {
-                            "a.txt": ""
-                        }
-                    }
-                },
-                "src": {
-                    "b.txt": ""
-                }
-            },
-            "c.txt": "",
+    async fn test_write_file(cx: &mut TestAppContext) {
+        let dir = temp_tree(json!({
+            ".git": {},
+            ".gitignore": "ignored-dir\n",
+            "tracked-dir": {},
+            "ignored-dir": {}
         }));
 
-        let http_client = FakeHttpClient::with_404_response();
-        let client = cx.read(|cx| Client::new(http_client, cx));
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
         let tree = Worktree::local(
             client,
-            root.path(),
+            dir.path(),
             true,
             Arc::new(RealFs),
             Default::default(),
@@ -4037,475 +4134,121 @@ mod tests {
         )
         .await
         .unwrap();
-
         cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
             .await;
         tree.flush_fs_events(cx).await;
 
-        tree.read_with(cx, |tree, _cx| {
-            let tree = tree.as_local().unwrap();
-
-            assert!(tree.repo_for("c.txt".as_ref()).is_none());
-
-            let entry = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap();
-            assert_eq!(
-                entry
-                    .work_directory(tree)
-                    .map(|directory| directory.as_ref().to_owned()),
-                Some(Path::new("dir1").to_owned())
-            );
+        tree.update(cx, |tree, cx| {
+            tree.as_local().unwrap().write_file(
+                Path::new("tracked-dir/file.txt"),
+                "hello".into(),
+                Default::default(),
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+        tree.update(cx, |tree, cx| {
+            tree.as_local().unwrap().write_file(
+                Path::new("ignored-dir/file.txt"),
+                "world".into(),
+                Default::default(),
+                cx,
+            )
+        })
+        .await
+        .unwrap();
 
-            let entry = tree.repo_for("dir1/deps/dep1/src/a.txt".as_ref()).unwrap();
-            assert_eq!(
-                entry
-                    .work_directory(tree)
-                    .map(|directory| directory.as_ref().to_owned()),
-                Some(Path::new("dir1/deps/dep1").to_owned())
-            );
+        tree.read_with(cx, |tree, _| {
+            let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap();
+            let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap();
+            assert!(!tracked.is_ignored);
+            assert!(ignored.is_ignored);
         });
+    }
 
-        let repo_update_events = Arc::new(Mutex::new(vec![]));
-        tree.update(cx, |_, cx| {
-            let repo_update_events = repo_update_events.clone();
-            cx.subscribe(&tree, move |_, _, event, _| {
-                if let Event::UpdatedGitRepositories(update) = event {
-                    repo_update_events.lock().push(update.clone());
-                }
-            })
-            .detach();
-        });
+    #[gpui::test(iterations = 30)]
+    async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
 
-        std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
-        tree.flush_fs_events(cx).await;
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/root",
+            json!({
+                "b": {},
+                "c": {},
+                "d": {},
+            }),
+        )
+        .await;
 
-        assert_eq!(
-            repo_update_events.lock()[0]
-                .keys()
-                .cloned()
-                .collect::<Vec<Arc<Path>>>(),
-            vec![Path::new("dir1").into()]
-        );
+        let tree = Worktree::local(
+            client,
+            "/root".as_ref(),
+            true,
+            fs,
+            Default::default(),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
 
-        std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
-        tree.flush_fs_events(cx).await;
+        let mut snapshot1 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
 
-        tree.read_with(cx, |tree, _cx| {
-            let tree = tree.as_local().unwrap();
+        let entry = tree
+            .update(cx, |tree, cx| {
+                tree.as_local_mut()
+                    .unwrap()
+                    .create_entry("a/e".as_ref(), true, cx)
+            })
+            .await
+            .unwrap();
+        assert!(entry.is_dir());
 
-            assert!(tree.repo_for("dir1/src/b.txt".as_ref()).is_none());
+        cx.foreground().run_until_parked();
+        tree.read_with(cx, |tree, _| {
+            assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir);
         });
-    }
 
-    #[gpui::test]
-    async fn test_git_status(cx: &mut TestAppContext) {
-        #[track_caller]
-        fn git_init(path: &Path) -> git2::Repository {
-            git2::Repository::init(path).expect("Failed to initialize git repository")
-        }
+        let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
+        let update = snapshot2.build_update(&snapshot1, 0, 0, true);
+        snapshot1.apply_remote_update(update).unwrap();
+        assert_eq!(snapshot1.to_vec(true), snapshot2.to_vec(true),);
+    }
 
-        #[track_caller]
-        fn git_add(path: &Path, repo: &git2::Repository) {
-            let mut index = repo.index().expect("Failed to get index");
-            index.add_path(path).expect("Failed to add a.txt");
-            index.write().expect("Failed to write index");
-        }
+    #[gpui::test(iterations = 100)]
+    async fn test_random_worktree_operations_during_initial_scan(
+        cx: &mut TestAppContext,
+        mut rng: StdRng,
+    ) {
+        let operations = env::var("OPERATIONS")
+            .map(|o| o.parse().unwrap())
+            .unwrap_or(5);
+        let initial_entries = env::var("INITIAL_ENTRIES")
+            .map(|o| o.parse().unwrap())
+            .unwrap_or(20);
 
-        #[track_caller]
-        fn git_remove_index(path: &Path, repo: &git2::Repository) {
-            let mut index = repo.index().expect("Failed to get index");
-            index.remove_path(path).expect("Failed to add a.txt");
-            index.write().expect("Failed to write index");
+        let root_dir = Path::new("/test");
+        let fs = FakeFs::new(cx.background()) as Arc<dyn Fs>;
+        fs.as_fake().insert_tree(root_dir, json!({})).await;
+        for _ in 0..initial_entries {
+            randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
         }
+        log::info!("generated initial tree");
 
-        #[track_caller]
-        fn git_commit(msg: &'static str, repo: &git2::Repository) {
-            use git2::Signature;
-
-            let signature = Signature::now("test", "test@zed.dev").unwrap();
-            let oid = repo.index().unwrap().write_tree().unwrap();
-            let tree = repo.find_tree(oid).unwrap();
-            if let Some(head) = repo.head().ok() {
-                let parent_obj = head.peel(git2::ObjectType::Commit).unwrap();
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+        let worktree = Worktree::local(
+            client.clone(),
+            root_dir,
+            true,
+            fs.clone(),
+            Default::default(),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
 
-                let parent_commit = parent_obj.as_commit().unwrap();
-
-                repo.commit(
-                    Some("HEAD"),
-                    &signature,
-                    &signature,
-                    msg,
-                    &tree,
-                    &[parent_commit],
-                )
-                .expect("Failed to commit with parent");
-            } else {
-                repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[])
-                    .expect("Failed to commit");
-            }
-        }
-
-        #[track_caller]
-        fn git_stash(repo: &mut git2::Repository) {
-            use git2::Signature;
-
-            let signature = Signature::now("test", "test@zed.dev").unwrap();
-            repo.stash_save(&signature, "N/A", None)
-                .expect("Failed to stash");
-        }
-
-        #[track_caller]
-        fn git_reset(offset: usize, repo: &git2::Repository) {
-            let head = repo.head().expect("Couldn't get repo head");
-            let object = head.peel(git2::ObjectType::Commit).unwrap();
-            let commit = object.as_commit().unwrap();
-            let new_head = commit
-                .parents()
-                .inspect(|parnet| {
-                    parnet.message();
-                })
-                .skip(offset)
-                .next()
-                .expect("Not enough history");
-            repo.reset(&new_head.as_object(), git2::ResetType::Soft, None)
-                .expect("Could not reset");
-        }
-
-        #[allow(dead_code)]
-        #[track_caller]
-        fn git_status(repo: &git2::Repository) -> HashMap<String, git2::Status> {
-            repo.statuses(None)
-                .unwrap()
-                .iter()
-                .map(|status| (status.path().unwrap().to_string(), status.status()))
-                .collect()
-        }
-
-        const IGNORE_RULE: &'static str = "**/target";
-
-        let root = temp_tree(json!({
-            "project": {
-                "a.txt": "a",
-                "b.txt": "bb",
-                "c": {
-                    "d": {
-                        "e.txt": "eee"
-                    }
-                },
-                "f.txt": "ffff",
-                "target": {
-                    "build_file": "???"
-                },
-                ".gitignore": IGNORE_RULE
-            },
-
-        }));
-
-        let http_client = FakeHttpClient::with_404_response();
-        let client = cx.read(|cx| Client::new(http_client, cx));
-        let tree = Worktree::local(
-            client,
-            root.path(),
-            true,
-            Arc::new(RealFs),
-            Default::default(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
-
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-
-        const A_TXT: &'static str = "a.txt";
-        const B_TXT: &'static str = "b.txt";
-        const E_TXT: &'static str = "c/d/e.txt";
-        const F_TXT: &'static str = "f.txt";
-        const DOTGITIGNORE: &'static str = ".gitignore";
-        const BUILD_FILE: &'static str = "target/build_file";
-
-        let work_dir = root.path().join("project");
-        let mut repo = git_init(work_dir.as_path());
-        repo.add_ignore_rule(IGNORE_RULE).unwrap();
-        git_add(Path::new(A_TXT), &repo);
-        git_add(Path::new(E_TXT), &repo);
-        git_add(Path::new(DOTGITIGNORE), &repo);
-        git_commit("Initial commit", &repo);
-
-        std::fs::write(work_dir.join(A_TXT), "aa").unwrap();
-
-        tree.flush_fs_events(cx).await;
-
-        // Check that the right git state is observed on startup
-        tree.read_with(cx, |tree, _cx| {
-            let snapshot = tree.snapshot();
-            assert_eq!(snapshot.repository_entries.iter().count(), 1);
-            let (dir, repo) = snapshot.repository_entries.iter().next().unwrap();
-            assert_eq!(dir.0.as_ref(), Path::new("project"));
-
-            assert_eq!(repo.statuses.iter().count(), 3);
-            assert_eq!(
-                repo.statuses.get(&Path::new(A_TXT).into()),
-                Some(&GitFileStatus::Modified)
-            );
-            assert_eq!(
-                repo.statuses.get(&Path::new(B_TXT).into()),
-                Some(&GitFileStatus::Added)
-            );
-            assert_eq!(
-                repo.statuses.get(&Path::new(F_TXT).into()),
-                Some(&GitFileStatus::Added)
-            );
-        });
-
-        git_add(Path::new(A_TXT), &repo);
-        git_add(Path::new(B_TXT), &repo);
-        git_commit("Committing modified and added", &repo);
-        tree.flush_fs_events(cx).await;
-
-        // Check that repo only changes are tracked
-        tree.read_with(cx, |tree, _cx| {
-            let snapshot = tree.snapshot();
-            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
-
-            assert_eq!(repo.statuses.iter().count(), 1);
-            assert_eq!(
-                repo.statuses.get(&Path::new(F_TXT).into()),
-                Some(&GitFileStatus::Added)
-            );
-        });
-
-        git_reset(0, &repo);
-        git_remove_index(Path::new(B_TXT), &repo);
-        git_stash(&mut repo);
-        std::fs::write(work_dir.join(E_TXT), "eeee").unwrap();
-        std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap();
-        tree.flush_fs_events(cx).await;
-
-        // Check that more complex repo changes are tracked
-        tree.read_with(cx, |tree, _cx| {
-            let snapshot = tree.snapshot();
-            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
-
-            assert_eq!(repo.statuses.iter().count(), 3);
-            assert_eq!(repo.statuses.get(&Path::new(A_TXT).into()), None);
-            assert_eq!(
-                repo.statuses.get(&Path::new(B_TXT).into()),
-                Some(&GitFileStatus::Added)
-            );
-            assert_eq!(
-                repo.statuses.get(&Path::new(E_TXT).into()),
-                Some(&GitFileStatus::Modified)
-            );
-            assert_eq!(
-                repo.statuses.get(&Path::new(F_TXT).into()),
-                Some(&GitFileStatus::Added)
-            );
-        });
-
-        std::fs::remove_file(work_dir.join(B_TXT)).unwrap();
-        std::fs::remove_dir_all(work_dir.join("c")).unwrap();
-        std::fs::write(
-            work_dir.join(DOTGITIGNORE),
-            [IGNORE_RULE, "f.txt"].join("\n"),
-        )
-        .unwrap();
-
-        git_add(Path::new(DOTGITIGNORE), &repo);
-        git_commit("Committing modified git ignore", &repo);
-
-        tree.flush_fs_events(cx).await;
-
-        // Check that non-repo behavior is tracked
-        tree.read_with(cx, |tree, _cx| {
-            let snapshot = tree.snapshot();
-            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
-
-            assert_eq!(repo.statuses.iter().count(), 0);
-        });
-
-        let mut renamed_dir_name = "first_directory/second_directory";
-        const RENAMED_FILE: &'static str = "rf.txt";
-
-        std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap();
-        std::fs::write(
-            work_dir.join(renamed_dir_name).join(RENAMED_FILE),
-            "new-contents",
-        )
-        .unwrap();
-
-        tree.flush_fs_events(cx).await;
-
-        tree.read_with(cx, |tree, _cx| {
-            let snapshot = tree.snapshot();
-            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
-
-            assert_eq!(repo.statuses.iter().count(), 1);
-            assert_eq!(
-                repo.statuses
-                    .get(&Path::new(renamed_dir_name).join(RENAMED_FILE).into()),
-                Some(&GitFileStatus::Added)
-            );
-        });
-
-        renamed_dir_name = "new_first_directory/second_directory";
-
-        std::fs::rename(
-            work_dir.join("first_directory"),
-            work_dir.join("new_first_directory"),
-        )
-        .unwrap();
-
-        tree.flush_fs_events(cx).await;
-
-        tree.read_with(cx, |tree, _cx| {
-            let snapshot = tree.snapshot();
-            let (_, repo) = snapshot.repository_entries.iter().next().unwrap();
-
-            assert_eq!(repo.statuses.iter().count(), 1);
-            assert_eq!(
-                repo.statuses
-                    .get(&Path::new(renamed_dir_name).join(RENAMED_FILE).into()),
-                Some(&GitFileStatus::Added)
-            );
-        });
-    }
-
-    #[gpui::test]
-    async fn test_write_file(cx: &mut TestAppContext) {
-        let dir = temp_tree(json!({
-            ".git": {},
-            ".gitignore": "ignored-dir\n",
-            "tracked-dir": {},
-            "ignored-dir": {}
-        }));
-
-        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
-
-        let tree = Worktree::local(
-            client,
-            dir.path(),
-            true,
-            Arc::new(RealFs),
-            Default::default(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-        tree.flush_fs_events(cx).await;
-
-        tree.update(cx, |tree, cx| {
-            tree.as_local().unwrap().write_file(
-                Path::new("tracked-dir/file.txt"),
-                "hello".into(),
-                Default::default(),
-                cx,
-            )
-        })
-        .await
-        .unwrap();
-        tree.update(cx, |tree, cx| {
-            tree.as_local().unwrap().write_file(
-                Path::new("ignored-dir/file.txt"),
-                "world".into(),
-                Default::default(),
-                cx,
-            )
-        })
-        .await
-        .unwrap();
-
-        tree.read_with(cx, |tree, _| {
-            let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap();
-            let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap();
-            assert!(!tracked.is_ignored);
-            assert!(ignored.is_ignored);
-        });
-    }
-
-    #[gpui::test(iterations = 30)]
-    async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
-        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
-
-        let fs = FakeFs::new(cx.background());
-        fs.insert_tree(
-            "/root",
-            json!({
-                "b": {},
-                "c": {},
-                "d": {},
-            }),
-        )
-        .await;
-
-        let tree = Worktree::local(
-            client,
-            "/root".as_ref(),
-            true,
-            fs,
-            Default::default(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
-
-        let mut snapshot1 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
-
-        let entry = tree
-            .update(cx, |tree, cx| {
-                tree.as_local_mut()
-                    .unwrap()
-                    .create_entry("a/e".as_ref(), true, cx)
-            })
-            .await
-            .unwrap();
-        assert!(entry.is_dir());
-
-        cx.foreground().run_until_parked();
-        tree.read_with(cx, |tree, _| {
-            assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir);
-        });
-
-        let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
-        let update = snapshot2.build_update(&snapshot1, 0, 0, true);
-        snapshot1.apply_remote_update(update).unwrap();
-        assert_eq!(snapshot1.to_vec(true), snapshot2.to_vec(true),);
-    }
-
-    #[gpui::test(iterations = 100)]
-    async fn test_random_worktree_operations_during_initial_scan(
-        cx: &mut TestAppContext,
-        mut rng: StdRng,
-    ) {
-        let operations = env::var("OPERATIONS")
-            .map(|o| o.parse().unwrap())
-            .unwrap_or(5);
-        let initial_entries = env::var("INITIAL_ENTRIES")
-            .map(|o| o.parse().unwrap())
-            .unwrap_or(20);
-
-        let root_dir = Path::new("/test");
-        let fs = FakeFs::new(cx.background()) as Arc<dyn Fs>;
-        fs.as_fake().insert_tree(root_dir, json!({})).await;
-        for _ in 0..initial_entries {
-            randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
-        }
-        log::info!("generated initial tree");
-
-        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
-        let worktree = Worktree::local(
-            client.clone(),
-            root_dir,
-            true,
-            fs.clone(),
-            Default::default(),
-            &mut cx.to_async(),
-        )
-        .await
-        .unwrap();
-
-        let mut snapshot = worktree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
+        let mut snapshot = worktree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
 
         for _ in 0..operations {
             worktree

crates/project_panel/Cargo.toml 🔗

@@ -10,6 +10,7 @@ doctest = false
 
 [dependencies]
 context_menu = { path = "../context_menu" }
+db = { path = "../db" }
 drag_and_drop = { path = "../drag_and_drop" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
@@ -21,6 +22,11 @@ util = { path = "../util" }
 workspace = { path = "../workspace" }
 postage.workspace = true
 futures.workspace = true
+serde.workspace = true
+serde_derive.workspace = true
+serde_json.workspace = true
+anyhow.workspace = true
+schemars.workspace = true
 unicase = "2.6"
 
 [dev-dependencies]

crates/project_panel/src/project_panel.rs 🔗

@@ -1,25 +1,31 @@
+mod project_panel_settings;
+
 use context_menu::{ContextMenu, ContextMenuItem};
+use db::kvp::KEY_VALUE_STORE;
 use drag_and_drop::{DragAndDrop, Draggable};
 use editor::{Cancel, Editor};
 use futures::stream::StreamExt;
 use gpui::{
     actions,
-    anyhow::{anyhow, Result},
+    anyhow::{self, anyhow, Result},
     elements::{
-        AnchorCorner, ChildView, ComponentHost, ContainerStyle, Empty, Flex, MouseEventHandler,
+        AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
         ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
     },
     geometry::vector::Vector2F,
     keymap_matcher::KeymapContext,
     platform::{CursorStyle, MouseButton, PromptLevel},
-    AnyElement, AppContext, ClipboardItem, Element, Entity, ModelHandle, Task, View, ViewContext,
-    ViewHandle, WeakViewHandle,
+    Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelHandle,
+    Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
 use menu::{Confirm, SelectNext, SelectPrev};
 use project::{
-    repository::GitFileStatus, Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree,
-    WorktreeId,
+    repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
+    Worktree, WorktreeId,
 };
+use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
+use serde::{Deserialize, Serialize};
+use settings::SettingsStore;
 use std::{
     cmp::Ordering,
     collections::{hash_map, HashMap},
@@ -28,14 +34,20 @@ use std::{
     path::Path,
     sync::Arc,
 };
-use theme::{ui::FileName, ProjectPanelEntry};
+use theme::ProjectPanelEntry;
 use unicase::UniCase;
-use workspace::Workspace;
+use util::{ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel},
+    Workspace,
+};
 
+const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 
 pub struct ProjectPanel {
     project: ModelHandle<Project>,
+    fs: Arc<dyn Fs>,
     list: UniformListState,
     visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
     last_worktree_root_id: Option<ProjectEntryId>,
@@ -47,6 +59,9 @@ pub struct ProjectPanel {
     context_menu: ViewHandle<ContextMenu>,
     dragged_entry_destination: Option<Arc<Path>>,
     workspace: WeakViewHandle<Workspace>,
+    has_focus: bool,
+    width: Option<f32>,
+    pending_serialization: Task<Option<()>>,
 }
 
 #[derive(Copy, Clone)]
@@ -110,7 +125,12 @@ actions!(
     ]
 );
 
+pub fn init_settings(cx: &mut AppContext) {
+    settings::register::<ProjectPanelSettings>(cx);
+}
+
 pub fn init(cx: &mut AppContext) {
+    init_settings(cx);
     cx.add_action(ProjectPanel::expand_selected_entry);
     cx.add_action(ProjectPanel::collapse_selected_entry);
     cx.add_action(ProjectPanel::select_prev);
@@ -138,10 +158,17 @@ pub enum Event {
         entry_id: ProjectEntryId,
         focus_opened_item: bool,
     },
+    DockPositionChanged,
+    Focus,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedProjectPanel {
+    width: Option<f32>,
 }
 
 impl ProjectPanel {
-    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
+    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
         let project = workspace.project().clone();
         let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
             cx.observe(&project, |this, _, cx| {
@@ -202,6 +229,7 @@ impl ProjectPanel {
             let view_id = cx.view_id();
             let mut this = Self {
                 project: project.clone(),
+                fs: workspace.app_state().fs.clone(),
                 list: Default::default(),
                 visible_entries: Default::default(),
                 last_worktree_root_id: Default::default(),
@@ -213,8 +241,23 @@ impl ProjectPanel {
                 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
                 dragged_entry_destination: None,
                 workspace: workspace.weak_handle(),
+                has_focus: false,
+                width: None,
+                pending_serialization: Task::ready(None),
             };
             this.update_visible_entries(None, cx);
+
+            // Update the dock position when the setting changes.
+            let mut old_dock_position = this.position(cx);
+            cx.observe_global::<SettingsStore, _>(move |this, cx| {
+                let new_dock_position = this.position(cx);
+                if new_dock_position != old_dock_position {
+                    old_dock_position = new_dock_position;
+                    cx.emit(Event::DockPositionChanged);
+                }
+            })
+            .detach();
+
             this
         });
 
@@ -246,6 +289,7 @@ impl ProjectPanel {
                         }
                     }
                 }
+                _ => {}
             }
         })
         .detach();
@@ -253,6 +297,51 @@ impl ProjectPanel {
         project_panel
     }
 
+    pub fn load(
+        workspace: WeakViewHandle<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let serialized_panel = if let Some(panel) = cx
+                .background()
+                .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
+                .await
+                .log_err()
+                .flatten()
+            {
+                Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
+            } else {
+                None
+            };
+            workspace.update(&mut cx, |workspace, cx| {
+                let panel = ProjectPanel::new(workspace, cx);
+                if let Some(serialized_panel) = serialized_panel {
+                    panel.update(cx, |panel, cx| {
+                        panel.width = serialized_panel.width;
+                        cx.notify();
+                    });
+                }
+                panel
+            })
+        })
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let width = self.width;
+        self.pending_serialization = cx.background().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        PROJECT_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedProjectPanel { width })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
+    }
+
     fn deploy_context_menu(
         &mut self,
         position: Vector2F,
@@ -1000,6 +1089,7 @@ impl ProjectPanel {
             }
 
             let end_ix = range.end.min(ix + visible_worktree_entries.len());
+            let git_status_setting = settings::get::<ProjectPanelSettings>(cx).git_status;
             if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
                 let snapshot = worktree.read(cx).snapshot();
                 let root_name = OsStr::new(snapshot.root_name());
@@ -1010,14 +1100,13 @@ impl ProjectPanel {
                     .unwrap_or(&[]);
 
                 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
-                for entry in &visible_worktree_entries[entry_range] {
-                    let path = &entry.path;
-                    let status = (entry.path.parent().is_some() && !entry.is_ignored)
-                        .then(|| {
-                            snapshot
-                                .repo_for(path)
-                                .and_then(|entry| entry.status_for_path(&snapshot, path))
-                        })
+                for (entry, repo) in
+                    snapshot.entries_with_repositories(visible_worktree_entries[entry_range].iter())
+                {
+                    let status = (git_status_setting
+                        && entry.path.parent().is_some()
+                        && !entry.is_ignored)
+                        .then(|| repo.and_then(|repo| repo.status_for_path(&snapshot, &entry.path)))
                         .flatten();
 
                     let mut details = EntryDetails {
@@ -1082,6 +1171,17 @@ impl ProjectPanel {
         let kind = details.kind;
         let show_editor = details.is_editing && !details.is_processing;
 
+        let mut filename_text_style = style.text.clone();
+        filename_text_style.color = details
+            .git_status
+            .as_ref()
+            .map(|status| match status {
+                GitFileStatus::Added => style.status.git.inserted,
+                GitFileStatus::Modified => style.status.git.modified,
+                GitFileStatus::Conflict => style.status.git.conflict,
+            })
+            .unwrap_or(style.text.color);
+
         Flex::row()
             .with_child(
                 if kind == EntryKind::Dir {
@@ -1109,16 +1209,12 @@ impl ProjectPanel {
                     .flex(1.0, true)
                     .into_any()
             } else {
-                ComponentHost::new(FileName::new(
-                    details.filename.clone(),
-                    details.git_status,
-                    FileName::style(style.text.clone(), &theme::current(cx)),
-                ))
-                .contained()
-                .with_margin_left(style.icon_spacing)
-                .aligned()
-                .left()
-                .into_any()
+                Label::new(details.filename.clone(), filename_text_style)
+                    .contained()
+                    .with_margin_left(style.icon_spacing)
+                    .aligned()
+                    .left()
+                    .into_any()
             })
             .constrained()
             .with_height(style.height)
@@ -1337,16 +1433,103 @@ impl View for ProjectPanel {
         Self::reset_to_default_keymap_context(keymap);
         keymap.add_identifier("menu");
     }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if !self.has_focus {
+            self.has_focus = true;
+            cx.emit(Event::Focus);
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
 }
 
 impl Entity for ProjectPanel {
     type Event = Event;
 }
 
-impl workspace::sidebar::SidebarItem for ProjectPanel {
-    fn should_show_badge(&self, _: &AppContext) -> bool {
+impl workspace::dock::Panel for ProjectPanel {
+    fn position(&self, cx: &WindowContext) -> DockPosition {
+        match settings::get::<ProjectPanelSettings>(cx).dock {
+            ProjectPanelDockPosition::Left => DockPosition::Left,
+            ProjectPanelDockPosition::Right => DockPosition::Right,
+        }
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(position, DockPosition::Left | DockPosition::Right)
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<ProjectPanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings| {
+                let dock = match position {
+                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
+                    DockPosition::Right => ProjectPanelDockPosition::Right,
+                };
+                settings.dock = Some(dock);
+            },
+        );
+    }
+
+    fn size(&self, cx: &WindowContext) -> f32 {
+        self.width
+            .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
+    }
+
+    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+        self.width = Some(size);
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn should_zoom_in_on_event(_: &Self::Event) -> bool {
+        false
+    }
+
+    fn should_zoom_out_on_event(_: &Self::Event) -> bool {
         false
     }
+
+    fn is_zoomed(&self, _: &WindowContext) -> bool {
+        false
+    }
+
+    fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
+
+    fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
+
+    fn icon_path(&self) -> &'static str {
+        "icons/folder_tree_16.svg"
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
+        ("Project Panel".into(), Some(Box::new(ToggleFocus)))
+    }
+
+    fn should_change_position_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::DockPositionChanged)
+    }
+
+    fn should_activate_on_event(_: &Self::Event) -> bool {
+        false
+    }
+
+    fn should_close_on_event(_: &Self::Event) -> bool {
+        false
+    }
+
+    fn has_focus(&self, _: &WindowContext) -> bool {
+        self.has_focus
+    }
+
+    fn is_focus_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Focus)
+    }
 }
 
 impl ClipboardEntry {
@@ -1378,6 +1561,7 @@ mod tests {
     use serde_json::json;
     use settings::SettingsStore;
     use std::{collections::HashSet, path::Path};
+    use workspace::{pane, AppState};
 
     #[gpui::test]
     async fn test_visible_list(cx: &mut gpui::TestAppContext) {
@@ -1853,6 +2037,95 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/src",
+            json!({
+                "test": {
+                    "first.rs": "// First Rust file",
+                    "second.rs": "// Second Rust file",
+                    "third.rs": "// Third Rust file",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+        toggle_expand_dir(&panel, "src/test", cx);
+        select_path(&panel, "src/test/first.rs", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          first.rs  <== selected",
+                "          second.rs",
+                "          third.rs"
+            ]
+        );
+        ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
+
+        submit_deletion(window_id, &panel, cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          second.rs",
+                "          third.rs"
+            ],
+            "Project panel should have no deleted file, no other file is selected in it"
+        );
+        ensure_no_open_items_and_panes(window_id, &workspace, cx);
+
+        select_path(&panel, "src/test/second.rs", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          second.rs  <== selected",
+                "          third.rs"
+            ]
+        );
+        ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
+
+        cx.update_window(window_id, |cx| {
+            let active_items = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item())
+                .collect::<Vec<_>>();
+            assert_eq!(active_items.len(), 1);
+            let open_editor = active_items
+                .into_iter()
+                .next()
+                .unwrap()
+                .downcast::<Editor>()
+                .expect("Open item should be an editor");
+            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
+        });
+        submit_deletion(window_id, &panel, cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &["v src", "    v test", "          third.rs"],
+            "Project panel should have no deleted file, with one last file remaining"
+        );
+        ensure_no_open_items_and_panes(window_id, &workspace, cx);
+    }
+
     fn toggle_expand_dir(
         panel: &ViewHandle<ProjectPanel>,
         path: impl AsRef<Path>,
@@ -1950,10 +2223,104 @@ mod tests {
         cx.foreground().forbid_parking();
         cx.update(|cx| {
             cx.set_global(SettingsStore::test(cx));
+            init_settings(cx);
             theme::init((), cx);
             language::init(cx);
             editor::init_settings(cx);
+            crate::init(cx);
             workspace::init_settings(cx);
         });
     }
+
+    fn init_test_with_editor(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            let app_state = AppState::test(cx);
+            theme::init((), cx);
+            init_settings(cx);
+            language::init(cx);
+            editor::init(cx);
+            pane::init(cx);
+            crate::init(cx);
+            workspace::init(app_state.clone(), cx);
+        });
+    }
+
+    fn ensure_single_file_is_opened(
+        window_id: usize,
+        workspace: &ViewHandle<Workspace>,
+        expected_path: &str,
+        cx: &mut TestAppContext,
+    ) {
+        cx.read_window(window_id, |cx| {
+            let workspace = workspace.read(cx);
+            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1);
+            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
+
+            let open_project_paths = workspace
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
+                .collect::<Vec<_>>();
+            assert_eq!(
+                open_project_paths,
+                vec![ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new(expected_path))
+                }],
+                "Should have opened file, selected in project panel"
+            );
+        });
+    }
+
+    fn submit_deletion(
+        window_id: usize,
+        panel: &ViewHandle<ProjectPanel>,
+        cx: &mut TestAppContext,
+    ) {
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts before the deletion"
+        );
+        panel.update(cx, |panel, cx| {
+            panel
+                .delete(&Delete, cx)
+                .expect("Deletion start")
+                .detach_and_log_err(cx);
+        });
+        assert!(
+            cx.has_pending_prompt(window_id),
+            "Should have a prompt after the deletion"
+        );
+        cx.simulate_prompt_answer(window_id, 0);
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts after prompt was replied to"
+        );
+        cx.foreground().run_until_parked();
+    }
+
+    fn ensure_no_open_items_and_panes(
+        window_id: usize,
+        workspace: &ViewHandle<Workspace>,
+        cx: &mut TestAppContext,
+    ) {
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts after deletion operation closes the file"
+        );
+        cx.read_window(window_id, |cx| {
+            let open_project_paths = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
+                .collect::<Vec<_>>();
+            assert!(
+                open_project_paths.is_empty(),
+                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
+            );
+        });
+    }
 }

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -0,0 +1,39 @@
+use anyhow;
+use schemars::JsonSchema;
+use serde_derive::{Deserialize, Serialize};
+use settings::Setting;
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ProjectPanelDockPosition {
+    Left,
+    Right,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct ProjectPanelSettings {
+    pub git_status: bool,
+    pub dock: ProjectPanelDockPosition,
+    pub default_width: f32,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct ProjectPanelSettingsContent {
+    pub git_status: Option<bool>,
+    pub dock: Option<ProjectPanelDockPosition>,
+    pub default_width: Option<f32>,
+}
+
+impl Setting for ProjectPanelSettings {
+    const KEY: Option<&'static str> = Some("project_panel");
+
+    type FileContent = ProjectPanelSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

crates/project_symbols/Cargo.toml 🔗

@@ -27,6 +27,7 @@ smol.workspace = true
 
 [dev-dependencies]
 futures.workspace = true
+editor = { path = "../editor", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }

crates/recent_projects/Cargo.toml 🔗

@@ -24,3 +24,6 @@ workspace = { path = "../workspace" }
 ordered-float.workspace = true
 postage.workspace = true
 smol.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/search/Cargo.toml 🔗

@@ -27,7 +27,7 @@ serde.workspace = true
 serde_derive.workspace = true
 smallvec.workspace = true
 smol.workspace = true
-glob.workspace = true
+globset.workspace = true
 
 [dev-dependencies]
 client = { path = "../client", features = ["test-support"] }

crates/search/src/project_search.rs 🔗

@@ -2,12 +2,14 @@ use crate::{
     SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
     ToggleWholeWord,
 };
+use anyhow::Result;
 use collections::HashMap;
 use editor::{
     items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
     SelectAll, MAX_TAB_TITLE_LEN,
 };
 use futures::StreamExt;
+use globset::{Glob, GlobMatcher};
 use gpui::{
     actions,
     elements::*,
@@ -46,7 +48,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(ProjectSearchBar::search_in_new);
     cx.add_action(ProjectSearchBar::select_next_match);
     cx.add_action(ProjectSearchBar::select_prev_match);
-    cx.add_action(ProjectSearchBar::toggle_focus);
+    cx.add_action(ProjectSearchBar::move_focus_to_results);
     cx.capture_action(ProjectSearchBar::tab);
     cx.capture_action(ProjectSearchBar::tab_previous);
     add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
@@ -571,46 +573,30 @@ impl ProjectSearchView {
 
     fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
         let text = self.query_editor.read(cx).text(cx);
-        let included_files = match self
-            .included_files_editor
-            .read(cx)
-            .text(cx)
-            .split(',')
-            .map(str::trim)
-            .filter(|glob_str| !glob_str.is_empty())
-            .map(|glob_str| glob::Pattern::new(glob_str))
-            .collect::<Result<_, _>>()
-        {
-            Ok(included_files) => {
-                self.panels_with_errors.remove(&InputPanel::Include);
-                included_files
-            }
-            Err(_e) => {
-                self.panels_with_errors.insert(InputPanel::Include);
-                cx.notify();
-                return None;
-            }
-        };
-        let excluded_files = match self
-            .excluded_files_editor
-            .read(cx)
-            .text(cx)
-            .split(',')
-            .map(str::trim)
-            .filter(|glob_str| !glob_str.is_empty())
-            .map(|glob_str| glob::Pattern::new(glob_str))
-            .collect::<Result<_, _>>()
-        {
-            Ok(excluded_files) => {
-                self.panels_with_errors.remove(&InputPanel::Exclude);
-                excluded_files
-            }
-            Err(_e) => {
-                self.panels_with_errors.insert(InputPanel::Exclude);
-                cx.notify();
-                return None;
-            }
-        };
+        let included_files =
+            match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
+                Ok(included_files) => {
+                    self.panels_with_errors.remove(&InputPanel::Include);
+                    included_files
+                }
+                Err(_e) => {
+                    self.panels_with_errors.insert(InputPanel::Include);
+                    cx.notify();
+                    return None;
+                }
+            };
+        let excluded_files =
+            match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
+                Ok(excluded_files) => {
+                    self.panels_with_errors.remove(&InputPanel::Exclude);
+                    excluded_files
+                }
+                Err(_e) => {
+                    self.panels_with_errors.insert(InputPanel::Exclude);
+                    cx.notify();
+                    return None;
+                }
+            };
         if self.regex {
             match SearchQuery::regex(
                 text,
@@ -640,6 +626,14 @@ impl ProjectSearchView {
         }
     }
 
+    fn load_glob_set(text: &str) -> Result<Vec<GlobMatcher>> {
+        text.split(',')
+            .map(str::trim)
+            .filter(|glob_str| !glob_str.is_empty())
+            .map(|glob_str| anyhow::Ok(Glob::new(glob_str)?.compile_matcher()))
+            .collect()
+    }
+
     fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
         if let Some(index) = self.active_match_index {
             let match_ranges = self.model.read(cx).match_ranges.clone();
@@ -800,18 +794,16 @@ impl ProjectSearchBar {
         }
     }
 
-    fn toggle_focus(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
+    fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext<Pane>) {
         if let Some(search_view) = pane
             .active_item()
             .and_then(|item| item.downcast::<ProjectSearchView>())
         {
             search_view.update(cx, |search_view, cx| {
-                if search_view.query_editor.is_focused(cx) {
-                    if !search_view.model.read(cx).match_ranges.is_empty() {
-                        search_view.focus_results_editor(cx);
-                    }
-                } else {
-                    search_view.focus_query_editor(cx);
+                if search_view.query_editor.is_focused(cx)
+                    && !search_view.model.read(cx).match_ranges.is_empty()
+                {
+                    search_view.focus_results_editor(cx);
                 }
             });
         } else {

crates/settings/Cargo.toml 🔗

@@ -22,7 +22,6 @@ util = { path = "../util" }
 
 anyhow.workspace = true
 futures.workspace = true
-glob.workspace = true
 json_comments = "0.2"
 lazy_static.workspace = true
 postage.workspace = true

crates/settings/src/settings_store.rs 🔗

@@ -25,7 +25,7 @@ pub trait Setting: 'static {
     const KEY: Option<&'static str>;
 
     /// The type that is stored in an individual JSON file.
-    type FileContent: Clone + Serialize + DeserializeOwned + JsonSchema;
+    type FileContent: Clone + Default + Serialize + DeserializeOwned + JsonSchema;
 
     /// The logic for combining together values from one or more JSON files into the
     /// final value for this setting.
@@ -460,11 +460,12 @@ impl SettingsStore {
 
                 // If the global settings file changed, reload the global value for the field.
                 if changed_local_path.is_none() {
-                    setting_value.set_global_value(setting_value.load_setting(
-                        &default_settings,
-                        &user_settings_stack,
-                        cx,
-                    )?);
+                    if let Some(value) = setting_value
+                        .load_setting(&default_settings, &user_settings_stack, cx)
+                        .log_err()
+                    {
+                        setting_value.set_global_value(value);
+                    }
                 }
 
                 // Reload the local values for the setting.
@@ -495,14 +496,12 @@ impl SettingsStore {
                             continue;
                         }
 
-                        setting_value.set_local_value(
-                            path.clone(),
-                            setting_value.load_setting(
-                                &default_settings,
-                                &user_settings_stack,
-                                cx,
-                            )?,
-                        );
+                        if let Some(value) = setting_value
+                            .load_setting(&default_settings, &user_settings_stack, cx)
+                            .log_err()
+                        {
+                            setting_value.set_local_value(path.clone(), value);
+                        }
                     }
                 }
             }
@@ -536,7 +535,12 @@ impl<T: Setting> AnySettingValue for SettingValue<T> {
 
     fn deserialize_setting(&self, mut json: &serde_json::Value) -> Result<DeserializedSetting> {
         if let Some(key) = T::KEY {
-            json = json.get(key).unwrap_or(&serde_json::Value::Null);
+            if let Some(value) = json.get(key) {
+                json = value;
+            } else {
+                let value = T::FileContent::default();
+                return Ok(DeserializedSetting(Box::new(value)));
+            }
         }
         let value = T::FileContent::deserialize(json)?;
         Ok(DeserializedSetting(Box::new(value)))
@@ -826,37 +830,6 @@ mod tests {
         store.register_setting::<UserSettings>(cx);
         store.register_setting::<TurboSetting>(cx);
         store.register_setting::<MultiKeySettings>(cx);
-
-        // error - missing required field in default settings
-        store
-            .set_default_settings(
-                r#"{
-                    "user": {
-                        "name": "John Doe",
-                        "age": 30,
-                        "staff": false
-                    }
-                }"#,
-                cx,
-            )
-            .unwrap_err();
-
-        // error - type error in default settings
-        store
-            .set_default_settings(
-                r#"{
-                    "turbo": "the-wrong-type",
-                    "user": {
-                        "name": "John Doe",
-                        "age": 30,
-                        "staff": false
-                    }
-                }"#,
-                cx,
-            )
-            .unwrap_err();
-
-        // valid default settings.
         store
             .set_default_settings(
                 r#"{
@@ -1126,7 +1099,7 @@ mod tests {
         staff: bool,
     }
 
-    #[derive(Clone, Serialize, Deserialize, JsonSchema)]
+    #[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
     struct UserSettingsJson {
         name: Option<String>,
         age: Option<u32>,
@@ -1170,7 +1143,7 @@ mod tests {
         key2: String,
     }
 
-    #[derive(Clone, Serialize, Deserialize, JsonSchema)]
+    #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
     struct MultiKeySettingsJson {
         key1: Option<String>,
         key2: Option<String>,
@@ -1203,7 +1176,7 @@ mod tests {
         Hour24,
     }
 
-    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+    #[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]
     struct JournalSettingsJson {
         pub path: Option<String>,
         pub hour_format: Option<HourFormat>,
@@ -1223,7 +1196,7 @@ mod tests {
         }
     }
 
-    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+    #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
     struct LanguageSettings {
         #[serde(default)]
         languages: HashMap<String, LanguageSettingEntry>,

crates/sqlez/src/bindable.rs 🔗

@@ -27,7 +27,7 @@ impl StaticColumnCount for bool {}
 impl Bind for bool {
     fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
         statement
-            .bind(self.then_some(1).unwrap_or(0), start_index)
+            .bind(&self.then_some(1).unwrap_or(0), start_index)
             .with_context(|| format!("Failed to bind bool at index {start_index}"))
     }
 }

crates/sqlez/src/statement.rs 🔗

@@ -236,7 +236,7 @@ impl<'a> Statement<'a> {
         Ok(str::from_utf8(slice)?)
     }
 
-    pub fn bind<T: Bind>(&self, value: T, index: i32) -> Result<i32> {
+    pub fn bind<T: Bind>(&self, value: &T, index: i32) -> Result<i32> {
         debug_assert!(index > 0);
         Ok(value.bind(self, index)?)
     }
@@ -258,7 +258,7 @@ impl<'a> Statement<'a> {
         }
     }
 
-    pub fn with_bindings(&mut self, bindings: impl Bind) -> Result<&mut Self> {
+    pub fn with_bindings(&mut self, bindings: &impl Bind) -> Result<&mut Self> {
         self.bind(bindings, 1)?;
         Ok(self)
     }
@@ -464,7 +464,7 @@ mod test {
         connection
             .exec(indoc! {"
                 CREATE TABLE texts (
-                    text TEXT 
+                    text TEXT
                 )"})
             .unwrap()()
         .unwrap();

crates/sqlez/src/typed_statements.rs 🔗

@@ -29,7 +29,7 @@ impl Connection {
         query: &str,
     ) -> Result<impl 'a + FnMut(B) -> Result<()>> {
         let mut statement = Statement::prepare(self, query)?;
-        Ok(move |bindings| statement.with_bindings(bindings)?.exec())
+        Ok(move |bindings| statement.with_bindings(&bindings)?.exec())
     }
 
     /// Prepare a statement which has no bindings and returns a `Vec<C>`.
@@ -55,7 +55,7 @@ impl Connection {
         query: &str,
     ) -> Result<impl 'a + FnMut(B) -> Result<Vec<C>>> {
         let mut statement = Statement::prepare(self, query)?;
-        Ok(move |bindings| statement.with_bindings(bindings)?.rows::<C>())
+        Ok(move |bindings| statement.with_bindings(&bindings)?.rows::<C>())
     }
 
     /// Prepare a statement that selects a single row from the database.
@@ -87,7 +87,7 @@ impl Connection {
         let mut statement = Statement::prepare(self, query)?;
         Ok(move |bindings| {
             statement
-                .with_bindings(bindings)
+                .with_bindings(&bindings)
                 .context("Bindings failed")?
                 .maybe_row::<C>()
                 .context("Maybe row failed")

crates/terminal/src/terminal.rs 🔗

@@ -119,6 +119,14 @@ pub fn init(cx: &mut AppContext) {
     settings::register::<TerminalSettings>(cx);
 }
 
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum TerminalDockPosition {
+    Left,
+    Bottom,
+    Right,
+}
+
 #[derive(Deserialize)]
 pub struct TerminalSettings {
     pub shell: Shell,
@@ -132,6 +140,9 @@ pub struct TerminalSettings {
     pub alternate_scroll: AlternateScroll,
     pub option_as_meta: bool,
     pub copy_on_select: bool,
+    pub dock: TerminalDockPosition,
+    pub default_width: f32,
+    pub default_height: f32,
 }
 
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
@@ -147,6 +158,9 @@ pub struct TerminalSettingsContent {
     pub alternate_scroll: Option<AlternateScroll>,
     pub option_as_meta: Option<bool>,
     pub copy_on_select: Option<bool>,
+    pub dock: Option<TerminalDockPosition>,
+    pub default_width: Option<f32>,
+    pub default_height: Option<f32>,
 }
 
 impl TerminalSettings {

crates/terminal_view/Cargo.toml 🔗

@@ -39,6 +39,7 @@ serde_derive.workspace = true
 
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 client = { path = "../client", features = ["test-support"]}
 project = { path = "../project", features = ["test-support"]}

crates/terminal_view/src/terminal_button.rs 🔗

@@ -1,173 +0,0 @@
-use crate::TerminalView;
-use context_menu::{ContextMenu, ContextMenuItem};
-use gpui::{
-    elements::*,
-    platform::{CursorStyle, MouseButton},
-    AnyElement, Element, Entity, View, ViewContext, ViewHandle, WeakViewHandle,
-};
-use std::any::TypeId;
-use workspace::{
-    dock::{Dock, FocusDock},
-    item::ItemHandle,
-    NewTerminal, StatusItemView, Workspace,
-};
-
-pub struct TerminalButton {
-    workspace: WeakViewHandle<Workspace>,
-    popup_menu: ViewHandle<ContextMenu>,
-}
-
-impl Entity for TerminalButton {
-    type Event = ();
-}
-
-impl View for TerminalButton {
-    fn ui_name() -> &'static str {
-        "TerminalButton"
-    }
-
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let workspace = self.workspace.upgrade(cx);
-        let project = match workspace {
-            Some(workspace) => workspace.read(cx).project().read(cx),
-            None => return Empty::new().into_any(),
-        };
-
-        let focused_view = cx.focused_view_id();
-        let active = focused_view
-            .map(|view_id| {
-                cx.view_type_id(cx.window_id(), view_id) == Some(TypeId::of::<TerminalView>())
-            })
-            .unwrap_or(false);
-
-        let has_terminals = !project.local_terminal_handles().is_empty();
-        let terminal_count = project.local_terminal_handles().len() as i32;
-        let theme = theme::current(cx).clone();
-
-        Stack::new()
-            .with_child(
-                MouseEventHandler::<Self, _>::new(0, cx, {
-                    let theme = theme.clone();
-                    move |state, _cx| {
-                        let style = theme
-                            .workspace
-                            .status_bar
-                            .sidebar_buttons
-                            .item
-                            .style_for(state, active);
-
-                        Flex::row()
-                            .with_child(
-                                Svg::new("icons/terminal_12.svg")
-                                    .with_color(style.icon_color)
-                                    .constrained()
-                                    .with_width(style.icon_size)
-                                    .aligned()
-                                    .into_any_named("terminals-icon"),
-                            )
-                            .with_children(has_terminals.then(|| {
-                                Label::new(terminal_count.to_string(), style.label.text.clone())
-                                    .contained()
-                                    .with_style(style.label.container)
-                                    .aligned()
-                            }))
-                            .constrained()
-                            .with_height(style.icon_size)
-                            .contained()
-                            .with_style(style.container)
-                    }
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, this, cx| {
-                    if has_terminals {
-                        this.deploy_terminal_menu(cx);
-                    } else {
-                        if !active {
-                            if let Some(workspace) = this.workspace.upgrade(cx) {
-                                workspace.update(cx, |workspace, cx| {
-                                    Dock::focus_dock(workspace, &Default::default(), cx)
-                                })
-                            }
-                        }
-                    };
-                })
-                .with_tooltip::<Self>(
-                    0,
-                    "Show Terminal".into(),
-                    Some(Box::new(FocusDock)),
-                    theme.tooltip.clone(),
-                    cx,
-                ),
-            )
-            .with_child(ChildView::new(&self.popup_menu, cx).aligned().top().right())
-            .into_any_named("terminal button")
-    }
-}
-
-impl TerminalButton {
-    pub fn new(workspace: ViewHandle<Workspace>, cx: &mut ViewContext<Self>) -> Self {
-        let button_view_id = cx.view_id();
-        cx.observe(&workspace, |_, _, cx| cx.notify()).detach();
-        Self {
-            workspace: workspace.downgrade(),
-            popup_menu: cx.add_view(|cx| {
-                let mut menu = ContextMenu::new(button_view_id, cx);
-                menu.set_position_mode(OverlayPositionMode::Local);
-                menu
-            }),
-        }
-    }
-
-    pub fn deploy_terminal_menu(&mut self, cx: &mut ViewContext<Self>) {
-        let mut menu_options = vec![ContextMenuItem::action("New Terminal", NewTerminal)];
-
-        if let Some(workspace) = self.workspace.upgrade(cx) {
-            let project = workspace.read(cx).project().read(cx);
-            let local_terminal_handles = project.local_terminal_handles();
-
-            if !local_terminal_handles.is_empty() {
-                menu_options.push(ContextMenuItem::Separator)
-            }
-
-            for local_terminal_handle in local_terminal_handles {
-                if let Some(terminal) = local_terminal_handle.upgrade(cx) {
-                    let workspace = self.workspace.clone();
-                    let local_terminal_handle = local_terminal_handle.clone();
-                    menu_options.push(ContextMenuItem::handler(
-                        terminal.read(cx).title(),
-                        move |cx| {
-                            if let Some(workspace) = workspace.upgrade(cx) {
-                                workspace.update(cx, |workspace, cx| {
-                                    let terminal = workspace
-                                        .items_of_type::<TerminalView>(cx)
-                                        .find(|terminal| {
-                                            terminal.read(cx).model().downgrade()
-                                                == local_terminal_handle
-                                        });
-                                    if let Some(terminal) = terminal {
-                                        workspace.activate_item(&terminal, cx);
-                                    }
-                                });
-                            }
-                        },
-                    ))
-                }
-            }
-        }
-
-        self.popup_menu.update(cx, |menu, cx| {
-            menu.show(
-                Default::default(),
-                AnchorCorner::BottomRight,
-                menu_options,
-                cx,
-            );
-        });
-    }
-}
-
-impl StatusItemView for TerminalButton {
-    fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
-        cx.notify();
-    }
-}

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -0,0 +1,408 @@
+use std::sync::Arc;
+
+use crate::TerminalView;
+use db::kvp::KEY_VALUE_STORE;
+use gpui::{
+    actions, anyhow::Result, elements::*, serde_json, Action, AppContext, AsyncAppContext, Entity,
+    Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
+};
+use project::Fs;
+use serde::{Deserialize, Serialize};
+use settings::SettingsStore;
+use terminal::{TerminalDockPosition, TerminalSettings};
+use util::{ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel},
+    item::Item,
+    pane, DraggedItem, Pane, Workspace,
+};
+
+const TERMINAL_PANEL_KEY: &'static str = "TerminalPanel";
+
+actions!(terminal_panel, [ToggleFocus]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.add_action(TerminalPanel::add_terminal);
+}
+
+pub enum Event {
+    Close,
+    DockPositionChanged,
+    ZoomIn,
+    ZoomOut,
+    Focus,
+}
+
+pub struct TerminalPanel {
+    pane: ViewHandle<Pane>,
+    fs: Arc<dyn Fs>,
+    workspace: WeakViewHandle<Workspace>,
+    width: Option<f32>,
+    height: Option<f32>,
+    pending_serialization: Task<Option<()>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl TerminalPanel {
+    fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
+        let weak_self = cx.weak_handle();
+        let pane = cx.add_view(|cx| {
+            let window_id = cx.window_id();
+            let mut pane = Pane::new(
+                workspace.weak_handle(),
+                workspace.app_state().background_actions,
+                Default::default(),
+                cx,
+            );
+            pane.set_can_split(false, cx);
+            pane.on_can_drop(move |drag_and_drop, cx| {
+                drag_and_drop
+                    .currently_dragged::<DraggedItem>(window_id)
+                    .map_or(false, |(_, item)| {
+                        item.handle.act_as::<TerminalView>(cx).is_some()
+                    })
+            });
+            pane.set_render_tab_bar_buttons(cx, move |pane, cx| {
+                let this = weak_self.clone();
+                Flex::row()
+                    .with_child(Pane::render_tab_bar_button(
+                        0,
+                        "icons/plus_12.svg",
+                        Some((
+                            "New Terminal".into(),
+                            Some(Box::new(workspace::NewTerminal)),
+                        )),
+                        cx,
+                        move |_, cx| {
+                            let this = this.clone();
+                            cx.window_context().defer(move |cx| {
+                                if let Some(this) = this.upgrade(cx) {
+                                    this.update(cx, |this, cx| {
+                                        this.add_terminal(&Default::default(), cx);
+                                    });
+                                }
+                            })
+                        },
+                        None,
+                    ))
+                    .with_child(Pane::render_tab_bar_button(
+                        1,
+                        if pane.is_zoomed() {
+                            "icons/minimize_8.svg"
+                        } else {
+                            "icons/maximize_8.svg"
+                        },
+                        Some(("Toggle Zoom".into(), Some(Box::new(workspace::ToggleZoom)))),
+                        cx,
+                        move |pane, cx| pane.toggle_zoom(&Default::default(), cx),
+                        None,
+                    ))
+                    .into_any()
+            });
+            pane
+        });
+        let subscriptions = vec![
+            cx.observe(&pane, |_, _, cx| cx.notify()),
+            cx.subscribe(&pane, Self::handle_pane_event),
+        ];
+        let this = Self {
+            pane,
+            fs: workspace.app_state().fs.clone(),
+            workspace: workspace.weak_handle(),
+            pending_serialization: Task::ready(None),
+            width: None,
+            height: None,
+            _subscriptions: subscriptions,
+        };
+        let mut old_dock_position = this.position(cx);
+        cx.observe_global::<SettingsStore, _>(move |this, cx| {
+            let new_dock_position = this.position(cx);
+            if new_dock_position != old_dock_position {
+                old_dock_position = new_dock_position;
+                cx.emit(Event::DockPositionChanged);
+            }
+        })
+        .detach();
+        this
+    }
+
+    pub fn load(
+        workspace: WeakViewHandle<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let serialized_panel = if let Some(panel) = cx
+                .background()
+                .spawn(async move { KEY_VALUE_STORE.read_kvp(TERMINAL_PANEL_KEY) })
+                .await
+                .log_err()
+                .flatten()
+            {
+                Some(serde_json::from_str::<SerializedTerminalPanel>(&panel)?)
+            } else {
+                None
+            };
+            let (panel, pane, items) = workspace.update(&mut cx, |workspace, cx| {
+                let panel = cx.add_view(|cx| TerminalPanel::new(workspace, cx));
+                let items = if let Some(serialized_panel) = serialized_panel.as_ref() {
+                    panel.update(cx, |panel, cx| {
+                        cx.notify();
+                        panel.height = serialized_panel.height;
+                        panel.width = serialized_panel.width;
+                        panel.pane.update(cx, |_, cx| {
+                            serialized_panel
+                                .items
+                                .iter()
+                                .map(|item_id| {
+                                    TerminalView::deserialize(
+                                        workspace.project().clone(),
+                                        workspace.weak_handle(),
+                                        workspace.database_id(),
+                                        *item_id,
+                                        cx,
+                                    )
+                                })
+                                .collect::<Vec<_>>()
+                        })
+                    })
+                } else {
+                    Default::default()
+                };
+                let pane = panel.read(cx).pane.clone();
+                (panel, pane, items)
+            })?;
+
+            let items = futures::future::join_all(items).await;
+            workspace.update(&mut cx, |workspace, cx| {
+                let active_item_id = serialized_panel
+                    .as_ref()
+                    .and_then(|panel| panel.active_item_id);
+                let mut active_ix = None;
+                for item in items {
+                    if let Some(item) = item.log_err() {
+                        let item_id = item.id();
+                        Pane::add_item(workspace, &pane, Box::new(item), false, false, None, cx);
+                        if Some(item_id) == active_item_id {
+                            active_ix = Some(pane.read(cx).items_len() - 1);
+                        }
+                    }
+                }
+
+                if let Some(active_ix) = active_ix {
+                    pane.update(cx, |pane, cx| {
+                        pane.activate_item(active_ix, false, false, cx)
+                    });
+                }
+            })?;
+
+            Ok(panel)
+        })
+    }
+
+    fn handle_pane_event(
+        &mut self,
+        _pane: ViewHandle<Pane>,
+        event: &pane::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            pane::Event::ActivateItem { .. } => self.serialize(cx),
+            pane::Event::RemoveItem { .. } => self.serialize(cx),
+            pane::Event::Remove => cx.emit(Event::Close),
+            pane::Event::ZoomIn => cx.emit(Event::ZoomIn),
+            pane::Event::ZoomOut => cx.emit(Event::ZoomOut),
+            pane::Event::Focus => cx.emit(Event::Focus),
+            _ => {}
+        }
+    }
+
+    fn add_terminal(&mut self, _: &workspace::NewTerminal, cx: &mut ViewContext<Self>) {
+        let workspace = self.workspace.clone();
+        cx.spawn(|this, mut cx| async move {
+            let pane = this.read_with(&cx, |this, _| this.pane.clone())?;
+            workspace.update(&mut cx, |workspace, cx| {
+                let working_directory_strategy = settings::get::<TerminalSettings>(cx)
+                    .working_directory
+                    .clone();
+                let working_directory =
+                    crate::get_working_directory(workspace, cx, working_directory_strategy);
+                let window_id = cx.window_id();
+                if let Some(terminal) = workspace.project().update(cx, |project, cx| {
+                    project
+                        .create_terminal(working_directory, window_id, cx)
+                        .log_err()
+                }) {
+                    let terminal =
+                        Box::new(cx.add_view(|cx| {
+                            TerminalView::new(terminal, workspace.database_id(), cx)
+                        }));
+                    let focus = pane.read(cx).has_focus();
+                    Pane::add_item(workspace, &pane, terminal, true, focus, None, cx);
+                }
+            })?;
+            this.update(&mut cx, |this, cx| this.serialize(cx))?;
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let items = self
+            .pane
+            .read(cx)
+            .items()
+            .map(|item| item.id())
+            .collect::<Vec<_>>();
+        let active_item_id = self.pane.read(cx).active_item().map(|item| item.id());
+        let height = self.height;
+        let width = self.width;
+        self.pending_serialization = cx.background().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        TERMINAL_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedTerminalPanel {
+                            items,
+                            active_item_id,
+                            height,
+                            width,
+                        })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
+    }
+}
+
+impl Entity for TerminalPanel {
+    type Event = Event;
+}
+
+impl View for TerminalPanel {
+    fn ui_name() -> &'static str {
+        "TerminalPanel"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> gpui::AnyElement<Self> {
+        ChildView::new(&self.pane, cx).into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            cx.focus(&self.pane);
+        }
+    }
+}
+
+impl Panel for TerminalPanel {
+    fn position(&self, cx: &WindowContext) -> DockPosition {
+        match settings::get::<TerminalSettings>(cx).dock {
+            TerminalDockPosition::Left => DockPosition::Left,
+            TerminalDockPosition::Bottom => DockPosition::Bottom,
+            TerminalDockPosition::Right => DockPosition::Right,
+        }
+    }
+
+    fn position_is_valid(&self, _: DockPosition) -> bool {
+        true
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<TerminalSettings>(self.fs.clone(), cx, move |settings| {
+            let dock = match position {
+                DockPosition::Left => TerminalDockPosition::Left,
+                DockPosition::Bottom => TerminalDockPosition::Bottom,
+                DockPosition::Right => TerminalDockPosition::Right,
+            };
+            settings.dock = Some(dock);
+        });
+    }
+
+    fn size(&self, cx: &WindowContext) -> f32 {
+        let settings = settings::get::<TerminalSettings>(cx);
+        match self.position(cx) {
+            DockPosition::Left | DockPosition::Right => {
+                self.width.unwrap_or_else(|| settings.default_width)
+            }
+            DockPosition::Bottom => self.height.unwrap_or_else(|| settings.default_height),
+        }
+    }
+
+    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+        match self.position(cx) {
+            DockPosition::Left | DockPosition::Right => self.width = Some(size),
+            DockPosition::Bottom => self.height = Some(size),
+        }
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn should_zoom_in_on_event(event: &Event) -> bool {
+        matches!(event, Event::ZoomIn)
+    }
+
+    fn should_zoom_out_on_event(event: &Event) -> bool {
+        matches!(event, Event::ZoomOut)
+    }
+
+    fn is_zoomed(&self, cx: &WindowContext) -> bool {
+        self.pane.read(cx).is_zoomed()
+    }
+
+    fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>) {
+        self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx));
+    }
+
+    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        if active && self.pane.read(cx).items_len() == 0 {
+            self.add_terminal(&Default::default(), cx)
+        }
+    }
+
+    fn icon_path(&self) -> &'static str {
+        "icons/terminal_12.svg"
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
+        ("Terminal Panel".into(), Some(Box::new(ToggleFocus)))
+    }
+
+    fn icon_label(&self, cx: &WindowContext) -> Option<String> {
+        let count = self.pane.read(cx).items_len();
+        if count == 0 {
+            None
+        } else {
+            Some(count.to_string())
+        }
+    }
+
+    fn should_change_position_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::DockPositionChanged)
+    }
+
+    fn should_activate_on_event(_: &Self::Event) -> bool {
+        false
+    }
+
+    fn should_close_on_event(event: &Event) -> bool {
+        matches!(event, Event::Close)
+    }
+
+    fn has_focus(&self, cx: &WindowContext) -> bool {
+        self.pane.read(cx).has_focus()
+    }
+
+    fn is_focus_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Focus)
+    }
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedTerminalPanel {
+    items: Vec<usize>,
+    active_item_id: Option<usize>,
+    width: Option<f32>,
+    height: Option<f32>,
+}

crates/terminal_view/src/terminal_view.rs 🔗

@@ -1,6 +1,6 @@
 mod persistence;
-pub mod terminal_button;
 pub mod terminal_element;
+pub mod terminal_panel;
 
 use crate::{persistence::TERMINAL_DB, terminal_element::TerminalElement};
 use context_menu::{ContextMenu, ContextMenuItem};
@@ -63,6 +63,7 @@ actions!(
 impl_actions!(terminal, [SendText, SendKeystroke]);
 
 pub fn init(cx: &mut AppContext) {
+    terminal_panel::init(cx);
     terminal::init(cx);
 
     cx.add_action(TerminalView::deploy);

crates/text/src/text.rs 🔗

@@ -1783,6 +1783,19 @@ impl BufferSnapshot {
     where
         D: 'a + TextDimension,
         A: 'a + IntoIterator<Item = &'a Anchor>,
+    {
+        let anchors = anchors.into_iter();
+        self.summaries_for_anchors_with_payload::<D, _, ()>(anchors.map(|a| (a, ())))
+            .map(|d| d.0)
+    }
+
+    pub fn summaries_for_anchors_with_payload<'a, D, A, T>(
+        &'a self,
+        anchors: A,
+    ) -> impl 'a + Iterator<Item = (D, T)>
+    where
+        D: 'a + TextDimension,
+        A: 'a + IntoIterator<Item = (&'a Anchor, T)>,
     {
         let anchors = anchors.into_iter();
         let mut insertion_cursor = self.insertions.cursor::<InsertionFragmentKey>();
@@ -1790,11 +1803,11 @@ impl BufferSnapshot {
         let mut text_cursor = self.visible_text.cursor(0);
         let mut position = D::default();
 
-        anchors.map(move |anchor| {
+        anchors.map(move |(anchor, payload)| {
             if *anchor == Anchor::MIN {
-                return D::default();
+                return (D::default(), payload);
             } else if *anchor == Anchor::MAX {
-                return D::from_text_summary(&self.visible_text.summary());
+                return (D::from_text_summary(&self.visible_text.summary()), payload);
             }
 
             let anchor_key = InsertionFragmentKey {
@@ -1825,7 +1838,7 @@ impl BufferSnapshot {
             }
 
             position.add_assign(&text_cursor.summary(fragment_offset));
-            position.clone()
+            (position.clone(), payload)
         })
     }
 

crates/theme/src/theme.rs 🔗

@@ -82,19 +82,20 @@ pub struct Workspace {
     pub pane_divider: Border,
     pub leader_border_opacity: f32,
     pub leader_border_width: f32,
-    pub sidebar: Sidebar,
+    pub dock: Dock,
     pub status_bar: StatusBar,
     pub toolbar: Toolbar,
     pub breadcrumb_height: f32,
     pub breadcrumbs: Interactive<ContainedText>,
     pub disconnected_overlay: ContainedText,
     pub modal: ContainerStyle,
+    pub zoomed_foreground: ContainerStyle,
+    pub zoomed_background: ContainerStyle,
     pub notification: ContainerStyle,
     pub notifications: Notifications,
     pub joining_project_avatar: ImageStyle,
     pub joining_project_message: ContainedText,
     pub external_location_message: ContainedText,
-    pub dock: Dock,
     pub drop_target_overlay_color: Color,
 }
 
@@ -317,15 +318,6 @@ pub struct Toolbar {
     pub nav_button: Interactive<IconButton>,
 }
 
-#[derive(Clone, Deserialize, Default)]
-pub struct Dock {
-    pub initial_size_right: f32,
-    pub initial_size_bottom: f32,
-    pub wash_color: Color,
-    pub panel: ContainerStyle,
-    pub maximized: ContainerStyle,
-}
-
 #[derive(Clone, Deserialize, Default)]
 pub struct Notifications {
     #[serde(flatten)]
@@ -369,17 +361,17 @@ pub struct StatusBar {
     pub auto_update_progress_message: TextStyle,
     pub auto_update_done_message: TextStyle,
     pub lsp_status: Interactive<StatusBarLspStatus>,
-    pub sidebar_buttons: StatusBarSidebarButtons,
+    pub panel_buttons: StatusBarPanelButtons,
     pub diagnostic_summary: Interactive<StatusBarDiagnosticSummary>,
     pub diagnostic_message: Interactive<ContainedText>,
 }
 
 #[derive(Deserialize, Default)]
-pub struct StatusBarSidebarButtons {
+pub struct StatusBarPanelButtons {
     pub group_left: ContainerStyle,
+    pub group_bottom: ContainerStyle,
     pub group_right: ContainerStyle,
-    pub item: Interactive<SidebarItem>,
-    pub badge: ContainerStyle,
+    pub button: Interactive<PanelButton>,
 }
 
 #[derive(Deserialize, Default)]
@@ -409,14 +401,14 @@ pub struct StatusBarLspStatus {
 }
 
 #[derive(Deserialize, Default)]
-pub struct Sidebar {
-    pub initial_size: f32,
-    #[serde(flatten)]
-    pub container: ContainerStyle,
+pub struct Dock {
+    pub left: ContainerStyle,
+    pub bottom: ContainerStyle,
+    pub right: ContainerStyle,
 }
 
 #[derive(Clone, Deserialize, Default)]
-pub struct SidebarItem {
+pub struct PanelButton {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub icon_color: Color,
@@ -446,6 +438,19 @@ pub struct ProjectPanelEntry {
     pub icon_color: Color,
     pub icon_size: f32,
     pub icon_spacing: f32,
+    pub status: EntryStatus,
+}
+
+#[derive(Clone, Debug, Deserialize, Default)]
+pub struct EntryStatus {
+    pub git: GitProjectStatus,
+}
+
+#[derive(Clone, Debug, Deserialize, Default)]
+pub struct GitProjectStatus {
+    pub modified: Color,
+    pub inserted: Color,
+    pub conflict: Color,
 }
 
 #[derive(Clone, Debug, Deserialize, Default)]
@@ -670,6 +675,14 @@ pub struct Scrollbar {
     pub thumb: ContainerStyle,
     pub width: f32,
     pub min_height_factor: f32,
+    pub git: GitDiffColors,
+}
+
+#[derive(Clone, Deserialize, Default)]
+pub struct GitDiffColors {
+    pub inserted: Color,
+    pub modified: Color,
+    pub deleted: Color,
 }
 
 #[derive(Clone, Deserialize, Default)]

crates/theme/src/ui.rs 🔗

@@ -1,10 +1,9 @@
 use std::borrow::Cow;
 
-use fs::repository::GitFileStatus;
 use gpui::{
     color::Color,
     elements::{
-        ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label, LabelStyle,
+        ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label,
         MouseEventHandler, ParentElement, Stack, Svg,
     },
     fonts::TextStyle,
@@ -12,11 +11,11 @@ use gpui::{
     platform,
     platform::MouseButton,
     scene::MouseClick,
-    Action, AnyElement, Element, EventContext, MouseState, View, ViewContext,
+    Action, Element, EventContext, MouseState, View, ViewContext,
 };
 use serde::Deserialize;
 
-use crate::{ContainedText, Interactive, Theme};
+use crate::{ContainedText, Interactive};
 
 #[derive(Clone, Deserialize, Default)]
 pub struct CheckboxStyle {
@@ -253,53 +252,3 @@ where
         .constrained()
         .with_height(style.dimensions().y())
 }
-
-pub struct FileName {
-    filename: String,
-    git_status: Option<GitFileStatus>,
-    style: FileNameStyle,
-}
-
-pub struct FileNameStyle {
-    template_style: LabelStyle,
-    git_inserted: Color,
-    git_modified: Color,
-    git_deleted: Color,
-}
-
-impl FileName {
-    pub fn new(filename: String, git_status: Option<GitFileStatus>, style: FileNameStyle) -> Self {
-        FileName {
-            filename,
-            git_status,
-            style,
-        }
-    }
-
-    pub fn style<I: Into<LabelStyle>>(style: I, theme: &Theme) -> FileNameStyle {
-        FileNameStyle {
-            template_style: style.into(),
-            git_inserted: theme.editor.diff.inserted,
-            git_modified: theme.editor.diff.modified,
-            git_deleted: theme.editor.diff.deleted,
-        }
-    }
-}
-
-impl<V: View> gpui::elements::Component<V> for FileName {
-    fn render(&self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
-        // Prepare colors for git statuses
-        let mut filename_text_style = self.style.template_style.text.clone();
-        filename_text_style.color = self
-            .git_status
-            .as_ref()
-            .map(|status| match status {
-                GitFileStatus::Added => self.style.git_inserted,
-                GitFileStatus::Modified => self.style.git_modified,
-                GitFileStatus::Conflict => self.style.git_deleted,
-            })
-            .unwrap_or(self.style.template_style.text.color);
-
-        Label::new(self.filename.clone(), filename_text_style).into_any()
-    }
-}

crates/theme_selector/Cargo.toml 🔗

@@ -23,3 +23,6 @@ log.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
 smol.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/util/src/channel.rs 🔗

@@ -3,15 +3,12 @@ use std::env;
 use lazy_static::lazy_static;
 
 lazy_static! {
-    // TODO: Put this back!
-    pub static ref RELEASE_CHANNEL_NAME: String = env::var("ZED_RELEASE_CHANNEL")
-        .unwrap_or_else(|_| include_str!("../../zed/RELEASE_CHANNEL").to_string());
-    // pub static ref RELEASE_CHANNEL_NAME: String = if cfg!(debug_assertions) {
-    //     env::var("ZED_RELEASE_CHANNEL")
-    //         .unwrap_or_else(|_| include_str!("../../zed/RELEASE_CHANNEL").to_string())
-    // } else {
-    //     include_str!("../../zed/RELEASE_CHANNEL").to_string()
-    // };
+    pub static ref RELEASE_CHANNEL_NAME: String = if cfg!(debug_assertions) {
+        env::var("ZED_RELEASE_CHANNEL")
+            .unwrap_or_else(|_| include_str!("../../zed/RELEASE_CHANNEL").to_string())
+    } else {
+        include_str!("../../zed/RELEASE_CHANNEL").to_string()
+    };
     pub static ref RELEASE_CHANNEL: ReleaseChannel = match RELEASE_CHANNEL_NAME.as_str() {
         "dev" => ReleaseChannel::Dev,
         "preview" => ReleaseChannel::Preview,

crates/welcome/Cargo.toml 🔗

@@ -30,3 +30,6 @@ anyhow.workspace = true
 log.workspace = true
 schemars.workspace = true
 serde.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/welcome/src/welcome.rs 🔗

@@ -11,7 +11,7 @@ use gpui::{
 use settings::{update_settings_file, SettingsStore};
 use std::{borrow::Cow, sync::Arc};
 use workspace::{
-    item::Item, open_new, sidebar::SidebarSide, AppState, PaneBackdrop, Welcome, Workspace,
+    dock::DockPosition, item::Item, open_new, AppState, PaneBackdrop, Welcome, Workspace,
     WorkspaceId,
 };
 
@@ -32,7 +32,7 @@ pub fn init(cx: &mut AppContext) {
 
 pub fn show_welcome_experience(app_state: &Arc<AppState>, cx: &mut AppContext) {
     open_new(&app_state, cx, |workspace, cx| {
-        workspace.toggle_sidebar(SidebarSide::Left, cx);
+        workspace.toggle_dock(DockPosition::Left, false, cx);
         let welcome_page = cx.add_view(|cx| WelcomePage::new(workspace, cx));
         workspace.add_item_to_center(Box::new(welcome_page.clone()), cx);
         cx.focus(&welcome_page);

crates/workspace/src/dock.rs 🔗

@@ -1,826 +1,699 @@
-mod toggle_dock_button;
-
-use crate::{
-    sidebar::SidebarSide, BackgroundActions, DockAnchor, ItemHandle, Pane, Workspace,
-    WorkspaceSettings,
-};
-use collections::HashMap;
+use crate::{StatusItemView, Workspace};
+use context_menu::{ContextMenu, ContextMenuItem};
 use gpui::{
-    actions,
-    elements::{ChildView, Empty, MouseEventHandler, ParentElement, Side, Stack},
-    geometry::vector::Vector2F,
-    platform::{CursorStyle, MouseButton},
-    AnyElement, AppContext, Border, Element, SizeConstraint, ViewContext, ViewHandle,
+    elements::*, platform::CursorStyle, platform::MouseButton, Action, AnyViewHandle, AppContext,
+    Axis, Entity, Subscription, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
-use std::sync::{atomic::AtomicUsize, Arc};
-use theme::Theme;
-pub use toggle_dock_button::ToggleDockButton;
-
-actions!(
-    dock,
-    [
-        FocusDock,
-        HideDock,
-        AnchorDockRight,
-        AnchorDockBottom,
-        ExpandDock,
-        AddTabToDock,
-        RemoveTabFromDock,
-    ]
-);
-
-pub fn init(cx: &mut AppContext) {
-    cx.add_action(Dock::focus_dock);
-    cx.add_action(Dock::hide_dock);
-    cx.add_action(
-        |workspace: &mut Workspace, _: &AnchorDockRight, cx: &mut ViewContext<Workspace>| {
-            Dock::move_dock(workspace, DockAnchor::Right, true, cx);
-        },
-    );
-    cx.add_action(
-        |workspace: &mut Workspace, _: &AnchorDockBottom, cx: &mut ViewContext<Workspace>| {
-            Dock::move_dock(workspace, DockAnchor::Bottom, true, cx)
-        },
-    );
-    cx.add_action(
-        |workspace: &mut Workspace, _: &ExpandDock, cx: &mut ViewContext<Workspace>| {
-            Dock::move_dock(workspace, DockAnchor::Expanded, true, cx)
-        },
-    );
-    cx.add_action(
-        |workspace: &mut Workspace, _: &AddTabToDock, cx: &mut ViewContext<Workspace>| {
-            if let Some(active_item) = workspace.active_item(cx) {
-                let item_id = active_item.id();
-
-                let from = workspace.active_pane();
-                let to = workspace.dock_pane();
-                if from.id() == to.id() {
-                    return;
-                }
+use serde::Deserialize;
+use std::rc::Rc;
+use theme::ThemeSettings;
+
+pub trait Panel: View {
+    fn position(&self, cx: &WindowContext) -> DockPosition;
+    fn position_is_valid(&self, position: DockPosition) -> bool;
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>);
+    fn size(&self, cx: &WindowContext) -> f32;
+    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>);
+    fn icon_path(&self) -> &'static str;
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>);
+    fn icon_label(&self, _: &WindowContext) -> Option<String> {
+        None
+    }
+    fn should_change_position_on_event(_: &Self::Event) -> bool;
+    fn should_zoom_in_on_event(_: &Self::Event) -> bool;
+    fn should_zoom_out_on_event(_: &Self::Event) -> bool;
+    fn is_zoomed(&self, cx: &WindowContext) -> bool;
+    fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>);
+    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>);
+    fn should_activate_on_event(_: &Self::Event) -> bool;
+    fn should_close_on_event(_: &Self::Event) -> bool;
+    fn has_focus(&self, cx: &WindowContext) -> bool;
+    fn is_focus_event(_: &Self::Event) -> bool;
+}
 
-                let destination_index = to.read(cx).items_len() + 1;
+pub trait PanelHandle {
+    fn id(&self) -> usize;
+    fn position(&self, cx: &WindowContext) -> DockPosition;
+    fn position_is_valid(&self, position: DockPosition, cx: &WindowContext) -> bool;
+    fn set_position(&self, position: DockPosition, cx: &mut WindowContext);
+    fn is_zoomed(&self, cx: &WindowContext) -> bool;
+    fn set_zoomed(&self, zoomed: bool, cx: &mut WindowContext);
+    fn set_active(&self, active: bool, cx: &mut WindowContext);
+    fn size(&self, cx: &WindowContext) -> f32;
+    fn set_size(&self, size: f32, cx: &mut WindowContext);
+    fn icon_path(&self, cx: &WindowContext) -> &'static str;
+    fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option<Box<dyn Action>>);
+    fn icon_label(&self, cx: &WindowContext) -> Option<String>;
+    fn has_focus(&self, cx: &WindowContext) -> bool;
+    fn as_any(&self) -> &AnyViewHandle;
+}
 
-                Pane::move_item(
-                    workspace,
-                    from.clone(),
-                    to.clone(),
-                    item_id,
-                    destination_index,
-                    cx,
-                );
-            }
-        },
-    );
-    cx.add_action(
-        |workspace: &mut Workspace, _: &RemoveTabFromDock, cx: &mut ViewContext<Workspace>| {
-            if let Some(active_item) = workspace.active_item(cx) {
-                let item_id = active_item.id();
-
-                let from = workspace.dock_pane();
-                let to = workspace
-                    .last_active_center_pane
-                    .as_ref()
-                    .and_then(|pane| pane.upgrade(cx))
-                    .unwrap_or_else(|| {
-                        workspace
-                            .panes
-                            .first()
-                            .expect("There must be a pane")
-                            .clone()
-                    });
-
-                if from.id() == to.id() {
-                    return;
-                }
+impl<T> PanelHandle for ViewHandle<T>
+where
+    T: Panel,
+{
+    fn id(&self) -> usize {
+        self.id()
+    }
 
-                let destination_index = to.read(cx).items_len() + 1;
+    fn position(&self, cx: &WindowContext) -> DockPosition {
+        self.read(cx).position(cx)
+    }
 
-                Pane::move_item(
-                    workspace,
-                    from.clone(),
-                    to.clone(),
-                    item_id,
-                    destination_index,
-                    cx,
-                );
-            }
-        },
-    );
-}
+    fn position_is_valid(&self, position: DockPosition, cx: &WindowContext) -> bool {
+        self.read(cx).position_is_valid(position)
+    }
 
-#[derive(Copy, Clone, PartialEq, Eq, Debug)]
-pub enum DockPosition {
-    Shown(DockAnchor),
-    Hidden(DockAnchor),
-}
+    fn set_position(&self, position: DockPosition, cx: &mut WindowContext) {
+        self.update(cx, |this, cx| this.set_position(position, cx))
+    }
 
-impl Default for DockPosition {
-    fn default() -> Self {
-        DockPosition::Hidden(Default::default())
+    fn size(&self, cx: &WindowContext) -> f32 {
+        self.read(cx).size(cx)
+    }
+
+    fn set_size(&self, size: f32, cx: &mut WindowContext) {
+        self.update(cx, |this, cx| this.set_size(size, cx))
+    }
+
+    fn is_zoomed(&self, cx: &WindowContext) -> bool {
+        self.read(cx).is_zoomed(cx)
+    }
+
+    fn set_zoomed(&self, zoomed: bool, cx: &mut WindowContext) {
+        self.update(cx, |this, cx| this.set_zoomed(zoomed, cx))
+    }
+
+    fn set_active(&self, active: bool, cx: &mut WindowContext) {
+        self.update(cx, |this, cx| this.set_active(active, cx))
+    }
+
+    fn icon_path(&self, cx: &WindowContext) -> &'static str {
+        self.read(cx).icon_path()
+    }
+
+    fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option<Box<dyn Action>>) {
+        self.read(cx).icon_tooltip()
+    }
+
+    fn icon_label(&self, cx: &WindowContext) -> Option<String> {
+        self.read(cx).icon_label(cx)
+    }
+
+    fn has_focus(&self, cx: &WindowContext) -> bool {
+        self.read(cx).has_focus(cx)
     }
-}
 
-pub fn icon_for_dock_anchor(anchor: DockAnchor) -> &'static str {
-    match anchor {
-        DockAnchor::Right => "icons/dock_right_12.svg",
-        DockAnchor::Bottom => "icons/dock_bottom_12.svg",
-        DockAnchor::Expanded => "icons/dock_modal_12.svg",
+    fn as_any(&self) -> &AnyViewHandle {
+        self
     }
 }
 
-impl DockPosition {
-    pub fn is_visible(&self) -> bool {
-        match self {
-            DockPosition::Shown(_) => true,
-            DockPosition::Hidden(_) => false,
-        }
+impl From<&dyn PanelHandle> for AnyViewHandle {
+    fn from(val: &dyn PanelHandle) -> Self {
+        val.as_any().clone()
     }
+}
+
+pub struct Dock {
+    position: DockPosition,
+    panel_entries: Vec<PanelEntry>,
+    is_open: bool,
+    active_panel_index: usize,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
+pub enum DockPosition {
+    Left,
+    Bottom,
+    Right,
+}
 
-    pub fn anchor(&self) -> DockAnchor {
+impl DockPosition {
+    fn to_label(&self) -> &'static str {
         match self {
-            DockPosition::Shown(anchor) | DockPosition::Hidden(anchor) => *anchor,
+            Self::Left => "left",
+            Self::Bottom => "bottom",
+            Self::Right => "right",
         }
     }
 
-    fn hide(self) -> Self {
+    fn to_resize_handle_side(self) -> HandleSide {
         match self {
-            DockPosition::Shown(anchor) => DockPosition::Hidden(anchor),
-            DockPosition::Hidden(_) => self,
+            Self::Left => HandleSide::Right,
+            Self::Bottom => HandleSide::Top,
+            Self::Right => HandleSide::Left,
         }
     }
 
-    fn show(self) -> Self {
+    pub fn axis(&self) -> Axis {
         match self {
-            DockPosition::Hidden(anchor) => DockPosition::Shown(anchor),
-            DockPosition::Shown(_) => self,
+            Self::Left | Self::Right => Axis::Horizontal,
+            Self::Bottom => Axis::Vertical,
         }
     }
 }
 
-pub type DockDefaultItemFactory =
-    fn(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<Box<dyn ItemHandle>>;
+struct PanelEntry {
+    panel: Rc<dyn PanelHandle>,
+    context_menu: ViewHandle<ContextMenu>,
+    _subscriptions: [Subscription; 2],
+}
 
-pub struct Dock {
-    position: DockPosition,
-    panel_sizes: HashMap<DockAnchor, f32>,
-    pane: ViewHandle<Pane>,
-    default_item_factory: DockDefaultItemFactory,
+pub struct PanelButtons {
+    dock: ViewHandle<Dock>,
+    workspace: WeakViewHandle<Workspace>,
 }
 
 impl Dock {
-    pub fn new(
-        default_item_factory: DockDefaultItemFactory,
-        background_actions: BackgroundActions,
-        pane_history_timestamp: Arc<AtomicUsize>,
-        cx: &mut ViewContext<Workspace>,
-    ) -> Self {
-        let position =
-            DockPosition::Hidden(settings::get::<WorkspaceSettings>(cx).default_dock_anchor);
-        let workspace = cx.weak_handle();
-        let pane = cx.add_view(|cx| {
-            Pane::new(
-                workspace,
-                Some(position.anchor()),
-                background_actions,
-                pane_history_timestamp,
-                cx,
-            )
-        });
-        pane.update(cx, |pane, cx| {
-            pane.set_active(false, cx);
-        });
-        cx.subscribe(&pane, Workspace::handle_pane_event).detach();
-
+    pub fn new(position: DockPosition) -> Self {
         Self {
-            pane,
-            panel_sizes: Default::default(),
             position,
-            default_item_factory,
+            panel_entries: Default::default(),
+            active_panel_index: 0,
+            is_open: false,
         }
     }
 
-    pub fn pane(&self) -> &ViewHandle<Pane> {
-        &self.pane
+    pub fn is_open(&self) -> bool {
+        self.is_open
     }
 
-    pub fn visible_pane(&self) -> Option<&ViewHandle<Pane>> {
-        self.position.is_visible().then(|| self.pane())
+    pub fn has_focus(&self, cx: &WindowContext) -> bool {
+        self.active_panel()
+            .map_or(false, |panel| panel.has_focus(cx))
     }
 
-    pub fn is_anchored_at(&self, anchor: DockAnchor) -> bool {
-        self.position.is_visible() && self.position.anchor() == anchor
+    pub fn panel_index_for_type<T: Panel>(&self) -> Option<usize> {
+        self.panel_entries
+            .iter()
+            .position(|entry| entry.panel.as_any().is::<T>())
     }
 
-    pub(crate) fn set_dock_position(
-        workspace: &mut Workspace,
-        new_position: DockPosition,
-        focus: bool,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        workspace.dock.position = new_position;
-        // Tell the pane about the new anchor position
-        workspace.dock.pane.update(cx, |pane, cx| {
-            pane.set_docked(Some(new_position.anchor()), cx)
-        });
+    pub fn panel_index_for_ui_name(&self, ui_name: &str, cx: &AppContext) -> Option<usize> {
+        self.panel_entries.iter().position(|entry| {
+            let panel = entry.panel.as_any();
+            cx.view_ui_name(panel.window_id(), panel.id()) == Some(ui_name)
+        })
+    }
 
-        if workspace.dock.position.is_visible() {
-            // Close the right sidebar if the dock is on the right side and the right sidebar is open
-            if workspace.dock.position.anchor() == DockAnchor::Right {
-                if workspace.right_sidebar().read(cx).is_open() {
-                    workspace.toggle_sidebar(SidebarSide::Right, cx);
-                }
+    pub fn active_panel_index(&self) -> usize {
+        self.active_panel_index
+    }
+
+    pub fn set_open(&mut self, open: bool, cx: &mut ViewContext<Self>) {
+        if open != self.is_open {
+            self.is_open = open;
+            if let Some(active_panel) = self.panel_entries.get(self.active_panel_index) {
+                active_panel.panel.set_active(open, cx);
             }
 
-            // Ensure that the pane has at least one item or construct a default item to put in it
-            let pane = workspace.dock.pane.clone();
-            if pane.read(cx).items().next().is_none() {
-                if let Some(item_to_add) = (workspace.dock.default_item_factory)(workspace, cx) {
-                    Pane::add_item(workspace, &pane, item_to_add, focus, focus, None, cx);
-                } else {
-                    workspace.dock.position = workspace.dock.position.hide();
-                }
-            } else {
-                if focus {
-                    cx.focus(&pane);
+            cx.notify();
+        }
+    }
+
+    pub fn toggle_open(&mut self, cx: &mut ViewContext<Self>) {
+        self.set_open(!self.is_open, cx);
+        cx.notify();
+    }
+
+    pub fn set_panel_zoomed(
+        &mut self,
+        panel: &AnyViewHandle,
+        zoomed: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        for entry in &mut self.panel_entries {
+            if entry.panel.as_any() == panel {
+                if zoomed != entry.panel.is_zoomed(cx) {
+                    entry.panel.set_zoomed(zoomed, cx);
                 }
-            }
-        } else if let Some(last_active_center_pane) = workspace
-            .last_active_center_pane
-            .as_ref()
-            .and_then(|pane| pane.upgrade(cx))
-        {
-            if focus {
-                cx.focus(&last_active_center_pane);
+            } else if entry.panel.is_zoomed(cx) {
+                entry.panel.set_zoomed(false, cx);
             }
         }
-        cx.emit(crate::Event::DockAnchorChanged);
-        workspace.serialize_workspace(cx);
+
         cx.notify();
     }
 
-    pub fn hide(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
-        Self::set_dock_position(workspace, workspace.dock.position.hide(), true, cx);
+    pub fn zoom_out(&mut self, cx: &mut ViewContext<Self>) {
+        for entry in &mut self.panel_entries {
+            if entry.panel.is_zoomed(cx) {
+                entry.panel.set_zoomed(false, cx);
+            }
+        }
     }
 
-    pub fn show(workspace: &mut Workspace, focus: bool, cx: &mut ViewContext<Workspace>) {
-        Self::set_dock_position(workspace, workspace.dock.position.show(), focus, cx);
+    pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
+        let subscriptions = [
+            cx.observe(&panel, |_, _, cx| cx.notify()),
+            cx.subscribe(&panel, |this, panel, event, cx| {
+                if T::should_activate_on_event(event) {
+                    if let Some(ix) = this
+                        .panel_entries
+                        .iter()
+                        .position(|entry| entry.panel.id() == panel.id())
+                    {
+                        this.set_open(true, cx);
+                        this.activate_panel(ix, cx);
+                        cx.focus(&panel);
+                    }
+                } else if T::should_close_on_event(event)
+                    && this.active_panel().map_or(false, |p| p.id() == panel.id())
+                {
+                    this.set_open(false, cx);
+                }
+            }),
+        ];
+
+        let dock_view_id = cx.view_id();
+        self.panel_entries.push(PanelEntry {
+            panel: Rc::new(panel),
+            context_menu: cx.add_view(|cx| {
+                let mut menu = ContextMenu::new(dock_view_id, cx);
+                menu.set_position_mode(OverlayPositionMode::Local);
+                menu
+            }),
+            _subscriptions: subscriptions,
+        });
+        cx.notify()
     }
 
-    pub fn hide_on_sidebar_shown(
-        workspace: &mut Workspace,
-        sidebar_side: SidebarSide,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        if (sidebar_side == SidebarSide::Right && workspace.dock.is_anchored_at(DockAnchor::Right))
-            || workspace.dock.is_anchored_at(DockAnchor::Expanded)
+    pub fn remove_panel<T: Panel>(&mut self, panel: &ViewHandle<T>, cx: &mut ViewContext<Self>) {
+        if let Some(panel_ix) = self
+            .panel_entries
+            .iter()
+            .position(|entry| entry.panel.id() == panel.id())
         {
-            Self::hide(workspace, cx);
+            if panel_ix == self.active_panel_index {
+                self.active_panel_index = 0;
+                self.set_open(false, cx);
+            } else if panel_ix < self.active_panel_index {
+                self.active_panel_index -= 1;
+            }
+            self.panel_entries.remove(panel_ix);
+            cx.notify();
         }
     }
 
-    pub fn focus_dock(workspace: &mut Workspace, _: &FocusDock, cx: &mut ViewContext<Workspace>) {
-        Self::set_dock_position(workspace, workspace.dock.position.show(), true, cx);
+    pub fn panels_len(&self) -> usize {
+        self.panel_entries.len()
     }
 
-    pub fn hide_dock(workspace: &mut Workspace, _: &HideDock, cx: &mut ViewContext<Workspace>) {
-        Self::set_dock_position(workspace, workspace.dock.position.hide(), true, cx);
+    pub fn activate_panel(&mut self, panel_ix: usize, cx: &mut ViewContext<Self>) {
+        if panel_ix != self.active_panel_index {
+            if let Some(active_panel) = self.panel_entries.get(self.active_panel_index) {
+                active_panel.panel.set_active(false, cx);
+            }
+
+            self.active_panel_index = panel_ix;
+            if let Some(active_panel) = self.panel_entries.get(self.active_panel_index) {
+                active_panel.panel.set_active(true, cx);
+            }
+
+            cx.notify();
+        }
     }
 
-    pub fn move_dock(
-        workspace: &mut Workspace,
-        new_anchor: DockAnchor,
-        focus: bool,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        Self::set_dock_position(workspace, DockPosition::Shown(new_anchor), focus, cx);
-    }
-
-    pub fn render(
-        &self,
-        theme: &Theme,
-        anchor: DockAnchor,
-        cx: &mut ViewContext<Workspace>,
-    ) -> Option<AnyElement<Workspace>> {
-        let style = &theme.workspace.dock;
-
-        self.position
-            .is_visible()
-            .then(|| self.position.anchor())
-            .filter(|current_anchor| *current_anchor == anchor)
-            .map(|anchor| match anchor {
-                DockAnchor::Bottom | DockAnchor::Right => {
-                    let mut panel_style = style.panel.clone();
-                    let (resize_side, initial_size) = if anchor == DockAnchor::Bottom {
-                        panel_style.border = Border {
-                            top: true,
-                            bottom: false,
-                            left: false,
-                            right: false,
-                            ..panel_style.border
-                        };
-
-                        (Side::Top, style.initial_size_bottom)
-                    } else {
-                        panel_style.border = Border {
-                            top: false,
-                            bottom: false,
-                            left: true,
-                            right: false,
-                            ..panel_style.border
-                        };
-                        (Side::Left, style.initial_size_right)
-                    };
-
-                    enum DockResizeHandle {}
-
-                    let resizable = ChildView::new(&self.pane, cx)
-                        .contained()
-                        .with_style(panel_style)
-                        .with_resize_handle::<DockResizeHandle>(
-                            resize_side as usize,
-                            resize_side,
-                            4.,
-                            self.panel_sizes
-                                .get(&anchor)
-                                .copied()
-                                .unwrap_or(initial_size),
-                            cx,
-                        );
-
-                    let size = resizable.current_size();
-                    cx.defer(move |workspace, _| {
-                        workspace.dock.panel_sizes.insert(anchor, size);
-                    });
-
-                    if anchor == DockAnchor::Right {
-                        resizable.constrained().dynamically(|constraint, _, cx| {
-                            SizeConstraint::new(
-                                Vector2F::new(20., constraint.min.y()),
-                                Vector2F::new(cx.window_size().x() * 0.8, constraint.max.y()),
-                            )
-                        })
-                    } else {
-                        resizable.constrained().dynamically(|constraint, _, cx| {
-                            SizeConstraint::new(
-                                Vector2F::new(constraint.min.x(), 50.),
-                                Vector2F::new(constraint.max.x(), cx.window_size().y() * 0.8),
-                            )
-                        })
-                    }
-                    .into_any()
-                }
-                DockAnchor::Expanded => {
-                    enum ExpandedDockWash {}
-                    enum ExpandedDockPane {}
+    pub fn active_panel(&self) -> Option<&Rc<dyn PanelHandle>> {
+        let entry = self.active_entry()?;
+        Some(&entry.panel)
+    }
+
+    fn active_entry(&self) -> Option<&PanelEntry> {
+        if self.is_open {
+            self.panel_entries.get(self.active_panel_index)
+        } else {
+            None
+        }
+    }
+
+    pub fn zoomed_panel(&self, cx: &WindowContext) -> Option<Rc<dyn PanelHandle>> {
+        let entry = self.active_entry()?;
+        if entry.panel.is_zoomed(cx) {
+            Some(entry.panel.clone())
+        } else {
+            None
+        }
+    }
+
+    pub fn panel_size(&self, panel: &dyn PanelHandle, cx: &WindowContext) -> Option<f32> {
+        self.panel_entries
+            .iter()
+            .find(|entry| entry.panel.id() == panel.id())
+            .map(|entry| entry.panel.size(cx))
+    }
+
+    pub fn active_panel_size(&self, cx: &WindowContext) -> Option<f32> {
+        if self.is_open {
+            self.panel_entries
+                .get(self.active_panel_index)
+                .map(|entry| entry.panel.size(cx))
+        } else {
+            None
+        }
+    }
+
+    pub fn resize_active_panel(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+        if let Some(entry) = self.panel_entries.get_mut(self.active_panel_index) {
+            entry.panel.set_size(size, cx);
+            cx.notify();
+        }
+    }
+
+    pub fn render_placeholder(&self, cx: &WindowContext) -> AnyElement<Workspace> {
+        if let Some(active_entry) = self.active_entry() {
+            Empty::new()
+                .into_any()
+                .contained()
+                .with_style(self.style(cx))
+                .resizable(
+                    self.position.to_resize_handle_side(),
+                    active_entry.panel.size(cx),
+                    |_, _, _| {},
+                )
+                .into_any()
+        } else {
+            Empty::new().into_any()
+        }
+    }
+
+    fn style(&self, cx: &WindowContext) -> ContainerStyle {
+        let theme = &settings::get::<ThemeSettings>(cx).theme;
+        let style = match self.position {
+            DockPosition::Left => theme.workspace.dock.left,
+            DockPosition::Bottom => theme.workspace.dock.bottom,
+            DockPosition::Right => theme.workspace.dock.right,
+        };
+        style
+    }
+}
+
+impl Entity for Dock {
+    type Event = ();
+}
+
+impl View for Dock {
+    fn ui_name() -> &'static str {
+        "Dock"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        if let Some(active_entry) = self.active_entry() {
+            let style = self.style(cx);
+            ChildView::new(active_entry.panel.as_any(), cx)
+                .contained()
+                .with_style(style)
+                .resizable(
+                    self.position.to_resize_handle_side(),
+                    active_entry.panel.size(cx),
+                    |dock: &mut Self, size, cx| dock.resize_active_panel(size, cx),
+                )
+                .into_any()
+        } else {
+            Empty::new().into_any()
+        }
+    }
+
+    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            if let Some(active_entry) = self.active_entry() {
+                cx.focus(active_entry.panel.as_any());
+            } else {
+                cx.focus_parent();
+            }
+        }
+    }
+}
+
+impl PanelButtons {
+    pub fn new(
+        dock: ViewHandle<Dock>,
+        workspace: WeakViewHandle<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        cx.observe(&dock, |_, _, cx| cx.notify()).detach();
+        Self { dock, workspace }
+    }
+}
+
+impl Entity for PanelButtons {
+    type Event = ();
+}
+
+impl View for PanelButtons {
+    fn ui_name() -> &'static str {
+        "PanelButtons"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = &settings::get::<ThemeSettings>(cx).theme;
+        let tooltip_style = theme.tooltip.clone();
+        let theme = &theme.workspace.status_bar.panel_buttons;
+        let button_style = theme.button.clone();
+        let dock = self.dock.read(cx);
+        let active_ix = dock.active_panel_index;
+        let is_open = dock.is_open;
+        let dock_position = dock.position;
+        let group_style = match dock_position {
+            DockPosition::Left => theme.group_left,
+            DockPosition::Bottom => theme.group_bottom,
+            DockPosition::Right => theme.group_right,
+        };
+        let menu_corner = match dock_position {
+            DockPosition::Left => AnchorCorner::BottomLeft,
+            DockPosition::Bottom | DockPosition::Right => AnchorCorner::BottomRight,
+        };
+
+        let panels = dock
+            .panel_entries
+            .iter()
+            .map(|item| (item.panel.clone(), item.context_menu.clone()))
+            .collect::<Vec<_>>();
+        Flex::row()
+            .with_children(panels.into_iter().enumerate().map(
+                |(panel_ix, (view, context_menu))| {
+                    let (tooltip, tooltip_action) = view.icon_tooltip(cx);
                     Stack::new()
                         .with_child(
-                            // Render wash under the dock which when clicked hides it
-                            MouseEventHandler::<ExpandedDockWash, _>::new(0, cx, |_, _| {
-                                Empty::new()
+                            MouseEventHandler::<Self, _>::new(panel_ix, cx, |state, cx| {
+                                let is_active = is_open && panel_ix == active_ix;
+                                let style = button_style.style_for(state, is_active);
+                                Flex::row()
+                                    .with_child(
+                                        Svg::new(view.icon_path(cx))
+                                            .with_color(style.icon_color)
+                                            .constrained()
+                                            .with_width(style.icon_size)
+                                            .aligned(),
+                                    )
+                                    .with_children(if let Some(label) = view.icon_label(cx) {
+                                        Some(
+                                            Label::new(label, style.label.text.clone())
+                                                .contained()
+                                                .with_style(style.label.container)
+                                                .aligned(),
+                                        )
+                                    } else {
+                                        None
+                                    })
+                                    .constrained()
+                                    .with_height(style.icon_size)
                                     .contained()
-                                    .with_background_color(style.wash_color)
+                                    .with_style(style.container)
                             })
-                            .capture_all()
-                            .on_down(MouseButton::Left, |_, workspace, cx| {
-                                Dock::hide_dock(workspace, &Default::default(), cx)
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(MouseButton::Left, {
+                                move |_, this, cx| {
+                                    if let Some(workspace) = this.workspace.upgrade(cx) {
+                                        cx.window_context().defer(move |cx| {
+                                            workspace.update(cx, |workspace, cx| {
+                                                workspace.toggle_panel(dock_position, panel_ix, cx)
+                                            });
+                                        });
+                                    }
+                                }
                             })
-                            .with_cursor_style(CursorStyle::Arrow),
-                        )
-                        .with_child(
-                            MouseEventHandler::<ExpandedDockPane, _>::new(0, cx, |_state, cx| {
-                                ChildView::new(&self.pane, cx)
+                            .on_click(MouseButton::Right, {
+                                let view = view.clone();
+                                let menu = context_menu.clone();
+                                move |_, _, cx| {
+                                    const POSITIONS: [DockPosition; 3] = [
+                                        DockPosition::Left,
+                                        DockPosition::Right,
+                                        DockPosition::Bottom,
+                                    ];
+
+                                    menu.update(cx, |menu, cx| {
+                                        let items = POSITIONS
+                                            .into_iter()
+                                            .filter(|position| {
+                                                *position != dock_position
+                                                    && view.position_is_valid(*position, cx)
+                                            })
+                                            .map(|position| {
+                                                let view = view.clone();
+                                                ContextMenuItem::handler(
+                                                    format!("Dock {}", position.to_label()),
+                                                    move |cx| view.set_position(position, cx),
+                                                )
+                                            })
+                                            .collect();
+                                        menu.show(Default::default(), menu_corner, items, cx);
+                                    })
+                                }
                             })
-                            // Make sure all events directly under the dock pane
-                            // are captured
-                            .capture_all()
-                            .contained()
-                            .with_style(style.maximized),
+                            .with_tooltip::<Self>(
+                                panel_ix,
+                                tooltip,
+                                tooltip_action,
+                                tooltip_style.clone(),
+                                cx,
+                            ),
                         )
-                        .into_any()
-                }
-            })
+                        .with_child(ChildView::new(&context_menu, cx))
+                },
+            ))
+            .contained()
+            .with_style(group_style)
+            .into_any()
     }
+}
 
-    pub fn position(&self) -> DockPosition {
-        self.position
+impl StatusItemView for PanelButtons {
+    fn set_active_pane_item(
+        &mut self,
+        _: Option<&dyn crate::ItemHandle>,
+        _: &mut ViewContext<Self>,
+    ) {
     }
 }
 
 #[cfg(test)]
-mod tests {
-    use std::{
-        ops::{Deref, DerefMut},
-        path::PathBuf,
-        sync::Arc,
-    };
-
-    use gpui::{AppContext, BorrowWindowContext, TestAppContext, ViewContext, WindowContext};
-    use project::{FakeFs, Project};
-
+pub(crate) mod test {
     use super::*;
-    use crate::{
-        dock,
-        item::{self, test::TestItem},
-        persistence::model::{
-            SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
-        },
-        register_deserializable_item,
-        sidebar::Sidebar,
-        tests::init_test,
-        AppState, ItemHandle, Workspace,
-    };
-
-    pub fn default_item_factory(
-        _workspace: &mut Workspace,
-        cx: &mut ViewContext<Workspace>,
-    ) -> Option<Box<dyn ItemHandle>> {
-        Some(Box::new(cx.add_view(|_| TestItem::new())))
-    }
-
-    #[gpui::test]
-    async fn test_dock_workspace_infinite_loop(cx: &mut TestAppContext) {
-        init_test(cx);
-
-        cx.update(|cx| {
-            register_deserializable_item::<item::test::TestItem>(cx);
-        });
-
-        let serialized_workspace = SerializedWorkspace {
-            id: 0,
-            location: Vec::<PathBuf>::new().into(),
-            dock_position: dock::DockPosition::Shown(DockAnchor::Expanded),
-            center_group: SerializedPaneGroup::Pane(SerializedPane {
-                active: false,
-                children: vec![],
-            }),
-            dock_pane: SerializedPane {
-                active: true,
-                children: vec![SerializedItem {
-                    active: true,
-                    item_id: 0,
-                    kind: "TestItem".into(),
-                }],
-            },
-            left_sidebar_open: false,
-            bounds: Default::default(),
-            display: Default::default(),
-        };
-
-        let fs = FakeFs::new(cx.background());
-        let project = Project::test(fs, [], cx).await;
-
-        let (_, _workspace) = cx.add_window(|cx| {
-            Workspace::new(
-                0,
-                project.clone(),
-                Arc::new(AppState {
-                    languages: project.read(cx).languages().clone(),
-                    client: project.read(cx).client(),
-                    user_store: project.read(cx).user_store(),
-                    fs: project.read(cx).fs().clone(),
-                    build_window_options: |_, _, _| Default::default(),
-                    initialize_workspace: |_, _, _| {},
-                    dock_default_item_factory: default_item_factory,
-                    background_actions: || &[],
-                }),
-                cx,
-            )
-        });
+    use gpui::{ViewContext, WindowContext};
+
+    pub enum TestPanelEvent {
+        PositionChanged,
+        Activated,
+        Closed,
+        ZoomIn,
+        ZoomOut,
+        Focus,
+    }
 
-        cx.update(|cx| {
-            Workspace::load_workspace(_workspace.downgrade(), serialized_workspace, Vec::new(), cx)
-        })
-        .await;
-
-        cx.foreground().run_until_parked();
-        //Should terminate
-    }
-
-    #[gpui::test]
-    async fn test_dock_hides_when_pane_empty(cx: &mut TestAppContext) {
-        let mut cx = DockTestContext::new(cx).await;
-
-        // Closing the last item in the dock hides the dock
-        cx.move_dock(DockAnchor::Right);
-        let old_items = cx.dock_items();
-        assert!(!old_items.is_empty());
-        cx.close_dock_items().await;
-        cx.assert_dock_position(DockPosition::Hidden(DockAnchor::Right));
-
-        // Reopening the dock adds a new item
-        cx.move_dock(DockAnchor::Right);
-        let new_items = cx.dock_items();
-        assert!(!new_items.is_empty());
-        assert!(new_items
-            .into_iter()
-            .all(|new_item| !old_items.contains(&new_item)));
-    }
-
-    #[gpui::test]
-    async fn test_dock_panel_collisions(cx: &mut TestAppContext) {
-        let mut cx = DockTestContext::new(cx).await;
-
-        // Dock closes when expanded for either panel
-        cx.move_dock(DockAnchor::Expanded);
-        cx.open_sidebar(SidebarSide::Left);
-        cx.assert_dock_position(DockPosition::Hidden(DockAnchor::Expanded));
-        cx.close_sidebar(SidebarSide::Left);
-        cx.move_dock(DockAnchor::Expanded);
-        cx.open_sidebar(SidebarSide::Right);
-        cx.assert_dock_position(DockPosition::Hidden(DockAnchor::Expanded));
-
-        // Dock closes in the right position if the right sidebar is opened
-        cx.move_dock(DockAnchor::Right);
-        cx.open_sidebar(SidebarSide::Left);
-        cx.assert_dock_position(DockPosition::Shown(DockAnchor::Right));
-        cx.open_sidebar(SidebarSide::Right);
-        cx.assert_dock_position(DockPosition::Hidden(DockAnchor::Right));
-        cx.close_sidebar(SidebarSide::Right);
-
-        // Dock in bottom position ignores sidebars
-        cx.move_dock(DockAnchor::Bottom);
-        cx.open_sidebar(SidebarSide::Left);
-        cx.open_sidebar(SidebarSide::Right);
-        cx.assert_dock_position(DockPosition::Shown(DockAnchor::Bottom));
-
-        // Opening the dock in the right position closes the right sidebar
-        cx.move_dock(DockAnchor::Right);
-        cx.assert_sidebar_closed(SidebarSide::Right);
-    }
-
-    #[gpui::test]
-    async fn test_focusing_panes_shows_and_hides_dock(cx: &mut TestAppContext) {
-        let mut cx = DockTestContext::new(cx).await;
-
-        // Focusing an item not in the dock when expanded hides the dock
-        let center_item = cx.add_item_to_center_pane();
-        cx.move_dock(DockAnchor::Expanded);
-        let dock_item = cx
-            .dock_items()
-            .get(0)
-            .cloned()
-            .expect("Dock should have an item at this point");
-        center_item.update(&mut cx, |_, cx| cx.focus_self());
-        cx.assert_dock_position(DockPosition::Hidden(DockAnchor::Expanded));
-
-        // Focusing an item not in the dock when not expanded, leaves the dock open but inactive
-        cx.move_dock(DockAnchor::Right);
-        center_item.update(&mut cx, |_, cx| cx.focus_self());
-        cx.assert_dock_position(DockPosition::Shown(DockAnchor::Right));
-        cx.assert_dock_pane_inactive();
-        cx.assert_workspace_pane_active();
-
-        // Focusing an item in the dock activates it's pane
-        dock_item.update(&mut cx, |_, cx| cx.focus_self());
-        cx.assert_dock_position(DockPosition::Shown(DockAnchor::Right));
-        cx.assert_dock_pane_active();
-        cx.assert_workspace_pane_inactive();
-    }
-
-    #[gpui::test]
-    async fn test_toggle_dock_focus(cx: &mut TestAppContext) {
-        let mut cx = DockTestContext::new(cx).await;
-
-        cx.move_dock(DockAnchor::Right);
-        cx.assert_dock_pane_active();
-        cx.hide_dock();
-        cx.move_dock(DockAnchor::Right);
-        cx.assert_dock_pane_active();
-    }
-
-    #[gpui::test]
-    async fn test_activate_next_and_prev_pane(cx: &mut TestAppContext) {
-        let mut cx = DockTestContext::new(cx).await;
-
-        cx.move_dock(DockAnchor::Right);
-        cx.assert_dock_pane_active();
-
-        cx.update_workspace(|workspace, cx| workspace.activate_next_pane(cx));
-        cx.assert_dock_pane_active();
-
-        cx.update_workspace(|workspace, cx| workspace.activate_previous_pane(cx));
-        cx.assert_dock_pane_active();
-    }
-
-    struct DockTestContext<'a> {
-        pub cx: &'a mut TestAppContext,
-        pub window_id: usize,
-        pub workspace: ViewHandle<Workspace>,
-    }
-
-    impl<'a> DockTestContext<'a> {
-        pub async fn new(cx: &'a mut TestAppContext) -> DockTestContext<'a> {
-            init_test(cx);
-            let fs = FakeFs::new(cx.background());
-
-            cx.update(|cx| init(cx));
-            let project = Project::test(fs, [], cx).await;
-            let (window_id, workspace) = cx.add_window(|cx| {
-                Workspace::new(
-                    0,
-                    project.clone(),
-                    Arc::new(AppState {
-                        languages: project.read(cx).languages().clone(),
-                        client: project.read(cx).client(),
-                        user_store: project.read(cx).user_store(),
-                        fs: project.read(cx).fs().clone(),
-                        build_window_options: |_, _, _| Default::default(),
-                        initialize_workspace: |_, _, _| {},
-                        dock_default_item_factory: default_item_factory,
-                        background_actions: || &[],
-                    }),
-                    cx,
-                )
-            });
-
-            workspace.update(cx, |workspace, cx| {
-                let left_panel = cx.add_view(|_| TestItem::new());
-                workspace.left_sidebar().update(cx, |sidebar, cx| {
-                    sidebar.add_item(
-                        "icons/folder_tree_16.svg",
-                        "Left Test Panel".to_string(),
-                        left_panel.clone(),
-                        cx,
-                    );
-                });
-
-                let right_panel = cx.add_view(|_| TestItem::new());
-                workspace.right_sidebar().update(cx, |sidebar, cx| {
-                    sidebar.add_item(
-                        "icons/folder_tree_16.svg",
-                        "Right Test Panel".to_string(),
-                        right_panel.clone(),
-                        cx,
-                    );
-                });
-            });
+    pub struct TestPanel {
+        pub position: DockPosition,
+        pub zoomed: bool,
+        pub active: bool,
+        pub has_focus: bool,
+        pub size: f32,
+    }
 
+    impl TestPanel {
+        pub fn new(position: DockPosition) -> Self {
             Self {
-                cx,
-                window_id,
-                workspace,
+                position,
+                zoomed: false,
+                active: false,
+                has_focus: false,
+                size: 300.,
             }
         }
+    }
 
-        pub fn workspace<F, T>(&self, read: F) -> T
-        where
-            F: FnOnce(&Workspace, &ViewContext<Workspace>) -> T,
-        {
-            self.workspace.read_with(self.cx, read)
+    impl Entity for TestPanel {
+        type Event = TestPanelEvent;
+    }
+
+    impl View for TestPanel {
+        fn ui_name() -> &'static str {
+            "TestPanel"
         }
 
-        pub fn update_workspace<F, T>(&mut self, update: F) -> T
-        where
-            F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
-        {
-            self.workspace.update(self.cx, update)
+        fn render(&mut self, _: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
+            Empty::new().into_any()
         }
 
-        pub fn sidebar<F, T>(&self, sidebar_side: SidebarSide, read: F) -> T
-        where
-            F: FnOnce(&Sidebar, &AppContext) -> T,
-        {
-            self.workspace(|workspace, cx| {
-                let sidebar = match sidebar_side {
-                    SidebarSide::Left => workspace.left_sidebar(),
-                    SidebarSide::Right => workspace.right_sidebar(),
-                }
-                .read(cx);
-
-                read(sidebar, cx)
-            })
-        }
-
-        pub fn center_pane_handle(&self) -> ViewHandle<Pane> {
-            self.workspace(|workspace, cx| {
-                workspace
-                    .last_active_center_pane
-                    .clone()
-                    .and_then(|pane| pane.upgrade(cx))
-                    .unwrap_or_else(|| workspace.center.panes()[0].clone())
-            })
-        }
-
-        pub fn add_item_to_center_pane(&mut self) -> ViewHandle<TestItem> {
-            self.update_workspace(|workspace, cx| {
-                let item = cx.add_view(|_| TestItem::new());
-                let pane = workspace
-                    .last_active_center_pane
-                    .clone()
-                    .and_then(|pane| pane.upgrade(cx))
-                    .unwrap_or_else(|| workspace.center.panes()[0].clone());
-                Pane::add_item(
-                    workspace,
-                    &pane,
-                    Box::new(item.clone()),
-                    true,
-                    true,
-                    None,
-                    cx,
-                );
-                item
-            })
-        }
-
-        pub fn dock_pane<F, T>(&self, read: F) -> T
-        where
-            F: FnOnce(&Pane, &AppContext) -> T,
-        {
-            self.workspace(|workspace, cx| {
-                let dock_pane = workspace.dock_pane().read(cx);
-                read(dock_pane, cx)
-            })
+        fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+            self.has_focus = true;
+            cx.emit(TestPanelEvent::Focus);
         }
 
-        pub fn move_dock(&mut self, anchor: DockAnchor) {
-            self.update_workspace(|workspace, cx| Dock::move_dock(workspace, anchor, true, cx));
+        fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
+            self.has_focus = false;
         }
+    }
 
-        pub fn hide_dock(&mut self) {
-            self.cx.dispatch_action(self.window_id, HideDock);
+    impl Panel for TestPanel {
+        fn position(&self, _: &gpui::WindowContext) -> super::DockPosition {
+            self.position
         }
 
-        pub fn open_sidebar(&mut self, sidebar_side: SidebarSide) {
-            if !self.sidebar(sidebar_side, |sidebar, _| sidebar.is_open()) {
-                self.update_workspace(|workspace, cx| workspace.toggle_sidebar(sidebar_side, cx));
-            }
+        fn position_is_valid(&self, _: super::DockPosition) -> bool {
+            true
         }
 
-        pub fn close_sidebar(&mut self, sidebar_side: SidebarSide) {
-            if self.sidebar(sidebar_side, |sidebar, _| sidebar.is_open()) {
-                self.update_workspace(|workspace, cx| workspace.toggle_sidebar(sidebar_side, cx));
-            }
+        fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+            self.position = position;
+            cx.emit(TestPanelEvent::PositionChanged);
         }
 
-        pub fn dock_items(&self) -> Vec<ViewHandle<TestItem>> {
-            self.dock_pane(|pane, cx| {
-                pane.items()
-                    .map(|item| {
-                        item.act_as::<TestItem>(cx)
-                            .expect("Dock Test Context uses TestItems in the dock")
-                    })
-                    .collect()
-            })
+        fn is_zoomed(&self, _: &WindowContext) -> bool {
+            self.zoomed
         }
 
-        pub async fn close_dock_items(&mut self) {
-            self.update_workspace(|workspace, cx| {
-                Pane::close_items(workspace, workspace.dock_pane().clone(), cx, |_| true)
-            })
-            .await
-            .expect("Could not close dock items")
+        fn set_zoomed(&mut self, zoomed: bool, _cx: &mut ViewContext<Self>) {
+            self.zoomed = zoomed;
         }
 
-        pub fn assert_dock_position(&self, expected_position: DockPosition) {
-            self.workspace(|workspace, _| assert_eq!(workspace.dock.position, expected_position));
+        fn set_active(&mut self, active: bool, _cx: &mut ViewContext<Self>) {
+            self.active = active;
         }
 
-        pub fn assert_sidebar_closed(&self, sidebar_side: SidebarSide) {
-            assert!(!self.sidebar(sidebar_side, |sidebar, _| sidebar.is_open()));
+        fn size(&self, _: &WindowContext) -> f32 {
+            self.size
         }
 
-        pub fn assert_workspace_pane_active(&self) {
-            assert!(self
-                .center_pane_handle()
-                .read_with(self.cx, |pane, _| pane.is_active()));
+        fn set_size(&mut self, size: f32, _: &mut ViewContext<Self>) {
+            self.size = size;
         }
 
-        pub fn assert_workspace_pane_inactive(&self) {
-            assert!(!self
-                .center_pane_handle()
-                .read_with(self.cx, |pane, _| pane.is_active()));
+        fn icon_path(&self) -> &'static str {
+            "icons/test_panel.svg"
         }
 
-        pub fn assert_dock_pane_active(&self) {
-            assert!(self.dock_pane(|pane, _| pane.is_active()))
+        fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
+            ("Test Panel".into(), None)
         }
 
-        pub fn assert_dock_pane_inactive(&self) {
-            assert!(!self.dock_pane(|pane, _| pane.is_active()))
+        fn should_change_position_on_event(event: &Self::Event) -> bool {
+            matches!(event, TestPanelEvent::PositionChanged)
         }
-    }
 
-    impl<'a> Deref for DockTestContext<'a> {
-        type Target = gpui::TestAppContext;
+        fn should_zoom_in_on_event(event: &Self::Event) -> bool {
+            matches!(event, TestPanelEvent::ZoomIn)
+        }
 
-        fn deref(&self) -> &Self::Target {
-            self.cx
+        fn should_zoom_out_on_event(event: &Self::Event) -> bool {
+            matches!(event, TestPanelEvent::ZoomOut)
         }
-    }
 
-    impl<'a> DerefMut for DockTestContext<'a> {
-        fn deref_mut(&mut self) -> &mut Self::Target {
-            &mut self.cx
+        fn should_activate_on_event(event: &Self::Event) -> bool {
+            matches!(event, TestPanelEvent::Activated)
+        }
+
+        fn should_close_on_event(event: &Self::Event) -> bool {
+            matches!(event, TestPanelEvent::Closed)
         }
-    }
 
-    impl BorrowWindowContext for DockTestContext<'_> {
-        fn read_with<T, F: FnOnce(&WindowContext) -> T>(&self, window_id: usize, f: F) -> T {
-            BorrowWindowContext::read_with(self.cx, window_id, f)
+        fn has_focus(&self, _cx: &WindowContext) -> bool {
+            self.has_focus
         }
 
-        fn update<T, F: FnOnce(&mut WindowContext) -> T>(&mut self, window_id: usize, f: F) -> T {
-            BorrowWindowContext::update(self.cx, window_id, f)
+        fn is_focus_event(event: &Self::Event) -> bool {
+            matches!(event, TestPanelEvent::Focus)
         }
     }
 }

crates/workspace/src/dock/toggle_dock_button.rs 🔗

@@ -1,125 +0,0 @@
-use super::{icon_for_dock_anchor, Dock, FocusDock, HideDock};
-use crate::{handle_dropped_item, StatusItemView, Workspace};
-use gpui::{
-    elements::{Empty, MouseEventHandler, Svg},
-    platform::CursorStyle,
-    platform::MouseButton,
-    AnyElement, Element, Entity, View, ViewContext, ViewHandle, WeakViewHandle,
-};
-
-pub struct ToggleDockButton {
-    workspace: WeakViewHandle<Workspace>,
-}
-
-impl ToggleDockButton {
-    pub fn new(workspace: ViewHandle<Workspace>, cx: &mut ViewContext<Self>) -> Self {
-        // When dock moves, redraw so that the icon and toggle status matches.
-        cx.subscribe(&workspace, |_, _, _, cx| cx.notify()).detach();
-
-        Self {
-            workspace: workspace.downgrade(),
-        }
-    }
-}
-
-impl Entity for ToggleDockButton {
-    type Event = ();
-}
-
-impl View for ToggleDockButton {
-    fn ui_name() -> &'static str {
-        "Dock Toggle"
-    }
-
-    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> AnyElement<Self> {
-        let workspace = self.workspace.upgrade(cx);
-
-        if workspace.is_none() {
-            return Empty::new().into_any();
-        }
-
-        let workspace = workspace.unwrap();
-        let dock_position = workspace.read(cx).dock.position;
-        let dock_pane = workspace.read(cx).dock_pane().clone();
-
-        let theme = theme::current(cx).clone();
-
-        let button = MouseEventHandler::<Self, _>::new(0, cx, {
-            let theme = theme.clone();
-            move |state, _| {
-                let style = theme
-                    .workspace
-                    .status_bar
-                    .sidebar_buttons
-                    .item
-                    .style_for(state, dock_position.is_visible());
-
-                Svg::new(icon_for_dock_anchor(dock_position.anchor()))
-                    .with_color(style.icon_color)
-                    .constrained()
-                    .with_width(style.icon_size)
-                    .with_height(style.icon_size)
-                    .contained()
-                    .with_style(style.container)
-            }
-        })
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_up(MouseButton::Left, move |event, this, cx| {
-            let drop_index = dock_pane.read(cx).items_len() + 1;
-            handle_dropped_item(
-                event,
-                this.workspace.clone(),
-                &dock_pane.downgrade(),
-                drop_index,
-                false,
-                None,
-                cx,
-            );
-        });
-
-        if dock_position.is_visible() {
-            button
-                .on_click(MouseButton::Left, |_, this, cx| {
-                    if let Some(workspace) = this.workspace.upgrade(cx) {
-                        workspace.update(cx, |workspace, cx| {
-                            Dock::hide_dock(workspace, &Default::default(), cx)
-                        })
-                    }
-                })
-                .with_tooltip::<Self>(
-                    0,
-                    "Hide Dock".into(),
-                    Some(Box::new(HideDock)),
-                    theme.tooltip.clone(),
-                    cx,
-                )
-        } else {
-            button
-                .on_click(MouseButton::Left, |_, this, cx| {
-                    if let Some(workspace) = this.workspace.upgrade(cx) {
-                        workspace.update(cx, |workspace, cx| {
-                            Dock::focus_dock(workspace, &Default::default(), cx)
-                        })
-                    }
-                })
-                .with_tooltip::<Self>(
-                    0,
-                    "Focus Dock".into(),
-                    Some(Box::new(FocusDock)),
-                    theme.tooltip.clone(),
-                    cx,
-                )
-        }
-        .into_any()
-    }
-}
-
-impl StatusItemView for ToggleDockButton {
-    fn set_active_pane_item(
-        &mut self,
-        _active_pane_item: Option<&dyn crate::ItemHandle>,
-        _cx: &mut ViewContext<Self>,
-    ) {
-        //Not applicable
-    }
-}

crates/workspace/src/item.rs 🔗

@@ -437,7 +437,7 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                     for item_event in T::to_item_events(event).into_iter() {
                         match item_event {
                             ItemEvent::CloseItem => {
-                                Pane::close_item_by_id(workspace, pane, item.id(), cx)
+                                pane.update(cx, |pane, cx| pane.close_item_by_id(item.id(), cx))
                                     .detach_and_log_err(cx);
                                 return;
                             }
@@ -769,7 +769,7 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
 #[cfg(test)]
 pub(crate) mod test {
     use super::{Item, ItemEvent};
-    use crate::{sidebar::SidebarItem, ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
+    use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
     use gpui::{
         elements::Empty, AnyElement, AppContext, Element, Entity, ModelHandle, Task, View,
         ViewContext, ViewHandle, WeakViewHandle,
@@ -1062,6 +1062,4 @@ pub(crate) mod test {
             Task::Ready(Some(anyhow::Ok(view)))
         }
     }
-
-    impl SidebarItem for TestItem {}
 }

crates/workspace/src/pane.rs 🔗

@@ -2,17 +2,14 @@ mod dragged_item_receiver;
 
 use super::{ItemHandle, SplitDirection};
 use crate::{
-    dock::{icon_for_dock_anchor, AnchorDockBottom, AnchorDockRight, Dock, ExpandDock},
-    item::WeakItemHandle,
-    toolbar::Toolbar,
-    AutosaveSetting, DockAnchor, Item, NewFile, NewSearch, NewTerminal, Workspace,
-    WorkspaceSettings,
+    item::WeakItemHandle, toolbar::Toolbar, AutosaveSetting, Item, NewFile, NewSearch, NewTerminal,
+    ToggleZoom, Workspace, WorkspaceSettings,
 };
 use anyhow::{anyhow, Result};
 use collections::{HashMap, HashSet, VecDeque};
 use context_menu::{ContextMenu, ContextMenuItem};
-use drag_and_drop::Draggable;
-pub use dragged_item_receiver::{dragged_item_receiver, handle_dropped_item};
+use drag_and_drop::{DragAndDrop, Draggable};
+use dragged_item_receiver::dragged_item_receiver;
 use futures::StreamExt;
 use gpui::{
     actions,
@@ -41,7 +38,7 @@ use std::{
         Arc,
     },
 };
-use theme::Theme;
+use theme::{Theme, ThemeSettings};
 use util::ResultExt;
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -104,6 +101,7 @@ const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
 pub type BackgroundActions = fn() -> &'static [(&'static str, &'static dyn Action)];
 
 pub fn init(cx: &mut AppContext) {
+    cx.add_action(Pane::toggle_zoom);
     cx.add_action(|pane: &mut Pane, action: &ActivateItem, cx| {
         pane.activate_item(action.0, true, true, cx);
     });
@@ -145,12 +143,15 @@ pub enum Event {
     Split(SplitDirection),
     ChangeItemTitle,
     Focus,
+    ZoomIn,
+    ZoomOut,
 }
 
 pub struct Pane {
     items: Vec<Box<dyn ItemHandle>>,
     activation_history: Vec<usize>,
     is_active: bool,
+    zoomed: bool,
     active_item_index: usize,
     last_focused_view_by_item: HashMap<usize, AnyWeakViewHandle>,
     autoscroll: bool,
@@ -158,10 +159,12 @@ pub struct Pane {
     toolbar: ViewHandle<Toolbar>,
     tab_bar_context_menu: TabBarContextMenu,
     tab_context_menu: ViewHandle<ContextMenu>,
-    docked: Option<DockAnchor>,
     _background_actions: BackgroundActions,
     workspace: WeakViewHandle<Workspace>,
     has_focus: bool,
+    can_drop: Rc<dyn Fn(&DragAndDrop<Workspace>, &WindowContext) -> bool>,
+    can_split: bool,
+    render_tab_bar_buttons: Rc<dyn Fn(&mut Pane, &mut ViewContext<Pane>) -> AnyElement<Pane>>,
 }
 
 pub struct ItemNavHistory {
@@ -203,9 +206,9 @@ pub struct NavigationEntry {
     pub timestamp: usize,
 }
 
-struct DraggedItem {
-    item: Box<dyn ItemHandle>,
-    pane: WeakViewHandle<Pane>,
+pub struct DraggedItem {
+    pub handle: Box<dyn ItemHandle>,
+    pub pane: WeakViewHandle<Pane>,
 }
 
 pub enum ReorderBehavior {
@@ -218,7 +221,6 @@ pub enum ReorderBehavior {
 enum TabBarContextMenuKind {
     New,
     Split,
-    Dock,
 }
 
 struct TabBarContextMenu {
@@ -238,7 +240,6 @@ impl TabBarContextMenu {
 impl Pane {
     pub fn new(
         workspace: WeakViewHandle<Workspace>,
-        docked: Option<DockAnchor>,
         background_actions: BackgroundActions,
         next_timestamp: Arc<AtomicUsize>,
         cx: &mut ViewContext<Self>,
@@ -254,6 +255,7 @@ impl Pane {
             items: Vec::new(),
             activation_history: Vec::new(),
             is_active: true,
+            zoomed: false,
             active_item_index: 0,
             last_focused_view_by_item: Default::default(),
             autoscroll: false,
@@ -272,10 +274,46 @@ impl Pane {
                 handle: context_menu,
             },
             tab_context_menu: cx.add_view(|cx| ContextMenu::new(pane_view_id, cx)),
-            docked,
             _background_actions: background_actions,
             workspace,
             has_focus: false,
+            can_drop: Rc::new(|_, _| true),
+            can_split: true,
+            render_tab_bar_buttons: Rc::new(|pane, cx| {
+                Flex::row()
+                    // New menu
+                    .with_child(Self::render_tab_bar_button(
+                        0,
+                        "icons/plus_12.svg",
+                        Some(("New...".into(), None)),
+                        cx,
+                        |pane, cx| pane.deploy_new_menu(cx),
+                        pane.tab_bar_context_menu
+                            .handle_if_kind(TabBarContextMenuKind::New),
+                    ))
+                    .with_child(Self::render_tab_bar_button(
+                        1,
+                        "icons/split_12.svg",
+                        Some(("Split Pane".into(), None)),
+                        cx,
+                        |pane, cx| pane.deploy_split_menu(cx),
+                        pane.tab_bar_context_menu
+                            .handle_if_kind(TabBarContextMenuKind::Split),
+                    ))
+                    .with_child(Pane::render_tab_bar_button(
+                        2,
+                        if pane.is_zoomed() {
+                            "icons/minimize_8.svg"
+                        } else {
+                            "icons/maximize_8.svg"
+                        },
+                        Some(("Toggle Zoom".into(), Some(Box::new(ToggleZoom)))),
+                        cx,
+                        move |pane, cx| pane.toggle_zoom(&Default::default(), cx),
+                        None,
+                    ))
+                    .into_any()
+            }),
         }
     }
 
@@ -296,8 +334,23 @@ impl Pane {
         self.has_focus
     }
 
-    pub fn set_docked(&mut self, docked: Option<DockAnchor>, cx: &mut ViewContext<Self>) {
-        self.docked = docked;
+    pub fn on_can_drop<F>(&mut self, can_drop: F)
+    where
+        F: 'static + Fn(&DragAndDrop<Workspace>, &WindowContext) -> bool,
+    {
+        self.can_drop = Rc::new(can_drop);
+    }
+
+    pub fn set_can_split(&mut self, can_split: bool, cx: &mut ViewContext<Self>) {
+        self.can_split = can_split;
+        cx.notify();
+    }
+
+    pub fn set_render_tab_bar_buttons<F>(&mut self, cx: &mut ViewContext<Self>, render: F)
+    where
+        F: 'static + Fn(&mut Pane, &mut ViewContext<Pane>) -> AnyElement<Pane>,
+    {
+        self.render_tab_bar_buttons = Rc::new(render);
         cx.notify();
     }
 
@@ -515,7 +568,7 @@ impl Pane {
         }
     }
 
-    pub(crate) fn add_item(
+    pub fn add_item(
         workspace: &mut Workspace,
         pane: &ViewHandle<Pane>,
         item: Box<dyn ItemHandle>,
@@ -641,6 +694,17 @@ impl Pane {
         self.items.iter().position(|i| i.id() == item.id())
     }
 
+    pub fn toggle_zoom(&mut self, _: &ToggleZoom, cx: &mut ViewContext<Self>) {
+        if self.zoomed {
+            cx.emit(Event::ZoomOut);
+        } else if !self.items.is_empty() {
+            if !self.has_focus {
+                cx.focus_self();
+            }
+            cx.emit(Event::ZoomIn);
+        }
+    }
+
     pub fn activate_item(
         &mut self,
         index: usize,
@@ -704,187 +768,118 @@ impl Pane {
     }
 
     pub fn close_active_item(
-        workspace: &mut Workspace,
+        &mut self,
         _: &CloseActiveItem,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let pane_handle = workspace.active_pane().clone();
-        let pane = pane_handle.read(cx);
-
-        if pane.items.is_empty() {
+        if self.items.is_empty() {
             return None;
         }
-        let active_item_id = pane.items[pane.active_item_index].id();
-
-        let task = Self::close_item_by_id(workspace, pane_handle, active_item_id, cx);
-
-        Some(cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        }))
+        let active_item_id = self.items[self.active_item_index].id();
+        Some(self.close_item_by_id(active_item_id, cx))
     }
 
     pub fn close_item_by_id(
-        workspace: &mut Workspace,
-        pane: ViewHandle<Pane>,
+        &mut self,
         item_id_to_close: usize,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
-        Self::close_items(workspace, pane, cx, move |view_id| {
-            view_id == item_id_to_close
-        })
+        self.close_items(cx, move |view_id| view_id == item_id_to_close)
     }
 
     pub fn close_inactive_items(
-        workspace: &mut Workspace,
+        &mut self,
         _: &CloseInactiveItems,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let pane_handle = workspace.active_pane().clone();
-        let pane = pane_handle.read(cx);
-        let active_item_id = pane.items[pane.active_item_index].id();
-
-        let task = Self::close_items(workspace, pane_handle, cx, move |item_id| {
-            item_id != active_item_id
-        });
+        if self.items.is_empty() {
+            return None;
+        }
 
-        Some(cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        }))
+        let active_item_id = self.items[self.active_item_index].id();
+        Some(self.close_items(cx, move |item_id| item_id != active_item_id))
     }
 
     pub fn close_clean_items(
-        workspace: &mut Workspace,
+        &mut self,
         _: &CloseCleanItems,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let pane_handle = workspace.active_pane().clone();
-        let pane = pane_handle.read(cx);
-
-        let item_ids: Vec<_> = pane
+        let item_ids: Vec<_> = self
             .items()
             .filter(|item| !item.is_dirty(cx))
             .map(|item| item.id())
             .collect();
-
-        let task = Self::close_items(workspace, pane_handle, cx, move |item_id| {
-            item_ids.contains(&item_id)
-        });
-
-        Some(cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        }))
+        Some(self.close_items(cx, move |item_id| item_ids.contains(&item_id)))
     }
 
     pub fn close_items_to_the_left(
-        workspace: &mut Workspace,
+        &mut self,
         _: &CloseItemsToTheLeft,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let pane_handle = workspace.active_pane().clone();
-        let pane = pane_handle.read(cx);
-        let active_item_id = pane.items[pane.active_item_index].id();
-
-        let task = Self::close_items_to_the_left_by_id(workspace, pane_handle, active_item_id, cx);
-
-        Some(cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        }))
+        if self.items.is_empty() {
+            return None;
+        }
+        let active_item_id = self.items[self.active_item_index].id();
+        Some(self.close_items_to_the_left_by_id(active_item_id, cx))
     }
 
     pub fn close_items_to_the_left_by_id(
-        workspace: &mut Workspace,
-        pane: ViewHandle<Pane>,
+        &mut self,
         item_id: usize,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
-        let item_ids: Vec<_> = pane
-            .read(cx)
+        let item_ids: Vec<_> = self
             .items()
             .take_while(|item| item.id() != item_id)
             .map(|item| item.id())
             .collect();
-
-        let task = Self::close_items(workspace, pane, cx, move |item_id| {
-            item_ids.contains(&item_id)
-        });
-
-        cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        })
+        self.close_items(cx, move |item_id| item_ids.contains(&item_id))
     }
 
     pub fn close_items_to_the_right(
-        workspace: &mut Workspace,
+        &mut self,
         _: &CloseItemsToTheRight,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let pane_handle = workspace.active_pane().clone();
-        let pane = pane_handle.read(cx);
-        let active_item_id = pane.items[pane.active_item_index].id();
-
-        let task = Self::close_items_to_the_right_by_id(workspace, pane_handle, active_item_id, cx);
-
-        Some(cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        }))
+        if self.items.is_empty() {
+            return None;
+        }
+        let active_item_id = self.items[self.active_item_index].id();
+        Some(self.close_items_to_the_right_by_id(active_item_id, cx))
     }
 
     pub fn close_items_to_the_right_by_id(
-        workspace: &mut Workspace,
-        pane: ViewHandle<Pane>,
+        &mut self,
         item_id: usize,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
-        let item_ids: Vec<_> = pane
-            .read(cx)
+        let item_ids: Vec<_> = self
             .items()
             .rev()
             .take_while(|item| item.id() != item_id)
             .map(|item| item.id())
             .collect();
-
-        let task = Self::close_items(workspace, pane, cx, move |item_id| {
-            item_ids.contains(&item_id)
-        });
-
-        cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        })
+        self.close_items(cx, move |item_id| item_ids.contains(&item_id))
     }
 
     pub fn close_all_items(
-        workspace: &mut Workspace,
+        &mut self,
         _: &CloseAllItems,
-        cx: &mut ViewContext<Workspace>,
+        cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let pane_handle = workspace.active_pane().clone();
-
-        let task = Self::close_items(workspace, pane_handle, cx, move |_| true);
-
-        Some(cx.foreground().spawn(async move {
-            task.await?;
-            Ok(())
-        }))
+        Some(self.close_items(cx, move |_| true))
     }
 
     pub fn close_items(
-        workspace: &mut Workspace,
-        pane: ViewHandle<Pane>,
-        cx: &mut ViewContext<Workspace>,
+        &mut self,
+        cx: &mut ViewContext<Pane>,
         should_close: impl 'static + Fn(usize) -> bool,
     ) -> Task<Result<()>> {
-        let project = workspace.project().clone();
-
         // Find the items to close.
         let mut items_to_close = Vec::new();
-        for item in &pane.read(cx).items {
+        for item in &self.items {
             if should_close(item.id()) {
                 items_to_close.push(item.boxed_clone());
             }
@@ -896,8 +891,8 @@ impl Pane {
         // of what content they would be saving.
         items_to_close.sort_by_key(|item| !item.is_singleton(cx));
 
-        let pane = pane.downgrade();
-        cx.spawn(|workspace, mut cx| async move {
+        let workspace = self.workspace.clone();
+        cx.spawn(|pane, mut cx| async move {
             let mut saved_project_items_ids = HashSet::default();
             for item in items_to_close.clone() {
                 // Find the item's current index and its set of project item models. Avoid
@@ -915,7 +910,7 @@ impl Pane {
                 // Check if this view has any project items that are not open anywhere else
                 // in the workspace, AND that the user has not already been prompted to save.
                 // If there are any such project entries, prompt the user to save this item.
-                workspace.read_with(&cx, |workspace, cx| {
+                let project = workspace.read_with(&cx, |workspace, cx| {
                     for item in workspace.items(cx) {
                         if !items_to_close
                             .iter()
@@ -925,6 +920,7 @@ impl Pane {
                             project_item_ids.retain(|id| !other_project_item_ids.contains(id));
                         }
                     }
+                    workspace.project().clone()
                 })?;
                 let should_save = project_item_ids
                     .iter()
@@ -967,7 +963,8 @@ impl Pane {
                 // to activating the item to the left
                 .unwrap_or_else(|| item_index.min(self.items.len()).saturating_sub(1));
 
-            self.activate_item(index_to_activate, activate_pane, activate_pane, cx);
+            let should_activate = activate_pane || self.has_focus;
+            self.activate_item(index_to_activate, should_activate, should_activate, cx);
         }
 
         let item = self.items.remove(item_index);
@@ -1003,6 +1000,10 @@ impl Pane {
                 .remove(&item.id());
         }
 
+        if self.items.is_empty() && self.zoomed {
+            cx.emit(Event::ZoomOut);
+        }
+
         cx.notify();
     }
 
@@ -1177,23 +1178,6 @@ impl Pane {
         self.tab_bar_context_menu.kind = TabBarContextMenuKind::Split;
     }
 
-    fn deploy_dock_menu(&mut self, cx: &mut ViewContext<Self>) {
-        self.tab_bar_context_menu.handle.update(cx, |menu, cx| {
-            menu.show(
-                Default::default(),
-                AnchorCorner::TopRight,
-                vec![
-                    ContextMenuItem::action("Anchor Dock Right", AnchorDockRight),
-                    ContextMenuItem::action("Anchor Dock Bottom", AnchorDockBottom),
-                    ContextMenuItem::action("Expand Dock", ExpandDock),
-                ],
-                cx,
-            );
-        });
-
-        self.tab_bar_context_menu.kind = TabBarContextMenuKind::Dock;
-    }
-
     fn deploy_new_menu(&mut self, cx: &mut ViewContext<Self>) {
         self.tab_bar_context_menu.handle.update(cx, |menu, cx| {
             menu.show(
@@ -1240,14 +1224,11 @@ impl Pane {
                     // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command.
                     vec![
                         ContextMenuItem::handler("Close Inactive Item", {
-                            let workspace = self.workspace.clone();
                             let pane = target_pane.clone();
                             move |cx| {
-                                if let Some((workspace, pane)) =
-                                    workspace.upgrade(cx).zip(pane.upgrade(cx))
-                                {
-                                    workspace.update(cx, |workspace, cx| {
-                                        Self::close_item_by_id(workspace, pane, target_item_id, cx)
+                                if let Some(pane) = pane.upgrade(cx) {
+                                    pane.update(cx, |pane, cx| {
+                                        pane.close_item_by_id(target_item_id, cx)
                                             .detach_and_log_err(cx);
                                     })
                                 }
@@ -1256,39 +1237,23 @@ impl Pane {
                         ContextMenuItem::action("Close Inactive Items", CloseInactiveItems),
                         ContextMenuItem::action("Close Clean Items", CloseCleanItems),
                         ContextMenuItem::handler("Close Items To The Left", {
-                            let workspace = self.workspace.clone();
                             let pane = target_pane.clone();
                             move |cx| {
-                                if let Some((workspace, pane)) =
-                                    workspace.upgrade(cx).zip(pane.upgrade(cx))
-                                {
-                                    workspace.update(cx, |workspace, cx| {
-                                        Self::close_items_to_the_left_by_id(
-                                            workspace,
-                                            pane,
-                                            target_item_id,
-                                            cx,
-                                        )
-                                        .detach_and_log_err(cx);
+                                if let Some(pane) = pane.upgrade(cx) {
+                                    pane.update(cx, |pane, cx| {
+                                        pane.close_items_to_the_left_by_id(target_item_id, cx)
+                                            .detach_and_log_err(cx);
                                     })
                                 }
                             }
                         }),
                         ContextMenuItem::handler("Close Items To The Right", {
-                            let workspace = self.workspace.clone();
                             let pane = target_pane.clone();
                             move |cx| {
-                                if let Some((workspace, pane)) =
-                                    workspace.upgrade(cx).zip(pane.upgrade(cx))
-                                {
-                                    workspace.update(cx, |workspace, cx| {
-                                        Self::close_items_to_the_right_by_id(
-                                            workspace,
-                                            pane,
-                                            target_item_id,
-                                            cx,
-                                        )
-                                        .detach_and_log_err(cx);
+                                if let Some(pane) = pane.upgrade(cx) {
+                                    pane.update(cx, |pane, cx| {
+                                        pane.close_items_to_the_right_by_id(target_item_id, cx)
+                                            .detach_and_log_err(cx);
                                     })
                                 }
                             }
@@ -1305,6 +1270,25 @@ impl Pane {
         &self.toolbar
     }
 
+    pub fn handle_deleted_project_item(
+        &mut self,
+        entry_id: ProjectEntryId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Option<()> {
+        let (item_index_to_delete, item_id) = self.items().enumerate().find_map(|(i, item)| {
+            if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == [entry_id] {
+                Some((i, item.id()))
+            } else {
+                None
+            }
+        })?;
+
+        self.remove_item(item_index_to_delete, false, cx);
+        self.nav_history.borrow_mut().remove_item(item_id);
+
+        Some(())
+    }
+
     fn update_toolbar(&mut self, cx: &mut ViewContext<Self>) {
         let active_item = self
             .items
@@ -1342,7 +1326,7 @@ impl Pane {
             row.add_child({
                 enum TabDragReceiver {}
                 let mut receiver =
-                    dragged_item_receiver::<TabDragReceiver, _, _>(ix, ix, true, None, cx, {
+                    dragged_item_receiver::<TabDragReceiver, _, _>(self, ix, ix, true, None, cx, {
                         let item = item.clone();
                         let pane = pane.clone();
                         let detail = detail.clone();
@@ -1376,20 +1360,7 @@ impl Pane {
                                 .on_click(MouseButton::Middle, {
                                     let item_id = item.id();
                                     move |_, pane, cx| {
-                                        let workspace = pane.workspace.clone();
-                                        let pane = cx.weak_handle();
-                                        cx.window_context().defer(move |cx| {
-                                            if let Some((workspace, pane)) =
-                                                workspace.upgrade(cx).zip(pane.upgrade(cx))
-                                            {
-                                                workspace.update(cx, |workspace, cx| {
-                                                    Self::close_item_by_id(
-                                                        workspace, pane, item_id, cx,
-                                                    )
-                                                    .detach_and_log_err(cx);
-                                                });
-                                            }
-                                        });
+                                        pane.close_item_by_id(item_id, cx).detach_and_log_err(cx);
                                     }
                                 })
                                 .on_down(
@@ -1421,7 +1392,7 @@ impl Pane {
 
                 receiver.as_draggable(
                     DraggedItem {
-                        item,
+                        handle: item,
                         pane: pane.clone(),
                     },
                     {
@@ -1431,7 +1402,7 @@ impl Pane {
                         move |dragged_item: &DraggedItem, cx: &mut ViewContext<Workspace>| {
                             let tab_style = &theme.workspace.tab_bar.dragged_tab;
                             Self::render_dragged_tab(
-                                &dragged_item.item,
+                                &dragged_item.handle,
                                 dragged_item.pane.clone(),
                                 false,
                                 detail,
@@ -1451,7 +1422,7 @@ impl Pane {
         let filler_style = theme.workspace.tab_bar.tab_style(pane_active, false);
         enum Filler {}
         row.add_child(
-            dragged_item_receiver::<Filler, _, _>(0, filler_index, true, None, cx, |_, _| {
+            dragged_item_receiver::<Filler, _, _>(self, 0, filler_index, true, None, cx, |_, _| {
                 Empty::new()
                     .contained()
                     .with_style(filler_style.container)
@@ -1596,12 +1567,9 @@ impl Pane {
                             let pane = pane.clone();
                             cx.window_context().defer(move |cx| {
                                 if let Some(pane) = pane.upgrade(cx) {
-                                    if let Some(workspace) = pane.read(cx).workspace.upgrade(cx) {
-                                        workspace.update(cx, |workspace, cx| {
-                                            Self::close_item_by_id(workspace, pane, item_id, cx)
-                                                .detach_and_log_err(cx);
-                                        });
-                                    }
+                                    pane.update(cx, |pane, cx| {
+                                        pane.close_item_by_id(item_id, cx).detach_and_log_err(cx);
+                                    });
                                 }
                             });
                         }
@@ -1621,83 +1589,63 @@ impl Pane {
             .into_any()
     }
 
-    fn render_tab_bar_buttons(
-        &mut self,
-        theme: &Theme,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        Flex::row()
-            // New menu
-            .with_child(render_tab_bar_button(
-                0,
-                "icons/plus_12.svg",
-                cx,
-                |pane, cx| pane.deploy_new_menu(cx),
-                self.tab_bar_context_menu
-                    .handle_if_kind(TabBarContextMenuKind::New),
-            ))
-            .with_child(
-                self.docked
-                    .map(|anchor| {
-                        // Add the dock menu button if this pane is a dock
-                        let dock_icon = icon_for_dock_anchor(anchor);
-
-                        render_tab_bar_button(
-                            1,
-                            dock_icon,
-                            cx,
-                            |pane, cx| pane.deploy_dock_menu(cx),
-                            self.tab_bar_context_menu
-                                .handle_if_kind(TabBarContextMenuKind::Dock),
-                        )
-                    })
-                    .unwrap_or_else(|| {
-                        // Add the split menu if this pane is not a dock
-                        render_tab_bar_button(
-                            2,
-                            "icons/split_12.svg",
-                            cx,
-                            |pane, cx| pane.deploy_split_menu(cx),
-                            self.tab_bar_context_menu
-                                .handle_if_kind(TabBarContextMenuKind::Split),
-                        )
-                    }),
+    pub fn render_tab_bar_button<F: 'static + Fn(&mut Pane, &mut EventContext<Pane>)>(
+        index: usize,
+        icon: &'static str,
+        tooltip: Option<(String, Option<Box<dyn Action>>)>,
+        cx: &mut ViewContext<Pane>,
+        on_click: F,
+        context_menu: Option<ViewHandle<ContextMenu>>,
+    ) -> AnyElement<Pane> {
+        enum TabBarButton {}
+
+        let mut button = MouseEventHandler::<TabBarButton, _>::new(index, cx, |mouse_state, cx| {
+            let theme = &settings::get::<ThemeSettings>(cx).theme.workspace.tab_bar;
+            let style = theme.pane_button.style_for(mouse_state, false);
+            Svg::new(icon)
+                .with_color(style.color)
+                .constrained()
+                .with_width(style.icon_width)
+                .aligned()
+                .constrained()
+                .with_width(style.button_width)
+                .with_height(style.button_width)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, pane, cx| on_click(pane, cx))
+        .into_any();
+        if let Some((tooltip, action)) = tooltip {
+            let tooltip_style = settings::get::<ThemeSettings>(cx).theme.tooltip.clone();
+            button = button
+                .with_tooltip::<TabBarButton>(index, tooltip, action, tooltip_style, cx)
+                .into_any();
+        }
+
+        Stack::new()
+            .with_child(button)
+            .with_children(
+                context_menu.map(|menu| ChildView::new(&menu, cx).aligned().bottom().right()),
             )
-            // Add the close dock button if this pane is a dock
-            .with_children(self.docked.map(|_| {
-                render_tab_bar_button(
-                    3,
-                    "icons/x_mark_8.svg",
-                    cx,
-                    |this, cx| {
-                        if let Some(workspace) = this.workspace.upgrade(cx) {
-                            cx.window_context().defer(move |cx| {
-                                workspace.update(cx, |workspace, cx| {
-                                    Dock::hide_dock(workspace, &Default::default(), cx)
-                                })
-                            });
-                        }
-                    },
-                    None,
-                )
-            }))
-            .contained()
-            .with_style(theme.workspace.tab_bar.pane_button_container)
             .flex(1., false)
-            .into_any()
+            .into_any_named("tab bar button")
     }
 
-    fn render_blank_pane(
-        &mut self,
-        theme: &Theme,
-        _cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
+    fn render_blank_pane(&self, theme: &Theme, _cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         let background = theme.workspace.background;
         Empty::new()
             .contained()
             .with_background_color(background)
             .into_any()
     }
+
+    pub fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>) {
+        self.zoomed = zoomed;
+        cx.notify();
+    }
+
+    pub fn is_zoomed(&self) -> bool {
+        self.zoomed
+    }
 }
 
 impl Entity for Pane {
@@ -1741,7 +1689,14 @@ impl View for Pane {
                             .with_child(self.render_tabs(cx).flex(1., true).into_any_named("tabs"));
 
                         if self.is_active {
-                            tab_row.add_child(self.render_tab_bar_buttons(&theme, cx))
+                            let render_tab_bar_buttons = self.render_tab_bar_buttons.clone();
+                            tab_row.add_child(
+                                (render_tab_bar_buttons)(self, cx)
+                                    .contained()
+                                    .with_style(theme.workspace.tab_bar.pane_button_container)
+                                    .flex(1., false)
+                                    .into_any(),
+                            )
                         }
 
                         stack.add_child(tab_row);
@@ -1754,14 +1709,11 @@ impl View for Pane {
                     .with_child({
                         enum PaneContentTabDropTarget {}
                         dragged_item_receiver::<PaneContentTabDropTarget, _, _>(
+                            self,
                             0,
                             self.active_item_index + 1,
-                            false,
-                            if self.docked.is_some() {
-                                None
-                            } else {
-                                Some(100.)
-                            },
+                            !self.can_split,
+                            if self.can_split { Some(100.) } else { None },
                             cx,
                             {
                                 let toolbar = self.toolbar.clone();
@@ -1786,7 +1738,7 @@ impl View for Pane {
                 enum EmptyPane {}
                 let theme = theme::current(cx).clone();
 
-                dragged_item_receiver::<EmptyPane, _, _>(0, 0, false, None, cx, |_, cx| {
+                dragged_item_receiver::<EmptyPane, _, _>(self, 0, 0, false, None, cx, |_, cx| {
                     self.render_blank_pane(&theme, cx)
                 })
                 .on_down(MouseButton::Left, |_, _, cx| {
@@ -1824,7 +1776,11 @@ impl View for Pane {
     }
 
     fn focus_in(&mut self, focused: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        self.has_focus = true;
+        if !self.has_focus {
+            self.has_focus = true;
+            cx.emit(Event::Focus);
+        }
+
         self.toolbar.update(cx, |toolbar, cx| {
             toolbar.pane_focus_update(true, cx);
         });
@@ -1850,8 +1806,6 @@ impl View for Pane {
                     .insert(active_item.id(), focused.downgrade());
             }
         }
-
-        cx.emit(Event::Focus);
     }
 
     fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -1863,45 +1817,9 @@ impl View for Pane {
 
     fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
         Self::reset_to_default_keymap_context(keymap);
-        if self.docked.is_some() {
-            keymap.add_identifier("docked");
-        }
     }
 }
 
-fn render_tab_bar_button<F: 'static + Fn(&mut Pane, &mut EventContext<Pane>)>(
-    index: usize,
-    icon: &'static str,
-    cx: &mut ViewContext<Pane>,
-    on_click: F,
-    context_menu: Option<ViewHandle<ContextMenu>>,
-) -> AnyElement<Pane> {
-    enum TabBarButton {}
-
-    Stack::new()
-        .with_child(
-            MouseEventHandler::<TabBarButton, _>::new(index, cx, |mouse_state, cx| {
-                let theme = &theme::current(cx).workspace.tab_bar;
-                let style = theme.pane_button.style_for(mouse_state, false);
-                Svg::new(icon)
-                    .with_color(style.color)
-                    .constrained()
-                    .with_width(style.icon_width)
-                    .aligned()
-                    .constrained()
-                    .with_width(style.button_width)
-                    .with_height(style.button_width)
-            })
-            .with_cursor_style(CursorStyle::PointingHand)
-            .on_click(MouseButton::Left, move |_, pane, cx| on_click(pane, cx)),
-        )
-        .with_children(
-            context_menu.map(|menu| ChildView::new(&menu, cx).aligned().bottom().right()),
-        )
-        .flex(1., false)
-        .into_any_named("tab bar button")
-}
-
 impl ItemNavHistory {
     pub fn push<D: 'static + Any>(&self, data: Option<D>, cx: &mut WindowContext) {
         self.history.borrow_mut().push(data, self.item.clone(), cx);
@@ -2007,6 +1925,15 @@ impl NavHistory {
             });
         }
     }
+
+    fn remove_item(&mut self, item_id: usize) {
+        self.paths_by_item.remove(&item_id);
+        self.backward_stack
+            .retain(|entry| entry.item.id() != item_id);
+        self.forward_stack
+            .retain(|entry| entry.item.id() != item_id);
+        self.closed_stack.retain(|entry| entry.item.id() != item_id);
+    }
 }
 
 impl PaneNavHistory {
@@ -2130,11 +2057,9 @@ impl<V: View> Element<V> for PaneBackdrop<V> {
 
 #[cfg(test)]
 mod tests {
-    use std::sync::Arc;
-
     use super::*;
     use crate::item::test::{TestItem, TestProjectItem};
-    use gpui::{executor::Deterministic, TestAppContext};
+    use gpui::TestAppContext;
     use project::FakeFs;
     use settings::SettingsStore;
 
@@ -2145,9 +2070,10 @@ mod tests {
 
         let project = Project::test(fs, None, cx).await;
         let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
-        workspace.update(cx, |workspace, cx| {
-            assert!(Pane::close_active_item(workspace, &CloseActiveItem, cx).is_none())
+        pane.update(cx, |pane, cx| {
+            assert!(pane.close_active_item(&CloseActiveItem, cx).is_none())
         });
     }
 
@@ -2426,7 +2352,7 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_remove_item_ordering(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
+    async fn test_remove_item_ordering(cx: &mut TestAppContext) {
         init_test(cx);
         let fs = FakeFs::new(cx.background());
 
@@ -2444,36 +2370,36 @@ mod tests {
         add_labeled_item(&workspace, &pane, "1", false, cx);
         assert_item_labels(&pane, ["A", "B", "1*", "C", "D"], cx);
 
-        workspace.update(cx, |workspace, cx| {
-            Pane::close_active_item(workspace, &CloseActiveItem, cx);
-        });
-        deterministic.run_until_parked();
+        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
+            .unwrap()
+            .await
+            .unwrap();
         assert_item_labels(&pane, ["A", "B*", "C", "D"], cx);
 
         pane.update(cx, |pane, cx| pane.activate_item(3, false, false, cx));
         assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
 
-        workspace.update(cx, |workspace, cx| {
-            Pane::close_active_item(workspace, &CloseActiveItem, cx);
-        });
-        deterministic.run_until_parked();
+        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
+            .unwrap()
+            .await
+            .unwrap();
         assert_item_labels(&pane, ["A", "B*", "C"], cx);
 
-        workspace.update(cx, |workspace, cx| {
-            Pane::close_active_item(workspace, &CloseActiveItem, cx);
-        });
-        deterministic.run_until_parked();
+        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
+            .unwrap()
+            .await
+            .unwrap();
         assert_item_labels(&pane, ["A", "C*"], cx);
 
-        workspace.update(cx, |workspace, cx| {
-            Pane::close_active_item(workspace, &CloseActiveItem, cx);
-        });
-        deterministic.run_until_parked();
+        pane.update(cx, |pane, cx| pane.close_active_item(&CloseActiveItem, cx))
+            .unwrap()
+            .await
+            .unwrap();
         assert_item_labels(&pane, ["A*"], cx);
     }
 
     #[gpui::test]
-    async fn test_close_inactive_items(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
+    async fn test_close_inactive_items(cx: &mut TestAppContext) {
         init_test(cx);
         let fs = FakeFs::new(cx.background());
 

crates/workspace/src/pane/dragged_item_receiver.rs 🔗

@@ -12,6 +12,7 @@ use gpui::{
 use project::ProjectEntryId;
 
 pub fn dragged_item_receiver<Tag, D, F>(
+    pane: &Pane,
     region_id: usize,
     drop_index: usize,
     allow_same_pane: bool,
@@ -24,22 +25,24 @@ where
     D: Element<Pane>,
     F: FnOnce(&mut MouseState, &mut ViewContext<Pane>) -> D,
 {
-    MouseEventHandler::<Tag, _>::above(region_id, cx, |state, cx| {
+    let drag_and_drop = cx.global::<DragAndDrop<Workspace>>();
+    let drag_position = if (pane.can_drop)(drag_and_drop, cx) {
+        drag_and_drop
+            .currently_dragged::<DraggedItem>(cx.window_id())
+            .map(|(drag_position, _)| drag_position)
+            .or_else(|| {
+                drag_and_drop
+                    .currently_dragged::<ProjectEntryId>(cx.window_id())
+                    .map(|(drag_position, _)| drag_position)
+            })
+    } else {
+        None
+    };
+
+    let mut handler = MouseEventHandler::<Tag, _>::above(region_id, cx, |state, cx| {
         // Observing hovered will cause a render when the mouse enters regardless
         // of if mouse position was accessed before
-        let drag_position = if state.hovered() {
-            cx.global::<DragAndDrop<Workspace>>()
-                .currently_dragged::<DraggedItem>(cx.window_id())
-                .map(|(drag_position, _)| drag_position)
-                .or_else(|| {
-                    cx.global::<DragAndDrop<Workspace>>()
-                        .currently_dragged::<ProjectEntryId>(cx.window_id())
-                        .map(|(drag_position, _)| drag_position)
-                })
-        } else {
-            None
-        };
-
+        let drag_position = if state.hovered() { drag_position } else { None };
         Stack::new()
             .with_child(render_child(state, cx))
             .with_children(drag_position.map(|drag_position| {
@@ -64,38 +67,44 @@ where
                     }
                 })
             }))
-    })
-    .on_up(MouseButton::Left, {
-        move |event, pane, cx| {
-            let workspace = pane.workspace.clone();
-            let pane = cx.weak_handle();
-            handle_dropped_item(
-                event,
-                workspace,
-                &pane,
-                drop_index,
-                allow_same_pane,
-                split_margin,
-                cx,
-            );
-            cx.notify();
-        }
-    })
-    .on_move(|_, _, cx| {
-        let drag_and_drop = cx.global::<DragAndDrop<Workspace>>();
+    });
 
-        if drag_and_drop
-            .currently_dragged::<DraggedItem>(cx.window_id())
-            .is_some()
-            || drag_and_drop
-                .currently_dragged::<ProjectEntryId>(cx.window_id())
-                .is_some()
-        {
-            cx.notify();
-        } else {
-            cx.propagate_event();
-        }
-    })
+    if drag_position.is_some() {
+        handler = handler
+            .on_up(MouseButton::Left, {
+                move |event, pane, cx| {
+                    let workspace = pane.workspace.clone();
+                    let pane = cx.weak_handle();
+                    handle_dropped_item(
+                        event,
+                        workspace,
+                        &pane,
+                        drop_index,
+                        allow_same_pane,
+                        split_margin,
+                        cx,
+                    );
+                    cx.notify();
+                }
+            })
+            .on_move(|_, _, cx| {
+                let drag_and_drop = cx.global::<DragAndDrop<Workspace>>();
+
+                if drag_and_drop
+                    .currently_dragged::<DraggedItem>(cx.window_id())
+                    .is_some()
+                    || drag_and_drop
+                        .currently_dragged::<ProjectEntryId>(cx.window_id())
+                        .is_some()
+                {
+                    cx.notify();
+                } else {
+                    cx.propagate_event();
+                }
+            })
+    }
+
+    handler
 }
 
 pub fn handle_dropped_item<V: View>(
@@ -115,7 +124,7 @@ pub fn handle_dropped_item<V: View>(
     let action = if let Some((_, dragged_item)) =
         drag_and_drop.currently_dragged::<DraggedItem>(cx.window_id())
     {
-        Action::Move(dragged_item.pane.clone(), dragged_item.item.id())
+        Action::Move(dragged_item.pane.clone(), dragged_item.handle.id())
     } else if let Some((_, project_entry)) =
         drag_and_drop.currently_dragged::<ProjectEntryId>(cx.window_id())
     {

crates/workspace/src/pane_group.rs 🔗

@@ -7,7 +7,7 @@ use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::Vector2F},
     platform::{CursorStyle, MouseButton},
-    Axis, Border, ModelHandle, ViewContext, ViewHandle,
+    AnyViewHandle, Axis, Border, ModelHandle, ViewContext, ViewHandle,
 };
 use project::Project;
 use serde::Deserialize;
@@ -71,6 +71,7 @@ impl PaneGroup {
         follower_states: &FollowerStatesByLeader,
         active_call: Option<&ModelHandle<ActiveCall>>,
         active_pane: &ViewHandle<Pane>,
+        zoomed: Option<&AnyViewHandle>,
         app_state: &Arc<AppState>,
         cx: &mut ViewContext<Workspace>,
     ) -> AnyElement<Workspace> {
@@ -80,6 +81,7 @@ impl PaneGroup {
             follower_states,
             active_call,
             active_pane,
+            zoomed,
             app_state,
             cx,
         )
@@ -134,6 +136,7 @@ impl Member {
         follower_states: &FollowerStatesByLeader,
         active_call: Option<&ModelHandle<ActiveCall>>,
         active_pane: &ViewHandle<Pane>,
+        zoomed: Option<&AnyViewHandle>,
         app_state: &Arc<AppState>,
         cx: &mut ViewContext<Workspace>,
     ) -> AnyElement<Workspace> {
@@ -141,6 +144,12 @@ impl Member {
 
         match self {
             Member::Pane(pane) => {
+                let pane_element = if Some(&**pane) == zoomed {
+                    Empty::new().into_any()
+                } else {
+                    ChildView::new(pane, cx).into_any()
+                };
+
                 let leader = follower_states
                     .iter()
                     .find_map(|(leader_id, follower_states)| {
@@ -257,7 +266,7 @@ impl Member {
                 };
 
                 Stack::new()
-                    .with_child(ChildView::new(pane, cx).contained().with_border(border))
+                    .with_child(pane_element.contained().with_border(border))
                     .with_children(leader_status_box)
                     .into_any()
             }
@@ -267,6 +276,7 @@ impl Member {
                 follower_states,
                 active_call,
                 active_pane,
+                zoomed,
                 app_state,
                 cx,
             ),
@@ -371,6 +381,7 @@ impl PaneAxis {
         follower_state: &FollowerStatesByLeader,
         active_call: Option<&ModelHandle<ActiveCall>>,
         active_pane: &ViewHandle<Pane>,
+        zoomed: Option<&AnyViewHandle>,
         app_state: &Arc<AppState>,
         cx: &mut ViewContext<Workspace>,
     ) -> AnyElement<Workspace> {
@@ -388,6 +399,7 @@ impl PaneAxis {
                     follower_state,
                     active_call,
                     active_pane,
+                    zoomed,
                     app_state,
                     cx,
                 );

crates/workspace/src/persistence.rs 🔗

@@ -11,7 +11,6 @@ use gpui::{platform::WindowBounds, Axis};
 use util::{unzip_option, ResultExt};
 use uuid::Uuid;
 
-use crate::dock::DockPosition;
 use crate::WorkspaceId;
 
 use model::{
@@ -19,15 +18,17 @@ use model::{
     WorkspaceLocation,
 };
 
+use self::model::DockStructure;
+
 define_connection! {
     // Current schema shape using pseudo-rust syntax:
     //
     // workspaces(
     //   workspace_id: usize, // Primary key for workspaces
     //   workspace_location: Bincode<Vec<PathBuf>>,
-    //   dock_visible: bool,
-    //   dock_anchor: DockAnchor, // 'Bottom' / 'Right' / 'Expanded'
-    //   dock_pane: Option<usize>, // PaneId
+    //   dock_visible: bool, // Deprecated
+    //   dock_anchor: DockAnchor, // Deprecated
+    //   dock_pane: Option<usize>, // Deprecated
     //   left_sidebar_open: boolean,
     //   timestamp: String, // UTC YYYY-MM-DD HH:MM:SS
     //   window_state: String, // WindowBounds Discriminant
@@ -71,10 +72,10 @@ define_connection! {
         CREATE TABLE workspaces(
             workspace_id INTEGER PRIMARY KEY,
             workspace_location BLOB UNIQUE,
-            dock_visible INTEGER, // Boolean
-            dock_anchor TEXT, // Enum: 'Bottom' / 'Right' / 'Expanded'
-            dock_pane INTEGER, // NULL indicates that we don't have a dock pane yet
-            left_sidebar_open INTEGER, //Boolean
+            dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
+            dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
+            dock_pane INTEGER, // Deprecated.  Preserving so users can downgrade Zed.
+            left_sidebar_open INTEGER, // Boolean
             timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
             FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
         ) STRICT;
@@ -131,6 +132,36 @@ define_connection! {
         ALTER TABLE workspaces ADD COLUMN window_width REAL;
         ALTER TABLE workspaces ADD COLUMN window_height REAL;
         ALTER TABLE workspaces ADD COLUMN display BLOB;
+    ),
+    // Drop foreign key constraint from workspaces.dock_pane to panes table.
+    sql!(
+        CREATE TABLE workspaces_2(
+            workspace_id INTEGER PRIMARY KEY,
+            workspace_location BLOB UNIQUE,
+            dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
+            dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
+            dock_pane INTEGER, // Deprecated.  Preserving so users can downgrade Zed.
+            left_sidebar_open INTEGER, // Boolean
+            timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+            window_state TEXT,
+            window_x REAL,
+            window_y REAL,
+            window_width REAL,
+            window_height REAL,
+            display BLOB
+        ) STRICT;
+        INSERT INTO workspaces_2 SELECT * FROM workspaces;
+        DROP TABLE workspaces;
+        ALTER TABLE workspaces_2 RENAME TO workspaces;
+    ),
+    // Add panels related information
+    sql!(
+        ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool
+        ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT;
+        ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool
+        ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT;
+        ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool
+        ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT;
     )];
 }
 
@@ -146,27 +177,29 @@ impl WorkspaceDb {
 
         // Note that we re-assign the workspace_id here in case it's empty
         // and we've grabbed the most recent workspace
-        let (workspace_id, workspace_location, left_sidebar_open, dock_position, bounds, display): (
+        let (workspace_id, workspace_location, bounds, display, docks): (
             WorkspaceId,
             WorkspaceLocation,
-            bool,
-            DockPosition,
             Option<WindowBounds>,
             Option<Uuid>,
+            DockStructure,
         ) = self
             .select_row_bound(sql! {
                 SELECT
                     workspace_id,
                     workspace_location,
-                    left_sidebar_open,
-                    dock_visible,
-                    dock_anchor,
                     window_state,
                     window_x,
                     window_y,
                     window_width,
                     window_height,
-                    display
+                    display,
+                    left_dock_visible,
+                    left_dock_active_panel,
+                    right_dock_visible,
+                    right_dock_active_panel,
+                    bottom_dock_visible,
+                    bottom_dock_active_panel
                 FROM workspaces
                 WHERE workspace_location = ?
             })
@@ -178,18 +211,13 @@ impl WorkspaceDb {
         Some(SerializedWorkspace {
             id: workspace_id,
             location: workspace_location.clone(),
-            dock_pane: self
-                .get_dock_pane(workspace_id)
-                .context("Getting dock pane")
-                .log_err()?,
             center_group: self
                 .get_center_pane_group(workspace_id)
                 .context("Getting center group")
                 .log_err()?,
-            dock_position,
-            left_sidebar_open,
             bounds,
             display,
+            docks,
         })
     }
 
@@ -200,7 +228,6 @@ impl WorkspaceDb {
             conn.with_savepoint("update_worktrees", || {
                 // Clear out panes and pane_groups
                 conn.exec_bound(sql!(
-                    UPDATE workspaces SET dock_pane = NULL WHERE workspace_id = ?1;
                     DELETE FROM pane_groups WHERE workspace_id = ?1;
                     DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id)
                 .expect("Clearing old panes");
@@ -215,42 +242,32 @@ impl WorkspaceDb {
                     INSERT INTO workspaces(
                         workspace_id,
                         workspace_location,
-                        left_sidebar_open,
-                        dock_visible,
-                        dock_anchor,
+                        left_dock_visible,
+                        left_dock_active_panel,
+                        right_dock_visible,
+                        right_dock_active_panel,
+                        bottom_dock_visible,
+                        bottom_dock_active_panel,
                         timestamp
                     )
-                    VALUES (?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP)
+                    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, CURRENT_TIMESTAMP)
                     ON CONFLICT DO
                     UPDATE SET
                         workspace_location = ?2,
-                        left_sidebar_open = ?3,
-                        dock_visible = ?4,
-                        dock_anchor = ?5,
+                        left_dock_visible = ?3,
+                        left_dock_active_panel = ?4,
+                        right_dock_visible = ?5,
+                        right_dock_active_panel = ?6,
+                        bottom_dock_visible = ?7,
+                        bottom_dock_active_panel = ?8,
                         timestamp = CURRENT_TIMESTAMP
-                ))?((
-                    workspace.id,
-                    &workspace.location,
-                    workspace.left_sidebar_open,
-                    workspace.dock_position,
-                ))
+                ))?((workspace.id, &workspace.location, workspace.docks))
                 .context("Updating workspace")?;
 
-                // Save center pane group and dock pane
+                // Save center pane group
                 Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
                     .context("save pane group in save workspace")?;
 
-                let dock_id = Self::save_pane(conn, workspace.id, &workspace.dock_pane, None, true)
-                    .context("save pane in save workspace")?;
-
-                // Complete workspace initialization
-                conn.exec_bound(sql!(
-                    UPDATE workspaces
-                    SET dock_pane = ?
-                        WHERE workspace_id = ?
-                ))?((dock_id, workspace.id))
-                .context("Finishing initialization with dock pane")?;
-
                 Ok(())
             })
             .log_err();
@@ -402,32 +419,17 @@ impl WorkspaceDb {
                 Ok(())
             }
             SerializedPaneGroup::Pane(pane) => {
-                Self::save_pane(conn, workspace_id, &pane, parent, false)?;
+                Self::save_pane(conn, workspace_id, &pane, parent)?;
                 Ok(())
             }
         }
     }
 
-    fn get_dock_pane(&self, workspace_id: WorkspaceId) -> Result<SerializedPane> {
-        let (pane_id, active) = self.select_row_bound(sql!(
-            SELECT pane_id, active
-            FROM panes
-            WHERE pane_id = (SELECT dock_pane FROM workspaces WHERE workspace_id = ?)
-        ))?(workspace_id)?
-        .context("No dock pane for workspace")?;
-
-        Ok(SerializedPane::new(
-            self.get_items(pane_id).context("Reading items")?,
-            active,
-        ))
-    }
-
     fn save_pane(
         conn: &Connection,
         workspace_id: WorkspaceId,
         pane: &SerializedPane,
-        parent: Option<(GroupId, usize)>, // None indicates BOTH dock pane AND center_pane
-        dock: bool,
+        parent: Option<(GroupId, usize)>,
     ) -> Result<PaneId> {
         let pane_id = conn.select_row_bound::<_, i64>(sql!(
             INSERT INTO panes(workspace_id, active)
@@ -436,13 +438,11 @@ impl WorkspaceDb {
         ))?((workspace_id, pane.active))?
         .ok_or_else(|| anyhow!("Could not retrieve inserted pane_id"))?;
 
-        if !dock {
-            let (parent_id, order) = unzip_option(parent);
-            conn.exec_bound(sql!(
-                INSERT INTO center_panes(pane_id, parent_group_id, position)
-                VALUES (?, ?, ?)
-            ))?((pane_id, parent_id, order))?;
-        }
+        let (parent_id, order) = unzip_option(parent);
+        conn.exec_bound(sql!(
+            INSERT INTO center_panes(pane_id, parent_group_id, position)
+            VALUES (?, ?, ?)
+        ))?((pane_id, parent_id, order))?;
 
         Self::save_items(conn, workspace_id, pane_id, &pane.children).context("Saving items")?;
 
@@ -498,9 +498,7 @@ impl WorkspaceDb {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::DockAnchor;
     use db::open_test_db;
-    use std::sync::Arc;
 
     #[gpui::test]
     async fn test_next_id_stability() {
@@ -575,23 +573,19 @@ mod tests {
         let mut workspace_1 = SerializedWorkspace {
             id: 1,
             location: (["/tmp", "/tmp2"]).into(),
-            dock_position: crate::dock::DockPosition::Shown(DockAnchor::Bottom),
             center_group: Default::default(),
-            dock_pane: Default::default(),
-            left_sidebar_open: true,
             bounds: Default::default(),
             display: Default::default(),
+            docks: Default::default(),
         };
 
-        let mut workspace_2 = SerializedWorkspace {
+        let workspace_2 = SerializedWorkspace {
             id: 2,
             location: (["/tmp"]).into(),
-            dock_position: crate::dock::DockPosition::Hidden(DockAnchor::Expanded),
             center_group: Default::default(),
-            dock_pane: Default::default(),
-            left_sidebar_open: false,
             bounds: Default::default(),
             display: Default::default(),
+            docks: Default::default(),
         };
 
         db.save_workspace(workspace_1.clone()).await;
@@ -615,12 +609,6 @@ mod tests {
         workspace_1.location = (["/tmp", "/tmp3"]).into();
         db.save_workspace(workspace_1.clone()).await;
         db.save_workspace(workspace_1).await;
-
-        workspace_2.dock_pane.children.push(SerializedItem {
-            kind: Arc::from("Test"),
-            item_id: 10,
-            active: true,
-        });
         db.save_workspace(workspace_2).await;
 
         let test_text_2 = db
@@ -644,16 +632,6 @@ mod tests {
 
         let db = WorkspaceDb(open_test_db("test_full_workspace_serialization").await);
 
-        let dock_pane = crate::persistence::model::SerializedPane {
-            children: vec![
-                SerializedItem::new("Terminal", 1, false),
-                SerializedItem::new("Terminal", 2, false),
-                SerializedItem::new("Terminal", 3, true),
-                SerializedItem::new("Terminal", 4, false),
-            ],
-            active: false,
-        };
-
         //  -----------------
         //  | 1,2   | 5,6   |
         //  | - - - |       |
@@ -694,12 +672,10 @@ mod tests {
         let workspace = SerializedWorkspace {
             id: 5,
             location: (["/tmp", "/tmp2"]).into(),
-            dock_position: DockPosition::Shown(DockAnchor::Bottom),
             center_group,
-            dock_pane,
-            left_sidebar_open: true,
             bounds: Default::default(),
             display: Default::default(),
+            docks: Default::default(),
         };
 
         db.save_workspace(workspace.clone()).await;
@@ -724,23 +700,19 @@ mod tests {
         let workspace_1 = SerializedWorkspace {
             id: 1,
             location: (["/tmp", "/tmp2"]).into(),
-            dock_position: crate::dock::DockPosition::Shown(DockAnchor::Bottom),
             center_group: Default::default(),
-            dock_pane: Default::default(),
-            left_sidebar_open: true,
             bounds: Default::default(),
             display: Default::default(),
+            docks: Default::default(),
         };
 
         let mut workspace_2 = SerializedWorkspace {
             id: 2,
             location: (["/tmp"]).into(),
-            dock_position: crate::dock::DockPosition::Hidden(DockAnchor::Expanded),
             center_group: Default::default(),
-            dock_pane: Default::default(),
-            left_sidebar_open: false,
             bounds: Default::default(),
             display: Default::default(),
+            docks: Default::default(),
         };
 
         db.save_workspace(workspace_1.clone()).await;
@@ -773,12 +745,10 @@ mod tests {
         let mut workspace_3 = SerializedWorkspace {
             id: 3,
             location: (&["/tmp", "/tmp2"]).into(),
-            dock_position: DockPosition::Shown(DockAnchor::Right),
             center_group: Default::default(),
-            dock_pane: Default::default(),
-            left_sidebar_open: false,
             bounds: Default::default(),
             display: Default::default(),
+            docks: Default::default(),
         };
 
         db.save_workspace(workspace_3.clone()).await;
@@ -798,52 +768,23 @@ mod tests {
         );
     }
 
-    use crate::dock::DockPosition;
     use crate::persistence::model::SerializedWorkspace;
     use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
 
     fn default_workspace<P: AsRef<Path>>(
         workspace_id: &[P],
-        dock_pane: SerializedPane,
         center_group: &SerializedPaneGroup,
     ) -> SerializedWorkspace {
         SerializedWorkspace {
             id: 4,
             location: workspace_id.into(),
-            dock_position: crate::dock::DockPosition::Hidden(DockAnchor::Right),
             center_group: center_group.clone(),
-            dock_pane,
-            left_sidebar_open: true,
             bounds: Default::default(),
             display: Default::default(),
+            docks: Default::default(),
         }
     }
 
-    #[gpui::test]
-    async fn test_basic_dock_pane() {
-        env_logger::try_init().ok();
-
-        let db = WorkspaceDb(open_test_db("basic_dock_pane").await);
-
-        let dock_pane = crate::persistence::model::SerializedPane::new(
-            vec![
-                SerializedItem::new("Terminal", 1, false),
-                SerializedItem::new("Terminal", 4, false),
-                SerializedItem::new("Terminal", 2, false),
-                SerializedItem::new("Terminal", 3, true),
-            ],
-            false,
-        );
-
-        let workspace = default_workspace(&["/tmp"], dock_pane, &Default::default());
-
-        db.save_workspace(workspace.clone()).await;
-
-        let new_workspace = db.workspace_for_roots(&["/tmp"]).unwrap();
-
-        assert_eq!(workspace.dock_pane, new_workspace.dock_pane);
-    }
-
     #[gpui::test]
     async fn test_simple_split() {
         env_logger::try_init().ok();
@@ -887,7 +828,7 @@ mod tests {
             ],
         };
 
-        let workspace = default_workspace(&["/tmp"], Default::default(), &center_pane);
+        let workspace = default_workspace(&["/tmp"], &center_pane);
 
         db.save_workspace(workspace.clone()).await;
 
@@ -936,7 +877,7 @@ mod tests {
 
         let id = &["/tmp"];
 
-        let mut workspace = default_workspace(id, Default::default(), &center_pane);
+        let mut workspace = default_workspace(id, &center_pane);
 
         db.save_workspace(workspace.clone()).await;
 

crates/workspace/src/persistence/model.rs 🔗

@@ -1,7 +1,4 @@
-use crate::{
-    dock::DockPosition, item::ItemHandle, DockAnchor, ItemDeserializers, Member, Pane, PaneAxis,
-    Workspace, WorkspaceId,
-};
+use crate::{item::ItemHandle, ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId};
 use anyhow::{anyhow, Context, Result};
 use async_recursion::async_recursion;
 use db::sqlez::{
@@ -62,12 +59,68 @@ impl Column for WorkspaceLocation {
 pub struct SerializedWorkspace {
     pub id: WorkspaceId,
     pub location: WorkspaceLocation,
-    pub dock_position: DockPosition,
     pub center_group: SerializedPaneGroup,
-    pub dock_pane: SerializedPane,
-    pub left_sidebar_open: bool,
     pub bounds: Option<WindowBounds>,
     pub display: Option<Uuid>,
+    pub docks: DockStructure,
+}
+
+#[derive(Debug, PartialEq, Clone, Default)]
+pub struct DockStructure {
+    pub(crate) left: DockData,
+    pub(crate) right: DockData,
+    pub(crate) bottom: DockData,
+}
+
+impl Column for DockStructure {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (left, next_index) = DockData::column(statement, start_index)?;
+        let (right, next_index) = DockData::column(statement, next_index)?;
+        let (bottom, next_index) = DockData::column(statement, next_index)?;
+        Ok((
+            DockStructure {
+                left,
+                right,
+                bottom,
+            },
+            next_index,
+        ))
+    }
+}
+
+impl Bind for DockStructure {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = statement.bind(&self.left, start_index)?;
+        let next_index = statement.bind(&self.right, next_index)?;
+        statement.bind(&self.bottom, next_index)
+    }
+}
+
+#[derive(Debug, PartialEq, Clone, Default)]
+pub struct DockData {
+    pub(crate) visible: bool,
+    pub(crate) active_panel: Option<String>,
+}
+
+impl Column for DockData {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (visible, next_index) = Option::<bool>::column(statement, start_index)?;
+        let (active_panel, next_index) = Option::<String>::column(statement, next_index)?;
+        Ok((
+            DockData {
+                visible: visible.unwrap_or(false),
+                active_panel,
+            },
+            next_index,
+        ))
+    }
+}
+
+impl Bind for DockData {
+    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+        let next_index = statement.bind(&self.visible, start_index)?;
+        statement.bind(&self.active_panel, next_index)
+    }
 }
 
 #[derive(Debug, PartialEq, Eq, Clone)]
@@ -266,9 +319,9 @@ impl StaticColumnCount for SerializedItem {
 }
 impl Bind for &SerializedItem {
     fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
-        let next_index = statement.bind(self.kind.clone(), start_index)?;
-        let next_index = statement.bind(self.item_id, next_index)?;
-        statement.bind(self.active, next_index)
+        let next_index = statement.bind(&self.kind, start_index)?;
+        let next_index = statement.bind(&self.item_id, next_index)?;
+        statement.bind(&self.active, next_index)
     }
 }
 
@@ -287,64 +340,3 @@ impl Column for SerializedItem {
         ))
     }
 }
-
-impl StaticColumnCount for DockPosition {
-    fn column_count() -> usize {
-        2
-    }
-}
-impl Bind for DockPosition {
-    fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
-        let next_index = statement.bind(self.is_visible(), start_index)?;
-        statement.bind(self.anchor(), next_index)
-    }
-}
-
-impl Column for DockPosition {
-    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
-        let (visible, next_index) = bool::column(statement, start_index)?;
-        let (dock_anchor, next_index) = DockAnchor::column(statement, next_index)?;
-        let position = if visible {
-            DockPosition::Shown(dock_anchor)
-        } else {
-            DockPosition::Hidden(dock_anchor)
-        };
-        Ok((position, next_index))
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::WorkspaceLocation;
-    use crate::DockAnchor;
-    use db::sqlez::connection::Connection;
-
-    #[test]
-    fn test_workspace_round_trips() {
-        let db = Connection::open_memory(Some("workspace_id_round_trips"));
-
-        db.exec(indoc::indoc! {"
-                CREATE TABLE workspace_id_test(
-                    workspace_id INTEGER,
-                    dock_anchor TEXT
-                );"})
-            .unwrap()()
-        .unwrap();
-
-        let workspace_id: WorkspaceLocation = WorkspaceLocation::from(&["\test2", "\test1"]);
-
-        db.exec_bound("INSERT INTO workspace_id_test(workspace_id, dock_anchor) VALUES (?,?)")
-            .unwrap()((&workspace_id, DockAnchor::Bottom))
-        .unwrap();
-
-        assert_eq!(
-            db.select_row("SELECT workspace_id, dock_anchor FROM workspace_id_test LIMIT 1")
-                .unwrap()()
-            .unwrap(),
-            Some((
-                WorkspaceLocation::from(&["\test1", "\test2"]),
-                DockAnchor::Bottom
-            ))
-        );
-    }
-}

crates/workspace/src/sidebar.rs 🔗

@@ -1,321 +0,0 @@
-use crate::{StatusItemView, Workspace};
-use gpui::{
-    elements::*, impl_actions, platform::CursorStyle, platform::MouseButton, AnyViewHandle,
-    AppContext, Entity, Subscription, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
-};
-use serde::Deserialize;
-use std::rc::Rc;
-
-pub trait SidebarItem: View {
-    fn should_activate_item_on_event(&self, _: &Self::Event, _: &AppContext) -> bool {
-        false
-    }
-    fn should_show_badge(&self, _: &AppContext) -> bool {
-        false
-    }
-    fn contains_focused_view(&self, _: &AppContext) -> bool {
-        false
-    }
-}
-
-pub trait SidebarItemHandle {
-    fn id(&self) -> usize;
-    fn should_show_badge(&self, cx: &WindowContext) -> bool;
-    fn is_focused(&self, cx: &WindowContext) -> bool;
-    fn as_any(&self) -> &AnyViewHandle;
-}
-
-impl<T> SidebarItemHandle for ViewHandle<T>
-where
-    T: SidebarItem,
-{
-    fn id(&self) -> usize {
-        self.id()
-    }
-
-    fn should_show_badge(&self, cx: &WindowContext) -> bool {
-        self.read(cx).should_show_badge(cx)
-    }
-
-    fn is_focused(&self, cx: &WindowContext) -> bool {
-        ViewHandle::is_focused(self, cx) || self.read(cx).contains_focused_view(cx)
-    }
-
-    fn as_any(&self) -> &AnyViewHandle {
-        self
-    }
-}
-
-impl From<&dyn SidebarItemHandle> for AnyViewHandle {
-    fn from(val: &dyn SidebarItemHandle) -> Self {
-        val.as_any().clone()
-    }
-}
-
-pub struct Sidebar {
-    sidebar_side: SidebarSide,
-    items: Vec<Item>,
-    is_open: bool,
-    active_item_ix: usize,
-}
-
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
-pub enum SidebarSide {
-    Left,
-    Right,
-}
-
-impl SidebarSide {
-    fn to_resizable_side(self) -> Side {
-        match self {
-            Self::Left => Side::Right,
-            Self::Right => Side::Left,
-        }
-    }
-}
-
-struct Item {
-    icon_path: &'static str,
-    tooltip: String,
-    view: Rc<dyn SidebarItemHandle>,
-    _subscriptions: [Subscription; 2],
-}
-
-pub struct SidebarButtons {
-    sidebar: ViewHandle<Sidebar>,
-    workspace: WeakViewHandle<Workspace>,
-}
-
-#[derive(Clone, Debug, Deserialize, PartialEq)]
-pub struct ToggleSidebarItem {
-    pub sidebar_side: SidebarSide,
-    pub item_index: usize,
-}
-
-impl_actions!(workspace, [ToggleSidebarItem]);
-
-impl Sidebar {
-    pub fn new(sidebar_side: SidebarSide) -> Self {
-        Self {
-            sidebar_side,
-            items: Default::default(),
-            active_item_ix: 0,
-            is_open: false,
-        }
-    }
-
-    pub fn is_open(&self) -> bool {
-        self.is_open
-    }
-
-    pub fn active_item_ix(&self) -> usize {
-        self.active_item_ix
-    }
-
-    pub fn set_open(&mut self, open: bool, cx: &mut ViewContext<Self>) {
-        if open != self.is_open {
-            self.is_open = open;
-            cx.notify();
-        }
-    }
-
-    pub fn toggle_open(&mut self, cx: &mut ViewContext<Self>) {
-        if self.is_open {}
-        self.is_open = !self.is_open;
-        cx.notify();
-    }
-
-    pub fn add_item<T: SidebarItem>(
-        &mut self,
-        icon_path: &'static str,
-        tooltip: String,
-        view: ViewHandle<T>,
-        cx: &mut ViewContext<Self>,
-    ) {
-        let subscriptions = [
-            cx.observe(&view, |_, _, cx| cx.notify()),
-            cx.subscribe(&view, |this, view, event, cx| {
-                if view.read(cx).should_activate_item_on_event(event, cx) {
-                    if let Some(ix) = this
-                        .items
-                        .iter()
-                        .position(|item| item.view.id() == view.id())
-                    {
-                        this.activate_item(ix, cx);
-                    }
-                }
-            }),
-        ];
-
-        self.items.push(Item {
-            icon_path,
-            tooltip,
-            view: Rc::new(view),
-            _subscriptions: subscriptions,
-        });
-        cx.notify()
-    }
-
-    pub fn activate_item(&mut self, item_ix: usize, cx: &mut ViewContext<Self>) {
-        self.active_item_ix = item_ix;
-        cx.notify();
-    }
-
-    pub fn toggle_item(&mut self, item_ix: usize, cx: &mut ViewContext<Self>) {
-        if self.active_item_ix == item_ix {
-            self.is_open = false;
-        } else {
-            self.active_item_ix = item_ix;
-        }
-        cx.notify();
-    }
-
-    pub fn active_item(&self) -> Option<&Rc<dyn SidebarItemHandle>> {
-        if self.is_open {
-            self.items.get(self.active_item_ix).map(|item| &item.view)
-        } else {
-            None
-        }
-    }
-}
-
-impl Entity for Sidebar {
-    type Event = ();
-}
-
-impl View for Sidebar {
-    fn ui_name() -> &'static str {
-        "Sidebar"
-    }
-
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        if let Some(active_item) = self.active_item() {
-            enum ResizeHandleTag {}
-            let style = &theme::current(cx).workspace.sidebar;
-            ChildView::new(active_item.as_any(), cx)
-                .contained()
-                .with_style(style.container)
-                .with_resize_handle::<ResizeHandleTag>(
-                    self.sidebar_side as usize,
-                    self.sidebar_side.to_resizable_side(),
-                    4.,
-                    style.initial_size,
-                    cx,
-                )
-                .into_any()
-        } else {
-            Empty::new().into_any()
-        }
-    }
-}
-
-impl SidebarButtons {
-    pub fn new(
-        sidebar: ViewHandle<Sidebar>,
-        workspace: WeakViewHandle<Workspace>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        cx.observe(&sidebar, |_, _, cx| cx.notify()).detach();
-        Self { sidebar, workspace }
-    }
-}
-
-impl Entity for SidebarButtons {
-    type Event = ();
-}
-
-impl View for SidebarButtons {
-    fn ui_name() -> &'static str {
-        "SidebarToggleButton"
-    }
-
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &theme::current(cx);
-        let tooltip_style = theme.tooltip.clone();
-        let theme = &theme.workspace.status_bar.sidebar_buttons;
-        let sidebar = self.sidebar.read(cx);
-        let item_style = theme.item.clone();
-        let badge_style = theme.badge;
-        let active_ix = sidebar.active_item_ix;
-        let is_open = sidebar.is_open;
-        let sidebar_side = sidebar.sidebar_side;
-        let group_style = match sidebar_side {
-            SidebarSide::Left => theme.group_left,
-            SidebarSide::Right => theme.group_right,
-        };
-
-        #[allow(clippy::needless_collect)]
-        let items = sidebar
-            .items
-            .iter()
-            .map(|item| (item.icon_path, item.tooltip.clone(), item.view.clone()))
-            .collect::<Vec<_>>();
-
-        Flex::row()
-            .with_children(items.into_iter().enumerate().map(
-                |(ix, (icon_path, tooltip, item_view))| {
-                    let action = ToggleSidebarItem {
-                        sidebar_side,
-                        item_index: ix,
-                    };
-                    MouseEventHandler::<Self, _>::new(ix, cx, |state, cx| {
-                        let is_active = is_open && ix == active_ix;
-                        let style = item_style.style_for(state, is_active);
-                        Stack::new()
-                            .with_child(Svg::new(icon_path).with_color(style.icon_color))
-                            .with_children(if !is_active && item_view.should_show_badge(cx) {
-                                Some(
-                                    Empty::new()
-                                        .collapsed()
-                                        .contained()
-                                        .with_style(badge_style)
-                                        .aligned()
-                                        .bottom()
-                                        .right(),
-                                )
-                            } else {
-                                None
-                            })
-                            .constrained()
-                            .with_width(style.icon_size)
-                            .with_height(style.icon_size)
-                            .contained()
-                            .with_style(style.container)
-                    })
-                    .with_cursor_style(CursorStyle::PointingHand)
-                    .on_click(MouseButton::Left, {
-                        let action = action.clone();
-                        move |_, this, cx| {
-                            if let Some(workspace) = this.workspace.upgrade(cx) {
-                                let action = action.clone();
-                                cx.window_context().defer(move |cx| {
-                                    workspace.update(cx, |workspace, cx| {
-                                        workspace.toggle_sidebar_item(&action, cx)
-                                    });
-                                });
-                            }
-                        }
-                    })
-                    .with_tooltip::<Self>(
-                        ix,
-                        tooltip,
-                        Some(Box::new(action)),
-                        tooltip_style.clone(),
-                        cx,
-                    )
-                },
-            ))
-            .contained()
-            .with_style(group_style)
-            .into_any()
-    }
-}
-
-impl StatusItemView for SidebarButtons {
-    fn set_active_pane_item(
-        &mut self,
-        _: Option<&dyn crate::ItemHandle>,
-        _: &mut ViewContext<Self>,
-    ) {
-    }
-}

crates/workspace/src/workspace.rs 🔗

@@ -1,8 +1,8 @@
+pub mod dock;
 /// NOTE: Focus only 'takes' after an update has flushed_effects.
 ///
 /// This may cause issues when you're trying to write tests that use workspace focus to add items at
 /// specific locations.
-pub mod dock;
 pub mod item;
 pub mod notifications;
 pub mod pane;
@@ -10,7 +10,6 @@ pub mod pane_group;
 mod persistence;
 pub mod searchable;
 pub mod shared_screen;
-pub mod sidebar;
 mod status_bar;
 mod toolbar;
 mod workspace_settings;
@@ -23,7 +22,6 @@ use client::{
     Client, TypedEnvelope, UserStore,
 };
 use collections::{hash_map, HashMap, HashSet};
-use dock::{Dock, DockDefaultItemFactory, ToggleDockButton};
 use drag_and_drop::DragAndDrop;
 use futures::{
     channel::{mpsc, oneshot},
@@ -62,10 +60,12 @@ use std::{
 
 use crate::{
     notifications::simple_message_notification::MessageNotification,
-    persistence::model::{SerializedPane, SerializedPaneGroup, SerializedWorkspace},
+    persistence::model::{
+        DockData, DockStructure, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
+    },
 };
+use dock::{Dock, DockPosition, Panel, PanelButtons, PanelHandle};
 use lazy_static::lazy_static;
-use log::warn;
 use notifications::{NotificationHandle, NotifyResultExt};
 pub use pane::*;
 pub use pane_group::*;
@@ -78,13 +78,12 @@ use postage::prelude::Stream;
 use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use serde::Deserialize;
 use shared_screen::SharedScreen;
-use sidebar::{Sidebar, SidebarButtons, SidebarSide, ToggleSidebarItem};
 use status_bar::StatusBar;
 pub use status_bar::StatusItemView;
 use theme::Theme;
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
 use util::{async_iife, paths, ResultExt};
-pub use workspace_settings::{AutosaveSetting, DockAnchor, GitGutterSetting, WorkspaceSettings};
+pub use workspace_settings::{AutosaveSetting, GitGutterSetting, WorkspaceSettings};
 
 lazy_static! {
     static ref ZED_WINDOW_SIZE: Option<Vector2F> = env::var("ZED_WINDOW_SIZE")
@@ -104,6 +103,21 @@ pub trait Modal: View {
 #[derive(Clone, PartialEq)]
 pub struct RemoveWorktreeFromProject(pub WorktreeId);
 
+#[derive(Copy, Clone, Default, Deserialize, PartialEq)]
+pub struct ToggleLeftDock {
+    pub focus: bool,
+}
+
+#[derive(Copy, Clone, Default, Deserialize, PartialEq)]
+pub struct ToggleBottomDock {
+    pub focus: bool,
+}
+
+#[derive(Copy, Clone, Default, Deserialize, PartialEq)]
+pub struct ToggleRightDock {
+    pub focus: bool,
+}
+
 actions!(
     workspace,
     [
@@ -119,17 +133,23 @@ actions!(
         ActivatePreviousPane,
         ActivateNextPane,
         FollowNextCollaborator,
-        ToggleLeftSidebar,
         NewTerminal,
+        ToggleTerminalFocus,
         NewSearch,
         Feedback,
         Restart,
-        Welcome
+        Welcome,
+        ToggleZoom,
     ]
 );
 
 actions!(zed, [OpenSettings]);
 
+impl_actions!(
+    workspace,
+    [ToggleLeftDock, ToggleBottomDock, ToggleRightDock]
+);
+
 #[derive(Clone, PartialEq)]
 pub struct OpenPaths {
     pub paths: Vec<PathBuf>,
@@ -192,7 +212,6 @@ pub fn init_settings(cx: &mut AppContext) {
 pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
     init_settings(cx);
     pane::init(cx);
-    dock::init(cx);
     notifications::init(cx);
 
     cx.add_global_action({
@@ -240,15 +259,20 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
             workspace.save_active_item(true, cx).detach_and_log_err(cx);
         },
     );
-    cx.add_action(Workspace::toggle_sidebar_item);
     cx.add_action(|workspace: &mut Workspace, _: &ActivatePreviousPane, cx| {
         workspace.activate_previous_pane(cx)
     });
     cx.add_action(|workspace: &mut Workspace, _: &ActivateNextPane, cx| {
         workspace.activate_next_pane(cx)
     });
-    cx.add_action(|workspace: &mut Workspace, _: &ToggleLeftSidebar, cx| {
-        workspace.toggle_sidebar(SidebarSide::Left, cx);
+    cx.add_action(|workspace: &mut Workspace, action: &ToggleLeftDock, cx| {
+        workspace.toggle_dock(DockPosition::Left, action.focus, cx);
+    });
+    cx.add_action(|workspace: &mut Workspace, action: &ToggleRightDock, cx| {
+        workspace.toggle_dock(DockPosition::Right, action.focus, cx);
+    });
+    cx.add_action(|workspace: &mut Workspace, action: &ToggleBottomDock, cx| {
+        workspace.toggle_dock(DockPosition::Bottom, action.focus, cx);
     });
     cx.add_action(Workspace::activate_pane_at_index);
 
@@ -366,8 +390,8 @@ pub struct AppState {
     pub fs: Arc<dyn fs::Fs>,
     pub build_window_options:
         fn(Option<WindowBounds>, Option<uuid::Uuid>, &dyn Platform) -> WindowOptions<'static>,
-    pub initialize_workspace: fn(&mut Workspace, &Arc<AppState>, &mut ViewContext<Workspace>),
-    pub dock_default_item_factory: DockDefaultItemFactory,
+    pub initialize_workspace:
+        fn(WeakViewHandle<Workspace>, bool, Arc<AppState>, AsyncAppContext) -> Task<Result<()>>,
     pub background_actions: BackgroundActions,
 }
 
@@ -395,9 +419,8 @@ impl AppState {
             fs,
             languages,
             user_store,
-            initialize_workspace: |_, _, _| {},
+            initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             build_window_options: |_, _, _| Default::default(),
-            dock_default_item_factory: |_, _| None,
             background_actions: || &[],
         })
     }
@@ -450,7 +473,6 @@ impl DelayedDebouncedEditAction {
 }
 
 pub enum Event {
-    DockAnchorChanged,
     PaneAdded(ViewHandle<Pane>),
     ContactRequestedJoin(u64),
 }
@@ -460,15 +482,15 @@ pub struct Workspace {
     remote_entity_subscription: Option<client::Subscription>,
     modal: Option<AnyViewHandle>,
     center: PaneGroup,
-    left_sidebar: ViewHandle<Sidebar>,
-    right_sidebar: ViewHandle<Sidebar>,
+    left_dock: ViewHandle<Dock>,
+    bottom_dock: ViewHandle<Dock>,
+    right_dock: ViewHandle<Dock>,
     panes: Vec<ViewHandle<Pane>>,
     panes_by_item: HashMap<usize, WeakViewHandle<Pane>>,
     active_pane: ViewHandle<Pane>,
     last_active_center_pane: Option<WeakViewHandle<Pane>>,
     status_bar: ViewHandle<StatusBar>,
     titlebar_item: Option<AnyViewHandle>,
-    dock: Dock,
     notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
     project: ModelHandle<Project>,
     leader_state: LeaderState,
@@ -479,7 +501,7 @@ pub struct Workspace {
     leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
     database_id: WorkspaceId,
     app_state: Arc<AppState>,
-    _window_subscriptions: [Subscription; 3],
+    subscriptions: Vec<Subscription>,
     _apply_leader_updates: Task<Result<()>>,
     _observe_current_user: Task<Result<()>>,
     pane_history_timestamp: Arc<AtomicUsize>,
@@ -537,6 +559,14 @@ impl Workspace {
                     cx.remove_window();
                 }
 
+                project::Event::DeletedEntry(entry_id) => {
+                    for pane in this.panes.iter() {
+                        pane.update(cx, |pane, cx| {
+                            pane.handle_deleted_project_item(*entry_id, cx)
+                        });
+                    }
+                }
+
                 _ => {}
             }
             cx.notify()
@@ -549,7 +579,6 @@ impl Workspace {
         let center_pane = cx.add_view(|cx| {
             Pane::new(
                 weak_handle.clone(),
-                None,
                 app_state.background_actions,
                 pane_history_timestamp.clone(),
                 cx,
@@ -558,13 +587,6 @@ impl Workspace {
         cx.subscribe(&center_pane, Self::handle_pane_event).detach();
         cx.focus(&center_pane);
         cx.emit(Event::PaneAdded(center_pane.clone()));
-        let dock = Dock::new(
-            app_state.dock_default_item_factory,
-            app_state.background_actions,
-            pane_history_timestamp.clone(),
-            cx,
-        );
-        let dock_pane = dock.pane().clone();
 
         let mut current_user = app_state.user_store.read(cx).watch_current_user();
         let mut connection_status = app_state.client.status();
@@ -579,7 +601,6 @@ impl Workspace {
             }
             anyhow::Ok(())
         });
-        let handle = cx.handle();
 
         // All leader updates are enqueued and then processed in a single task, so
         // that each asynchronous operation can be run in order.
@@ -597,18 +618,20 @@ impl Workspace {
 
         cx.emit_global(WorkspaceCreated(weak_handle.clone()));
 
-        let left_sidebar = cx.add_view(|_| Sidebar::new(SidebarSide::Left));
-        let right_sidebar = cx.add_view(|_| Sidebar::new(SidebarSide::Right));
-        let left_sidebar_buttons =
-            cx.add_view(|cx| SidebarButtons::new(left_sidebar.clone(), weak_handle.clone(), cx));
-        let toggle_dock = cx.add_view(|cx| ToggleDockButton::new(handle, cx));
-        let right_sidebar_buttons =
-            cx.add_view(|cx| SidebarButtons::new(right_sidebar.clone(), weak_handle.clone(), cx));
+        let left_dock = cx.add_view(|_| Dock::new(DockPosition::Left));
+        let bottom_dock = cx.add_view(|_| Dock::new(DockPosition::Bottom));
+        let right_dock = cx.add_view(|_| Dock::new(DockPosition::Right));
+        let left_dock_buttons =
+            cx.add_view(|cx| PanelButtons::new(left_dock.clone(), weak_handle.clone(), cx));
+        let bottom_dock_buttons =
+            cx.add_view(|cx| PanelButtons::new(bottom_dock.clone(), weak_handle.clone(), cx));
+        let right_dock_buttons =
+            cx.add_view(|cx| PanelButtons::new(right_dock.clone(), weak_handle.clone(), cx));
         let status_bar = cx.add_view(|cx| {
             let mut status_bar = StatusBar::new(&center_pane.clone(), cx);
-            status_bar.add_left_item(left_sidebar_buttons, cx);
-            status_bar.add_right_item(right_sidebar_buttons, cx);
-            status_bar.add_right_item(toggle_dock, cx);
+            status_bar.add_left_item(left_dock_buttons, cx);
+            status_bar.add_right_item(right_dock_buttons, cx);
+            status_bar.add_right_item(bottom_dock_buttons, cx);
             status_bar
         });
 
@@ -624,7 +647,7 @@ impl Workspace {
             active_call = Some((call, subscriptions));
         }
 
-        let subscriptions = [
+        let subscriptions = vec![
             cx.observe_fullscreen(|_, _, cx| cx.notify()),
             cx.observe_window_activation(Self::on_window_activation_changed),
             cx.observe_window_bounds(move |_, mut bounds, display, cx| {
@@ -644,17 +667,25 @@ impl Workspace {
                     .spawn(DB.set_window_bounds(workspace_id, bounds, display))
                     .detach_and_log_err(cx);
             }),
+            cx.observe(&left_dock, |this, _, cx| {
+                this.serialize_workspace(cx);
+                cx.notify();
+            }),
+            cx.observe(&bottom_dock, |this, _, cx| {
+                this.serialize_workspace(cx);
+                cx.notify();
+            }),
+            cx.observe(&right_dock, |this, _, cx| {
+                this.serialize_workspace(cx);
+                cx.notify();
+            }),
         ];
 
         let mut this = Workspace {
-            modal: None,
             weak_self: weak_handle.clone(),
+            modal: None,
             center: PaneGroup::new(center_pane.clone()),
-            dock,
-            // When removing an item, the last element remaining in this array
-            // is used to find where focus should fallback to. As such, the order
-            // of these two variables is important.
-            panes: vec![dock_pane.clone(), center_pane.clone()],
+            panes: vec![center_pane.clone()],
             panes_by_item: Default::default(),
             active_pane: center_pane.clone(),
             last_active_center_pane: Some(center_pane.downgrade()),
@@ -662,8 +693,9 @@ impl Workspace {
             titlebar_item: None,
             notifications: Default::default(),
             remote_entity_subscription: None,
-            left_sidebar,
-            right_sidebar,
+            left_dock,
+            bottom_dock,
+            right_dock,
             project: project.clone(),
             leader_state: Default::default(),
             follower_states_by_leader: Default::default(),
@@ -675,12 +707,11 @@ impl Workspace {
             _observe_current_user,
             _apply_leader_updates,
             leader_updates_tx,
-            _window_subscriptions: subscriptions,
+            subscriptions,
             pane_history_timestamp,
         };
         this.project_remote_id_changed(project.read(cx).remote_id(), cx);
         cx.defer(|this, cx| this.update_window_title(cx));
-
         this
     }
 
@@ -742,11 +773,7 @@ impl Workspace {
                     });
 
             let build_workspace = |cx: &mut ViewContext<Workspace>| {
-                let mut workspace =
-                    Workspace::new(workspace_id, project_handle.clone(), app_state.clone(), cx);
-                (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
-
-                workspace
+                Workspace::new(workspace_id, project_handle.clone(), app_state.clone(), cx)
             };
 
             let workspace = requesting_window_id
@@ -794,6 +821,17 @@ impl Workspace {
                     .1
                 });
 
+            (app_state.initialize_workspace)(
+                workspace.downgrade(),
+                serialized_workspace.is_some(),
+                app_state.clone(),
+                cx.clone(),
+            )
+            .await
+            .log_err();
+
+            cx.update_window(workspace.window_id(), |cx| cx.activate_window());
+
             let workspace = workspace.downgrade();
             notify_if_database_failed(&workspace, &mut cx);
             let opened_items = open_items(
@@ -813,12 +851,66 @@ impl Workspace {
         self.weak_self.clone()
     }
 
-    pub fn left_sidebar(&self) -> &ViewHandle<Sidebar> {
-        &self.left_sidebar
+    pub fn left_dock(&self) -> &ViewHandle<Dock> {
+        &self.left_dock
     }
 
-    pub fn right_sidebar(&self) -> &ViewHandle<Sidebar> {
-        &self.right_sidebar
+    pub fn bottom_dock(&self) -> &ViewHandle<Dock> {
+        &self.bottom_dock
+    }
+
+    pub fn right_dock(&self) -> &ViewHandle<Dock> {
+        &self.right_dock
+    }
+
+    pub fn add_panel<T: Panel>(&mut self, panel: ViewHandle<T>, cx: &mut ViewContext<Self>) {
+        let dock = match panel.position(cx) {
+            DockPosition::Left => &self.left_dock,
+            DockPosition::Bottom => &self.bottom_dock,
+            DockPosition::Right => &self.right_dock,
+        };
+
+        self.subscriptions.push(cx.subscribe(&panel, {
+            let mut dock = dock.clone();
+            let mut prev_position = panel.position(cx);
+            move |this, panel, event, cx| {
+                if T::should_change_position_on_event(event) {
+                    let new_position = panel.read(cx).position(cx);
+                    let mut was_visible = false;
+                    dock.update(cx, |dock, cx| {
+                        prev_position = new_position;
+
+                        was_visible = dock.is_open()
+                            && dock
+                                .active_panel()
+                                .map_or(false, |active_panel| active_panel.id() == panel.id());
+                        dock.remove_panel(&panel, cx);
+                    });
+                    dock = match panel.read(cx).position(cx) {
+                        DockPosition::Left => &this.left_dock,
+                        DockPosition::Bottom => &this.bottom_dock,
+                        DockPosition::Right => &this.right_dock,
+                    }
+                    .clone();
+                    dock.update(cx, |dock, cx| {
+                        dock.add_panel(panel.clone(), cx);
+                        if was_visible {
+                            dock.set_open(true, cx);
+                            dock.activate_panel(dock.panels_len() - 1, cx);
+                        }
+                    });
+                } else if T::should_zoom_in_on_event(event) {
+                    this.zoom_out(cx);
+                    dock.update(cx, |dock, cx| dock.set_panel_zoomed(&panel, true, cx));
+                } else if T::should_zoom_out_on_event(event) {
+                    this.zoom_out(cx);
+                } else if T::is_focus_event(event) {
+                    cx.notify();
+                }
+            }
+        }));
+
+        dock.update(cx, |dock, cx| dock.add_panel(panel, cx));
     }
 
     pub fn status_bar(&self) -> &ViewHandle<StatusBar> {
@@ -1264,6 +1356,44 @@ impl Workspace {
         }
     }
 
+    fn zoomed(&self, cx: &WindowContext) -> Option<AnyViewHandle> {
+        self.zoomed_panel_for_dock(DockPosition::Left, cx)
+            .or_else(|| self.zoomed_panel_for_dock(DockPosition::Bottom, cx))
+            .or_else(|| self.zoomed_panel_for_dock(DockPosition::Right, cx))
+            .or_else(|| self.zoomed_pane(cx))
+    }
+
+    fn zoomed_panel_for_dock(
+        &self,
+        position: DockPosition,
+        cx: &WindowContext,
+    ) -> Option<AnyViewHandle> {
+        let (dock, other_docks) = match position {
+            DockPosition::Left => (&self.left_dock, [&self.bottom_dock, &self.right_dock]),
+            DockPosition::Bottom => (&self.bottom_dock, [&self.left_dock, &self.right_dock]),
+            DockPosition::Right => (&self.right_dock, [&self.left_dock, &self.bottom_dock]),
+        };
+
+        let zoomed_panel = dock.read(&cx).zoomed_panel(cx)?;
+        if other_docks.iter().all(|dock| !dock.read(cx).has_focus(cx))
+            && !self.active_pane.read(cx).has_focus()
+        {
+            Some(zoomed_panel.as_any().clone())
+        } else {
+            None
+        }
+    }
+
+    fn zoomed_pane(&self, cx: &WindowContext) -> Option<AnyViewHandle> {
+        let active_pane = self.active_pane.read(cx);
+        let docks = [&self.left_dock, &self.bottom_dock, &self.right_dock];
+        if active_pane.is_zoomed() && docks.iter().all(|dock| !dock.read(cx).has_focus(cx)) {
+            Some(self.active_pane.clone().into_any())
+        } else {
+            None
+        }
+    }
+
     pub fn items<'a>(
         &'a self,
         cx: &'a AppContext,
@@ -1341,47 +1471,55 @@ impl Workspace {
         }
     }
 
-    pub fn toggle_sidebar(&mut self, sidebar_side: SidebarSide, cx: &mut ViewContext<Self>) {
-        let sidebar = match sidebar_side {
-            SidebarSide::Left => &mut self.left_sidebar,
-            SidebarSide::Right => &mut self.right_sidebar,
+    pub fn toggle_dock(
+        &mut self,
+        dock_side: DockPosition,
+        focus: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let dock = match dock_side {
+            DockPosition::Left => &self.left_dock,
+            DockPosition::Bottom => &self.bottom_dock,
+            DockPosition::Right => &self.right_dock,
         };
-        let open = sidebar.update(cx, |sidebar, cx| {
-            let open = !sidebar.is_open();
-            sidebar.set_open(open, cx);
-            open
+        dock.update(cx, |dock, cx| {
+            let open = !dock.is_open();
+            dock.set_open(open, cx);
         });
 
-        if open {
-            Dock::hide_on_sidebar_shown(self, sidebar_side, cx);
+        if dock.read(cx).is_open() && focus {
+            cx.focus(dock);
+        } else {
+            cx.focus_self();
         }
-
-        self.serialize_workspace(cx);
-
-        cx.focus_self();
         cx.notify();
+        self.serialize_workspace(cx);
     }
 
-    pub fn toggle_sidebar_item(&mut self, action: &ToggleSidebarItem, cx: &mut ViewContext<Self>) {
-        let sidebar = match action.sidebar_side {
-            SidebarSide::Left => &mut self.left_sidebar,
-            SidebarSide::Right => &mut self.right_sidebar,
+    pub fn toggle_panel(
+        &mut self,
+        position: DockPosition,
+        panel_index: usize,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let dock = match position {
+            DockPosition::Left => &mut self.left_dock,
+            DockPosition::Bottom => &mut self.bottom_dock,
+            DockPosition::Right => &mut self.right_dock,
         };
-        let active_item = sidebar.update(cx, move |sidebar, cx| {
-            if sidebar.is_open() && sidebar.active_item_ix() == action.item_index {
-                sidebar.set_open(false, cx);
+        let active_item = dock.update(cx, move |dock, cx| {
+            if dock.is_open() && dock.active_panel_index() == panel_index {
+                dock.set_open(false, cx);
                 None
             } else {
-                sidebar.set_open(true, cx);
-                sidebar.activate_item(action.item_index, cx);
-                sidebar.active_item().cloned()
+                dock.set_open(true, cx);
+                dock.activate_panel(panel_index, cx);
+                dock.active_panel().cloned()
             }
         });
 
         if let Some(active_item) = active_item {
-            Dock::hide_on_sidebar_shown(self, action.sidebar_side, cx);
-
-            if active_item.is_focused(cx) {
+            if active_item.has_focus(cx) {
                 cx.focus_self();
             } else {
                 cx.focus(active_item.as_any());
@@ -1395,32 +1533,37 @@ impl Workspace {
         cx.notify();
     }
 
-    pub fn toggle_sidebar_item_focus(
-        &mut self,
-        sidebar_side: SidebarSide,
-        item_index: usize,
-        cx: &mut ViewContext<Self>,
-    ) {
-        let sidebar = match sidebar_side {
-            SidebarSide::Left => &mut self.left_sidebar,
-            SidebarSide::Right => &mut self.right_sidebar,
-        };
-        let active_item = sidebar.update(cx, |sidebar, cx| {
-            sidebar.set_open(true, cx);
-            sidebar.activate_item(item_index, cx);
-            sidebar.active_item().cloned()
-        });
-        if let Some(active_item) = active_item {
-            Dock::hide_on_sidebar_shown(self, sidebar_side, cx);
+    pub fn toggle_panel_focus<T: Panel>(&mut self, cx: &mut ViewContext<Self>) {
+        for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] {
+            if let Some(panel_index) = dock.read(cx).panel_index_for_type::<T>() {
+                let active_item = dock.update(cx, |dock, cx| {
+                    dock.set_open(true, cx);
+                    dock.activate_panel(panel_index, cx);
+                    dock.active_panel().cloned()
+                });
+                if let Some(active_item) = active_item {
+                    if active_item.has_focus(cx) {
+                        cx.focus_self();
+                    } else {
+                        cx.focus(active_item.as_any());
+                    }
+                }
 
-            if active_item.is_focused(cx) {
-                cx.focus_self();
-            } else {
-                cx.focus(active_item.as_any());
+                self.serialize_workspace(cx);
+                cx.notify();
+                break;
             }
         }
+    }
 
-        self.serialize_workspace(cx);
+    fn zoom_out(&mut self, cx: &mut ViewContext<Self>) {
+        for pane in &self.panes {
+            pane.update(cx, |pane, cx| pane.set_zoomed(false, cx));
+        }
+
+        self.left_dock.update(cx, |dock, cx| dock.zoom_out(cx));
+        self.bottom_dock.update(cx, |dock, cx| dock.zoom_out(cx));
+        self.right_dock.update(cx, |dock, cx| dock.zoom_out(cx));
 
         cx.notify();
     }
@@ -1429,7 +1572,6 @@ impl Workspace {
         let pane = cx.add_view(|cx| {
             Pane::new(
                 self.weak_handle(),
-                None,
                 self.app_state.background_actions,
                 self.pane_history_timestamp.clone(),
                 cx,
@@ -1472,16 +1614,12 @@ impl Workspace {
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
         let pane = pane.unwrap_or_else(|| {
-            if !self.dock_active() {
-                self.active_pane().downgrade()
-            } else {
-                self.last_active_center_pane.clone().unwrap_or_else(|| {
-                    self.panes
-                        .first()
-                        .expect("There must be an active pane")
-                        .downgrade()
-                })
-            }
+            self.last_active_center_pane.clone().unwrap_or_else(|| {
+                self.panes
+                    .first()
+                    .expect("There must be an active pane")
+                    .downgrade()
+            })
         });
 
         let task = self.load_path(path.into(), cx);
@@ -1560,9 +1698,6 @@ impl Workspace {
                 .map(|ix| (pane.clone(), ix))
         });
         if let Some((pane, ix)) = result {
-            if &pane == self.dock_pane() {
-                Dock::show(self, false, cx);
-            }
             pane.update(cx, |pane, cx| pane.activate_item(ix, true, true, cx));
             true
         } else {
@@ -1608,16 +1743,7 @@ impl Workspace {
                 status_bar.set_active_pane(&self.active_pane, cx);
             });
             self.active_item_path_changed(cx);
-
-            if &pane == self.dock_pane() {
-                Dock::show(self, false, cx);
-            } else {
-                self.last_active_center_pane = Some(pane.downgrade());
-                if self.dock.is_anchored_at(DockAnchor::Expanded) {
-                    Dock::hide(self, cx);
-                }
-            }
-            cx.notify();
+            self.last_active_center_pane = Some(pane.downgrade());
         }
 
         self.update_followers(
@@ -1631,6 +1757,8 @@ impl Workspace {
             }),
             cx,
         );
+
+        cx.notify();
     }
 
     fn handle_pane_event(
@@ -1639,13 +1767,11 @@ impl Workspace {
         event: &pane::Event,
         cx: &mut ViewContext<Self>,
     ) {
-        let is_dock = &pane == self.dock.pane();
         match event {
-            pane::Event::Split(direction) if !is_dock => {
+            pane::Event::Split(direction) => {
                 self.split_pane(pane, *direction, cx);
             }
-            pane::Event::Remove if !is_dock => self.remove_pane(pane, cx),
-            pane::Event::Remove if is_dock => Dock::hide(self, cx),
+            pane::Event::Remove => self.remove_pane(pane, cx),
             pane::Event::ActivateItem { local } => {
                 if *local {
                     self.unfollow(&pane, cx);
@@ -1671,7 +1797,14 @@ impl Workspace {
             pane::Event::Focus => {
                 self.handle_pane_focused(pane.clone(), cx);
             }
-            _ => {}
+            pane::Event::ZoomIn => {
+                if pane == self.active_pane {
+                    self.zoom_out(cx);
+                    pane.update(cx, |pane, cx| pane.set_zoomed(true, cx));
+                    cx.notify();
+                }
+            }
+            pane::Event::ZoomOut => self.zoom_out(cx),
         }
 
         self.serialize_workspace(cx);
@@ -1683,11 +1816,6 @@ impl Workspace {
         direction: SplitDirection,
         cx: &mut ViewContext<Self>,
     ) -> Option<ViewHandle<Pane>> {
-        if &pane == self.dock_pane() {
-            warn!("Can't split dock pane.");
-            return None;
-        }
-
         let item = pane.read(cx).active_item()?;
         let maybe_pane_handle = if let Some(clone) = item.clone_on_split(self.database_id(), cx) {
             let new_pane = self.add_pane(cx);
@@ -1711,10 +1839,6 @@ impl Workspace {
     ) {
         let Some(pane_to_split) = pane_to_split.upgrade(cx) else { return; };
         let Some(from) = from.upgrade(cx) else { return; };
-        if &pane_to_split == self.dock_pane() {
-            warn!("Can't split dock pane.");
-            return;
-        }
 
         let new_pane = self.add_pane(cx);
         Pane::move_item(self, from.clone(), new_pane.clone(), item_id_to_move, 0, cx);
@@ -1732,11 +1856,6 @@ impl Workspace {
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
         let pane_to_split = pane_to_split.upgrade(cx)?;
-        if &pane_to_split == self.dock_pane() {
-            warn!("Can't split dock pane.");
-            return None;
-        }
-
         let new_pane = self.add_pane(cx);
         self.center
             .split(&pane_to_split, &new_pane, split_direction)
@@ -1773,14 +1892,6 @@ impl Workspace {
         &self.active_pane
     }
 
-    pub fn dock_pane(&self) -> &ViewHandle<Pane> {
-        self.dock.pane()
-    }
-
-    fn dock_active(&self) -> bool {
-        &self.active_pane == self.dock.pane()
-    }
-
     fn project_remote_id_changed(&mut self, remote_id: Option<u64>, cx: &mut ViewContext<Self>) {
         if let Some(remote_id) = remote_id {
             self.remote_entity_subscription = Some(
@@ -2522,23 +2633,65 @@ impl Workspace {
             }
         }
 
+        fn build_serialized_docks(this: &Workspace, cx: &AppContext) -> DockStructure {
+            let left_dock = this.left_dock.read(cx);
+            let left_visible = left_dock.is_open();
+            let left_active_panel = left_dock.active_panel().and_then(|panel| {
+                Some(
+                    cx.view_ui_name(panel.as_any().window_id(), panel.id())?
+                        .to_string(),
+                )
+            });
+
+            let right_dock = this.right_dock.read(cx);
+            let right_visible = right_dock.is_open();
+            let right_active_panel = right_dock.active_panel().and_then(|panel| {
+                Some(
+                    cx.view_ui_name(panel.as_any().window_id(), panel.id())?
+                        .to_string(),
+                )
+            });
+
+            let bottom_dock = this.bottom_dock.read(cx);
+            let bottom_visible = bottom_dock.is_open();
+            let bottom_active_panel = bottom_dock.active_panel().and_then(|panel| {
+                Some(
+                    cx.view_ui_name(panel.as_any().window_id(), panel.id())?
+                        .to_string(),
+                )
+            });
+
+            DockStructure {
+                left: DockData {
+                    visible: left_visible,
+                    active_panel: left_active_panel,
+                },
+                right: DockData {
+                    visible: right_visible,
+                    active_panel: right_active_panel,
+                },
+                bottom: DockData {
+                    visible: bottom_visible,
+                    active_panel: bottom_active_panel,
+                },
+            }
+        }
+
         if let Some(location) = self.location(cx) {
             // Load bearing special case:
             //  - with_local_workspace() relies on this to not have other stuff open
             //    when you open your log
             if !location.paths().is_empty() {
-                let dock_pane = serialize_pane_handle(self.dock.pane(), cx);
                 let center_group = build_serialized_pane_group(&self.center.root, cx);
+                let docks = build_serialized_docks(self, cx);
 
                 let serialized_workspace = SerializedWorkspace {
                     id: self.database_id,
                     location,
-                    dock_position: self.dock.position(),
-                    dock_pane,
                     center_group,
-                    left_sidebar_open: self.left_sidebar.read(cx).is_open(),
                     bounds: Default::default(),
                     display: Default::default(),
+                    docks,
                 };
 
                 cx.background()
@@ -2556,26 +2709,14 @@ impl Workspace {
     ) -> Task<Vec<Option<Result<Box<dyn ItemHandle>, anyhow::Error>>>> {
         cx.spawn(|mut cx| async move {
             let result = async_iife! {{
-                let (project, dock_pane_handle, old_center_pane) =
+                let (project, old_center_pane) =
                 workspace.read_with(&cx, |workspace, _| {
                     (
                         workspace.project().clone(),
-                        workspace.dock_pane().downgrade(),
                         workspace.last_active_center_pane.clone(),
                     )
                 })?;
 
-                let dock_items = serialized_workspace
-                    .dock_pane
-                    .deserialize_to(
-                        &project,
-                        &dock_pane_handle,
-                        serialized_workspace.id,
-                        &workspace,
-                        &mut cx,
-                    )
-                    .await?;
-
                 let mut center_items = None;
                 let mut center_group = None;
                 // Traverse the splits tree and add to things
@@ -2591,7 +2732,6 @@ impl Workspace {
                     let mut opened_items = center_items
                         .unwrap_or_default()
                         .into_iter()
-                        .chain(dock_items.into_iter())
                         .filter_map(|item| {
                             let item = item?;
                             let project_path = item.project_path(cx)?;
@@ -2637,22 +2777,30 @@ impl Workspace {
                         }
                     }
 
-                    if workspace.left_sidebar().read(cx).is_open()
-                        != serialized_workspace.left_sidebar_open
-                    {
-                        workspace.toggle_sidebar(SidebarSide::Left, cx);
-                    }
-
-                    // Note that without after_window, the focus_self() and
-                    // the focus the dock generates start generating alternating
-                    // focus due to the deferred execution each triggering each other
-                    cx.after_window_update(move |workspace, cx| {
-                        Dock::set_dock_position(
-                            workspace,
-                            serialized_workspace.dock_position,
-                            false,
-                            cx,
-                        );
+                    let docks = serialized_workspace.docks;
+                    workspace.left_dock.update(cx, |dock, cx| {
+                        dock.set_open(docks.left.visible, cx);
+                        if let Some(active_panel) = docks.left.active_panel {
+                            if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
+                                dock.activate_panel(ix, cx);
+                            }
+                        }
+                    });
+                    workspace.right_dock.update(cx, |dock, cx| {
+                        dock.set_open(docks.right.visible, cx);
+                        if let Some(active_panel) = docks.right.active_panel {
+                            if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
+                                dock.activate_panel(ix, cx);
+                            }
+                        }
+                    });
+                    workspace.bottom_dock.update(cx, |dock, cx| {
+                        dock.set_open(docks.bottom.visible, cx);
+                        if let Some(active_panel) = docks.bottom.active_panel {
+                            if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
+                                dock.activate_panel(ix, cx);
+                            }
+                        }
                     });
 
                     cx.notify();
@@ -2676,12 +2824,38 @@ impl Workspace {
             user_store: project.read(cx).user_store(),
             fs: project.read(cx).fs().clone(),
             build_window_options: |_, _, _| Default::default(),
-            initialize_workspace: |_, _, _| {},
-            dock_default_item_factory: |_, _| None,
+            initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             background_actions: || &[],
         });
         Self::new(0, project, app_state, cx)
     }
+
+    fn render_dock(&self, position: DockPosition, cx: &WindowContext) -> Option<AnyElement<Self>> {
+        let dock = match position {
+            DockPosition::Left => &self.left_dock,
+            DockPosition::Right => &self.right_dock,
+            DockPosition::Bottom => &self.bottom_dock,
+        };
+        let active_panel = dock.read(cx).active_panel()?;
+        let element = if Some(active_panel.as_any()) == self.zoomed(cx).as_ref() {
+            dock.read(cx).render_placeholder(cx)
+        } else {
+            ChildView::new(dock, cx).into_any()
+        };
+
+        Some(
+            element
+                .constrained()
+                .dynamically(move |constraint, _, cx| match position {
+                    DockPosition::Left | DockPosition::Right => SizeConstraint::new(
+                        Vector2F::new(20., constraint.min.y()),
+                        Vector2F::new(cx.window_size().x() * 0.8, constraint.max.y()),
+                    ),
+                    _ => constraint,
+                })
+                .into_any(),
+        )
+    }
 }
 
 async fn open_items(
@@ -2827,76 +3001,46 @@ impl View for Workspace {
                             .with_child({
                                 let project = self.project.clone();
                                 Flex::row()
-                                    .with_children(
-                                        if self.left_sidebar.read(cx).active_item().is_some() {
-                                            Some(
-                                                ChildView::new(&self.left_sidebar, cx)
-                                                    .constrained()
-                                                    .dynamically(|constraint, _, cx| {
-                                                        SizeConstraint::new(
-                                                            Vector2F::new(20., constraint.min.y()),
-                                                            Vector2F::new(
-                                                                cx.window_size().x() * 0.8,
-                                                                constraint.max.y(),
-                                                            ),
-                                                        )
-                                                    }),
-                                            )
-                                        } else {
-                                            None
-                                        },
-                                    )
+                                    .with_children(self.render_dock(DockPosition::Left, cx))
                                     .with_child(
-                                        FlexItem::new(
-                                            Flex::column()
-                                                .with_child(
-                                                    FlexItem::new(self.center.render(
-                                                        &project,
-                                                        &theme,
-                                                        &self.follower_states_by_leader,
-                                                        self.active_call(),
-                                                        self.active_pane(),
-                                                        &self.app_state,
-                                                        cx,
-                                                    ))
-                                                    .flex(1., true),
-                                                )
-                                                .with_children(self.dock.render(
+                                        Flex::column()
+                                            .with_child(
+                                                FlexItem::new(self.center.render(
+                                                    &project,
                                                     &theme,
-                                                    DockAnchor::Bottom,
+                                                    &self.follower_states_by_leader,
+                                                    self.active_call(),
+                                                    self.active_pane(),
+                                                    self.zoomed(cx).as_ref(),
+                                                    &self.app_state,
                                                     cx,
-                                                )),
-                                        )
-                                        .flex(1., true),
-                                    )
-                                    .with_children(self.dock.render(&theme, DockAnchor::Right, cx))
-                                    .with_children(
-                                        if self.right_sidebar.read(cx).active_item().is_some() {
-                                            Some(
-                                                ChildView::new(&self.right_sidebar, cx)
-                                                    .constrained()
-                                                    .dynamically(|constraint, _, cx| {
-                                                        SizeConstraint::new(
-                                                            Vector2F::new(20., constraint.min.y()),
-                                                            Vector2F::new(
-                                                                cx.window_size().x() * 0.8,
-                                                                constraint.max.y(),
-                                                            ),
-                                                        )
-                                                    }),
+                                                ))
+                                                .flex(1., true),
+                                            )
+                                            .with_children(
+                                                self.render_dock(DockPosition::Bottom, cx),
                                             )
-                                        } else {
-                                            None
-                                        },
+                                            .flex(1., true),
                                     )
+                                    .with_children(self.render_dock(DockPosition::Right, cx))
                             })
                             .with_child(Overlay::new(
                                 Stack::new()
-                                    .with_children(self.dock.render(
-                                        &theme,
-                                        DockAnchor::Expanded,
-                                        cx,
-                                    ))
+                                    .with_children(self.zoomed(cx).map(|zoomed| {
+                                        enum ZoomBackground {}
+
+                                        ChildView::new(&zoomed, cx)
+                                            .contained()
+                                            .with_style(theme.workspace.zoomed_foreground)
+                                            .aligned()
+                                            .contained()
+                                            .with_style(theme.workspace.zoomed_background)
+                                            .mouse::<ZoomBackground>(0)
+                                            .capture_all()
+                                            .on_down(MouseButton::Left, |_, this: &mut Self, cx| {
+                                                this.zoom_out(cx);
+                                            })
+                                    }))
                                     .with_children(self.modal.as_ref().map(|modal| {
                                         ChildView::new(modal, cx)
                                             .contained()

crates/workspace/src/workspace_settings.rs 🔗

@@ -1,8 +1,3 @@
-use anyhow::bail;
-use db::sqlez::{
-    bindable::{Bind, Column, StaticColumnCount},
-    statement::Statement,
-};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::Setting;
@@ -13,17 +8,15 @@ pub struct WorkspaceSettings {
     pub confirm_quit: bool,
     pub show_call_status_icon: bool,
     pub autosave: AutosaveSetting,
-    pub default_dock_anchor: DockAnchor,
     pub git: GitSettings,
 }
 
-#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct WorkspaceSettingsContent {
     pub active_pane_magnification: Option<f32>,
     pub confirm_quit: Option<bool>,
     pub show_call_status_icon: Option<bool>,
     pub autosave: Option<AutosaveSetting>,
-    pub default_dock_anchor: Option<DockAnchor>,
     pub git: Option<GitSettings>,
 }
 
@@ -36,15 +29,6 @@ pub enum AutosaveSetting {
     OnWindowChange,
 }
 
-#[derive(PartialEq, Eq, Debug, Default, Copy, Clone, Hash, Serialize, Deserialize, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum DockAnchor {
-    #[default]
-    Bottom,
-    Right,
-    Expanded,
-}
-
 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct GitSettings {
     pub git_gutter: Option<GitGutterSetting>,
@@ -59,35 +43,6 @@ pub enum GitGutterSetting {
     Hide,
 }
 
-impl StaticColumnCount for DockAnchor {}
-
-impl Bind for DockAnchor {
-    fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result<i32> {
-        match self {
-            DockAnchor::Bottom => "Bottom",
-            DockAnchor::Right => "Right",
-            DockAnchor::Expanded => "Expanded",
-        }
-        .bind(statement, start_index)
-    }
-}
-
-impl Column for DockAnchor {
-    fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> {
-        String::column(statement, start_index).and_then(|(anchor_text, next_index)| {
-            Ok((
-                match anchor_text.as_ref() {
-                    "Bottom" => DockAnchor::Bottom,
-                    "Right" => DockAnchor::Right,
-                    "Expanded" => DockAnchor::Expanded,
-                    _ => bail!("Stored dock anchor is incorrect"),
-                },
-                next_index,
-            ))
-        })
-    }
-}
-
 impl Setting for WorkspaceSettings {
     const KEY: Option<&'static str> = None;
 

crates/zed/src/main.rs 🔗

@@ -56,8 +56,7 @@ use fs::RealFs;
 use staff_mode::StaffMode;
 use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt};
 use workspace::{
-    dock::FocusDock, item::ItemHandle, notifications::NotifyResultExt, AppState, OpenSettings,
-    Workspace,
+    item::ItemHandle, notifications::NotifyResultExt, AppState, OpenSettings, Workspace,
 };
 use zed::{
     self, build_window_options, handle_keymap_file_changes, initialize_workspace, languages, menus,
@@ -187,7 +186,6 @@ fn main() {
             fs,
             build_window_options,
             initialize_workspace,
-            dock_default_item_factory,
             background_actions,
         });
         cx.set_global(Arc::downgrade(&app_state));
@@ -817,7 +815,6 @@ pub fn background_actions() -> &'static [(&'static str, &'static dyn Action)] {
     &[
         ("Go to file", &file_finder::Toggle),
         ("Open command palette", &command_palette::Toggle),
-        ("Focus the dock", &FocusDock),
         ("Open recent projects", &recent_projects::OpenRecent),
         ("Change your settings", &OpenSettings),
     ]

crates/zed/src/menus.rs 🔗

@@ -89,7 +89,18 @@ pub fn menus() -> Vec<Menu<'static>> {
                 MenuItem::action("Zoom Out", super::DecreaseBufferFontSize),
                 MenuItem::action("Reset Zoom", super::ResetBufferFontSize),
                 MenuItem::separator(),
-                MenuItem::action("Toggle Left Sidebar", workspace::ToggleLeftSidebar),
+                MenuItem::action(
+                    "Toggle Left Dock",
+                    workspace::ToggleLeftDock { focus: false },
+                ),
+                MenuItem::action(
+                    "Toggle Right Dock",
+                    workspace::ToggleRightDock { focus: false },
+                ),
+                MenuItem::action(
+                    "Toggle Bottom Dock",
+                    workspace::ToggleBottomDock { focus: false },
+                ),
                 MenuItem::submenu(Menu {
                     name: "Editor Layout",
                     items: vec![

crates/zed/src/zed.rs 🔗

@@ -18,10 +18,11 @@ use feedback::{
 use futures::{channel::mpsc, StreamExt};
 use gpui::{
     actions,
+    anyhow::{self, Result},
     geometry::vector::vec2f,
     impl_actions,
     platform::{Platform, PromptLevel, TitlebarOptions, WindowBounds, WindowKind, WindowOptions},
-    AppContext, ViewContext,
+    AppContext, AsyncAppContext, Task, ViewContext, WeakViewHandle,
 };
 pub use lsp;
 pub use project;
@@ -31,13 +32,13 @@ use serde::Deserialize;
 use serde_json::to_string_pretty;
 use settings::{KeymapFileContent, SettingsStore, DEFAULT_SETTINGS_ASSET_PATH};
 use std::{borrow::Cow, str, sync::Arc};
-use terminal_view::terminal_button::TerminalButton;
+use terminal_view::terminal_panel::{self, TerminalPanel};
 use util::{channel::ReleaseChannel, paths, ResultExt};
 use uuid::Uuid;
 use welcome::BaseKeymap;
 pub use workspace;
 use workspace::{
-    create_and_open_local_file, open_new, sidebar::SidebarSide, AppState, NewFile, NewWindow,
+    create_and_open_local_file, dock::PanelHandle, open_new, AppState, NewFile, NewWindow,
     Workspace, WorkspaceSettings,
 };
 
@@ -223,7 +224,14 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
         |workspace: &mut Workspace,
          _: &project_panel::ToggleFocus,
          cx: &mut ViewContext<Workspace>| {
-            workspace.toggle_sidebar_item_focus(SidebarSide::Left, 0, cx);
+            workspace.toggle_panel_focus::<ProjectPanel>(cx);
+        },
+    );
+    cx.add_action(
+        |workspace: &mut Workspace,
+         _: &terminal_panel::ToggleFocus,
+         cx: &mut ViewContext<Workspace>| {
+            workspace.toggle_panel_focus::<TerminalPanel>(cx);
         },
     );
     cx.add_global_action({
@@ -252,85 +260,107 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
 }
 
 pub fn initialize_workspace(
-    workspace: &mut Workspace,
-    app_state: &Arc<AppState>,
-    cx: &mut ViewContext<Workspace>,
-) {
-    let workspace_handle = cx.handle();
-    cx.subscribe(&workspace_handle, {
-        move |workspace, _, event, cx| {
-            if let workspace::Event::PaneAdded(pane) = event {
-                pane.update(cx, |pane, cx| {
-                    pane.toolbar().update(cx, |toolbar, cx| {
-                        let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(workspace));
-                        toolbar.add_item(breadcrumbs, cx);
-                        let buffer_search_bar = cx.add_view(BufferSearchBar::new);
-                        toolbar.add_item(buffer_search_bar, cx);
-                        let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
-                        toolbar.add_item(project_search_bar, cx);
-                        let submit_feedback_button = cx.add_view(|_| SubmitFeedbackButton::new());
-                        toolbar.add_item(submit_feedback_button, cx);
-                        let feedback_info_text = cx.add_view(|_| FeedbackInfoText::new());
-                        toolbar.add_item(feedback_info_text, cx);
-                        let lsp_log_item = cx.add_view(|_| {
-                            lsp_log::LspLogToolbarItemView::new(workspace.project().clone())
+    workspace_handle: WeakViewHandle<Workspace>,
+    was_deserialized: bool,
+    app_state: Arc<AppState>,
+    cx: AsyncAppContext,
+) -> Task<Result<()>> {
+    cx.spawn(|mut cx| async move {
+        workspace_handle.update(&mut cx, |workspace, cx| {
+            let workspace_handle = cx.handle();
+            cx.subscribe(&workspace_handle, {
+                move |workspace, _, event, cx| {
+                    if let workspace::Event::PaneAdded(pane) = event {
+                        pane.update(cx, |pane, cx| {
+                            pane.toolbar().update(cx, |toolbar, cx| {
+                                let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(workspace));
+                                toolbar.add_item(breadcrumbs, cx);
+                                let buffer_search_bar = cx.add_view(BufferSearchBar::new);
+                                toolbar.add_item(buffer_search_bar, cx);
+                                let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
+                                toolbar.add_item(project_search_bar, cx);
+                                let submit_feedback_button =
+                                    cx.add_view(|_| SubmitFeedbackButton::new());
+                                toolbar.add_item(submit_feedback_button, cx);
+                                let feedback_info_text = cx.add_view(|_| FeedbackInfoText::new());
+                                toolbar.add_item(feedback_info_text, cx);
+                                let lsp_log_item = cx.add_view(|_| {
+                                    lsp_log::LspLogToolbarItemView::new(workspace.project().clone())
+                                });
+                                toolbar.add_item(lsp_log_item, cx);
+                            })
                         });
-                        toolbar.add_item(lsp_log_item, cx);
-                    })
-                });
-            }
-        }
-    })
-    .detach();
+                    }
+                }
+            })
+            .detach();
 
-    cx.emit(workspace::Event::PaneAdded(workspace.active_pane().clone()));
-    cx.emit(workspace::Event::PaneAdded(workspace.dock_pane().clone()));
+            cx.emit(workspace::Event::PaneAdded(workspace.active_pane().clone()));
 
-    let collab_titlebar_item =
-        cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx));
-    workspace.set_titlebar_item(collab_titlebar_item.into_any(), cx);
+            let collab_titlebar_item =
+                cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx));
+            workspace.set_titlebar_item(collab_titlebar_item.into_any(), cx);
 
-    let project_panel = ProjectPanel::new(workspace, cx);
-    workspace.left_sidebar().update(cx, |sidebar, cx| {
-        sidebar.add_item(
-            "icons/folder_tree_16.svg",
-            "Project Panel".to_string(),
-            project_panel,
-            cx,
-        )
-    });
+            let copilot =
+                cx.add_view(|cx| copilot_button::CopilotButton::new(app_state.fs.clone(), cx));
+            let diagnostic_summary =
+                cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
+            let activity_indicator = activity_indicator::ActivityIndicator::new(
+                workspace,
+                app_state.languages.clone(),
+                cx,
+            );
+            let active_buffer_language =
+                cx.add_view(|_| language_selector::ActiveBufferLanguage::new(workspace));
+            let feedback_button = cx.add_view(|_| {
+                feedback::deploy_feedback_button::DeployFeedbackButton::new(workspace)
+            });
+            let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
+            workspace.status_bar().update(cx, |status_bar, cx| {
+                status_bar.add_left_item(diagnostic_summary, cx);
+                status_bar.add_left_item(activity_indicator, cx);
+                status_bar.add_right_item(feedback_button, cx);
+                status_bar.add_right_item(copilot, cx);
+                status_bar.add_right_item(active_buffer_language, cx);
+                status_bar.add_right_item(cursor_position, cx);
+            });
 
-    let toggle_terminal = cx.add_view(|cx| TerminalButton::new(workspace_handle.clone(), cx));
-    let copilot = cx.add_view(|cx| copilot_button::CopilotButton::new(app_state.fs.clone(), cx));
-    let diagnostic_summary =
-        cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
-    let activity_indicator =
-        activity_indicator::ActivityIndicator::new(workspace, app_state.languages.clone(), cx);
-    let active_buffer_language =
-        cx.add_view(|_| language_selector::ActiveBufferLanguage::new(workspace));
-    let feedback_button =
-        cx.add_view(|_| feedback::deploy_feedback_button::DeployFeedbackButton::new(workspace));
-    let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
-    workspace.status_bar().update(cx, |status_bar, cx| {
-        status_bar.add_left_item(diagnostic_summary, cx);
-        status_bar.add_left_item(activity_indicator, cx);
-        status_bar.add_right_item(toggle_terminal, cx);
-        status_bar.add_right_item(feedback_button, cx);
-        status_bar.add_right_item(copilot, cx);
-        status_bar.add_right_item(active_buffer_language, cx);
-        status_bar.add_right_item(cursor_position, cx);
-    });
+            auto_update::notify_of_any_new_update(cx.weak_handle(), cx);
 
-    auto_update::notify_of_any_new_update(cx.weak_handle(), cx);
+            vim::observe_keystrokes(cx);
 
-    vim::observe_keystrokes(cx);
+            cx.on_window_should_close(|workspace, cx| {
+                if let Some(task) = workspace.close(&Default::default(), cx) {
+                    task.detach_and_log_err(cx);
+                }
+                false
+            });
+        })?;
+
+        let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
+        let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
+        let (project_panel, terminal_panel) = futures::try_join!(project_panel, terminal_panel)?;
+        workspace_handle.update(&mut cx, |workspace, cx| {
+            let project_panel_position = project_panel.position(cx);
+            workspace.add_panel(project_panel, cx);
+            if !was_deserialized
+                && workspace
+                    .project()
+                    .read(cx)
+                    .visible_worktrees(cx)
+                    .any(|tree| {
+                        tree.read(cx)
+                            .root_entry()
+                            .map_or(false, |entry| entry.is_dir())
+                    })
+            {
+                workspace.toggle_dock(project_panel_position, false, cx);
+            }
 
-    cx.on_window_should_close(|workspace, cx| {
-        if let Some(task) = workspace.close(&Default::default(), cx) {
-            task.detach_and_log_err(cx);
-        }
-        false
-    });
+            workspace.add_panel(terminal_panel, cx)
+        })?;
+        Ok(())
+    })
 }
 
 pub fn build_window_options(
@@ -348,7 +378,8 @@ pub fn build_window_options(
             traffic_light_position: Some(vec2f(8., 8.)),
         }),
         center: false,
-        focus: true,
+        focus: false,
+        show: false,
         kind: WindowKind::Normal,
         is_movable: true,
         bounds,
@@ -687,7 +718,7 @@ mod tests {
             .unwrap();
         workspace_1.update(cx, |workspace, cx| {
             assert_eq!(workspace.worktrees(cx).count(), 2);
-            assert!(workspace.left_sidebar().read(cx).is_open());
+            assert!(workspace.left_dock().read(cx).is_open());
             assert!(workspace.active_pane().is_focused(cx));
         });
 
@@ -730,7 +761,7 @@ mod tests {
                     .collect::<Vec<_>>(),
                 &[Path::new("/root/c").into(), Path::new("/root/d").into()]
             );
-            assert!(workspace.left_sidebar().read(cx).is_open());
+            assert!(workspace.left_dock().read(cx).is_open());
             assert!(workspace.active_pane().is_focused(cx));
         });
     }
@@ -755,6 +786,7 @@ mod tests {
             .unwrap()
             .downcast::<Workspace>()
             .unwrap();
+        let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
         let editor = workspace.read_with(cx, |workspace, cx| {
             workspace
                 .active_item(cx)
@@ -777,9 +809,9 @@ mod tests {
         assert!(cx.is_window_edited(workspace.window_id()));
 
         // Closing the item restores the window's edited state.
-        let close = workspace.update(cx, |workspace, cx| {
+        let close = pane.update(cx, |pane, cx| {
             drop(editor);
-            Pane::close_active_item(workspace, &Default::default(), cx).unwrap()
+            pane.close_active_item(&Default::default(), cx).unwrap()
         });
         executor.run_until_parked();
         cx.simulate_prompt_answer(workspace.window_id(), 1);
@@ -1364,7 +1396,7 @@ mod tests {
 
         cx.foreground().run_until_parked();
         workspace.read_with(cx, |workspace, _| {
-            assert_eq!(workspace.panes().len(), 2); //Center pane + Dock pane
+            assert_eq!(workspace.panes().len(), 1);
             assert_eq!(workspace.active_pane(), &pane_1);
         });
 
@@ -1374,7 +1406,7 @@ mod tests {
         cx.foreground().run_until_parked();
 
         workspace.read_with(cx, |workspace, cx| {
-            assert_eq!(workspace.panes().len(), 2);
+            assert_eq!(workspace.panes().len(), 1);
             assert!(workspace.active_item(cx).is_none());
         });
 
@@ -1403,6 +1435,7 @@ mod tests {
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
         let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
 
         let entries = cx.read(|cx| workspace.file_project_paths(cx));
         let file1 = entries[0].clone();
@@ -1520,14 +1553,13 @@ mod tests {
 
         // Go forward to an item that has been closed, ensuring it gets re-opened at the same
         // location.
-        workspace
-            .update(cx, |workspace, cx| {
-                let editor3_id = editor3.id();
-                drop(editor3);
-                Pane::close_item_by_id(workspace, workspace.active_pane().clone(), editor3_id, cx)
-            })
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            let editor3_id = editor3.id();
+            drop(editor3);
+            pane.close_item_by_id(editor3_id, cx)
+        })
+        .await
+        .unwrap();
         workspace
             .update(cx, |w, cx| Pane::go_forward(w, None, cx))
             .await
@@ -1556,14 +1588,13 @@ mod tests {
         );
 
         // Go back to an item that has been closed and removed from disk, ensuring it gets skipped.
-        workspace
-            .update(cx, |workspace, cx| {
-                let editor2_id = editor2.id();
-                drop(editor2);
-                Pane::close_item_by_id(workspace, workspace.active_pane().clone(), editor2_id, cx)
-            })
-            .await
-            .unwrap();
+        pane.update(cx, |pane, cx| {
+            let editor2_id = editor2.id();
+            drop(editor2);
+            pane.close_item_by_id(editor2_id, cx)
+        })
+        .await
+        .unwrap();
         app_state
             .fs
             .remove_file(Path::new("/root/a/file2"), Default::default())
@@ -1712,34 +1743,22 @@ mod tests {
         assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
 
         // Close all the pane items in some arbitrary order.
-        workspace
-            .update(cx, |workspace, cx| {
-                Pane::close_item_by_id(workspace, pane.clone(), file1_item_id, cx)
-            })
+        pane.update(cx, |pane, cx| pane.close_item_by_id(file1_item_id, cx))
             .await
             .unwrap();
         assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
 
-        workspace
-            .update(cx, |workspace, cx| {
-                Pane::close_item_by_id(workspace, pane.clone(), file4_item_id, cx)
-            })
+        pane.update(cx, |pane, cx| pane.close_item_by_id(file4_item_id, cx))
             .await
             .unwrap();
         assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
 
-        workspace
-            .update(cx, |workspace, cx| {
-                Pane::close_item_by_id(workspace, pane.clone(), file2_item_id, cx)
-            })
+        pane.update(cx, |pane, cx| pane.close_item_by_id(file2_item_id, cx))
             .await
             .unwrap();
         assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
 
-        workspace
-            .update(cx, |workspace, cx| {
-                Pane::close_item_by_id(workspace, pane.clone(), file3_item_id, cx)
-            })
+        pane.update(cx, |pane, cx| pane.close_item_by_id(file3_item_id, cx))
             .await
             .unwrap();
         assert_eq!(active_path(&workspace, cx), None);
@@ -2068,7 +2087,10 @@ mod tests {
             workspace::init(app_state.clone(), cx);
             language::init(cx);
             editor::init(cx);
+            project_panel::init_settings(cx);
             pane::init(cx);
+            project_panel::init(cx);
+            terminal_view::init(cx);
             app_state
         })
     }

script/get-preview-channel-changes 🔗

@@ -2,8 +2,8 @@
 
 const { execFileSync } = require("child_process");
 const { GITHUB_ACCESS_TOKEN } = process.env;
-const PR_REGEX = /pull request #(\d+)/;
-const FIXES_REGEX = /(fixes|closes) (.+[/#]\d+.*)$/im;
+const PR_REGEX = /#\d+/ // Ex: matches on #4241
+const FIXES_REGEX = /(fixes|closes|completes) (.+[/#]\d+.*)$/im;
 
 main();
 
@@ -15,7 +15,7 @@ async function main() {
     { encoding: "utf8" }
   )
     .split("\n")
-    .filter((t) => t.startsWith("v") && t.endsWith('-pre'));
+    .filter((t) => t.startsWith("v") && t.endsWith("-pre"));
 
   // Print the previous release
   console.log(`Changes from ${oldTag} to ${newTag}\n`);
@@ -34,42 +34,16 @@ async function main() {
   }
 
   // Get the PRs merged between those two tags.
-  const pullRequestNumbers = execFileSync(
-    "git",
-    [
-      "log",
-      `${oldTag}..${newTag}`,
-      "--oneline",
-      "--grep",
-      "Merge pull request",
-    ],
-    { encoding: "utf8" }
-  )
-    .split("\n")
-    .filter((line) => line.length > 0)
-    .map((line) => line.match(PR_REGEX)[1]);
+  const pullRequestNumbers = getPullRequestNumbers(oldTag, newTag);
 
   // Get the PRs that were cherry-picked between main and the old tag.
-  const existingPullRequestNumbers = new Set(execFileSync(
-    "git",
-    [
-      "log",
-      `main..${oldTag}`,
-      "--oneline",
-      "--grep",
-      "Merge pull request",
-    ],
-    { encoding: "utf8" }
-  )
-    .split("\n")
-    .filter((line) => line.length > 0)
-    .map((line) => line.match(PR_REGEX)[1]));
-    
+  const existingPullRequestNumbers = new Set(getPullRequestNumbers("main", oldTag));
+
   // Filter out those existing PRs from the set of new PRs.
   const newPullRequestNumbers = pullRequestNumbers.filter(number => !existingPullRequestNumbers.has(number));
 
   // Fetch the pull requests from the GitHub API.
-  console.log("Merged Pull requests:")
+  console.log("Merged Pull requests:");
   for (const pullRequestNumber of newPullRequestNumbers) {
     const webURL = `https://github.com/zed-industries/zed/pull/${pullRequestNumber}`;
     const apiURL = `https://api.github.com/repos/zed-industries/zed/pulls/${pullRequestNumber}`;
@@ -83,13 +57,47 @@ async function main() {
     // Print the pull request title and URL.
     const pullRequest = await response.json();
     console.log("*", pullRequest.title);
-    console.log("  URL:    ", webURL);
+    console.log("  PR URL:    ", webURL);
 
     // If the pull request contains a 'closes' line, print the closed issue.
-    const fixesMatch = (pullRequest.body || '').match(FIXES_REGEX);
+    const fixesMatch = (pullRequest.body || "").match(FIXES_REGEX);
     if (fixesMatch) {
       const fixedIssueURL = fixesMatch[2];
-      console.log("  Issue: ", fixedIssueURL);
+      console.log("  Issue URL:    ", fixedIssueURL);
     }
+
+    let releaseNotes = (pullRequest.body || "").split("Release Notes:")[1];
+
+    if (releaseNotes) {
+      releaseNotes = releaseNotes.trim().split("\n")
+      console.log("  Release Notes:");
+
+      for (const line of releaseNotes) {
+        console.log(`    ${line}`);
+      }
+    }
+
+    console.log()
   }
 }
+
+function getPullRequestNumbers(oldTag, newTag) {
+  const pullRequestNumbers = execFileSync(
+    "git",
+    [
+      "log",
+      `${oldTag}..${newTag}`,
+      "--oneline"
+    ],
+    { encoding: "utf8" }
+  )
+    .split("\n")
+    .filter(line => line.length > 0)
+    .map(line => {
+      const match = line.match(/#(\d+)/);
+      return match ? match[1] : null;
+    })
+    .filter(line => line);
+
+  return pullRequestNumbers;
+}

styles/src/styleTree/editor.ts 🔗

@@ -6,6 +6,8 @@ import hoverPopover from "./hoverPopover"
 import { SyntaxHighlightStyle, buildSyntax } from "../themes/common/syntax"
 
 export default function editor(colorScheme: ColorScheme) {
+    const { isLight } = colorScheme
+
     let layer = colorScheme.highest
 
     const autocompleteItem = {
@@ -97,12 +99,18 @@ export default function editor(colorScheme: ColorScheme) {
             foldBackground: foreground(layer, "variant"),
         },
         diff: {
-            deleted: foreground(layer, "negative"),
-            modified: foreground(layer, "warning"),
-            inserted: foreground(layer, "positive"),
+            deleted: isLight
+                ? colorScheme.ramps.red(0.5).hex()
+                : colorScheme.ramps.red(0.4).hex(),
+            modified: isLight
+                ? colorScheme.ramps.yellow(0.3).hex()
+                : colorScheme.ramps.yellow(0.5).hex(),
+            inserted: isLight
+                ? colorScheme.ramps.green(0.4).hex()
+                : colorScheme.ramps.green(0.5).hex(),
             removedWidthEm: 0.275,
-            widthEm: 0.22,
-            cornerRadius: 0.2,
+            widthEm: 0.15,
+            cornerRadius: 0.05,
         },
         /** Highlights matching occurences of what is under the cursor
          * as well as matched brackets
@@ -234,12 +242,27 @@ export default function editor(colorScheme: ColorScheme) {
                 border: border(layer, "variant", { left: true }),
             },
             thumb: {
-                background: withOpacity(background(layer, "inverted"), 0.4),
+                background: withOpacity(background(layer, "inverted"), 0.3),
                 border: {
-                    width: 1,
-                    color: borderColor(layer, "variant"),
-                },
+                        width: 1,
+                        color: borderColor(layer, "variant"),
+                        top: false,
+                        right: true,
+                        left: true,
+                        bottom: false,
+                }
             },
+            git: {
+                deleted: isLight
+                    ? withOpacity(colorScheme.ramps.red(0.5).hex(), 0.8)
+                    : withOpacity(colorScheme.ramps.red(0.4).hex(), 0.8),
+                modified: isLight
+                    ? withOpacity(colorScheme.ramps.yellow(0.5).hex(), 0.8)
+                    : withOpacity(colorScheme.ramps.yellow(0.4).hex(), 0.8),
+                inserted: isLight
+                    ? withOpacity(colorScheme.ramps.green(0.5).hex(), 0.8)
+                    : withOpacity(colorScheme.ramps.green(0.4).hex(), 0.8),
+            }
         },
         compositionMark: {
             underline: {

styles/src/styleTree/projectPanel.ts 🔗

@@ -3,6 +3,8 @@ import { withOpacity } from "../utils/color"
 import { background, border, foreground, text } from "./components"
 
 export default function projectPanel(colorScheme: ColorScheme) {
+    const { isLight } = colorScheme
+
     let layer = colorScheme.middle
 
     let baseEntry = {
@@ -12,6 +14,20 @@ export default function projectPanel(colorScheme: ColorScheme) {
         iconSpacing: 8,
     }
 
+    let status = {
+        git: {
+            modified: isLight
+                ? colorScheme.ramps.yellow(0.6).hex()
+                : colorScheme.ramps.yellow(0.5).hex(),
+            inserted: isLight
+                ? colorScheme.ramps.green(0.45).hex()
+                : colorScheme.ramps.green(0.5).hex(),
+            conflict: isLight
+                ? colorScheme.ramps.red(0.6).hex()
+                : colorScheme.ramps.red(0.5).hex()
+        }
+    }
+
     let entry = {
         ...baseEntry,
         text: text(layer, "mono", "variant", { size: "sm" }),
@@ -28,6 +44,7 @@ export default function projectPanel(colorScheme: ColorScheme) {
             background: background(layer, "active"),
             text: text(layer, "mono", "active", { size: "sm" }),
         },
+        status
     }
 
     return {
@@ -62,6 +79,7 @@ export default function projectPanel(colorScheme: ColorScheme) {
             text: text(layer, "mono", "on", { size: "sm" }),
             background: withOpacity(background(layer, "on"), 0.9),
             border: border(layer),
+            status
         },
         ignoredEntry: {
             ...entry,

styles/src/styleTree/statusBar.ts 🔗

@@ -93,10 +93,11 @@ export default function statusBar(colorScheme: ColorScheme) {
                 },
             },
         },
-        sidebarButtons: {
+        panelButtons: {
             groupLeft: {},
+            groupBottom: {},
             groupRight: {},
-            item: {
+            button: {
                 ...statusContainer,
                 iconSize: 16,
                 iconColor: foreground(layer, "variant"),

styles/src/styleTree/workspace.ts 🔗

@@ -118,9 +118,25 @@ export default function workspace(colorScheme: ColorScheme) {
             },
             cursor: "Arrow",
         },
-        sidebar: {
-            initialSize: 240,
-            border: border(layer, { left: true, right: true }),
+        zoomedBackground: {
+            padding: 10,
+            cursor: "Arrow",
+            background: withOpacity(background(colorScheme.lowest), 0.5)
+        },
+        zoomedForeground: {
+            shadow: colorScheme.modalShadow,
+            border: border(colorScheme.highest, { overlay: true }),
+        },
+        dock: {
+            left: {
+                border: border(layer, { right: true }),
+            },
+            bottom: {
+                border: border(layer, { top: true }),
+            },
+            right: {
+                border: border(layer, { left: true }),
+            }
         },
         paneDivider: {
             color: borderColor(layer),
@@ -310,19 +326,6 @@ export default function workspace(colorScheme: ColorScheme) {
             width: 400,
             margin: { right: 10, bottom: 10 },
         },
-        dock: {
-            initialSizeRight: 640,
-            initialSizeBottom: 304,
-            wash_color: withOpacity(background(colorScheme.highest), 0.5),
-            panel: {
-                border: border(colorScheme.middle),
-            },
-            maximized: {
-                margin: 32,
-                border: border(colorScheme.highest, { overlay: true }),
-                shadow: colorScheme.modalShadow,
-            },
-        },
         dropTargetOverlayColor: withOpacity(foreground(layer, "variant"), 0.5),
     }
 }