Merge branch 'main' into copilot-ui

Piotr Osiewicz created

Change summary

Cargo.lock                                 |   5 
Cargo.toml                                 |   1 
assets/keymaps/default.json                |   2 
crates/editor/src/items.rs                 |  33 ++
crates/gpui/Cargo.toml                     |   2 
crates/gpui/src/app/entity_map.rs          |   7 
crates/gpui/src/platform/mac/dispatcher.rs |  10 
crates/search/src/project_search.rs        | 304 ++++++++++++++++++++++-
crates/terminal_view/src/terminal_panel.rs |  11 
crates/workspace/src/pane.rs               |   7 
crates/workspace/src/pane_group.rs         |  10 
crates/workspace/src/workspace.rs          |   1 
12 files changed, 349 insertions(+), 44 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -576,8 +576,9 @@ dependencies = [
 
 [[package]]
 name = "async-task"
-version = "4.0.3"
-source = "git+https://github.com/zed-industries/async-task?rev=341b57d6de98cdfd7b418567b8de2022ca993a6e#341b57d6de98cdfd7b418567b8de2022ca993a6e"
+version = "4.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbb36e985947064623dbd357f727af08ffd077f93d696782f3c56365fa2e2799"
 
 [[package]]
 name = "async-tls"

Cargo.toml 🔗

@@ -164,7 +164,6 @@ tree-sitter-uiua = {git = "https://github.com/shnarazk/tree-sitter-uiua", rev =
 
 [patch.crates-io]
 tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "31c40449749c4263a91a43593831b82229049a4c" }
-async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
 # wasmtime = { git = "https://github.com/bytecodealliance/wasmtime", rev = "v16.0.0" }
 
 # TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/457

assets/keymaps/default.json 🔗

@@ -402,7 +402,7 @@
       "cmd-r": "workspace::ToggleRightDock",
       "cmd-j": "workspace::ToggleBottomDock",
       "alt-cmd-y": "workspace::CloseAllDocks",
-      "cmd-shift-f": "workspace::NewSearch",
+      "cmd-shift-f": "workspace::DeploySearch",
       "cmd-k cmd-t": "theme_selector::Toggle",
       "cmd-k cmd-s": "zed::OpenKeymap",
       "cmd-t": "project_symbols::Toggle",

crates/editor/src/items.rs 🔗

@@ -15,9 +15,11 @@ use language::{
     proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt,
     Point, SelectionGoal,
 };
+use project::repository::GitFileStatus;
 use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
 use rpc::proto::{self, update_view, PeerId};
 use settings::Settings;
+use workspace::item::ItemSettings;
 
 use std::fmt::Write;
 use std::{
@@ -29,7 +31,7 @@ use std::{
     sync::Arc,
 };
 use text::Selection;
-use theme::{ActiveTheme, Theme};
+use theme::Theme;
 use ui::{h_stack, prelude::*, Label};
 use util::{paths::PathExt, paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt};
 use workspace::{
@@ -579,7 +581,28 @@ impl Item for Editor {
     }
 
     fn tab_content(&self, detail: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
-        let _theme = cx.theme();
+        let git_status = if ItemSettings::get_global(cx).git_status {
+            self.buffer()
+                .read(cx)
+                .as_singleton()
+                .and_then(|buffer| buffer.read(cx).project_path(cx))
+                .and_then(|path| self.project.as_ref()?.read(cx).entry_for_path(&path, cx))
+                .and_then(|entry| entry.git_status())
+        } else {
+            None
+        };
+        let label_color = match git_status {
+            Some(GitFileStatus::Added) => Color::Created,
+            Some(GitFileStatus::Modified) => Color::Modified,
+            Some(GitFileStatus::Conflict) => Color::Conflict,
+            None => {
+                if selected {
+                    Color::Default
+                } else {
+                    Color::Muted
+                }
+            }
+        };
 
         let description = detail.and_then(|detail| {
             let path = path_for_buffer(&self.buffer, detail, false, cx)?;
@@ -595,11 +618,7 @@ impl Item for Editor {
 
         h_stack()
             .gap_2()
-            .child(Label::new(self.title(cx).to_string()).color(if selected {
-                Color::Default
-            } else {
-                Color::Muted
-            }))
+            .child(Label::new(self.title(cx).to_string()).color(label_color))
             .when_some(description, |this, description| {
                 this.child(
                     Label::new(description)

crates/gpui/Cargo.toml 🔗

@@ -19,7 +19,7 @@ gpui_macros = { path = "../gpui_macros" }
 util = { path = "../util" }
 sum_tree = { path = "../sum_tree" }
 sqlez = { path = "../sqlez" }
-async-task = "4.0.3"
+async-task = "4.7"
 backtrace = { version = "0.3", optional = true }
 ctor.workspace = true
 linkme = "0.3"

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

@@ -1,8 +1,6 @@
 use crate::{private::Sealed, AppContext, Context, Entity, ModelContext};
 use anyhow::{anyhow, Result};
-use collections::HashMap;
 use derive_more::{Deref, DerefMut};
-use lazy_static::lazy_static;
 use parking_lot::{RwLock, RwLockUpgradableReadGuard};
 use slotmap::{SecondaryMap, SlotMap};
 use std::{
@@ -18,6 +16,9 @@ use std::{
     thread::panicking,
 };
 
+#[cfg(any(test, feature = "test-support"))]
+use collections::HashMap;
+
 slotmap::new_key_type! { pub struct EntityId; }
 
 impl EntityId {
@@ -600,7 +601,7 @@ impl<T> PartialEq<Model<T>> for WeakModel<T> {
 }
 
 #[cfg(any(test, feature = "test-support"))]
-lazy_static! {
+lazy_static::lazy_static! {
     static ref LEAK_BACKTRACE: bool =
         std::env::var("LEAK_BACKTRACE").map_or(false, |b| !b.is_empty());
 }

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

@@ -11,7 +11,7 @@ use objc::{
 };
 use parking::{Parker, Unparker};
 use parking_lot::Mutex;
-use std::{ffi::c_void, sync::Arc, time::Duration};
+use std::{ffi::c_void, ptr::NonNull, sync::Arc, time::Duration};
 
 include!(concat!(env!("OUT_DIR"), "/dispatch_sys.rs"));
 
@@ -47,7 +47,7 @@ impl PlatformDispatcher for MacDispatcher {
         unsafe {
             dispatch_async_f(
                 dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.try_into().unwrap(), 0),
-                runnable.into_raw() as *mut c_void,
+                runnable.into_raw().as_ptr() as *mut c_void,
                 Some(trampoline),
             );
         }
@@ -57,7 +57,7 @@ impl PlatformDispatcher for MacDispatcher {
         unsafe {
             dispatch_async_f(
                 dispatch_get_main_queue(),
-                runnable.into_raw() as *mut c_void,
+                runnable.into_raw().as_ptr() as *mut c_void,
                 Some(trampoline),
             );
         }
@@ -71,7 +71,7 @@ impl PlatformDispatcher for MacDispatcher {
             dispatch_after_f(
                 when,
                 queue,
-                runnable.into_raw() as *mut c_void,
+                runnable.into_raw().as_ptr() as *mut c_void,
                 Some(trampoline),
             );
         }
@@ -91,6 +91,6 @@ impl PlatformDispatcher for MacDispatcher {
 }
 
 extern "C" fn trampoline(runnable: *mut c_void) {
-    let task = unsafe { Runnable::from_raw(runnable as *mut ()) };
+    let task = unsafe { Runnable::<()>::from_raw(NonNull::new_unchecked(runnable as *mut ())) };
     task.run();
 }

crates/search/src/project_search.rs 🔗

@@ -61,12 +61,12 @@ struct ActiveSearches(HashMap<WeakModel<Project>, WeakView<ProjectSearchView>>);
 struct ActiveSettings(HashMap<WeakModel<Project>, ProjectSearchSettings>);
 
 pub fn init(cx: &mut AppContext) {
-    // todo!() po
     cx.set_global(ActiveSearches::default());
     cx.set_global(ActiveSettings::default());
     cx.observe_new_views(|workspace: &mut Workspace, _cx| {
         workspace
-            .register_action(ProjectSearchView::deploy)
+            .register_action(ProjectSearchView::new_search)
+            .register_action(ProjectSearchView::deploy_search)
             .register_action(ProjectSearchBar::search_in_new);
     })
     .detach();
@@ -941,11 +941,41 @@ impl ProjectSearchView {
         });
     }
 
+    // Re-activate the most recently activated search or the most recent if it has been closed.
+    // If no search exists in the workspace, create a new one.
+    fn deploy_search(
+        workspace: &mut Workspace,
+        _: &workspace::DeploySearch,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let active_search = cx
+            .global::<ActiveSearches>()
+            .0
+            .get(&workspace.project().downgrade());
+        let existing = active_search
+            .and_then(|active_search| {
+                workspace
+                    .items_of_type::<ProjectSearchView>(cx)
+                    .filter(|search| &search.downgrade() == active_search)
+                    .last()
+            })
+            .or_else(|| workspace.item_of_type::<ProjectSearchView>(cx));
+        Self::existing_or_new_search(workspace, existing, cx)
+    }
+
     // Add another search tab to the workspace.
-    fn deploy(
+    fn new_search(
         workspace: &mut Workspace,
         _: &workspace::NewSearch,
         cx: &mut ViewContext<Workspace>,
+    ) {
+        Self::existing_or_new_search(workspace, None, cx)
+    }
+
+    fn existing_or_new_search(
+        workspace: &mut Workspace,
+        existing: Option<View<ProjectSearchView>>,
+        cx: &mut ViewContext<Workspace>,
     ) {
         // Clean up entries for dropped projects
         cx.update_global(|state: &mut ActiveSearches, _cx| {
@@ -962,19 +992,27 @@ impl ProjectSearchView {
             }
         });
 
-        let settings = cx
-            .global::<ActiveSettings>()
-            .0
-            .get(&workspace.project().downgrade());
-
-        let settings = if let Some(settings) = settings {
-            Some(settings.clone())
+        let search = if let Some(existing) = existing {
+            workspace.activate_item(&existing, cx);
+            existing
         } else {
-            None
-        };
+            let settings = cx
+                .global::<ActiveSettings>()
+                .0
+                .get(&workspace.project().downgrade());
 
-        let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
-        let search = cx.new_view(|cx| ProjectSearchView::new(model, cx, settings));
+            let settings = if let Some(settings) = settings {
+                Some(settings.clone())
+            } else {
+                None
+            };
+
+            let model = cx.new_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
+            let view = cx.new_view(|cx| ProjectSearchView::new(model, cx, settings));
+
+            workspace.add_item(Box::new(view.clone()), cx);
+            view
+        };
 
         workspace.add_item(Box::new(search.clone()), cx);
 
@@ -2060,7 +2098,237 @@ pub mod tests {
     }
 
     #[gpui::test]
-    async fn test_project_search_focus(cx: &mut TestAppContext) {
+    async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "one.rs": "const ONE: usize = 1;",
+                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
+                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
+                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
+        let workspace = window.clone();
+        let search_bar = window.build_view(cx, |_| ProjectSearchBar::new());
+
+        let active_item = cx.read(|cx| {
+            workspace
+                .read(cx)
+                .unwrap()
+                .active_pane()
+                .read(cx)
+                .active_item()
+                .and_then(|item| item.downcast::<ProjectSearchView>())
+        });
+        assert!(
+            active_item.is_none(),
+            "Expected no search panel to be active"
+        );
+
+        window
+            .update(cx, move |workspace, cx| {
+                assert_eq!(workspace.panes().len(), 1);
+                workspace.panes()[0].update(cx, move |pane, cx| {
+                    pane.toolbar()
+                        .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
+                });
+
+                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
+            })
+            .unwrap();
+
+        let Some(search_view) = cx.read(|cx| {
+            workspace
+                .read(cx)
+                .unwrap()
+                .active_pane()
+                .read(cx)
+                .active_item()
+                .and_then(|item| item.downcast::<ProjectSearchView>())
+        }) else {
+            panic!("Search view expected to appear after new search event trigger")
+        };
+
+        cx.spawn(|mut cx| async move {
+            window
+                .update(&mut cx, |_, cx| {
+                    cx.dispatch_action(ToggleFocus.boxed_clone())
+                })
+                .unwrap();
+        })
+        .detach();
+        cx.background_executor.run_until_parked();
+        window
+            .update(cx, |_, cx| {
+                search_view.update(cx, |search_view, cx| {
+                assert!(
+                    search_view.query_editor.focus_handle(cx).is_focused(cx),
+                    "Empty search view should be focused after the toggle focus event: no results panel to focus on",
+                );
+           });
+        }).unwrap();
+
+        window
+            .update(cx, |_, cx| {
+                search_view.update(cx, |search_view, cx| {
+                    let query_editor = &search_view.query_editor;
+                    assert!(
+                        query_editor.focus_handle(cx).is_focused(cx),
+                        "Search view should be focused after the new search view is activated",
+                    );
+                    let query_text = query_editor.read(cx).text(cx);
+                    assert!(
+                        query_text.is_empty(),
+                        "New search query should be empty but got '{query_text}'",
+                    );
+                    let results_text = search_view
+                        .results_editor
+                        .update(cx, |editor, cx| editor.display_text(cx));
+                    assert!(
+                        results_text.is_empty(),
+                        "Empty search view should have no results but got '{results_text}'"
+                    );
+                });
+            })
+            .unwrap();
+
+        window
+            .update(cx, |_, cx| {
+                search_view.update(cx, |search_view, cx| {
+                    search_view.query_editor.update(cx, |query_editor, cx| {
+                        query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx)
+                    });
+                    search_view.search(cx);
+                });
+            })
+            .unwrap();
+        cx.background_executor.run_until_parked();
+        window
+            .update(cx, |_, cx| {
+            search_view.update(cx, |search_view, cx| {
+                let results_text = search_view
+                    .results_editor
+                    .update(cx, |editor, cx| editor.display_text(cx));
+                assert!(
+                    results_text.is_empty(),
+                    "Search view for mismatching query should have no results but got '{results_text}'"
+                );
+                assert!(
+                    search_view.query_editor.focus_handle(cx).is_focused(cx),
+                    "Search view should be focused after mismatching query had been used in search",
+                );
+            });
+        }).unwrap();
+
+        cx.spawn(|mut cx| async move {
+            window.update(&mut cx, |_, cx| {
+                cx.dispatch_action(ToggleFocus.boxed_clone())
+            })
+        })
+        .detach();
+        cx.background_executor.run_until_parked();
+        window.update(cx, |_, cx| {
+            search_view.update(cx, |search_view, cx| {
+                assert!(
+                    search_view.query_editor.focus_handle(cx).is_focused(cx),
+                    "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on",
+                );
+            });
+        }).unwrap();
+
+        window
+            .update(cx, |_, cx| {
+                search_view.update(cx, |search_view, cx| {
+                    search_view
+                        .query_editor
+                        .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
+                    search_view.search(cx);
+                });
+            })
+            .unwrap();
+        cx.background_executor.run_until_parked();
+        window.update(cx, |_, cx| {
+            search_view.update(cx, |search_view, cx| {
+                assert_eq!(
+                    search_view
+                        .results_editor
+                        .update(cx, |editor, cx| editor.display_text(cx)),
+                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                    "Search view results should match the query"
+                );
+                assert!(
+                    search_view.results_editor.focus_handle(cx).is_focused(cx),
+                    "Search view with mismatching query should be focused after search results are available",
+                );
+            });
+        }).unwrap();
+        cx.spawn(|mut cx| async move {
+            window
+                .update(&mut cx, |_, cx| {
+                    cx.dispatch_action(ToggleFocus.boxed_clone())
+                })
+                .unwrap();
+        })
+        .detach();
+        cx.background_executor.run_until_parked();
+        window.update(cx, |_, cx| {
+            search_view.update(cx, |search_view, cx| {
+                assert!(
+                    search_view.results_editor.focus_handle(cx).is_focused(cx),
+                    "Search view with matching query should still have its results editor focused after the toggle focus event",
+                );
+            });
+        }).unwrap();
+
+        workspace
+            .update(cx, |workspace, cx| {
+                ProjectSearchView::deploy_search(workspace, &workspace::DeploySearch, cx)
+            })
+            .unwrap();
+        window.update(cx, |_, cx| {
+            search_view.update(cx, |search_view, cx| {
+                assert_eq!(search_view.query_editor.read(cx).text(cx), "two", "Query should be updated to first search result after search view 2nd open in a row");
+                assert_eq!(
+                    search_view
+                        .results_editor
+                        .update(cx, |editor, cx| editor.display_text(cx)),
+                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                    "Results should be unchanged after search view 2nd open in a row"
+                );
+                assert!(
+                    search_view.query_editor.focus_handle(cx).is_focused(cx),
+                    "Focus should be moved into query editor again after search view 2nd open in a row"
+                );
+            });
+        }).unwrap();
+
+        cx.spawn(|mut cx| async move {
+            window
+                .update(&mut cx, |_, cx| {
+                    cx.dispatch_action(ToggleFocus.boxed_clone())
+                })
+                .unwrap();
+        })
+        .detach();
+        cx.background_executor.run_until_parked();
+        window.update(cx, |_, cx| {
+            search_view.update(cx, |search_view, cx| {
+                assert!(
+                    search_view.results_editor.focus_handle(cx).is_focused(cx),
+                    "Search view with matching query should switch focus to the results editor after the toggle focus event",
+                );
+            });
+        }).unwrap();
+    }
+
+    #[gpui::test]
+    async fn test_new_project_search_focus(cx: &mut TestAppContext) {
         init_test(cx);
 
         let fs = FakeFs::new(cx.background_executor.clone());
@@ -2101,7 +2369,7 @@ pub mod tests {
                         .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
                 });
 
-                ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
+                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
             })
             .unwrap();
 
@@ -2250,7 +2518,7 @@ pub mod tests {
 
         workspace
             .update(cx, |workspace, cx| {
-                ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
+                ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
             })
             .unwrap();
         cx.background_executor.run_until_parked();
@@ -2536,7 +2804,7 @@ pub mod tests {
                             .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx))
                     });
 
-                    ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
+                    ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx)
                 }
             })
             .unwrap();

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -53,7 +53,7 @@ pub struct TerminalPanel {
 
 impl TerminalPanel {
     fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
-        let terminal_panel = cx.view().clone();
+        let terminal_panel = cx.view().downgrade();
         let pane = cx.new_view(|cx| {
             let mut pane = Pane::new(
                 workspace.weak_handle(),
@@ -77,14 +77,17 @@ impl TerminalPanel {
             pane.set_can_navigate(false, cx);
             pane.display_nav_history_buttons(false);
             pane.set_render_tab_bar_buttons(cx, move |pane, cx| {
+                let terminal_panel = terminal_panel.clone();
                 h_stack()
                     .gap_2()
                     .child(
                         IconButton::new("plus", Icon::Plus)
                             .icon_size(IconSize::Small)
-                            .on_click(cx.listener_for(&terminal_panel, |terminal_panel, _, cx| {
-                                terminal_panel.add_terminal(None, cx);
-                            }))
+                            .on_click(move |_, cx| {
+                                terminal_panel
+                                    .update(cx, |panel, cx| panel.add_terminal(None, cx))
+                                    .log_err();
+                            })
                             .tooltip(|cx| Tooltip::text("New Terminal", cx)),
                     )
                     .child({

crates/workspace/src/pane.rs 🔗

@@ -1128,7 +1128,12 @@ impl Pane {
             if self.items.len() == 1 && should_activate {
                 self.focus_handle.focus(cx);
             } else {
-                self.activate_item(index_to_activate, should_activate, should_activate, cx);
+                self.activate_item(
+                    dbg!(index_to_activate),
+                    dbg!(should_activate),
+                    should_activate,
+                    cx,
+                );
             }
         }
 

crates/workspace/src/pane_group.rs 🔗

@@ -246,7 +246,15 @@ impl Member {
                     .size_full()
                     .child(pane.clone())
                     .when_some(leader_border, |this, color| {
-                        this.border_2().border_color(color)
+                        this.child(
+                            div()
+                                .absolute()
+                                .size_full()
+                                .left_0()
+                                .top_0()
+                                .border_2()
+                                .border_color(color),
+                        )
                     })
                     .when_some(leader_status_box, |this, status_box| {
                         this.child(