Merge branch 'main' into terminal-modal

Mikayla Maki created

Change summary

Cargo.lock                                      |   4 
assets/icons/arrow-left.svg                     |   3 
assets/icons/arrow-right.svg                    |   3 
assets/keymaps/default.json                     |   3 
crates/client/src/client.rs                     |   2 
crates/collab/src/integration_tests.rs          |  34 +
crates/editor/src/editor.rs                     |  59 +
crates/editor/src/element.rs                    |  56 +
crates/editor/src/items.rs                      |   7 
crates/editor/src/test.rs                       |   8 
crates/gpui/src/app.rs                          |  46 +
crates/gpui/src/elements/event_handler.rs       |  23 
crates/gpui/src/elements/flex.rs                |   9 
crates/gpui/src/elements/list.rs                |   6 
crates/gpui/src/elements/mouse_event_handler.rs |   7 
crates/gpui/src/elements/uniform_list.rs        |   6 
crates/gpui/src/gpui.rs                         |   3 
crates/gpui/src/platform.rs                     |   2 
crates/gpui/src/platform/event.rs               | 148 ++---
crates/gpui/src/platform/mac/event.rs           | 189 +++---
crates/gpui/src/platform/mac/window.rs          |  43 
crates/gpui/src/presenter.rs                    |  95 ++-
crates/gpui/src/scene.rs                        |  15 
crates/language/src/buffer.rs                   |  10 
crates/project/src/fs.rs                        |  26 
crates/project/src/project.rs                   | 247 ++++++--
crates/settings/src/settings.rs                 |  25 
crates/terminal/Cargo.toml                      |   4 
crates/terminal/src/color_translation.rs        | 134 +++++
crates/terminal/src/terminal.rs                 | 222 ++++++-
crates/terminal/src/terminal_element.rs         | 497 +++++++++++++-----
crates/theme/src/theme.rs                       |  30 
crates/vim/src/vim_test_context.rs              |   8 
crates/workspace/src/pane.rs                    | 131 +++-
crates/workspace/src/sidebar.rs                 |   7 
crates/workspace/src/toolbar.rs                 | 107 +++
crates/workspace/src/workspace.rs               | 151 +++++
crates/zed/Cargo.toml                           |   2 
crates/zed/src/zed.rs                           |  33 
pbcpoy                                          |   1 
styles/package-lock.json                        |   1 
styles/src/styleTree/workspace.ts               |  13 
42 files changed, 1,681 insertions(+), 739 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4878,6 +4878,8 @@ name = "terminal"
 version = "0.1.0"
 dependencies = [
  "alacritty_terminal",
+ "client",
+ "dirs 4.0.0",
  "editor",
  "futures",
  "gpui",
@@ -6159,7 +6161,7 @@ dependencies = [
 
 [[package]]
 name = "zed"
-version = "0.44.1"
+version = "0.45.0"
 dependencies = [
  "activity_indicator",
  "anyhow",

assets/icons/arrow-left.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 3.99999C8 4.31671 7.76023 4.57258 7.44352 4.57258H1.95565L3.8416 6.45853C4.06527 6.6822 4.06527 7.04454 3.8416 7.2682C3.72887 7.38004 3.58215 7.43551 3.43542 7.43551C3.2887 7.43551 3.14233 7.37959 3.03068 7.26776L0.16775 4.40483C-0.0559165 4.18116 -0.0559165 3.81883 0.16775 3.59516L3.03068 0.732233C3.25434 0.508567 3.61668 0.508567 3.84035 0.732233C4.06401 0.955899 4.06401 1.31824 3.84035 1.5419L1.95565 3.42741H7.44352C7.76023 3.42741 8 3.68328 8 3.99999Z" fill="#839496"/>
+</svg>

assets/icons/arrow-right.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.83265 4.40382L4.97532 7.26115C4.8646 7.37365 4.71816 7.42901 4.57172 7.42901C4.42528 7.42901 4.2792 7.37321 4.16777 7.26159C3.94454 7.03836 3.94454 6.67673 4.16777 6.4535L6.05039 4.57169H0.571465C0.255909 4.57169 0 4.31631 0 4.00022C0 3.68413 0.255731 3.42876 0.571287 3.42876H6.05021L4.16795 1.54649C3.94472 1.32326 3.94472 0.961634 4.16795 0.738405C4.39117 0.515177 4.75281 0.515177 4.97603 0.738405L7.83336 3.59573C8.0557 3.81985 8.0557 4.18059 7.83247 4.40382H7.83265Z" fill="#FDF6E3"/>
+</svg>

assets/keymaps/default.json 🔗

@@ -417,7 +417,8 @@
             "up": "terminal::Up",
             "down": "terminal::Down",
             "tab": "terminal::Tab",
-            "cmd-v": "terminal::Paste"
+            "cmd-v": "terminal::Paste",
+            "cmd-c": "terminal::Copy"
         }
     }
 ]

crates/client/src/client.rs 🔗

@@ -549,7 +549,7 @@ impl Client {
                 client.respond_with_error(
                     receipt,
                     proto::Error {
-                        message: error.to_string(),
+                        message: format!("{:?}", error),
                     },
                 )?;
                 Err(error)

crates/collab/src/integration_tests.rs 🔗

@@ -35,7 +35,7 @@ use project::{
 use rand::prelude::*;
 use rpc::PeerId;
 use serde_json::json;
-use settings::Settings;
+use settings::{FormatOnSave, Settings};
 use sqlx::types::time::OffsetDateTime;
 use std::{
     cell::RefCell,
@@ -1912,7 +1912,6 @@ async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut Te
 
 #[gpui::test(iterations = 10)]
 async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
-    cx_a.foreground().forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -1932,11 +1931,15 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default());
     client_a.language_registry.add(Arc::new(language));
 
+    // Here we insert a fake tree with a directory that exists on disk. This is needed
+    // because later we'll invoke a command, which requires passing a working directory
+    // that points to a valid location on disk.
+    let directory = env::current_dir().unwrap();
     client_a
         .fs
-        .insert_tree("/a", json!({ "a.rs": "let one = two" }))
+        .insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" }))
         .await;
-    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
     let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
 
     let buffer_b = cx_b
@@ -1967,7 +1970,28 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon
         .unwrap();
     assert_eq!(
         buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
-        "let honey = two"
+        "let honey = \"two\""
+    );
+
+    // Ensure buffer can be formatted using an external command. Notice how the
+    // host's configuration is honored as opposed to using the guest's settings.
+    cx_a.update(|cx| {
+        cx.update_global(|settings: &mut Settings, _| {
+            settings.language_settings.format_on_save = Some(FormatOnSave::External {
+                command: "awk".to_string(),
+                arguments: vec!["{sub(/two/,\"{buffer_path}\")}1".to_string()],
+            });
+        });
+    });
+    project_b
+        .update(cx_b, |project, cx| {
+            project.format(HashSet::from_iter([buffer_b.clone()]), true, cx)
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
+        format!("let honey = \"{}/a.rs\"\n", directory.to_str().unwrap())
     );
 }
 

crates/editor/src/editor.rs 🔗

@@ -4065,13 +4065,16 @@ impl Editor {
                 }
             }
 
-            nav_history.push(Some(NavigationData {
-                cursor_anchor: position,
-                cursor_position: point,
-                scroll_position: self.scroll_position,
-                scroll_top_anchor: self.scroll_top_anchor.clone(),
-                scroll_top_row,
-            }));
+            nav_history.push(
+                Some(NavigationData {
+                    cursor_anchor: position,
+                    cursor_position: point,
+                    scroll_position: self.scroll_position,
+                    scroll_top_anchor: self.scroll_top_anchor.clone(),
+                    scroll_top_row,
+                }),
+                cx,
+            );
         }
     }
 
@@ -4669,7 +4672,7 @@ impl Editor {
         definitions: Vec<LocationLink>,
         cx: &mut ViewContext<Workspace>,
     ) {
-        let nav_history = workspace.active_pane().read(cx).nav_history().clone();
+        let pane = workspace.active_pane().clone();
         for definition in definitions {
             let range = definition
                 .target
@@ -4681,13 +4684,13 @@ impl Editor {
                 // When selecting a definition in a different buffer, disable the nav history
                 // to avoid creating a history entry at the previous cursor location.
                 if editor_handle != target_editor_handle {
-                    nav_history.borrow_mut().disable();
+                    pane.update(cx, |pane, _| pane.disable_history());
                 }
                 target_editor.change_selections(Some(Autoscroll::Center), cx, |s| {
                     s.select_ranges([range]);
                 });
 
-                nav_history.borrow_mut().enable();
+                pane.update(cx, |pane, _| pane.enable_history());
             });
         }
     }
@@ -5641,8 +5644,8 @@ impl Editor {
         editor_handle.update(cx, |editor, cx| {
             editor.push_to_nav_history(editor.selections.newest_anchor().head(), None, cx);
         });
-        let nav_history = workspace.active_pane().read(cx).nav_history().clone();
-        nav_history.borrow_mut().disable();
+        let pane = workspace.active_pane().clone();
+        pane.update(cx, |pane, _| pane.disable_history());
 
         // We defer the pane interaction because we ourselves are a workspace item
         // and activating a new item causes the pane to call a method on us reentrantly,
@@ -5657,7 +5660,7 @@ impl Editor {
                 });
             }
 
-            nav_history.borrow_mut().enable();
+            pane.update(cx, |pane, _| pane.enable_history());
         });
     }
 
@@ -6241,7 +6244,7 @@ mod tests {
         assert_set_eq,
         test::{marked_text_by, marked_text_ranges, marked_text_ranges_by, sample_text},
     };
-    use workspace::{FollowableItem, ItemHandle};
+    use workspace::{FollowableItem, ItemHandle, NavigationEntry, Pane};
 
     #[gpui::test]
     fn test_edit_events(cx: &mut MutableAppContext) {
@@ -6589,12 +6592,20 @@ mod tests {
     fn test_navigation_history(cx: &mut gpui::MutableAppContext) {
         cx.set_global(Settings::test(cx));
         use workspace::Item;
-        let nav_history = Rc::new(RefCell::new(workspace::NavHistory::default()));
+        let pane = cx.add_view(Default::default(), |cx| Pane::new(cx));
         let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx);
 
         cx.add_window(Default::default(), |cx| {
             let mut editor = build_editor(buffer.clone(), cx);
-            editor.nav_history = Some(ItemNavHistory::new(nav_history.clone(), &cx.handle()));
+            let handle = cx.handle();
+            editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle)));
+
+            fn pop_history(
+                editor: &mut Editor,
+                cx: &mut MutableAppContext,
+            ) -> Option<NavigationEntry> {
+                editor.nav_history.as_mut().unwrap().pop_backward(cx)
+            }
 
             // Move the cursor a small distance.
             // Nothing is added to the navigation history.
@@ -6604,21 +6615,21 @@ mod tests {
             editor.change_selections(None, cx, |s| {
                 s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)])
             });
-            assert!(nav_history.borrow_mut().pop_backward().is_none());
+            assert!(pop_history(&mut editor, cx).is_none());
 
             // Move the cursor a large distance.
             // The history can jump back to the previous position.
             editor.change_selections(None, cx, |s| {
                 s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 3)])
             });
-            let nav_entry = nav_history.borrow_mut().pop_backward().unwrap();
+            let nav_entry = pop_history(&mut editor, cx).unwrap();
             editor.navigate(nav_entry.data.unwrap(), cx);
             assert_eq!(nav_entry.item.id(), cx.view_id());
             assert_eq!(
                 editor.selections.display_ranges(cx),
                 &[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]
             );
-            assert!(nav_history.borrow_mut().pop_backward().is_none());
+            assert!(pop_history(&mut editor, cx).is_none());
 
             // Move the cursor a small distance via the mouse.
             // Nothing is added to the navigation history.
@@ -6628,7 +6639,7 @@ mod tests {
                 editor.selections.display_ranges(cx),
                 &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)]
             );
-            assert!(nav_history.borrow_mut().pop_backward().is_none());
+            assert!(pop_history(&mut editor, cx).is_none());
 
             // Move the cursor a large distance via the mouse.
             // The history can jump back to the previous position.
@@ -6638,14 +6649,14 @@ mod tests {
                 editor.selections.display_ranges(cx),
                 &[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)]
             );
-            let nav_entry = nav_history.borrow_mut().pop_backward().unwrap();
+            let nav_entry = pop_history(&mut editor, cx).unwrap();
             editor.navigate(nav_entry.data.unwrap(), cx);
             assert_eq!(nav_entry.item.id(), cx.view_id());
             assert_eq!(
                 editor.selections.display_ranges(cx),
                 &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)]
             );
-            assert!(nav_history.borrow_mut().pop_backward().is_none());
+            assert!(pop_history(&mut editor, cx).is_none());
 
             // Set scroll position to check later
             editor.set_scroll_position(Vector2F::new(5.5, 5.5), cx);
@@ -6658,7 +6669,7 @@ mod tests {
             assert_ne!(editor.scroll_position, original_scroll_position);
             assert_ne!(editor.scroll_top_anchor, original_scroll_top_anchor);
 
-            let nav_entry = nav_history.borrow_mut().pop_backward().unwrap();
+            let nav_entry = pop_history(&mut editor, cx).unwrap();
             editor.navigate(nav_entry.data.unwrap(), cx);
             assert_eq!(editor.scroll_position, original_scroll_position);
             assert_eq!(editor.scroll_top_anchor, original_scroll_top_anchor);
@@ -8221,7 +8232,7 @@ mod tests {
             fox ju|mps over
             the lazy dog"});
         cx.update_editor(|e, cx| e.copy(&Copy, cx));
-        cx.assert_clipboard_content(Some("fox jumps over\n"));
+        cx.cx.assert_clipboard_content(Some("fox jumps over\n"));
 
         // Paste with three selections, noticing how the copied full-line selection is inserted
         // before the empty selections but replaces the selection that is non-empty.

crates/editor/src/element.rs 🔗

@@ -23,8 +23,9 @@ use gpui::{
     json::{self, ToJson},
     platform::CursorStyle,
     text_layout::{self, Line, RunStyle, TextLayoutCache},
-    AppContext, Axis, Border, CursorRegion, Element, ElementBox, Event, EventContext,
-    LayoutContext, MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext,
+    AppContext, Axis, Border, CursorRegion, Element, ElementBox, Event, EventContext, KeyDownEvent,
+    LayoutContext, ModifiersChangedEvent, MouseButton, MouseEvent, MouseMovedEvent,
+    MutableAppContext, PaintContext, Quad, Scene, ScrollWheelEvent, SizeConstraint, ViewContext,
     WeakViewHandle,
 };
 use json::json;
@@ -1463,14 +1464,15 @@ impl Element for EditorElement {
         }
 
         match event {
-            Event::LeftMouseDown {
+            Event::MouseDown(MouseEvent {
+                button: MouseButton::Left,
                 position,
                 cmd,
                 alt,
                 shift,
                 click_count,
                 ..
-            } => self.mouse_down(
+            }) => self.mouse_down(
                 *position,
                 *cmd,
                 *alt,
@@ -1480,18 +1482,26 @@ impl Element for EditorElement {
                 paint,
                 cx,
             ),
-            Event::LeftMouseUp { position, .. } => self.mouse_up(*position, cx),
-            Event::LeftMouseDragged { position, .. } => {
-                self.mouse_dragged(*position, layout, paint, cx)
-            }
-            Event::ScrollWheel {
+            Event::MouseUp(MouseEvent {
+                button: MouseButton::Left,
+                position,
+                ..
+            }) => self.mouse_up(*position, cx),
+            Event::MouseMoved(MouseMovedEvent {
+                pressed_button: Some(MouseButton::Left),
+                position,
+                ..
+            }) => self.mouse_dragged(*position, layout, paint, cx),
+            Event::ScrollWheel(ScrollWheelEvent {
                 position,
                 delta,
                 precise,
-            } => self.scroll(*position, *delta, *precise, layout, paint, cx),
-            Event::KeyDown { input, .. } => self.key_down(input.as_deref(), cx),
-            Event::ModifiersChanged { cmd, .. } => self.modifiers_changed(*cmd, cx),
-            Event::MouseMoved { position, cmd, .. } => {
+            }) => self.scroll(*position, *delta, *precise, layout, paint, cx),
+            Event::KeyDown(KeyDownEvent { input, .. }) => self.key_down(input.as_deref(), cx),
+            Event::ModifiersChanged(ModifiersChangedEvent { cmd, .. }) => {
+                self.modifiers_changed(*cmd, cx)
+            }
+            Event::MouseMoved(MouseMovedEvent { position, cmd, .. }) => {
                 self.mouse_moved(*position, *cmd, layout, paint, cx)
             }
 
@@ -1685,22 +1695,22 @@ impl Cursor {
 }
 
 #[derive(Debug)]
-struct HighlightedRange {
-    start_y: f32,
-    line_height: f32,
-    lines: Vec<HighlightedRangeLine>,
-    color: Color,
-    corner_radius: f32,
+pub struct HighlightedRange {
+    pub start_y: f32,
+    pub line_height: f32,
+    pub lines: Vec<HighlightedRangeLine>,
+    pub color: Color,
+    pub corner_radius: f32,
 }
 
 #[derive(Debug)]
-struct HighlightedRangeLine {
-    start_x: f32,
-    end_x: f32,
+pub struct HighlightedRangeLine {
+    pub start_x: f32,
+    pub end_x: f32,
 }
 
 impl HighlightedRange {
-    fn paint(&self, bounds: RectF, scene: &mut Scene) {
+    pub fn paint(&self, bounds: RectF, scene: &mut Scene) {
         if self.lines.len() >= 2 && self.lines[0].start_x > self.lines[1].end_x {
             self.paint_lines(self.start_y, &self.lines[0..1], bounds, scene);
             self.paint_lines(

crates/editor/src/items.rs 🔗

@@ -352,13 +352,8 @@ impl Item for Editor {
         project: ModelHandle<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
-        let settings = cx.global::<Settings>();
         let buffer = self.buffer().clone();
-        let mut buffers = buffer.read(cx).all_buffers();
-        buffers.retain(|buffer| {
-            let language_name = buffer.read(cx).language().map(|l| l.name());
-            settings.format_on_save(language_name.as_deref())
-        });
+        let buffers = buffer.read(cx).all_buffers();
         let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse();
         let format = project.update(cx, |project, cx| project.format(buffers, true, cx));
         cx.spawn(|this, mut cx| async move {

crates/editor/src/test.rs 🔗

@@ -404,14 +404,6 @@ impl<'a> EditorTestContext<'a> {
 
         editor_text_with_selections
     }
-
-    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
-        self.cx.update(|cx| {
-            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
-            let expected_content = expected_content.map(|content| content.to_owned());
-            assert_eq!(actual_content, expected_content);
-        })
-    }
 }
 
 impl<'a> Deref for EditorTestContext<'a> {

crates/gpui/src/app.rs 🔗

@@ -4,7 +4,7 @@ use crate::{
     elements::ElementBox,
     executor::{self, Task},
     keymap::{self, Binding, Keystroke},
-    platform::{self, Platform, PromptLevel, WindowOptions},
+    platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions},
     presenter::Presenter,
     util::post_inc,
     AssetCache, AssetSource, ClipboardItem, FontCache, MouseRegionId, PathPromptOptions,
@@ -151,6 +151,7 @@ pub struct AsyncAppContext(Rc<RefCell<MutableAppContext>>);
 pub struct TestAppContext {
     cx: Rc<RefCell<MutableAppContext>>,
     foreground_platform: Rc<platform::test::ForegroundPlatform>,
+    condition_duration: Option<Duration>,
 }
 
 impl App {
@@ -337,6 +338,7 @@ impl TestAppContext {
         let cx = TestAppContext {
             cx: Rc::new(RefCell::new(cx)),
             foreground_platform,
+            condition_duration: None,
         };
         cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx));
         cx
@@ -377,11 +379,11 @@ impl TestAppContext {
 
             if !cx.dispatch_keystroke(window_id, dispatch_path, &keystroke) {
                 presenter.borrow_mut().dispatch_event(
-                    Event::KeyDown {
+                    Event::KeyDown(KeyDownEvent {
                         keystroke,
                         input,
                         is_held,
-                    },
+                    }),
                     cx,
                 );
             }
@@ -612,6 +614,27 @@ impl TestAppContext {
             test_window
         })
     }
+
+    pub fn set_condition_duration(&mut self, duration: Duration) {
+        self.condition_duration = Some(duration);
+    }
+    pub fn condition_duration(&self) -> Duration {
+        self.condition_duration.unwrap_or_else(|| {
+            if std::env::var("CI").is_ok() {
+                Duration::from_secs(2)
+            } else {
+                Duration::from_millis(500)
+            }
+        })
+    }
+
+    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
+        self.update(|cx| {
+            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
+            let expected_content = expected_content.map(|content| content.to_owned());
+            assert_eq!(actual_content, expected_content);
+        })
+    }
 }
 
 impl AsyncAppContext {
@@ -1820,7 +1843,7 @@ impl MutableAppContext {
             window.on_event(Box::new(move |event| {
                 app.update(|cx| {
                     if let Some(presenter) = presenter.upgrade() {
-                        if let Event::KeyDown { keystroke, .. } = &event {
+                        if let Event::KeyDown(KeyDownEvent { keystroke, .. }) = &event {
                             if cx.dispatch_keystroke(
                                 window_id,
                                 presenter.borrow().dispatch_path(cx.as_ref()),
@@ -4424,6 +4447,7 @@ impl<T: View> ViewHandle<T> {
         use postage::prelude::{Sink as _, Stream as _};
 
         let (tx, mut rx) = postage::mpsc::channel(1024);
+        let timeout_duration = cx.condition_duration();
 
         let mut cx = cx.cx.borrow_mut();
         let subscriptions = self.update(&mut *cx, |_, cx| {
@@ -4445,14 +4469,9 @@ impl<T: View> ViewHandle<T> {
 
         let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
         let handle = self.downgrade();
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(2)
-        } else {
-            Duration::from_millis(500)
-        };
 
         async move {
-            crate::util::timeout(duration, async move {
+            crate::util::timeout(timeout_duration, async move {
                 loop {
                     {
                         let cx = cx.borrow();
@@ -5381,7 +5400,7 @@ impl RefCounts {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{actions, elements::*, impl_actions};
+    use crate::{actions, elements::*, impl_actions, MouseButton, MouseEvent};
     use serde::Deserialize;
     use smol::future::poll_once;
     use std::{
@@ -5734,14 +5753,15 @@ mod tests {
         let presenter = cx.presenters_and_platform_windows[&window_id].0.clone();
         // Ensure window's root element is in a valid lifecycle state.
         presenter.borrow_mut().dispatch_event(
-            Event::LeftMouseDown {
+            Event::MouseDown(MouseEvent {
                 position: Default::default(),
+                button: MouseButton::Left,
                 ctrl: false,
                 alt: false,
                 shift: false,
                 cmd: false,
                 click_count: 1,
-            },
+            }),
             cx,
         );
         assert_eq!(mouse_down_count.load(SeqCst), 1);

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

@@ -1,6 +1,7 @@
 use crate::{
     geometry::vector::Vector2F, CursorRegion, DebugContext, Element, ElementBox, Event,
-    EventContext, LayoutContext, MouseRegion, NavigationDirection, PaintContext, SizeConstraint,
+    EventContext, LayoutContext, MouseButton, MouseEvent, MouseRegion, NavigationDirection,
+    PaintContext, SizeConstraint,
 };
 use pathfinder_geometry::rect::RectF;
 use serde_json::json;
@@ -90,7 +91,7 @@ impl Element for EventHandler {
                 click: Some(Rc::new(|_, _, _| {})),
                 right_mouse_down: Some(Rc::new(|_, _| {})),
                 right_click: Some(Rc::new(|_, _, _| {})),
-                drag: Some(Rc::new(|_, _| {})),
+                drag: Some(Rc::new(|_, _, _| {})),
                 mouse_down_out: Some(Rc::new(|_, _| {})),
                 right_mouse_down_out: Some(Rc::new(|_, _| {})),
             });
@@ -116,7 +117,11 @@ impl Element for EventHandler {
             true
         } else {
             match event {
-                Event::LeftMouseDown { position, .. } => {
+                Event::MouseDown(MouseEvent {
+                    button: MouseButton::Left,
+                    position,
+                    ..
+                }) => {
                     if let Some(callback) = self.mouse_down.as_mut() {
                         if visible_bounds.contains_point(*position) {
                             return callback(cx);
@@ -124,7 +129,11 @@ impl Element for EventHandler {
                     }
                     false
                 }
-                Event::RightMouseDown { position, .. } => {
+                Event::MouseDown(MouseEvent {
+                    button: MouseButton::Right,
+                    position,
+                    ..
+                }) => {
                     if let Some(callback) = self.right_mouse_down.as_mut() {
                         if visible_bounds.contains_point(*position) {
                             return callback(cx);
@@ -132,11 +141,11 @@ impl Element for EventHandler {
                     }
                     false
                 }
-                Event::NavigateMouseDown {
+                Event::MouseDown(MouseEvent {
+                    button: MouseButton::Navigate(direction),
                     position,
-                    direction,
                     ..
-                } => {
+                }) => {
                     if let Some(callback) = self.navigate_mouse_down.as_mut() {
                         if visible_bounds.contains_point(*position) {
                             return callback(*direction, cx);

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

@@ -3,7 +3,8 @@ use std::{any::Any, f32::INFINITY};
 use crate::{
     json::{self, ToJson, Value},
     Axis, DebugContext, Element, ElementBox, ElementStateHandle, Event, EventContext,
-    LayoutContext, PaintContext, RenderContext, SizeConstraint, Vector2FExt, View,
+    LayoutContext, MouseMovedEvent, PaintContext, RenderContext, ScrollWheelEvent, SizeConstraint,
+    Vector2FExt, View,
 };
 use pathfinder_geometry::{
     rect::RectF,
@@ -287,11 +288,11 @@ impl Element for Flex {
             handled = child.dispatch_event(event, cx) || handled;
         }
         if !handled {
-            if let &Event::ScrollWheel {
+            if let &Event::ScrollWheel(ScrollWheelEvent {
                 position,
                 delta,
                 precise,
-            } = event
+            }) = event
             {
                 if *remaining_space < 0. && bounds.contains_point(position) {
                     if let Some(scroll_state) = self.scroll_state.as_ref() {
@@ -321,7 +322,7 @@ impl Element for Flex {
         }
 
         if !handled {
-            if let &Event::MouseMoved { position, .. } = event {
+            if let &Event::MouseMoved(MouseMovedEvent { position, .. }) = event {
                 // If this is a scrollable flex, and the mouse is over it, eat the scroll event to prevent
                 // propogating it to the element below.
                 if self.scroll_state.is_some() && bounds.contains_point(position) {

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

@@ -5,7 +5,7 @@ use crate::{
     },
     json::json,
     DebugContext, Element, ElementBox, ElementRc, Event, EventContext, LayoutContext, PaintContext,
-    RenderContext, SizeConstraint, View, ViewContext,
+    RenderContext, ScrollWheelEvent, SizeConstraint, View, ViewContext,
 };
 use std::{cell::RefCell, collections::VecDeque, ops::Range, rc::Rc};
 use sum_tree::{Bias, SumTree};
@@ -311,11 +311,11 @@ impl Element for List {
         state.items = new_items;
 
         match event {
-            Event::ScrollWheel {
+            Event::ScrollWheel(ScrollWheelEvent {
                 position,
                 delta,
                 precise,
-            } => {
+            }) => {
                 if bounds.contains_point(*position) {
                     if state.scroll(scroll_top, bounds.height(), *delta, *precise, cx) {
                         handled = true;

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

@@ -24,7 +24,7 @@ pub struct MouseEventHandler {
     right_click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
     mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     right_mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
-    drag: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    drag: Option<Rc<dyn Fn(Vector2F, Vector2F, &mut EventContext)>>,
     hover: Option<Rc<dyn Fn(Vector2F, bool, &mut EventContext)>>,
     padding: Padding,
 }
@@ -106,7 +106,10 @@ impl MouseEventHandler {
         self
     }
 
-    pub fn on_drag(mut self, handler: impl Fn(Vector2F, &mut EventContext) + 'static) -> Self {
+    pub fn on_drag(
+        mut self,
+        handler: impl Fn(Vector2F, Vector2F, &mut EventContext) + 'static,
+    ) -> Self {
         self.drag = Some(Rc::new(handler));
         self
     }

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

@@ -5,7 +5,7 @@ use crate::{
         vector::{vec2f, Vector2F},
     },
     json::{self, json},
-    ElementBox, RenderContext, View,
+    ElementBox, RenderContext, ScrollWheelEvent, View,
 };
 use json::ToJson;
 use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
@@ -310,11 +310,11 @@ impl Element for UniformList {
         }
 
         match event {
-            Event::ScrollWheel {
+            Event::ScrollWheel(ScrollWheelEvent {
                 position,
                 delta,
                 precise,
-            } => {
+            }) => {
                 if bounds.contains_point(*position) {
                     if self.scroll(*position, *delta, *precise, layout.scroll_max, cx) {
                         handled = true;

crates/gpui/src/gpui.rs 🔗

@@ -28,8 +28,7 @@ pub mod json;
 pub mod keymap;
 pub mod platform;
 pub use gpui_macros::test;
-pub use platform::FontSystem;
-pub use platform::{Event, NavigationDirection, PathPromptOptions, Platform, PromptLevel};
+pub use platform::*;
 pub use presenter::{
     Axis, DebugContext, EventContext, LayoutContext, PaintContext, SizeConstraint, Vector2FExt,
 };

crates/gpui/src/platform.rs 🔗

@@ -20,7 +20,7 @@ use crate::{
 };
 use anyhow::{anyhow, Result};
 use async_task::Runnable;
-pub use event::{Event, NavigationDirection};
+pub use event::*;
 use postage::oneshot;
 use serde::Deserialize;
 use std::{

crates/gpui/src/platform/event.rs 🔗

@@ -1,85 +1,77 @@
 use crate::{geometry::vector::Vector2F, keymap::Keystroke};
 
+#[derive(Clone, Debug)]
+pub struct KeyDownEvent {
+    pub keystroke: Keystroke,
+    pub input: Option<String>,
+    pub is_held: bool,
+}
+
+#[derive(Clone, Debug)]
+pub struct KeyUpEvent {
+    pub keystroke: Keystroke,
+    pub input: Option<String>,
+}
+
+#[derive(Clone, Debug)]
+pub struct ModifiersChangedEvent {
+    pub ctrl: bool,
+    pub alt: bool,
+    pub shift: bool,
+    pub cmd: bool,
+}
+
+#[derive(Clone, Debug)]
+pub struct ScrollWheelEvent {
+    pub position: Vector2F,
+    pub delta: Vector2F,
+    pub precise: bool,
+}
+
 #[derive(Copy, Clone, Debug)]
 pub enum NavigationDirection {
     Back,
     Forward,
 }
 
+#[derive(Copy, Clone, Debug)]
+pub enum MouseButton {
+    Left,
+    Right,
+    Middle,
+    Navigate(NavigationDirection),
+}
+
+#[derive(Clone, Debug)]
+pub struct MouseEvent {
+    pub button: MouseButton,
+    pub position: Vector2F,
+    pub ctrl: bool,
+    pub alt: bool,
+    pub shift: bool,
+    pub cmd: bool,
+    pub click_count: usize,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct MouseMovedEvent {
+    pub position: Vector2F,
+    pub pressed_button: Option<MouseButton>,
+    pub ctrl: bool,
+    pub cmd: bool,
+    pub alt: bool,
+    pub shift: bool,
+}
+
 #[derive(Clone, Debug)]
 pub enum Event {
-    KeyDown {
-        keystroke: Keystroke,
-        input: Option<String>,
-        is_held: bool,
-    },
-    KeyUp {
-        keystroke: Keystroke,
-        input: Option<String>,
-    },
-    ModifiersChanged {
-        ctrl: bool,
-        alt: bool,
-        shift: bool,
-        cmd: bool,
-    },
-    ScrollWheel {
-        position: Vector2F,
-        delta: Vector2F,
-        precise: bool,
-    },
-    LeftMouseDown {
-        position: Vector2F,
-        ctrl: bool,
-        alt: bool,
-        shift: bool,
-        cmd: bool,
-        click_count: usize,
-    },
-    LeftMouseUp {
-        position: Vector2F,
-        click_count: usize,
-    },
-    LeftMouseDragged {
-        position: Vector2F,
-        ctrl: bool,
-        alt: bool,
-        shift: bool,
-        cmd: bool,
-    },
-    RightMouseDown {
-        position: Vector2F,
-        ctrl: bool,
-        alt: bool,
-        shift: bool,
-        cmd: bool,
-        click_count: usize,
-    },
-    RightMouseUp {
-        position: Vector2F,
-        click_count: usize,
-    },
-    NavigateMouseDown {
-        position: Vector2F,
-        direction: NavigationDirection,
-        ctrl: bool,
-        alt: bool,
-        shift: bool,
-        cmd: bool,
-        click_count: usize,
-    },
-    NavigateMouseUp {
-        position: Vector2F,
-        direction: NavigationDirection,
-    },
-    MouseMoved {
-        position: Vector2F,
-        left_mouse_down: bool,
-        ctrl: bool,
-        cmd: bool,
-        alt: bool,
-        shift: bool,
-    },
+    KeyDown(KeyDownEvent),
+    KeyUp(KeyUpEvent),
+    ModifiersChanged(ModifiersChangedEvent),
+    MouseDown(MouseEvent),
+    MouseUp(MouseEvent),
+    MouseMoved(MouseMovedEvent),
+    ScrollWheel(ScrollWheelEvent),
 }
 
 impl Event {
@@ -88,15 +80,9 @@ impl Event {
             Event::KeyDown { .. } => None,
             Event::KeyUp { .. } => None,
             Event::ModifiersChanged { .. } => None,
-            Event::ScrollWheel { position, .. }
-            | Event::LeftMouseDown { position, .. }
-            | Event::LeftMouseUp { position, .. }
-            | Event::LeftMouseDragged { position, .. }
-            | Event::RightMouseDown { position, .. }
-            | Event::RightMouseUp { position, .. }
-            | Event::NavigateMouseDown { position, .. }
-            | Event::NavigateMouseUp { position, .. }
-            | Event::MouseMoved { position, .. } => Some(*position),
+            Event::MouseDown(event) | Event::MouseUp(event) => Some(event.position),
+            Event::MouseMoved(event) => Some(event.position),
+            Event::ScrollWheel(event) => Some(event.position),
         }
     }
 }

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

@@ -2,10 +2,12 @@ use crate::{
     geometry::vector::vec2f,
     keymap::Keystroke,
     platform::{Event, NavigationDirection},
+    KeyDownEvent, KeyUpEvent, ModifiersChangedEvent, MouseButton, MouseEvent, MouseMovedEvent,
+    ScrollWheelEvent,
 };
 use cocoa::{
     appkit::{NSEvent, NSEventModifierFlags, NSEventType},
-    base::{id, nil, YES},
+    base::{id, YES},
     foundation::NSString as _,
 };
 use std::{borrow::Cow, ffi::CStr, os::raw::c_char};
@@ -59,12 +61,12 @@ impl Event {
                 let shift = modifiers.contains(NSEventModifierFlags::NSShiftKeyMask);
                 let cmd = modifiers.contains(NSEventModifierFlags::NSCommandKeyMask);
 
-                Some(Self::ModifiersChanged {
+                Some(Self::ModifiersChanged(ModifiersChangedEvent {
                     ctrl,
                     alt,
                     shift,
                     cmd,
-                })
+                }))
             }
             NSEventType::NSKeyDown => {
                 let modifiers = native_event.modifierFlags();
@@ -76,7 +78,7 @@ impl Event {
 
                 let (unmodified_chars, input) = get_key_text(native_event, cmd, ctrl, function)?;
 
-                Some(Self::KeyDown {
+                Some(Self::KeyDown(KeyDownEvent {
                     keystroke: Keystroke {
                         ctrl,
                         alt,
@@ -86,7 +88,7 @@ impl Event {
                     },
                     input,
                     is_held: native_event.isARepeat() == YES,
-                })
+                }))
             }
             NSEventType::NSKeyUp => {
                 let modifiers = native_event.modifierFlags();
@@ -98,7 +100,7 @@ impl Event {
 
                 let (unmodified_chars, input) = get_key_text(native_event, cmd, ctrl, function)?;
 
-                Some(Self::KeyUp {
+                Some(Self::KeyUp(KeyUpEvent {
                     keystroke: Keystroke {
                         ctrl,
                         alt,
@@ -107,125 +109,120 @@ impl Event {
                         key: unmodified_chars.into(),
                     },
                     input,
-                })
-            }
-            NSEventType::NSLeftMouseDown => {
-                let modifiers = native_event.modifierFlags();
-                window_height.map(|window_height| Self::LeftMouseDown {
-                    position: vec2f(
-                        native_event.locationInWindow().x as f32,
-                        window_height - native_event.locationInWindow().y as f32,
-                    ),
-                    ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
-                    alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
-                    shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
-                    cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
-                    click_count: native_event.clickCount() as usize,
-                })
+                }))
             }
-            NSEventType::NSLeftMouseUp => window_height.map(|window_height| Self::LeftMouseUp {
-                position: vec2f(
-                    native_event.locationInWindow().x as f32,
-                    window_height - native_event.locationInWindow().y as f32,
-                ),
-                click_count: native_event.clickCount() as usize,
-            }),
-            NSEventType::NSRightMouseDown => {
+            NSEventType::NSLeftMouseDown
+            | NSEventType::NSRightMouseDown
+            | NSEventType::NSOtherMouseDown => {
+                let button = match native_event.buttonNumber() {
+                    0 => MouseButton::Left,
+                    1 => MouseButton::Right,
+                    2 => MouseButton::Middle,
+                    3 => MouseButton::Navigate(NavigationDirection::Back),
+                    4 => MouseButton::Navigate(NavigationDirection::Forward),
+                    // Other mouse buttons aren't tracked currently
+                    _ => return None,
+                };
                 let modifiers = native_event.modifierFlags();
-                window_height.map(|window_height| Self::RightMouseDown {
-                    position: vec2f(
-                        native_event.locationInWindow().x as f32,
-                        window_height - native_event.locationInWindow().y as f32,
-                    ),
-                    ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
-                    alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
-                    shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
-                    cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
-                    click_count: native_event.clickCount() as usize,
+
+                window_height.map(|window_height| {
+                    Self::MouseDown(MouseEvent {
+                        button,
+                        position: vec2f(
+                            native_event.locationInWindow().x as f32,
+                            window_height - native_event.locationInWindow().y as f32,
+                        ),
+                        ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
+                        alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
+                        shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
+                        cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
+                        click_count: native_event.clickCount() as usize,
+                    })
                 })
             }
-            NSEventType::NSRightMouseUp => window_height.map(|window_height| Self::RightMouseUp {
-                position: vec2f(
-                    native_event.locationInWindow().x as f32,
-                    window_height - native_event.locationInWindow().y as f32,
-                ),
-                click_count: native_event.clickCount() as usize,
-            }),
-            NSEventType::NSOtherMouseDown => {
-                let direction = match native_event.buttonNumber() {
-                    3 => NavigationDirection::Back,
-                    4 => NavigationDirection::Forward,
+            NSEventType::NSLeftMouseUp
+            | NSEventType::NSRightMouseUp
+            | NSEventType::NSOtherMouseUp => {
+                let button = match native_event.buttonNumber() {
+                    0 => MouseButton::Left,
+                    1 => MouseButton::Right,
+                    2 => MouseButton::Middle,
+                    3 => MouseButton::Navigate(NavigationDirection::Back),
+                    4 => MouseButton::Navigate(NavigationDirection::Forward),
                     // Other mouse buttons aren't tracked currently
                     _ => return None,
                 };
 
-                let modifiers = native_event.modifierFlags();
-                window_height.map(|window_height| Self::NavigateMouseDown {
+                window_height.map(|window_height| {
+                    let modifiers = native_event.modifierFlags();
+                    Self::MouseUp(MouseEvent {
+                        button,
+                        position: vec2f(
+                            native_event.locationInWindow().x as f32,
+                            window_height - native_event.locationInWindow().y as f32,
+                        ),
+                        ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
+                        alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
+                        shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
+                        cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
+                        click_count: native_event.clickCount() as usize,
+                    })
+                })
+            }
+            NSEventType::NSScrollWheel => window_height.map(|window_height| {
+                Self::ScrollWheel(ScrollWheelEvent {
                     position: vec2f(
                         native_event.locationInWindow().x as f32,
                         window_height - native_event.locationInWindow().y as f32,
                     ),
-                    direction,
-                    ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
-                    alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
-                    shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
-                    cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
-                    click_count: native_event.clickCount() as usize,
+                    delta: vec2f(
+                        native_event.scrollingDeltaX() as f32,
+                        native_event.scrollingDeltaY() as f32,
+                    ),
+                    precise: native_event.hasPreciseScrollingDeltas() == YES,
                 })
-            }
-            NSEventType::NSOtherMouseUp => {
-                let direction = match native_event.buttonNumber() {
-                    3 => NavigationDirection::Back,
-                    4 => NavigationDirection::Forward,
+            }),
+            NSEventType::NSLeftMouseDragged
+            | NSEventType::NSRightMouseDragged
+            | NSEventType::NSOtherMouseDragged => {
+                let pressed_button = match native_event.buttonNumber() {
+                    0 => MouseButton::Left,
+                    1 => MouseButton::Right,
+                    2 => MouseButton::Middle,
+                    3 => MouseButton::Navigate(NavigationDirection::Back),
+                    4 => MouseButton::Navigate(NavigationDirection::Forward),
                     // Other mouse buttons aren't tracked currently
                     _ => return None,
                 };
 
-                window_height.map(|window_height| Self::NavigateMouseUp {
-                    position: vec2f(
-                        native_event.locationInWindow().x as f32,
-                        window_height - native_event.locationInWindow().y as f32,
-                    ),
-                    direction,
+                window_height.map(|window_height| {
+                    let modifiers = native_event.modifierFlags();
+                    Self::MouseMoved(MouseMovedEvent {
+                        pressed_button: Some(pressed_button),
+                        position: vec2f(
+                            native_event.locationInWindow().x as f32,
+                            window_height - native_event.locationInWindow().y as f32,
+                        ),
+                        ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
+                        alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
+                        shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
+                        cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
+                    })
                 })
             }
-            NSEventType::NSLeftMouseDragged => window_height.map(|window_height| {
-                let modifiers = native_event.modifierFlags();
-                Self::LeftMouseDragged {
-                    position: vec2f(
-                        native_event.locationInWindow().x as f32,
-                        window_height - native_event.locationInWindow().y as f32,
-                    ),
-                    ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
-                    alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
-                    shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
-                    cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
-                }
-            }),
-            NSEventType::NSScrollWheel => window_height.map(|window_height| Self::ScrollWheel {
-                position: vec2f(
-                    native_event.locationInWindow().x as f32,
-                    window_height - native_event.locationInWindow().y as f32,
-                ),
-                delta: vec2f(
-                    native_event.scrollingDeltaX() as f32,
-                    native_event.scrollingDeltaY() as f32,
-                ),
-                precise: native_event.hasPreciseScrollingDeltas() == YES,
-            }),
             NSEventType::NSMouseMoved => window_height.map(|window_height| {
                 let modifiers = native_event.modifierFlags();
-                Self::MouseMoved {
+                Self::MouseMoved(MouseMovedEvent {
                     position: vec2f(
                         native_event.locationInWindow().x as f32,
                         window_height - native_event.locationInWindow().y as f32,
                     ),
-                    left_mouse_down: NSEvent::pressedMouseButtons(nil) & 1 != 0,
+                    pressed_button: None,
                     ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
                     alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),
                     shift: modifiers.contains(NSEventModifierFlags::NSShiftKeyMask),
                     cmd: modifiers.contains(NSEventModifierFlags::NSCommandKeyMask),
-                }
+                })
             }),
             _ => None,
         }

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

@@ -6,7 +6,7 @@ use crate::{
     },
     keymap::Keystroke,
     platform::{self, Event, WindowBounds, WindowContext},
-    Scene,
+    KeyDownEvent, ModifiersChangedEvent, MouseButton, MouseEvent, MouseMovedEvent, Scene,
 };
 use block::ConcreteBlock;
 use cocoa::{
@@ -562,11 +562,11 @@ extern "C" fn handle_key_equivalent(this: &Object, _: Sel, native_event: id) ->
     let event = unsafe { Event::from_native(native_event, Some(window_state_borrow.size().y())) };
     if let Some(event) = event {
         match &event {
-            Event::KeyDown {
+            Event::KeyDown(KeyDownEvent {
                 keystroke,
                 input,
                 is_held,
-            } => {
+            }) => {
                 let keydown = (keystroke.clone(), input.clone());
                 // Ignore events from held-down keys after some of the initially-pressed keys
                 // were released.
@@ -603,33 +603,41 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) {
 
     if let Some(event) = event {
         match &event {
-            Event::LeftMouseDragged { position, .. } => {
+            Event::MouseMoved(
+                event @ MouseMovedEvent {
+                    pressed_button: Some(_),
+                    ..
+                },
+            ) => {
                 window_state_borrow.synthetic_drag_counter += 1;
                 window_state_borrow
                     .executor
                     .spawn(synthetic_drag(
                         weak_window_state,
                         window_state_borrow.synthetic_drag_counter,
-                        *position,
+                        *event,
                     ))
                     .detach();
             }
-            Event::LeftMouseUp { .. } => {
+            Event::MouseUp(MouseEvent {
+                button: MouseButton::Left,
+                ..
+            }) => {
                 window_state_borrow.synthetic_drag_counter += 1;
             }
-            Event::ModifiersChanged {
+            Event::ModifiersChanged(ModifiersChangedEvent {
                 ctrl,
                 alt,
                 shift,
                 cmd,
-            } => {
+            }) => {
                 // Only raise modifiers changed event when they have actually changed
-                if let Some(Event::ModifiersChanged {
+                if let Some(Event::ModifiersChanged(ModifiersChangedEvent {
                     ctrl: prev_ctrl,
                     alt: prev_alt,
                     shift: prev_shift,
                     cmd: prev_cmd,
-                }) = &window_state_borrow.previous_modifiers_changed_event
+                })) = &window_state_borrow.previous_modifiers_changed_event
                 {
                     if prev_ctrl == ctrl
                         && prev_alt == alt
@@ -667,11 +675,11 @@ extern "C" fn cancel_operation(this: &Object, _sel: Sel, _sender: id) {
         shift: false,
         key: chars.clone(),
     };
-    let event = Event::KeyDown {
+    let event = Event::KeyDown(KeyDownEvent {
         keystroke: keystroke.clone(),
         input: Some(chars.clone()),
         is_held: false,
-    };
+    });
 
     window_state_borrow.last_fresh_keydown = Some((keystroke, Some(chars)));
     if let Some(mut callback) = window_state_borrow.event_callback.take() {
@@ -835,7 +843,7 @@ extern "C" fn display_layer(this: &Object, _: Sel, _: id) {
 async fn synthetic_drag(
     window_state: Weak<RefCell<WindowState>>,
     drag_id: usize,
-    position: Vector2F,
+    event: MouseMovedEvent,
 ) {
     loop {
         Timer::after(Duration::from_millis(16)).await;
@@ -844,14 +852,7 @@ async fn synthetic_drag(
             if window_state_borrow.synthetic_drag_counter == drag_id {
                 if let Some(mut callback) = window_state_borrow.event_callback.take() {
                     drop(window_state_borrow);
-                    callback(Event::LeftMouseDragged {
-                        // TODO: Make sure empty modifiers is correct for this
-                        position,
-                        shift: false,
-                        ctrl: false,
-                        alt: false,
-                        cmd: false,
-                    });
+                    callback(Event::MouseMoved(event));
                     window_state.borrow_mut().event_callback = Some(callback);
                 }
             } else {

crates/gpui/src/presenter.rs 🔗

@@ -9,9 +9,9 @@ use crate::{
     scene::CursorRegion,
     text_layout::TextLayoutCache,
     Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AssetCache, ElementBox, Entity,
-    FontSystem, ModelHandle, MouseRegion, MouseRegionId, ReadModel, ReadView, RenderContext,
-    RenderParams, Scene, UpgradeModelHandle, UpgradeViewHandle, View, ViewHandle, WeakModelHandle,
-    WeakViewHandle,
+    FontSystem, ModelHandle, MouseButton, MouseEvent, MouseMovedEvent, MouseRegion, MouseRegionId,
+    ReadModel, ReadView, RenderContext, RenderParams, Scene, UpgradeModelHandle, UpgradeViewHandle,
+    View, ViewHandle, WeakModelHandle, WeakViewHandle,
 };
 use pathfinder_geometry::vector::{vec2f, Vector2F};
 use serde_json::json;
@@ -235,7 +235,11 @@ impl Presenter {
             let mut dragged_region = None;
 
             match event {
-                Event::LeftMouseDown { position, .. } => {
+                Event::MouseDown(MouseEvent {
+                    position,
+                    button: MouseButton::Left,
+                    ..
+                }) => {
                     let mut hit = false;
                     for (region, _) in self.mouse_regions.iter().rev() {
                         if region.bounds.contains_point(position) {
@@ -251,11 +255,12 @@ impl Presenter {
                         }
                     }
                 }
-                Event::LeftMouseUp {
+                Event::MouseUp(MouseEvent {
                     position,
                     click_count,
+                    button: MouseButton::Left,
                     ..
-                } => {
+                }) => {
                     self.prev_drag_position.take();
                     if let Some(region) = self.clicked_region.take() {
                         invalidated_views.push(region.view_id);
@@ -264,7 +269,11 @@ impl Presenter {
                         }
                     }
                 }
-                Event::RightMouseDown { position, .. } => {
+                Event::MouseDown(MouseEvent {
+                    position,
+                    button: MouseButton::Right,
+                    ..
+                }) => {
                     let mut hit = false;
                     for (region, _) in self.mouse_regions.iter().rev() {
                         if region.bounds.contains_point(position) {
@@ -279,11 +288,12 @@ impl Presenter {
                         }
                     }
                 }
-                Event::RightMouseUp {
+                Event::MouseUp(MouseEvent {
                     position,
                     click_count,
+                    button: MouseButton::Right,
                     ..
-                } => {
+                }) => {
                     if let Some(region) = self.right_clicked_region.take() {
                         invalidated_views.push(region.view_id);
                         if region.bounds.contains_point(position) {
@@ -291,34 +301,37 @@ impl Presenter {
                         }
                     }
                 }
-                Event::MouseMoved { .. } => {
-                    self.last_mouse_moved_event = Some(event.clone());
-                }
-                Event::LeftMouseDragged {
+                Event::MouseMoved(MouseMovedEvent {
+                    pressed_button,
                     position,
                     shift,
                     ctrl,
                     alt,
                     cmd,
-                } => {
-                    if let Some((clicked_region, prev_drag_position)) = self
-                        .clicked_region
-                        .as_ref()
-                        .zip(self.prev_drag_position.as_mut())
-                    {
-                        dragged_region =
-                            Some((clicked_region.clone(), position - *prev_drag_position));
-                        *prev_drag_position = position;
+                    ..
+                }) => {
+                    if let Some(MouseButton::Left) = pressed_button {
+                        if let Some((clicked_region, prev_drag_position)) = self
+                            .clicked_region
+                            .as_ref()
+                            .zip(self.prev_drag_position.as_mut())
+                        {
+                            dragged_region =
+                                Some((clicked_region.clone(), *prev_drag_position, position));
+                            *prev_drag_position = position;
+                        }
+
+                        self.last_mouse_moved_event = Some(Event::MouseMoved(MouseMovedEvent {
+                            position,
+                            pressed_button: Some(MouseButton::Left),
+                            shift,
+                            ctrl,
+                            alt,
+                            cmd,
+                        }));
                     }
 
-                    self.last_mouse_moved_event = Some(Event::MouseMoved {
-                        position,
-                        left_mouse_down: true,
-                        shift,
-                        ctrl,
-                        alt,
-                        cmd,
-                    });
+                    self.last_mouse_moved_event = Some(event.clone());
                 }
                 _ => {}
             }
@@ -366,11 +379,11 @@ impl Presenter {
                 }
             }
 
-            if let Some((dragged_region, delta)) = dragged_region {
+            if let Some((dragged_region, prev_position, position)) = dragged_region {
                 handled = true;
                 if let Some(drag_callback) = dragged_region.drag {
                     event_cx.with_current_view(dragged_region.view_id, |event_cx| {
-                        drag_callback(delta, event_cx);
+                        drag_callback(prev_position, position, event_cx);
                     })
                 }
             }
@@ -410,13 +423,13 @@ impl Presenter {
         let mut unhovered_regions = Vec::new();
         let mut hovered_regions = Vec::new();
 
-        if let Event::MouseMoved {
+        if let Event::MouseMoved(MouseMovedEvent {
             position,
-            left_mouse_down,
+            pressed_button,
             ..
-        } = event
+        }) = event
         {
-            if !left_mouse_down {
+            if let None = pressed_button {
                 let mut style_to_assign = CursorStyle::Arrow;
                 for region in self.cursor_regions.iter().rev() {
                     if region.bounds.contains_point(*position) {
@@ -648,6 +661,16 @@ impl<'a> PaintContext<'a> {
         }
     }
 
+    #[inline]
+    pub fn paint_layer<F>(&mut self, clip_bounds: Option<RectF>, f: F)
+    where
+        F: FnOnce(&mut Self) -> (),
+    {
+        self.scene.push_layer(clip_bounds);
+        f(self);
+        self.scene.pop_layer();
+    }
+
     pub fn current_view_id(&self) -> usize {
         *self.view_stack.last().unwrap()
     }

crates/gpui/src/scene.rs 🔗

@@ -8,7 +8,7 @@ use crate::{
     geometry::{rect::RectF, vector::Vector2F},
     json::ToJson,
     platform::CursorStyle,
-    EventContext, ImageData,
+    EventContext, ImageData, MouseEvent, MouseMovedEvent, ScrollWheelEvent,
 };
 
 pub struct Scene {
@@ -44,17 +44,28 @@ pub struct CursorRegion {
     pub style: CursorStyle,
 }
 
+pub enum MouseRegionEvent {
+    Moved(MouseMovedEvent),
+    Hover(MouseEvent),
+    Down(MouseEvent),
+    Up(MouseEvent),
+    Click(MouseEvent),
+    DownOut(MouseEvent),
+    ScrollWheel(ScrollWheelEvent),
+}
+
 #[derive(Clone, Default)]
 pub struct MouseRegion {
     pub view_id: usize,
     pub discriminant: Option<(TypeId, usize)>,
     pub bounds: RectF,
+
     pub hover: Option<Rc<dyn Fn(Vector2F, bool, &mut EventContext)>>,
     pub mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     pub click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
     pub right_mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     pub right_click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
-    pub drag: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    pub drag: Option<Rc<dyn Fn(Vector2F, Vector2F, &mut EventContext)>>,
     pub mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     pub right_mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
 }

crates/language/src/buffer.rs 🔗

@@ -273,7 +273,7 @@ pub struct Chunk<'a> {
     pub is_unnecessary: bool,
 }
 
-pub(crate) struct Diff {
+pub struct Diff {
     base_version: clock::Global,
     new_text: Arc<str>,
     changes: Vec<(ChangeTag, usize)>,
@@ -958,7 +958,7 @@ impl Buffer {
         }
     }
 
-    pub(crate) fn diff(&self, mut new_text: String, cx: &AppContext) -> Task<Diff> {
+    pub fn diff(&self, mut new_text: String, cx: &AppContext) -> Task<Diff> {
         let old_text = self.as_rope().clone();
         let base_version = self.version();
         cx.background().spawn(async move {
@@ -979,11 +979,7 @@ impl Buffer {
         })
     }
 
-    pub(crate) fn apply_diff(
-        &mut self,
-        diff: Diff,
-        cx: &mut ModelContext<Self>,
-    ) -> Option<&Transaction> {
+    pub fn apply_diff(&mut self, diff: Diff, cx: &mut ModelContext<Self>) -> Option<&Transaction> {
         if self.version == diff.base_version {
             self.finalize_last_transaction();
             self.start_transaction();

crates/project/src/fs.rs 🔗

@@ -334,28 +334,6 @@ impl FakeFs {
         })
     }
 
-    pub async fn insert_dir(&self, path: impl AsRef<Path>) {
-        let mut state = self.state.lock().await;
-        let path = path.as_ref();
-        state.validate_path(path).unwrap();
-
-        let inode = state.next_inode;
-        state.next_inode += 1;
-        state.entries.insert(
-            path.to_path_buf(),
-            FakeFsEntry {
-                metadata: Metadata {
-                    inode,
-                    mtime: SystemTime::now(),
-                    is_dir: true,
-                    is_symlink: false,
-                },
-                content: None,
-            },
-        );
-        state.emit_event(&[path]).await;
-    }
-
     pub async fn insert_file(&self, path: impl AsRef<Path>, content: String) {
         let mut state = self.state.lock().await;
         let path = path.as_ref();
@@ -392,7 +370,7 @@ impl FakeFs {
 
             match tree {
                 Object(map) => {
-                    self.insert_dir(path).await;
+                    self.create_dir(path).await.unwrap();
                     for (name, contents) in map {
                         let mut path = PathBuf::from(path);
                         path.push(name);
@@ -400,7 +378,7 @@ impl FakeFs {
                     }
                 }
                 Null => {
-                    self.insert_dir(&path).await;
+                    self.create_dir(&path).await.unwrap();
                 }
                 String(contents) => {
                     self.insert_file(&path, contents).await;

crates/project/src/project.rs 🔗

@@ -12,7 +12,7 @@ use anyhow::{anyhow, Context, Result};
 use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
 use clock::ReplicaId;
 use collections::{hash_map, BTreeMap, HashMap, HashSet};
-use futures::{future::Shared, Future, FutureExt, StreamExt, TryFutureExt};
+use futures::{future::Shared, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt};
 use fuzzy::{PathMatch, PathMatchCandidate, PathMatchCandidateSet};
 use gpui::{
     AnyModelHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
@@ -51,10 +51,12 @@ use std::{
     ffi::OsString,
     hash::Hash,
     mem,
+    num::NonZeroU32,
     ops::Range,
     os::unix::{ffi::OsStrExt, prelude::OsStringExt},
     path::{Component, Path, PathBuf},
     rc::Rc,
+    str,
     sync::{
         atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
         Arc,
@@ -3025,78 +3027,50 @@ impl Project {
             }
 
             for (buffer, buffer_abs_path, language_server) in local_buffers {
-                let text_document = lsp::TextDocumentIdentifier::new(
-                    lsp::Url::from_file_path(&buffer_abs_path).unwrap(),
-                );
-                let capabilities = &language_server.capabilities();
-                let tab_size = cx.update(|cx| {
-                    let language_name = buffer.read(cx).language().map(|language| language.name());
-                    cx.global::<Settings>().tab_size(language_name.as_deref())
+                let (format_on_save, tab_size) = buffer.read_with(&cx, |buffer, cx| {
+                    let settings = cx.global::<Settings>();
+                    let language_name = buffer.language().map(|language| language.name());
+                    (
+                        settings.format_on_save(language_name.as_deref()),
+                        settings.tab_size(language_name.as_deref()),
+                    )
                 });
-                let lsp_edits = if capabilities
-                    .document_formatting_provider
-                    .as_ref()
-                    .map_or(false, |provider| *provider != lsp::OneOf::Left(false))
-                {
-                    language_server
-                        .request::<lsp::request::Formatting>(lsp::DocumentFormattingParams {
-                            text_document,
-                            options: lsp::FormattingOptions {
-                                tab_size: tab_size.into(),
-                                insert_spaces: true,
-                                insert_final_newline: Some(true),
-                                ..Default::default()
-                            },
-                            work_done_progress_params: Default::default(),
-                        })
-                        .await?
-                } else if capabilities
-                    .document_range_formatting_provider
-                    .as_ref()
-                    .map_or(false, |provider| *provider != lsp::OneOf::Left(false))
-                {
-                    let buffer_start = lsp::Position::new(0, 0);
-                    let buffer_end =
-                        buffer.read_with(&cx, |buffer, _| point_to_lsp(buffer.max_point_utf16()));
-                    language_server
-                        .request::<lsp::request::RangeFormatting>(
-                            lsp::DocumentRangeFormattingParams {
-                                text_document,
-                                range: lsp::Range::new(buffer_start, buffer_end),
-                                options: lsp::FormattingOptions {
-                                    tab_size: tab_size.into(),
-                                    insert_spaces: true,
-                                    insert_final_newline: Some(true),
-                                    ..Default::default()
-                                },
-                                work_done_progress_params: Default::default(),
-                            },
+
+                let transaction = match format_on_save {
+                    settings::FormatOnSave::Off => continue,
+                    settings::FormatOnSave::LanguageServer => Self::format_via_lsp(
+                        &this,
+                        &buffer,
+                        &buffer_abs_path,
+                        &language_server,
+                        tab_size,
+                        &mut cx,
+                    )
+                    .await
+                    .context("failed to format via language server")?,
+                    settings::FormatOnSave::External { command, arguments } => {
+                        Self::format_via_external_command(
+                            &buffer,
+                            &buffer_abs_path,
+                            &command,
+                            &arguments,
+                            &mut cx,
                         )
-                        .await?
-                } else {
-                    continue;
+                        .await
+                        .context(format!(
+                            "failed to format via external command {:?}",
+                            command
+                        ))?
+                    }
                 };
 
-                if let Some(lsp_edits) = lsp_edits {
-                    let edits = this
-                        .update(&mut cx, |this, cx| {
-                            this.edits_from_lsp(&buffer, lsp_edits, None, cx)
-                        })
-                        .await?;
-                    buffer.update(&mut cx, |buffer, cx| {
-                        buffer.finalize_last_transaction();
-                        buffer.start_transaction();
-                        for (range, text) in edits {
-                            buffer.edit([(range, text)], cx);
-                        }
-                        if buffer.end_transaction(cx).is_some() {
-                            let transaction = buffer.finalize_last_transaction().unwrap().clone();
-                            if !push_to_history {
-                                buffer.forget_transaction(transaction.id);
-                            }
-                            project_transaction.0.insert(cx.handle(), transaction);
-                        }
-                    });
+                if let Some(transaction) = transaction {
+                    if !push_to_history {
+                        buffer.update(&mut cx, |buffer, _| {
+                            buffer.forget_transaction(transaction.id)
+                        });
+                    }
+                    project_transaction.0.insert(buffer, transaction);
                 }
             }
 
@@ -3104,6 +3078,141 @@ impl Project {
         })
     }
 
+    async fn format_via_lsp(
+        this: &ModelHandle<Self>,
+        buffer: &ModelHandle<Buffer>,
+        abs_path: &Path,
+        language_server: &Arc<LanguageServer>,
+        tab_size: NonZeroU32,
+        cx: &mut AsyncAppContext,
+    ) -> Result<Option<Transaction>> {
+        let text_document =
+            lsp::TextDocumentIdentifier::new(lsp::Url::from_file_path(abs_path).unwrap());
+        let capabilities = &language_server.capabilities();
+        let lsp_edits = if capabilities
+            .document_formatting_provider
+            .as_ref()
+            .map_or(false, |provider| *provider != lsp::OneOf::Left(false))
+        {
+            language_server
+                .request::<lsp::request::Formatting>(lsp::DocumentFormattingParams {
+                    text_document,
+                    options: lsp::FormattingOptions {
+                        tab_size: tab_size.into(),
+                        insert_spaces: true,
+                        insert_final_newline: Some(true),
+                        ..Default::default()
+                    },
+                    work_done_progress_params: Default::default(),
+                })
+                .await?
+        } else if capabilities
+            .document_range_formatting_provider
+            .as_ref()
+            .map_or(false, |provider| *provider != lsp::OneOf::Left(false))
+        {
+            let buffer_start = lsp::Position::new(0, 0);
+            let buffer_end =
+                buffer.read_with(cx, |buffer, _| point_to_lsp(buffer.max_point_utf16()));
+            language_server
+                .request::<lsp::request::RangeFormatting>(lsp::DocumentRangeFormattingParams {
+                    text_document,
+                    range: lsp::Range::new(buffer_start, buffer_end),
+                    options: lsp::FormattingOptions {
+                        tab_size: tab_size.into(),
+                        insert_spaces: true,
+                        insert_final_newline: Some(true),
+                        ..Default::default()
+                    },
+                    work_done_progress_params: Default::default(),
+                })
+                .await?
+        } else {
+            None
+        };
+
+        if let Some(lsp_edits) = lsp_edits {
+            let edits = this
+                .update(cx, |this, cx| {
+                    this.edits_from_lsp(&buffer, lsp_edits, None, cx)
+                })
+                .await?;
+            buffer.update(cx, |buffer, cx| {
+                buffer.finalize_last_transaction();
+                buffer.start_transaction();
+                for (range, text) in edits {
+                    buffer.edit([(range, text)], cx);
+                }
+                if buffer.end_transaction(cx).is_some() {
+                    let transaction = buffer.finalize_last_transaction().unwrap().clone();
+                    Ok(Some(transaction))
+                } else {
+                    Ok(None)
+                }
+            })
+        } else {
+            Ok(None)
+        }
+    }
+
+    async fn format_via_external_command(
+        buffer: &ModelHandle<Buffer>,
+        buffer_abs_path: &Path,
+        command: &str,
+        arguments: &[String],
+        cx: &mut AsyncAppContext,
+    ) -> Result<Option<Transaction>> {
+        let working_dir_path = buffer.read_with(cx, |buffer, cx| {
+            let file = File::from_dyn(buffer.file())?;
+            let worktree = file.worktree.read(cx).as_local()?;
+            let mut worktree_path = worktree.abs_path().to_path_buf();
+            if worktree.root_entry()?.is_file() {
+                worktree_path.pop();
+            }
+            Some(worktree_path)
+        });
+
+        if let Some(working_dir_path) = working_dir_path {
+            let mut child =
+                smol::process::Command::new(command)
+                    .args(arguments.iter().map(|arg| {
+                        arg.replace("{buffer_path}", &buffer_abs_path.to_string_lossy())
+                    }))
+                    .current_dir(&working_dir_path)
+                    .stdin(smol::process::Stdio::piped())
+                    .stdout(smol::process::Stdio::piped())
+                    .stderr(smol::process::Stdio::piped())
+                    .spawn()?;
+            let stdin = child
+                .stdin
+                .as_mut()
+                .ok_or_else(|| anyhow!("failed to acquire stdin"))?;
+            let text = buffer.read_with(cx, |buffer, _| buffer.as_rope().clone());
+            for chunk in text.chunks() {
+                stdin.write_all(chunk.as_bytes()).await?;
+            }
+            stdin.flush().await?;
+
+            let output = child.output().await?;
+            if !output.status.success() {
+                return Err(anyhow!(
+                    "command failed with exit code {:?}:\nstdout: {}\nstderr: {}",
+                    output.status.code(),
+                    String::from_utf8_lossy(&output.stdout),
+                    String::from_utf8_lossy(&output.stderr),
+                ));
+            }
+
+            let stdout = String::from_utf8(output.stdout)?;
+            let diff = buffer
+                .read_with(cx, |buffer, cx| buffer.diff(stdout, cx))
+                .await;
+            Ok(buffer.update(cx, |buffer, cx| buffer.apply_diff(diff, cx).cloned()))
+        } else {
+            Ok(None)
+        }
+    }
+
     pub fn definition<T: ToPointUtf16>(
         &self,
         buffer: &ModelHandle<Buffer>,

crates/settings/src/settings.rs 🔗

@@ -38,7 +38,7 @@ pub struct LanguageSettings {
     pub hard_tabs: Option<bool>,
     pub soft_wrap: Option<SoftWrap>,
     pub preferred_line_length: Option<u32>,
-    pub format_on_save: Option<bool>,
+    pub format_on_save: Option<FormatOnSave>,
     pub enable_language_server: Option<bool>,
 }
 
@@ -50,6 +50,17 @@ pub enum SoftWrap {
     PreferredLineLength,
 }
 
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum FormatOnSave {
+    Off,
+    LanguageServer,
+    External {
+        command: String,
+        arguments: Vec<String>,
+    },
+}
+
 #[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum Autosave {
@@ -72,7 +83,7 @@ pub struct SettingsFileContent {
     #[serde(default)]
     pub vim_mode: Option<bool>,
     #[serde(default)]
-    pub format_on_save: Option<bool>,
+    pub format_on_save: Option<FormatOnSave>,
     #[serde(default)]
     pub autosave: Option<Autosave>,
     #[serde(default)]
@@ -136,9 +147,9 @@ impl Settings {
             .unwrap_or(80)
     }
 
-    pub fn format_on_save(&self, language: Option<&str>) -> bool {
-        self.language_setting(language, |settings| settings.format_on_save)
-            .unwrap_or(true)
+    pub fn format_on_save(&self, language: Option<&str>) -> FormatOnSave {
+        self.language_setting(language, |settings| settings.format_on_save.clone())
+            .unwrap_or(FormatOnSave::LanguageServer)
     }
 
     pub fn enable_language_server(&self, language: Option<&str>) -> bool {
@@ -215,7 +226,7 @@ impl Settings {
         merge(&mut self.autosave, data.autosave);
         merge_option(
             &mut self.language_settings.format_on_save,
-            data.format_on_save,
+            data.format_on_save.clone(),
         );
         merge_option(
             &mut self.language_settings.enable_language_server,
@@ -339,7 +350,7 @@ fn merge<T: Copy>(target: &mut T, value: Option<T>) {
     }
 }
 
-fn merge_option<T: Copy>(target: &mut Option<T>, value: Option<T>) {
+fn merge_option<T>(target: &mut Option<T>, value: Option<T>) {
     if value.is_some() {
         *target = value;
     }

crates/terminal/Cargo.toml 🔗

@@ -21,7 +21,11 @@ mio-extras = "2.0.6"
 futures = "0.3"
 ordered-float = "2.1.1"
 itertools = "0.10"
+dirs = "4.0.0"
 
 
 [dev-dependencies]
 gpui = { path = "../gpui", features = ["test-support"] }
+client = { path = "../client", features = ["test-support"]}
+project = { path = "../project", features = ["test-support"]}
+

crates/terminal/src/color_translation.rs 🔗

@@ -0,0 +1,134 @@
+use alacritty_terminal::{ansi::Color as AnsiColor, term::color::Rgb as AlacRgb};
+use gpui::color::Color;
+use theme::TerminalStyle;
+
+///Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent
+pub fn convert_color(alac_color: &AnsiColor, style: &TerminalStyle) -> Color {
+    match alac_color {
+        //Named and theme defined colors
+        alacritty_terminal::ansi::Color::Named(n) => match n {
+            alacritty_terminal::ansi::NamedColor::Black => style.black,
+            alacritty_terminal::ansi::NamedColor::Red => style.red,
+            alacritty_terminal::ansi::NamedColor::Green => style.green,
+            alacritty_terminal::ansi::NamedColor::Yellow => style.yellow,
+            alacritty_terminal::ansi::NamedColor::Blue => style.blue,
+            alacritty_terminal::ansi::NamedColor::Magenta => style.magenta,
+            alacritty_terminal::ansi::NamedColor::Cyan => style.cyan,
+            alacritty_terminal::ansi::NamedColor::White => style.white,
+            alacritty_terminal::ansi::NamedColor::BrightBlack => style.bright_black,
+            alacritty_terminal::ansi::NamedColor::BrightRed => style.bright_red,
+            alacritty_terminal::ansi::NamedColor::BrightGreen => style.bright_green,
+            alacritty_terminal::ansi::NamedColor::BrightYellow => style.bright_yellow,
+            alacritty_terminal::ansi::NamedColor::BrightBlue => style.bright_blue,
+            alacritty_terminal::ansi::NamedColor::BrightMagenta => style.bright_magenta,
+            alacritty_terminal::ansi::NamedColor::BrightCyan => style.bright_cyan,
+            alacritty_terminal::ansi::NamedColor::BrightWhite => style.bright_white,
+            alacritty_terminal::ansi::NamedColor::Foreground => style.foreground,
+            alacritty_terminal::ansi::NamedColor::Background => style.background,
+            alacritty_terminal::ansi::NamedColor::Cursor => style.cursor,
+            alacritty_terminal::ansi::NamedColor::DimBlack => style.dim_black,
+            alacritty_terminal::ansi::NamedColor::DimRed => style.dim_red,
+            alacritty_terminal::ansi::NamedColor::DimGreen => style.dim_green,
+            alacritty_terminal::ansi::NamedColor::DimYellow => style.dim_yellow,
+            alacritty_terminal::ansi::NamedColor::DimBlue => style.dim_blue,
+            alacritty_terminal::ansi::NamedColor::DimMagenta => style.dim_magenta,
+            alacritty_terminal::ansi::NamedColor::DimCyan => style.dim_cyan,
+            alacritty_terminal::ansi::NamedColor::DimWhite => style.dim_white,
+            alacritty_terminal::ansi::NamedColor::BrightForeground => style.bright_foreground,
+            alacritty_terminal::ansi::NamedColor::DimForeground => style.dim_foreground,
+        },
+        //'True' colors
+        alacritty_terminal::ansi::Color::Spec(rgb) => Color::new(rgb.r, rgb.g, rgb.b, u8::MAX),
+        //8 bit, indexed colors
+        alacritty_terminal::ansi::Color::Indexed(i) => get_color_at_index(&(*i as usize), style),
+    }
+}
+
+///Converts an 8 bit ANSI color to it's GPUI equivalent.
+///Accepts usize for compatability with the alacritty::Colors interface,
+///Other than that use case, should only be called with values in the [0,255] range
+pub fn get_color_at_index(index: &usize, style: &TerminalStyle) -> Color {
+    match index {
+        //0-15 are the same as the named colors above
+        0 => style.black,
+        1 => style.red,
+        2 => style.green,
+        3 => style.yellow,
+        4 => style.blue,
+        5 => style.magenta,
+        6 => style.cyan,
+        7 => style.white,
+        8 => style.bright_black,
+        9 => style.bright_red,
+        10 => style.bright_green,
+        11 => style.bright_yellow,
+        12 => style.bright_blue,
+        13 => style.bright_magenta,
+        14 => style.bright_cyan,
+        15 => style.bright_white,
+        //16-231 are mapped to their RGB colors on a 0-5 range per channel
+        16..=231 => {
+            let (r, g, b) = rgb_for_index(&(*index as u8)); //Split the index into it's ANSI-RGB components
+            let step = (u8::MAX as f32 / 5.).floor() as u8; //Split the RGB range into 5 chunks, with floor so no overflow
+            Color::new(r * step, g * step, b * step, u8::MAX) //Map the ANSI-RGB components to an RGB color
+        }
+        //232-255 are a 24 step grayscale from black to white
+        232..=255 => {
+            let i = *index as u8 - 232; //Align index to 0..24
+            let step = (u8::MAX as f32 / 24.).floor() as u8; //Split the RGB grayscale values into 24 chunks
+            Color::new(i * step, i * step, i * step, u8::MAX) //Map the ANSI-grayscale components to the RGB-grayscale
+        }
+        //For compatability with the alacritty::Colors interface
+        256 => style.foreground,
+        257 => style.background,
+        258 => style.cursor,
+        259 => style.dim_black,
+        260 => style.dim_red,
+        261 => style.dim_green,
+        262 => style.dim_yellow,
+        263 => style.dim_blue,
+        264 => style.dim_magenta,
+        265 => style.dim_cyan,
+        266 => style.dim_white,
+        267 => style.bright_foreground,
+        268 => style.black, //'Dim Background', non-standard color
+        _ => Color::new(0, 0, 0, 255),
+    }
+}
+///Generates the rgb channels in [0, 5] for a given index into the 6x6x6 ANSI color cube
+///See: [8 bit ansi color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit).
+///
+///Wikipedia gives a formula for calculating the index for a given color:
+///
+///index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
+///
+///This function does the reverse, calculating the r, g, and b components from a given index.
+fn rgb_for_index(i: &u8) -> (u8, u8, u8) {
+    debug_assert!(i >= &16 && i <= &231);
+    let i = i - 16;
+    let r = (i - (i % 36)) / 36;
+    let g = ((i % 36) - (i % 6)) / 6;
+    let b = (i % 36) % 6;
+    (r, g, b)
+}
+
+//Convenience method to convert from a GPUI color to an alacritty Rgb
+pub fn to_alac_rgb(color: Color) -> AlacRgb {
+    AlacRgb {
+        r: color.r,
+        g: color.g,
+        b: color.g,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn test_rgb_for_index() {
+        //Test every possible value in the color cube
+        for i in 16..=231 {
+            let (r, g, b) = crate::color_translation::rgb_for_index(&(i as u8));
+            assert_eq!(i, 16 + 36 * r + 6 * g + b);
+        }
+    }
+}

crates/terminal/src/terminal.rs 🔗

@@ -3,32 +3,33 @@ mod modal;
 pub mod terminal_element;
 
 use alacritty_terminal::{
-    config::{Config, Program, PtyConfig},
+    config::{Config, PtyConfig},
     event::{Event as AlacTermEvent, EventListener, Notify},
     event_loop::{EventLoop, Msg, Notifier},
     grid::Scroll,
     sync::FairMutex,
-    term::{color::Rgb as AlacRgb, SizeInfo},
+    term::SizeInfo,
     tty::{self, setup_env},
     Term,
 };
-
+use color_translation::{get_color_at_index, to_alac_rgb};
+use dirs::home_dir;
 use futures::{
     channel::mpsc::{unbounded, UnboundedSender},
     StreamExt,
 };
 use gpui::{
-    actions, color::Color, elements::*, impl_internal_actions, platform::CursorStyle,
-    ClipboardItem, Entity, MutableAppContext, View, ViewContext,
+    actions, elements::*, impl_internal_actions, platform::CursorStyle, ClipboardItem, Entity,
+    MutableAppContext, View, ViewContext,
 };
 use modal::deploy_modal;
-use project::{Project, ProjectPath};
+use project::{LocalWorktree, Project, ProjectPath};
 use settings::Settings;
 use smallvec::SmallVec;
 use std::{collections::HashMap, path::PathBuf, sync::Arc};
 use workspace::{Item, Workspace};
 
-use crate::terminal_element::{get_color_at_index, TerminalEl};
+use crate::terminal_element::TerminalEl;
 
 //ASCII Control characters on a keyboard
 const ETX_CHAR: char = 3_u8 as char; //'End of text', the control code for 'ctrl-c'
@@ -41,6 +42,14 @@ const RIGHT_SEQ: &str = "\x1b[C";
 const UP_SEQ: &str = "\x1b[A";
 const DOWN_SEQ: &str = "\x1b[B";
 const DEFAULT_TITLE: &str = "Terminal";
+const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
+const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
+const DEBUG_CELL_WIDTH: f32 = 5.;
+const DEBUG_LINE_HEIGHT: f32 = 5.;
+
+pub mod color_translation;
+pub mod gpui_func_tools;
+pub mod terminal_element;
 
 ///Action for carrying the input to the PTY
 #[derive(Clone, Default, Debug, PartialEq, Eq)]
@@ -63,6 +72,7 @@ actions!(
         Down,
         Tab,
         Clear,
+        Copy,
         Paste,
         Deploy,
         Quit,
@@ -79,12 +89,13 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Terminal::escape);
     cx.add_action(Terminal::quit);
     cx.add_action(Terminal::del);
-    cx.add_action(Terminal::carriage_return); //TODO figure out how to do this properly. Should we be checking the terminal mode?
+    cx.add_action(Terminal::carriage_return);
     cx.add_action(Terminal::left);
     cx.add_action(Terminal::right);
     cx.add_action(Terminal::up);
     cx.add_action(Terminal::down);
     cx.add_action(Terminal::tab);
+    cx.add_action(Terminal::copy);
     cx.add_action(Terminal::paste);
     cx.add_action(Terminal::scroll_terminal);
     cx.add_action(deploy_modal);
@@ -109,6 +120,7 @@ pub struct Terminal {
     has_bell: bool, //Currently using iTerm bell, show bell emoji in tab until input is received
     cur_size: SizeInfo,
     modal: bool,
+    associated_directory: Option<PathBuf>,
 }
 
 ///Upward flowing events, for changing the title and such
@@ -143,12 +155,11 @@ impl Terminal {
         .detach();
 
         let pty_config = PtyConfig {
-            shell: Some(Program::Just("zsh".to_string())),
-            working_directory,
+            shell: None, //Use the users default shell
+            working_directory: working_directory.clone(),
             hold: false,
         };
 
-        //Does this mangle the zed Env? I'm guessing it does... do child processes have a seperate ENV?
         let mut env: HashMap<String, String> = HashMap::new();
         //TODO: Properly set the current locale,
         env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string());
@@ -162,8 +173,15 @@ impl Terminal {
         setup_env(&config);
 
         //The details here don't matter, the terminal will be resized on the first layout
-        //Set to something small for easier debugging
-        let size_info = SizeInfo::new(200., 100.0, 5., 5., 0., 0., false);
+        let size_info = SizeInfo::new(
+            DEBUG_TERMINAL_WIDTH,
+            DEBUG_TERMINAL_HEIGHT,
+            DEBUG_CELL_WIDTH,
+            DEBUG_LINE_HEIGHT,
+            0.,
+            0.,
+            false,
+        );
 
         //Set up the terminal...
         let term = Term::new(&config, size_info, ZedListener(events_tx.clone()));
@@ -192,6 +210,7 @@ impl Terminal {
             has_bell: false,
             cur_size: size_info,
             modal,
+            associated_directory: working_directory,
         }
     }
 
@@ -238,25 +257,8 @@ impl Terminal {
             ),
             AlacTermEvent::ColorRequest(index, format) => {
                 let color = self.term.lock().colors()[index].unwrap_or_else(|| {
-                    let term_style = &cx.global::<Settings>().theme.terminal.colors;
-                    match index {
-                        0..=255 => to_alac_rgb(get_color_at_index(&(index as u8), term_style)),
-                        //These additional values are required to match the Alacritty Colors object's behavior
-                        256 => to_alac_rgb(term_style.foreground),
-                        257 => to_alac_rgb(term_style.background),
-                        258 => to_alac_rgb(term_style.cursor),
-                        259 => to_alac_rgb(term_style.dim_black),
-                        260 => to_alac_rgb(term_style.dim_red),
-                        261 => to_alac_rgb(term_style.dim_green),
-                        262 => to_alac_rgb(term_style.dim_yellow),
-                        263 => to_alac_rgb(term_style.dim_blue),
-                        264 => to_alac_rgb(term_style.dim_magenta),
-                        265 => to_alac_rgb(term_style.dim_cyan),
-                        266 => to_alac_rgb(term_style.dim_white),
-                        267 => to_alac_rgb(term_style.bright_foreground),
-                        268 => to_alac_rgb(term_style.black), //Dim Background, non-standard
-                        _ => AlacRgb { r: 0, g: 0, b: 0 },
-                    }
+                    let term_style = &cx.global::<Settings>().theme.terminal;
+                    to_alac_rgb(get_color_at_index(&index, term_style))
                 });
                 self.write_to_pty(&Input(format(color)), cx)
             }
@@ -288,11 +290,12 @@ impl Terminal {
     ///Create a new Terminal in the current working directory or the user's home directory
     fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
         let project = workspace.project().read(cx);
+
         let abs_path = project
             .active_entry()
             .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
             .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
-            .map(|wt| wt.abs_path().to_path_buf());
+            .and_then(get_working_directory);
 
         workspace.add_item(
             Box::new(cx.add_view(|cx| Terminal::new(cx, abs_path, false))),
@@ -310,6 +313,16 @@ impl Terminal {
         cx.emit(Event::CloseTerminal);
     }
 
+    ///Attempt to paste the clipboard into the terminal
+    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
+        let term = self.term.lock();
+        let copy_text = term.selection_to_string();
+        match copy_text {
+            Some(s) => cx.write_to_clipboard(ClipboardItem::new(s)),
+            None => (),
+        }
+    }
+
     ///Attempt to paste the clipboard into the terminal
     fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
         if let Some(item) = cx.read_from_clipboard() {
@@ -444,6 +457,13 @@ impl Item for Terminal {
         .boxed()
     }
 
+    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
+        //From what I can tell, there's no  way to tell the current working
+        //Directory of the terminal from outside the terminal. There might be
+        //solutions to this, but they are non-trivial and require more IPC
+        Some(Terminal::new(cx, self.associated_directory.clone()))
+    }
+
     fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
         None
     }
@@ -504,27 +524,134 @@ impl Item for Terminal {
     }
 }
 
-//Convenience method for less lines
-fn to_alac_rgb(color: Color) -> AlacRgb {
-    AlacRgb {
-        r: color.r,
-        g: color.g,
-        b: color.g,
-    }
+fn get_working_directory(wt: &LocalWorktree) -> Option<PathBuf> {
+    Some(wt.abs_path().to_path_buf())
+        .filter(|path| path.is_dir())
+        .or_else(|| home_dir())
 }
 
 #[cfg(test)]
 mod tests {
+
     use super::*;
-    use alacritty_terminal::{grid::GridIterator, term::cell::Cell};
+    use alacritty_terminal::{
+        grid::GridIterator,
+        index::{Column, Line, Point, Side},
+        selection::{Selection, SelectionType},
+        term::cell::Cell,
+    };
     use gpui::TestAppContext;
     use itertools::Itertools;
+    use project::{FakeFs, Fs, RealFs, RemoveOptions, Worktree};
+    use std::{path::Path, sync::atomic::AtomicUsize, time::Duration};
 
     ///Basic integration test, can we get the terminal to show up, execute a command,
     //and produce noticable output?
     #[gpui::test]
     async fn test_terminal(cx: &mut TestAppContext) {
         let terminal = cx.add_view(Default::default(), |cx| Terminal::new(cx, None, false));
+        cx.set_condition_duration(Duration::from_secs(2));
+        terminal.update(cx, |terminal, cx| {
+            terminal.write_to_pty(&Input(("expr 3 + 4".to_string()).to_string()), cx);
+            terminal.carriage_return(&Return, cx);
+        });
+
+        terminal
+            .condition(cx, |terminal, _cx| {
+                let term = terminal.term.clone();
+                let content = grid_as_str(term.lock().renderable_content().display_iter);
+                dbg!(&content);
+                content.contains("7")
+            })
+            .await;
+    }
+
+    #[gpui::test]
+    async fn single_file_worktree(cx: &mut TestAppContext) {
+        let mut async_cx = cx.to_async();
+        let http_client = client::test::FakeHttpClient::with_404_response();
+        let client = client::Client::new(http_client.clone());
+        let fake_fs = FakeFs::new(cx.background().clone());
+
+        let path = Path::new("/file/");
+        fake_fs.insert_file(path, "a".to_string()).await;
+
+        let worktree_handle = Worktree::local(
+            client,
+            path,
+            true,
+            fake_fs,
+            Arc::new(AtomicUsize::new(0)),
+            &mut async_cx,
+        )
+        .await
+        .ok()
+        .unwrap();
+
+        async_cx.update(|cx| {
+            let wt = worktree_handle.read(cx).as_local().unwrap();
+            let wd = get_working_directory(wt);
+            assert!(wd.is_some());
+            let path = wd.unwrap();
+            //This should be the system's working directory, so querying the real file system is probably ok.
+            assert!(path.is_dir());
+            assert_eq!(path, home_dir().unwrap());
+        });
+    }
+
+    #[gpui::test]
+    async fn test_worktree_directory(cx: &mut TestAppContext) {
+        let mut async_cx = cx.to_async();
+        let http_client = client::test::FakeHttpClient::with_404_response();
+        let client = client::Client::new(http_client.clone());
+
+        let fs = RealFs;
+        let mut test_wd = home_dir().unwrap();
+        test_wd.push("dir");
+
+        fs.create_dir(test_wd.as_path())
+            .await
+            .expect("File could not be created");
+
+        let worktree_handle = Worktree::local(
+            client,
+            test_wd.clone(),
+            true,
+            Arc::new(RealFs),
+            Arc::new(AtomicUsize::new(0)),
+            &mut async_cx,
+        )
+        .await
+        .ok()
+        .unwrap();
+
+        async_cx.update(|cx| {
+            let wt = worktree_handle.read(cx).as_local().unwrap();
+            let wd = get_working_directory(wt);
+            assert!(wd.is_some());
+            let path = wd.unwrap();
+            assert!(path.is_dir());
+            assert_eq!(path, test_wd);
+        });
+
+        //Clean up after ourselves.
+        fs.remove_dir(
+            test_wd.as_path(),
+            RemoveOptions {
+                recursive: false,
+                ignore_if_not_exists: true,
+            },
+        )
+        .await
+        .ok()
+        .expect("Could not remove test directory");
+    }
+
+    ///If this test is failing for you, check that DEBUG_TERMINAL_WIDTH is wide enough to fit your entire command prompt!
+    #[gpui::test]
+    async fn test_copy(cx: &mut TestAppContext) {
+        let terminal = cx.add_view(Default::default(), |cx| Terminal::new(cx, None));
+        cx.set_condition_duration(Duration::from_secs(2));
 
         terminal.update(cx, |terminal, cx| {
             terminal.write_to_pty(&Input(("expr 3 + 4".to_string()).to_string()), cx);
@@ -538,6 +665,19 @@ mod tests {
                 content.contains("7")
             })
             .await;
+
+        terminal.update(cx, |terminal, cx| {
+            let mut term = terminal.term.lock();
+            term.selection = Some(Selection::new(
+                SelectionType::Semantic,
+                Point::new(Line(2), Column(0)),
+                Side::Right,
+            ));
+            drop(term);
+            terminal.copy(&Copy, cx)
+        });
+
+        cx.assert_clipboard_content(Some(&"7"));
     }
 
     pub(crate) fn grid_as_str(grid_iterator: GridIterator<Cell>) -> String {

crates/terminal/src/terminal_element.rs 🔗

@@ -1,13 +1,15 @@
 use alacritty_terminal::{
-    ansi::Color as AnsiColor,
     grid::{Dimensions, GridIterator, Indexed},
-    index::Point,
+    index::{Column as GridCol, Line as GridLine, Point, Side},
+    selection::{Selection, SelectionRange, SelectionType},
+    sync::FairMutex,
     term::{
         cell::{Cell, Flags},
         SizeInfo,
     },
+    Term,
 };
-use editor::{Cursor, CursorShape};
+use editor::{Cursor, CursorShape, HighlightedRange, HighlightedRangeLine};
 use gpui::{
     color::Color,
     elements::*,
@@ -18,16 +20,21 @@ use gpui::{
     },
     json::json,
     text_layout::{Line, RunStyle},
-    Event, FontCache, MouseRegion, PaintContext, Quad, SizeConstraint, TextLayoutCache,
-    WeakViewHandle,
+    Event, FontCache, KeyDownEvent, MouseRegion, PaintContext, Quad, ScrollWheelEvent,
+    SizeConstraint, TextLayoutCache, WeakViewHandle,
 };
 use itertools::Itertools;
 use ordered_float::OrderedFloat;
 use settings::Settings;
-use std::rc::Rc;
 use theme::{TerminalColors, TerminalStyle};
 
-use crate::{gpui_func_tools::paint_layer, Input, ScrollTerminal, Terminal};
+use std::{cmp::min, ops::Range, rc::Rc, sync::Arc};
+use std::{fmt::Debug, ops::Sub};
+
+use crate::{
+    color_translation::convert_color, gpui_func_tools::paint_layer, Input, ScrollTerminal,
+    Terminal, ZedListener,
+};
 
 ///Scrolling is unbearably sluggish by default. Alacritty supports a configurable
 ///Scroll multiplier that is set to 3 by default. This will be removed when I
@@ -44,14 +51,27 @@ pub struct TerminalEl {
     view: WeakViewHandle<Terminal>,
 }
 
-///Helper types so I don't mix these two up
+///New type pattern so I don't mix these two up
 struct CellWidth(f32);
 struct LineHeight(f32);
 
+struct LayoutLine {
+    cells: Vec<LayoutCell>,
+    highlighted_range: Option<Range<usize>>,
+}
+
+///New type pattern to ensure that we use adjusted mouse positions throughout the code base, rather than
+struct PaneRelativePos(Vector2F);
+
+///Functionally the constructor for the PaneRelativePos type, mutates the mouse_position
+fn relative_pos(mouse_position: Vector2F, origin: Vector2F) -> PaneRelativePos {
+    PaneRelativePos(mouse_position.sub(origin)) //Avoid the extra allocation by mutating
+}
+
 #[derive(Clone, Debug, Default)]
 struct LayoutCell {
     point: Point<i32, i32>,
-    text: Line,
+    text: Line, //NOTE TO SELF THIS IS BAD PERFORMANCE RN!
     background_color: Color,
 }
 
@@ -67,13 +87,14 @@ impl LayoutCell {
 
 ///The information generated during layout that is nescessary for painting
 pub struct LayoutState {
-    cells: Vec<(Point<i32, i32>, Line)>,
-    background_rects: Vec<(RectF, Color)>, //Vec index == Line index for the LineSpan
+    layout_lines: Vec<LayoutLine>,
     line_height: LineHeight,
     em_width: CellWidth,
     cursor: Option<Cursor>,
     background_color: Color,
     cur_size: SizeInfo,
+    terminal: Arc<FairMutex<Term<ZedListener>>>,
+    selection_color: Color,
 }
 
 impl TerminalEl {
@@ -105,48 +126,32 @@ impl Element for TerminalEl {
         //Tell the view our new size. Requires a mutable borrow of cx and the view
         let cur_size = make_new_size(constraint, &cell_width, &line_height);
         //Note that set_size locks and mutates the terminal.
-        //TODO: Would be nice to lock once for the whole of layout
         view_handle.update(cx.app, |view, _cx| view.set_size(cur_size));
 
         //Now that we're done with the mutable portion, grab the immutable settings and view again
-        let terminal_theme = &(cx.global::<Settings>()).theme.terminal;
         let view = view_handle.read(cx);
         let term = view.term.lock();
-
+        let (selection_color, terminal_theme) = {
+            let theme = &(cx.global::<Settings>()).theme;
+            (theme.editor.selection.selection, &theme.terminal)
+        };
+        let terminal_mutex = view_handle.read(cx).term.clone();
+        let term = terminal_mutex.lock();
         let grid = term.grid();
         let cursor_point = grid.cursor.point;
         let cursor_text = grid[cursor_point.line][cursor_point.column].c.to_string();
 
         let content = term.renderable_content();
 
-        let layout_cells = layout_cells(
+        let layout_lines = layout_lines(
             content.display_iter,
             &text_style,
             terminal_theme,
             cx.text_layout_cache,
             view.modal,
+            content.selection,
         );
 
-        let cells = layout_cells
-            .iter()
-            .map(|c| (c.point, c.text.clone()))
-            .collect::<Vec<(Point<i32, i32>, Line)>>();
-        let background_rects = layout_cells
-            .iter()
-            .map(|cell| {
-                (
-                    RectF::new(
-                        vec2f(
-                            cell.point.column as f32 * cell_width.0,
-                            cell.point.line as f32 * line_height.0,
-                        ),
-                        vec2f(cell_width.0, line_height.0),
-                    ),
-                    cell.background_color,
-                )
-            })
-            .collect::<Vec<(RectF, Color)>>();
-
         let block_text = cx.text_layout_cache.layout_str(
             &cursor_text,
             text_style.font_size,
@@ -185,6 +190,7 @@ impl Element for TerminalEl {
                 Some(block_text.clone()),
             )
         });
+        drop(term);
 
         let background_color = if view.modal {
             terminal_theme.colors.modal_background
@@ -195,13 +201,14 @@ impl Element for TerminalEl {
         (
             constraint.max,
             LayoutState {
-                cells,
+                layout_lines,
                 line_height,
                 em_width: cell_width,
                 cursor,
                 cur_size,
-                background_rects,
                 background_color,
+                terminal: terminal_mutex,
+                selection_color,
             },
         )
     }
@@ -215,17 +222,21 @@ impl Element for TerminalEl {
     ) -> Self::PaintState {
         //Setup element stuff
         let clip_bounds = Some(visible_bounds);
-        paint_layer(cx, clip_bounds, |cx| {
-            //Elements are ephemeral, only at paint time do we know what could be clicked by a mouse
-            cx.scene.push_mouse_region(MouseRegion {
-                view_id: self.view.id(),
-                mouse_down: Some(Rc::new(|_, cx| cx.focus_parent_view())),
-                bounds: visible_bounds,
-                ..Default::default()
-            });
 
+        paint_layer(cx, clip_bounds, |cx| {
+            let cur_size = layout.cur_size.clone();
             let origin = bounds.origin() + vec2f(layout.em_width.0, 0.);
 
+            //Elements are ephemeral, only at paint time do we know what could be clicked by a mouse
+            attach_mouse_handlers(
+                origin,
+                cur_size,
+                self.view.id(),
+                &layout.terminal,
+                visible_bounds,
+                cx,
+            );
+
             paint_layer(cx, clip_bounds, |cx| {
                 //Start with a background color
                 cx.scene.push_quad(Quad {
@@ -236,25 +247,83 @@ impl Element for TerminalEl {
                 });
 
                 //Draw cell backgrounds
-                for background_rect in &layout.background_rects {
-                    let new_origin = origin + background_rect.0.origin();
-                    cx.scene.push_quad(Quad {
-                        bounds: RectF::new(new_origin, background_rect.0.size()),
-                        background: Some(background_rect.1),
-                        border: Default::default(),
-                        corner_radius: 0.,
+                for layout_line in &layout.layout_lines {
+                    for layout_cell in &layout_line.cells {
+                        let position = vec2f(
+                            origin.x() + layout_cell.point.column as f32 * layout.em_width.0,
+                            origin.y() + layout_cell.point.line as f32 * layout.line_height.0,
+                        );
+                        let size = vec2f(layout.em_width.0, layout.line_height.0);
+
+                        cx.scene.push_quad(Quad {
+                            bounds: RectF::new(position, size),
+                            background: Some(layout_cell.background_color),
+                            border: Default::default(),
+                            corner_radius: 0.,
+                        })
+                    }
+                }
+            });
+
+            //Draw Selection
+            paint_layer(cx, clip_bounds, |cx| {
+                let mut highlight_y = None;
+                let highlight_lines = layout
+                    .layout_lines
+                    .iter()
+                    .filter_map(|line| {
+                        if let Some(range) = &line.highlighted_range {
+                            if let None = highlight_y {
+                                highlight_y = Some(
+                                    origin.y()
+                                        + line.cells[0].point.line as f32 * layout.line_height.0,
+                                );
+                            }
+                            let start_x = origin.x()
+                                + line.cells[range.start].point.column as f32 * layout.em_width.0;
+                            let end_x = origin.x()
+                                + line.cells[range.end].point.column as f32 * layout.em_width.0
+                                + layout.em_width.0;
+
+                            return Some(HighlightedRangeLine { start_x, end_x });
+                        } else {
+                            return None;
+                        }
                     })
+                    .collect::<Vec<HighlightedRangeLine>>();
+
+                if let Some(y) = highlight_y {
+                    let hr = HighlightedRange {
+                        start_y: y, //Need to change this
+                        line_height: layout.line_height.0,
+                        lines: highlight_lines,
+                        color: layout.selection_color,
+                        //Copied from editor. TODO: move to theme or something
+                        corner_radius: 0.15 * layout.line_height.0,
+                    };
+                    hr.paint(bounds, cx.scene);
                 }
             });
 
             //Draw text
             paint_layer(cx, clip_bounds, |cx| {
-                for (point, cell) in &layout.cells {
-                    let cell_origin = vec2f(
-                        origin.x() + point.column as f32 * layout.em_width.0,
-                        origin.y() + point.line as f32 * layout.line_height.0,
-                    );
-                    cell.paint(cell_origin, visible_bounds, layout.line_height.0, cx);
+                for layout_line in &layout.layout_lines {
+                    for layout_cell in &layout_line.cells {
+                        let point = layout_cell.point;
+
+                        //Don't actually know the start_x for a line, until here:
+                        let cell_origin = vec2f(
+                            origin.x() + point.column as f32 * layout.em_width.0,
+                            origin.y() + point.line as f32 * layout.line_height.0,
+                        );
+
+                        layout_cell.text.paint(
+                            cell_origin,
+                            visible_bounds,
+                            layout.line_height.0,
+                            cx,
+                        );
+                    }
                 }
             });
 
@@ -284,9 +353,9 @@ impl Element for TerminalEl {
         cx: &mut gpui::EventContext,
     ) -> bool {
         match event {
-            Event::ScrollWheel {
+            Event::ScrollWheel(ScrollWheelEvent {
                 delta, position, ..
-            } => visible_bounds
+            }) => visible_bounds
                 .contains_point(*position)
                 .then(|| {
                     let vertical_scroll =
@@ -294,9 +363,9 @@ impl Element for TerminalEl {
                     cx.dispatch_action(ScrollTerminal(vertical_scroll.round() as i32));
                 })
                 .is_some(),
-            Event::KeyDown {
+            Event::KeyDown(KeyDownEvent {
                 input: Some(input), ..
-            } => cx
+            }) => cx
                 .is_parent_view_focused()
                 .then(|| {
                     cx.dispatch_action(Input(input.to_string()));
@@ -319,6 +388,18 @@ impl Element for TerminalEl {
     }
 }
 
+fn mouse_to_cell_data(
+    pos: Vector2F,
+    origin: Vector2F,
+    cur_size: SizeInfo,
+    display_offset: usize,
+) -> (Point, alacritty_terminal::index::Direction) {
+    let relative_pos = relative_pos(pos, origin);
+    let point = grid_cell(&relative_pos, cur_size, display_offset);
+    let side = cell_side(&relative_pos, cur_size);
+    (point, side)
+}
+
 ///Configures a text style from the current settings.
 fn make_text_style(font_cache: &FontCache, settings: &Settings) -> TextStyle {
     TextStyle {
@@ -351,39 +432,57 @@ fn make_new_size(
     )
 }
 
-fn layout_cells(
+fn layout_lines(
     grid: GridIterator<Cell>,
     text_style: &TextStyle,
     terminal_theme: &TerminalStyle,
     text_layout_cache: &TextLayoutCache,
     modal: bool,
-) -> Vec<LayoutCell> {
-    let mut line_count: i32 = 0;
+    selection_range: Option<SelectionRange>,
+) -> Vec<LayoutLine> {
     let lines = grid.group_by(|i| i.point.line);
     lines
         .into_iter()
-        .map(|(_, line)| {
-            line_count += 1;
-            line.map(|indexed_cell| {
-                let cell_text = &indexed_cell.c.to_string();
-
-                let cell_style = cell_style(&indexed_cell, terminal_theme, text_style, modal);
-
-                let layout_cell = text_layout_cache.layout_str(
-                    cell_text,
-                    text_style.font_size,
-                    &[(cell_text.len(), cell_style)],
-                );
-                LayoutCell::new(
-                    Point::new(line_count - 1, indexed_cell.point.column.0 as i32),
-                    layout_cell,
-                    convert_color(&indexed_cell.bg, &terminal_theme.colors, modal),
-                )
-            })
-            .collect::<Vec<LayoutCell>>()
+        .enumerate()
+        .map(|(line_index, (_, line))| {
+            let mut highlighted_range = None;
+            let cells = line
+                .enumerate()
+                .map(|(x_index, indexed_cell)| {
+                    if selection_range
+                        .map(|range| range.contains(indexed_cell.point))
+                        .unwrap_or(false)
+                    {
+                        let mut range = highlighted_range.take().unwrap_or(x_index..x_index);
+                        range.end = range.end.max(x_index);
+                        highlighted_range = Some(range);
+                    }
+
+                    let cell_text = &indexed_cell.c.to_string();
+
+                    let cell_style = cell_style(&indexed_cell, terminal_theme, text_style, modal);
+
+                    //This is where we might be able to get better performance
+                    let layout_cell = text_layout_cache.layout_str(
+                        cell_text,
+                        text_style.font_size,
+                        &[(cell_text.len(), cell_style)],
+                    );
+
+                    LayoutCell::new(
+                        Point::new(line_index as i32, indexed_cell.point.column.0 as i32),
+                        layout_cell,
+                        convert_color(&indexed_cell.bg, &terminal_theme.colors, modal),
+                    )
+                })
+                .collect::<Vec<LayoutCell>>();
+
+            LayoutLine {
+                cells,
+                highlighted_range,
+            }
         })
-        .flatten()
-        .collect::<Vec<LayoutCell>>()
+        .collect::<Vec<LayoutLine>>()
 }
 
 // Compute the cursor position and expected block width, may return a zero width if x_for_index returns
@@ -492,56 +591,113 @@ fn convert_color(alac_color: &AnsiColor, colors: &TerminalColors, modal: bool) -
     }
 }
 
-///Converts an 8 bit ANSI color to it's GPUI equivalent.
-pub fn get_color_at_index(index: &u8, colors: &TerminalColors) -> Color {
-    match index {
-        //0-15 are the same as the named colors above
-        0 => colors.black,
-        1 => colors.red,
-        2 => colors.green,
-        3 => colors.yellow,
-        4 => colors.blue,
-        5 => colors.magenta,
-        6 => colors.cyan,
-        7 => colors.white,
-        8 => colors.bright_black,
-        9 => colors.bright_red,
-        10 => colors.bright_green,
-        11 => colors.bright_yellow,
-        12 => colors.bright_blue,
-        13 => colors.bright_magenta,
-        14 => colors.bright_cyan,
-        15 => colors.bright_white,
-        //16-231 are mapped to their RGB colors on a 0-5 range per channel
-        16..=231 => {
-            let (r, g, b) = rgb_for_index(index); //Split the index into it's ANSI-RGB components
-            let step = (u8::MAX as f32 / 5.).floor() as u8; //Split the RGB range into 5 chunks, with floor so no overflow
-            Color::new(r * step, g * step, b * step, u8::MAX) //Map the ANSI-RGB components to an RGB color
-        }
-        //232-255 are a 24 step grayscale from black to white
-        232..=255 => {
-            let i = index - 232; //Align index to 0..24
-            let step = (u8::MAX as f32 / 24.).floor() as u8; //Split the RGB grayscale values into 24 chunks
-            Color::new(i * step, i * step, i * step, u8::MAX) //Map the ANSI-grayscale components to the RGB-grayscale
-        }
+fn attach_mouse_handlers(
+    origin: Vector2F,
+    cur_size: SizeInfo,
+    view_id: usize,
+    terminal_mutex: &Arc<FairMutex<Term<ZedListener>>>,
+    visible_bounds: RectF,
+    cx: &mut PaintContext,
+) {
+    let click_mutex = terminal_mutex.clone();
+    let drag_mutex = terminal_mutex.clone();
+    let mouse_down_mutex = terminal_mutex.clone();
+
+    cx.scene.push_mouse_region(MouseRegion {
+        view_id,
+        mouse_down: Some(Rc::new(move |pos, _| {
+            let mut term = mouse_down_mutex.lock();
+            let (point, side) = mouse_to_cell_data(
+                pos,
+                origin,
+                cur_size,
+                term.renderable_content().display_offset,
+            );
+            term.selection = Some(Selection::new(SelectionType::Simple, point, side))
+        })),
+        click: Some(Rc::new(move |pos, click_count, cx| {
+            let mut term = click_mutex.lock();
+
+            let (point, side) = mouse_to_cell_data(
+                pos,
+                origin,
+                cur_size,
+                term.renderable_content().display_offset,
+            );
+
+            let selection_type = match click_count {
+                0 => return, //This is a release
+                1 => Some(SelectionType::Simple),
+                2 => Some(SelectionType::Semantic),
+                3 => Some(SelectionType::Lines),
+                _ => None,
+            };
+
+            let selection =
+                selection_type.map(|selection_type| Selection::new(selection_type, point, side));
+
+            term.selection = selection;
+            cx.focus_parent_view();
+            cx.notify();
+        })),
+        bounds: visible_bounds,
+        drag: Some(Rc::new(move |_delta, pos, cx| {
+            let mut term = drag_mutex.lock();
+
+            let (point, side) = mouse_to_cell_data(
+                pos,
+                origin,
+                cur_size,
+                term.renderable_content().display_offset,
+            );
+
+            if let Some(mut selection) = term.selection.take() {
+                selection.update(point, side);
+                term.selection = Some(selection);
+            }
+
+            cx.notify();
+        })),
+        ..Default::default()
+    });
+}
+
+///Copied (with modifications) from alacritty/src/input.rs > Processor::cell_side()
+fn cell_side(pos: &PaneRelativePos, cur_size: SizeInfo) -> Side {
+    let x = pos.0.x() as usize;
+    let cell_x = x.saturating_sub(cur_size.cell_width() as usize) % cur_size.cell_width() as usize;
+    let half_cell_width = (cur_size.cell_width() / 2.0) as usize;
+
+    let additional_padding =
+        (cur_size.width() - cur_size.cell_width() * 2.) % cur_size.cell_width();
+    let end_of_grid = cur_size.width() - cur_size.cell_width() - additional_padding;
+
+    if cell_x > half_cell_width
+            // Edge case when mouse leaves the window.
+            || x as f32 >= end_of_grid
+    {
+        Side::Right
+    } else {
+        Side::Left
     }
 }
 
-///Generates the rgb channels in [0, 5] for a given index into the 6x6x6 ANSI color cube
-///See: [8 bit ansi color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit).
-///
-///Wikipedia gives a formula for calculating the index for a given color:
-///
-///index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
-///
-///This function does the reverse, calculating the r, g, and b components from a given index.
-fn rgb_for_index(i: &u8) -> (u8, u8, u8) {
-    debug_assert!(i >= &16 && i <= &231);
-    let i = i - 16;
-    let r = (i - (i % 36)) / 36;
-    let g = ((i % 36) - (i % 6)) / 6;
-    let b = (i % 36) % 6;
-    (r, g, b)
+///Copied (with modifications) from alacritty/src/event.rs > Mouse::point()
+///Position is a pane-relative position. That means the top left corner of the mouse
+///Region should be (0,0)
+fn grid_cell(pos: &PaneRelativePos, cur_size: SizeInfo, display_offset: usize) -> Point {
+    let pos = pos.0;
+    let col = pos.x() / cur_size.cell_width(); //TODO: underflow...
+    let col = min(GridCol(col as usize), cur_size.last_column());
+
+    let line = pos.y() / cur_size.cell_height();
+    let line = min(line as i32, cur_size.bottommost_line().0);
+
+    //when clicking, need to ADD to get to the top left cell
+    //e.g. total_lines - viewport_height, THEN subtract display offset
+    //0 -> total_lines - viewport_height - display_offset + mouse_line
+
+    Point::new(GridLine(line - display_offset as i32), col)
 }
 
 ///Draws the grid as Alacritty sees it. Useful for checking if there is an inconsistency between
@@ -575,14 +731,73 @@ fn draw_debug_grid(bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContex
     }
 }
 
-#[cfg(test)]
-mod tests {
+mod test {
+
     #[test]
-    fn test_rgb_for_index() {
-        //Test every possible value in the color cube
-        for i in 16..=231 {
-            let (r, g, b) = crate::terminal_element::rgb_for_index(&(i as u8));
-            assert_eq!(i, 16 + 36 * r + 6 * g + b);
-        }
+    fn test_mouse_to_selection() {
+        let term_width = 100.;
+        let term_height = 200.;
+        let cell_width = 10.;
+        let line_height = 20.;
+        let mouse_pos_x = 100.; //Window relative
+        let mouse_pos_y = 100.; //Window relative
+        let origin_x = 10.;
+        let origin_y = 20.;
+
+        let cur_size = alacritty_terminal::term::SizeInfo::new(
+            term_width,
+            term_height,
+            cell_width,
+            line_height,
+            0.,
+            0.,
+            false,
+        );
+
+        let mouse_pos = gpui::geometry::vector::vec2f(mouse_pos_x, mouse_pos_y);
+        let origin = gpui::geometry::vector::vec2f(origin_x, origin_y); //Position of terminal window, 1 'cell' in
+        let (point, _) =
+            crate::terminal_element::mouse_to_cell_data(mouse_pos, origin, cur_size, 0);
+        assert_eq!(
+            point,
+            alacritty_terminal::index::Point::new(
+                alacritty_terminal::index::Line(((mouse_pos_y - origin_y) / line_height) as i32),
+                alacritty_terminal::index::Column(((mouse_pos_x - origin_x) / cell_width) as usize),
+            )
+        );
+    }
+
+    #[test]
+    fn test_mouse_to_selection_off_edge() {
+        let term_width = 100.;
+        let term_height = 200.;
+        let cell_width = 10.;
+        let line_height = 20.;
+        let mouse_pos_x = 100.; //Window relative
+        let mouse_pos_y = 100.; //Window relative
+        let origin_x = 10.;
+        let origin_y = 20.;
+
+        let cur_size = alacritty_terminal::term::SizeInfo::new(
+            term_width,
+            term_height,
+            cell_width,
+            line_height,
+            0.,
+            0.,
+            false,
+        );
+
+        let mouse_pos = gpui::geometry::vector::vec2f(mouse_pos_x, mouse_pos_y);
+        let origin = gpui::geometry::vector::vec2f(origin_x, origin_y); //Position of terminal window, 1 'cell' in
+        let (point, _) =
+            crate::terminal_element::mouse_to_cell_data(mouse_pos, origin, cur_size, 0);
+        assert_eq!(
+            point,
+            alacritty_terminal::index::Point::new(
+                alacritty_terminal::index::Line(((mouse_pos_y - origin_y) / line_height) as i32),
+                alacritty_terminal::index::Column(((mouse_pos_x - origin_x) / cell_width) as usize),
+            )
+        );
     }
 }

crates/theme/src/theme.rs 🔗

@@ -108,6 +108,7 @@ pub struct Toolbar {
     pub container: ContainerStyle,
     pub height: f32,
     pub item_spacing: f32,
+    pub nav_button: Interactive<IconButton>,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -509,28 +510,23 @@ pub struct Interactive<T> {
     pub default: T,
     pub hover: Option<T>,
     pub active: Option<T>,
-    pub active_hover: Option<T>,
+    pub disabled: Option<T>,
 }
 
 impl<T> Interactive<T> {
     pub fn style_for(&self, state: MouseState, active: bool) -> &T {
         if active {
-            if state.hovered {
-                self.active_hover
-                    .as_ref()
-                    .or(self.active.as_ref())
-                    .unwrap_or(&self.default)
-            } else {
-                self.active.as_ref().unwrap_or(&self.default)
-            }
+            self.active.as_ref().unwrap_or(&self.default)
+        } else if state.hovered {
+            self.hover.as_ref().unwrap_or(&self.default)
         } else {
-            if state.hovered {
-                self.hover.as_ref().unwrap_or(&self.default)
-            } else {
-                &self.default
-            }
+            &self.default
         }
     }
+
+    pub fn disabled_style(&self) -> &T {
+        self.disabled.as_ref().unwrap_or(&self.default)
+    }
 }
 
 impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive<T> {
@@ -544,7 +540,7 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive<T> {
             default: Value,
             hover: Option<Value>,
             active: Option<Value>,
-            active_hover: Option<Value>,
+            disabled: Option<Value>,
         }
 
         let json = Helper::deserialize(deserializer)?;
@@ -570,14 +566,14 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive<T> {
 
         let hover = deserialize_state(json.hover)?;
         let active = deserialize_state(json.active)?;
-        let active_hover = deserialize_state(json.active_hover)?;
+        let disabled = deserialize_state(json.disabled)?;
         let default = serde_json::from_value(json.default).map_err(serde::de::Error::custom)?;
 
         Ok(Interactive {
             default,
             hover,
             active,
-            active_hover,
+            disabled,
         })
     }
 }

crates/vim/src/vim_test_context.rs 🔗

@@ -147,14 +147,6 @@ impl<'a> VimTestContext<'a> {
         let mode = self.mode();
         VimBindingTestContext::new(keystrokes, mode, mode, self)
     }
-
-    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
-        self.cx.update(|cx| {
-            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
-            let expected_content = expected_content.map(|content| content.to_owned());
-            assert_eq!(actual_content, expected_content);
-        })
-    }
 }
 
 impl<'a> Deref for VimTestContext<'a> {

crates/workspace/src/pane.rs 🔗

@@ -136,13 +136,13 @@ pub struct ItemNavHistory {
     item: Rc<dyn WeakItemHandle>,
 }
 
-#[derive(Default)]
-pub struct NavHistory {
+struct NavHistory {
     mode: NavigationMode,
     backward_stack: VecDeque<NavigationEntry>,
     forward_stack: VecDeque<NavigationEntry>,
     closed_stack: VecDeque<NavigationEntry>,
     paths_by_item: HashMap<usize, ProjectPath>,
+    pane: WeakViewHandle<Pane>,
 }
 
 #[derive(Copy, Clone)]
@@ -168,17 +168,28 @@ pub struct NavigationEntry {
 
 impl Pane {
     pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        let handle = cx.weak_handle();
         Self {
             items: Vec::new(),
             active_item_index: 0,
             autoscroll: false,
-            nav_history: Default::default(),
-            toolbar: cx.add_view(|_| Toolbar::new()),
+            nav_history: Rc::new(RefCell::new(NavHistory {
+                mode: NavigationMode::Normal,
+                backward_stack: Default::default(),
+                forward_stack: Default::default(),
+                closed_stack: Default::default(),
+                paths_by_item: Default::default(),
+                pane: handle.clone(),
+            })),
+            toolbar: cx.add_view(|_| Toolbar::new(handle)),
         }
     }
 
-    pub fn nav_history(&self) -> &Rc<RefCell<NavHistory>> {
-        &self.nav_history
+    pub fn nav_history_for_item<T: Item>(&self, item: &ViewHandle<T>) -> ItemNavHistory {
+        ItemNavHistory {
+            history: self.nav_history.clone(),
+            item: Rc::new(item.downgrade()),
+        }
     }
 
     pub fn activate(&self, cx: &mut ViewContext<Self>) {
@@ -223,6 +234,26 @@ impl Pane {
         )
     }
 
+    pub fn disable_history(&mut self) {
+        self.nav_history.borrow_mut().disable();
+    }
+
+    pub fn enable_history(&mut self) {
+        self.nav_history.borrow_mut().enable();
+    }
+
+    pub fn can_navigate_backward(&self) -> bool {
+        !self.nav_history.borrow().backward_stack.is_empty()
+    }
+
+    pub fn can_navigate_forward(&self) -> bool {
+        !self.nav_history.borrow().forward_stack.is_empty()
+    }
+
+    fn history_updated(&mut self, cx: &mut ViewContext<Self>) {
+        self.toolbar.update(cx, |_, cx| cx.notify());
+    }
+
     fn navigate_history(
         workspace: &mut Workspace,
         pane: ViewHandle<Pane>,
@@ -234,7 +265,7 @@ impl Pane {
         let to_load = pane.update(cx, |pane, cx| {
             loop {
                 // Retrieve the weak item handle from the history.
-                let entry = pane.nav_history.borrow_mut().pop(mode)?;
+                let entry = pane.nav_history.borrow_mut().pop(mode, cx)?;
 
                 // If the item is still present in this pane, then activate it.
                 if let Some(index) = entry
@@ -367,7 +398,6 @@ impl Pane {
             return;
         }
 
-        item.set_nav_history(pane.read(cx).nav_history.clone(), cx);
         item.added_to_pane(workspace, pane.clone(), cx);
         pane.update(cx, |pane, cx| {
             // If there is already an active item, then insert the new item
@@ -625,11 +655,16 @@ impl Pane {
                             .borrow_mut()
                             .set_mode(NavigationMode::Normal);
 
-                        let mut nav_history = pane.nav_history().borrow_mut();
                         if let Some(path) = item.project_path(cx) {
-                            nav_history.paths_by_item.insert(item.id(), path);
+                            pane.nav_history
+                                .borrow_mut()
+                                .paths_by_item
+                                .insert(item.id(), path);
                         } else {
-                            nav_history.paths_by_item.remove(&item.id());
+                            pane.nav_history
+                                .borrow_mut()
+                                .paths_by_item
+                                .remove(&item.id());
                         }
                     }
                 });
@@ -953,57 +988,56 @@ impl View for Pane {
 }
 
 impl ItemNavHistory {
-    pub fn new<T: Item>(history: Rc<RefCell<NavHistory>>, item: &ViewHandle<T>) -> Self {
-        Self {
-            history,
-            item: Rc::new(item.downgrade()),
-        }
+    pub fn push<D: 'static + Any>(&self, data: Option<D>, cx: &mut MutableAppContext) {
+        self.history.borrow_mut().push(data, self.item.clone(), cx);
     }
 
-    pub fn history(&self) -> Rc<RefCell<NavHistory>> {
-        self.history.clone()
+    pub fn pop_backward(&self, cx: &mut MutableAppContext) -> Option<NavigationEntry> {
+        self.history.borrow_mut().pop(NavigationMode::GoingBack, cx)
     }
 
-    pub fn push<D: 'static + Any>(&self, data: Option<D>) {
-        self.history.borrow_mut().push(data, self.item.clone());
+    pub fn pop_forward(&self, cx: &mut MutableAppContext) -> Option<NavigationEntry> {
+        self.history
+            .borrow_mut()
+            .pop(NavigationMode::GoingForward, cx)
     }
 }
 
 impl NavHistory {
-    pub fn disable(&mut self) {
-        self.mode = NavigationMode::Disabled;
-    }
-
-    pub fn enable(&mut self) {
-        self.mode = NavigationMode::Normal;
-    }
-
-    pub fn pop_backward(&mut self) -> Option<NavigationEntry> {
-        self.backward_stack.pop_back()
+    fn set_mode(&mut self, mode: NavigationMode) {
+        self.mode = mode;
     }
 
-    pub fn pop_forward(&mut self) -> Option<NavigationEntry> {
-        self.forward_stack.pop_back()
+    fn disable(&mut self) {
+        self.mode = NavigationMode::Disabled;
     }
 
-    pub fn pop_closed(&mut self) -> Option<NavigationEntry> {
-        self.closed_stack.pop_back()
+    fn enable(&mut self) {
+        self.mode = NavigationMode::Normal;
     }
 
-    fn pop(&mut self, mode: NavigationMode) -> Option<NavigationEntry> {
-        match mode {
-            NavigationMode::Normal | NavigationMode::Disabled | NavigationMode::ClosingItem => None,
-            NavigationMode::GoingBack => self.pop_backward(),
-            NavigationMode::GoingForward => self.pop_forward(),
-            NavigationMode::ReopeningClosedItem => self.pop_closed(),
+    fn pop(&mut self, mode: NavigationMode, cx: &mut MutableAppContext) -> Option<NavigationEntry> {
+        let entry = match mode {
+            NavigationMode::Normal | NavigationMode::Disabled | NavigationMode::ClosingItem => {
+                return None
+            }
+            NavigationMode::GoingBack => &mut self.backward_stack,
+            NavigationMode::GoingForward => &mut self.forward_stack,
+            NavigationMode::ReopeningClosedItem => &mut self.closed_stack,
         }
+        .pop_back();
+        if entry.is_some() {
+            self.did_update(cx);
+        }
+        entry
     }
 
-    fn set_mode(&mut self, mode: NavigationMode) {
-        self.mode = mode;
-    }
-
-    pub fn push<D: 'static + Any>(&mut self, data: Option<D>, item: Rc<dyn WeakItemHandle>) {
+    fn push<D: 'static + Any>(
+        &mut self,
+        data: Option<D>,
+        item: Rc<dyn WeakItemHandle>,
+        cx: &mut MutableAppContext,
+    ) {
         match self.mode {
             NavigationMode::Disabled => {}
             NavigationMode::Normal | NavigationMode::ReopeningClosedItem => {
@@ -1044,5 +1078,12 @@ impl NavHistory {
                 });
             }
         }
+        self.did_update(cx);
+    }
+
+    fn did_update(&self, cx: &mut MutableAppContext) {
+        if let Some(pane) = self.pane.upgrade(cx) {
+            cx.defer(move |cx| pane.update(cx, |pane, cx| pane.history_updated(cx)));
+        }
     }
 }

crates/workspace/src/sidebar.rs 🔗

@@ -188,12 +188,13 @@ impl Sidebar {
         })
         .with_cursor_style(CursorStyle::ResizeLeftRight)
         .on_mouse_down(|_, _| {}) // This prevents the mouse down event from being propagated elsewhere
-        .on_drag(move |delta, cx| {
+        .on_drag(move |old_position, new_position, cx| {
+            let delta = new_position.x() - old_position.x();
             let prev_width = *actual_width.borrow();
             *custom_width.borrow_mut() = 0f32
                 .max(match side {
-                    Side::Left => prev_width + delta.x(),
-                    Side::Right => prev_width - delta.x(),
+                    Side::Left => prev_width + delta,
+                    Side::Right => prev_width - delta,
                 })
                 .round();
 

crates/workspace/src/toolbar.rs 🔗

@@ -1,7 +1,7 @@
-use crate::ItemHandle;
+use crate::{ItemHandle, Pane};
 use gpui::{
-    elements::*, AnyViewHandle, AppContext, ElementBox, Entity, MutableAppContext, RenderContext,
-    View, ViewContext, ViewHandle,
+    elements::*, platform::CursorStyle, Action, AnyViewHandle, AppContext, ElementBox, Entity,
+    MutableAppContext, RenderContext, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use settings::Settings;
 
@@ -42,6 +42,7 @@ pub enum ToolbarItemLocation {
 
 pub struct Toolbar {
     active_pane_item: Option<Box<dyn ItemHandle>>,
+    pane: WeakViewHandle<Pane>,
     items: Vec<(Box<dyn ToolbarItemViewHandle>, ToolbarItemLocation)>,
 }
 
@@ -60,6 +61,7 @@ impl View for Toolbar {
         let mut primary_left_items = Vec::new();
         let mut primary_right_items = Vec::new();
         let mut secondary_item = None;
+        let spacing = theme.item_spacing;
 
         for (item, position) in &self.items {
             match *position {
@@ -68,7 +70,7 @@ impl View for Toolbar {
                     let left_item = ChildView::new(item.as_ref())
                         .aligned()
                         .contained()
-                        .with_margin_right(theme.item_spacing);
+                        .with_margin_right(spacing);
                     if let Some((flex, expanded)) = flex {
                         primary_left_items.push(left_item.flex(flex, expanded).boxed());
                     } else {
@@ -79,7 +81,7 @@ impl View for Toolbar {
                     let right_item = ChildView::new(item.as_ref())
                         .aligned()
                         .contained()
-                        .with_margin_left(theme.item_spacing)
+                        .with_margin_left(spacing)
                         .flex_float();
                     if let Some((flex, expanded)) = flex {
                         primary_right_items.push(right_item.flex(flex, expanded).boxed());
@@ -98,26 +100,115 @@ impl View for Toolbar {
             }
         }
 
+        let pane = self.pane.clone();
+        let mut enable_go_backward = false;
+        let mut enable_go_forward = false;
+        if let Some(pane) = pane.upgrade(cx) {
+            let pane = pane.read(cx);
+            enable_go_backward = pane.can_navigate_backward();
+            enable_go_forward = pane.can_navigate_forward();
+        }
+
+        let container_style = theme.container;
+        let height = theme.height;
+        let button_style = theme.nav_button;
+        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+
         Flex::column()
             .with_child(
                 Flex::row()
+                    .with_child(nav_button(
+                        "icons/arrow-left.svg",
+                        button_style,
+                        tooltip_style.clone(),
+                        enable_go_backward,
+                        spacing,
+                        super::GoBack {
+                            pane: Some(pane.clone()),
+                        },
+                        super::GoBack { pane: None },
+                        "Go Back",
+                        cx,
+                    ))
+                    .with_child(nav_button(
+                        "icons/arrow-right.svg",
+                        button_style,
+                        tooltip_style.clone(),
+                        enable_go_forward,
+                        spacing,
+                        super::GoForward {
+                            pane: Some(pane.clone()),
+                        },
+                        super::GoForward { pane: None },
+                        "Go Forward",
+                        cx,
+                    ))
                     .with_children(primary_left_items)
                     .with_children(primary_right_items)
                     .constrained()
-                    .with_height(theme.height)
+                    .with_height(height)
                     .boxed(),
             )
             .with_children(secondary_item)
             .contained()
-            .with_style(theme.container)
+            .with_style(container_style)
             .boxed()
     }
 }
 
+fn nav_button<A: Action + Clone>(
+    svg_path: &'static str,
+    style: theme::Interactive<theme::IconButton>,
+    tooltip_style: TooltipStyle,
+    enabled: bool,
+    spacing: f32,
+    action: A,
+    tooltip_action: A,
+    action_name: &str,
+    cx: &mut RenderContext<Toolbar>,
+) -> ElementBox {
+    MouseEventHandler::new::<A, _, _>(0, cx, |state, _| {
+        let style = if enabled {
+            style.style_for(state, false)
+        } else {
+            style.disabled_style()
+        };
+        Svg::new(svg_path)
+            .with_color(style.color)
+            .constrained()
+            .with_width(style.icon_width)
+            .aligned()
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_width(style.button_width)
+            .with_height(style.button_width)
+            .aligned()
+            .boxed()
+    })
+    .with_cursor_style(if enabled {
+        CursorStyle::PointingHand
+    } else {
+        CursorStyle::default()
+    })
+    .on_click(move |_, _, cx| cx.dispatch_action(action.clone()))
+    .with_tooltip::<A, _>(
+        0,
+        action_name.to_string(),
+        Some(Box::new(tooltip_action)),
+        tooltip_style,
+        cx,
+    )
+    .contained()
+    .with_margin_right(spacing)
+    .boxed()
+}
+
 impl Toolbar {
-    pub fn new() -> Self {
+    pub fn new(pane: WeakViewHandle<Pane>) -> Self {
         Self {
             active_pane_item: None,
+            pane,
             items: Default::default(),
         }
     }

crates/workspace/src/workspace.rs 🔗

@@ -414,7 +414,6 @@ pub trait ItemHandle: 'static + fmt::Debug {
     fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
     fn is_singleton(&self, cx: &AppContext) -> bool;
     fn boxed_clone(&self) -> Box<dyn ItemHandle>;
-    fn set_nav_history(&self, nav_history: Rc<RefCell<NavHistory>>, cx: &mut MutableAppContext);
     fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemHandle>>;
     fn added_to_pane(
         &self,
@@ -484,12 +483,6 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
         Box::new(self.clone())
     }
 
-    fn set_nav_history(&self, nav_history: Rc<RefCell<NavHistory>>, cx: &mut MutableAppContext) {
-        self.update(cx, |item, cx| {
-            item.set_nav_history(ItemNavHistory::new(nav_history, &cx.handle()), cx);
-        })
-    }
-
     fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemHandle>> {
         self.update(cx, |item, cx| {
             cx.add_option_view(|cx| item.clone_on_split(cx))
@@ -503,6 +496,9 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
         pane: ViewHandle<Pane>,
         cx: &mut ViewContext<Workspace>,
     ) {
+        let history = pane.read(cx).nav_history_for_item(self);
+        self.update(cx, |this, cx| this.set_nav_history(history, cx));
+
         if let Some(followed_item) = self.to_followable_item_handle(cx) {
             if let Some(message) = followed_item.to_state_proto(cx) {
                 workspace.update_followers(
@@ -2360,7 +2356,12 @@ impl Workspace {
     }
 
     fn on_window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
-        if !active && cx.global::<Settings>().autosave == Autosave::OnWindowChange {
+        if !active
+            && matches!(
+                cx.global::<Settings>().autosave,
+                Autosave::OnWindowChange | Autosave::OnFocusChange
+            )
+        {
             for pane in &self.panes {
                 pane.update(cx, |pane, cx| {
                     for item in pane.items() {
@@ -3073,6 +3074,17 @@ mod tests {
         deterministic.run_until_parked();
         item.read_with(cx, |item, _| assert_eq!(item.save_count, 2));
 
+        // Deactivating the window still saves the file.
+        cx.simulate_window_activation(Some(window_id));
+        item.update(cx, |item, cx| {
+            cx.focus_self();
+            item.is_dirty = true;
+        });
+        cx.simulate_window_activation(None);
+
+        deterministic.run_until_parked();
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 3));
+
         // Autosave after delay.
         item.update(cx, |item, cx| {
             cx.update_global(|settings: &mut Settings, _| {
@@ -3084,11 +3096,11 @@ mod tests {
 
         // Delay hasn't fully expired, so the file is still dirty and unsaved.
         deterministic.advance_clock(Duration::from_millis(250));
-        item.read_with(cx, |item, _| assert_eq!(item.save_count, 2));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 3));
 
         // After delay expires, the file is saved.
         deterministic.advance_clock(Duration::from_millis(250));
-        item.read_with(cx, |item, _| assert_eq!(item.save_count, 3));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 4));
 
         // Autosave on focus change, ensuring closing the tab counts as such.
         item.update(cx, |item, cx| {
@@ -3106,7 +3118,7 @@ mod tests {
             .await
             .unwrap();
         assert!(!cx.has_pending_prompt(window_id));
-        item.read_with(cx, |item, _| assert_eq!(item.save_count, 4));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 5));
 
         // Add the item again, ensuring autosave is prevented if the underlying file has been deleted.
         workspace.update(cx, |workspace, cx| {
@@ -3118,7 +3130,7 @@ mod tests {
             cx.blur();
         });
         deterministic.run_until_parked();
-        item.read_with(cx, |item, _| assert_eq!(item.save_count, 4));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 5));
 
         // Ensure autosave is prevented for deleted files also when closing the buffer.
         let _close_items = workspace.update(cx, |workspace, cx| {
@@ -3127,28 +3139,107 @@ mod tests {
         });
         deterministic.run_until_parked();
         assert!(cx.has_pending_prompt(window_id));
-        item.read_with(cx, |item, _| assert_eq!(item.save_count, 4));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 5));
+    }
+
+    #[gpui::test]
+    async fn test_pane_navigation(
+        deterministic: Arc<Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        deterministic.forbid_parking();
+        Settings::test_async(cx);
+        let fs = FakeFs::new(cx.background());
+
+        let project = Project::test(fs, [], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
+
+        let item = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
+            item
+        });
+        let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
+        let toolbar = pane.read_with(cx, |pane, _| pane.toolbar().clone());
+        let toolbar_notify_count = Rc::new(RefCell::new(0));
+
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(Box::new(item.clone()), cx);
+            let toolbar_notification_count = toolbar_notify_count.clone();
+            cx.observe(&toolbar, move |_, _, _| {
+                *toolbar_notification_count.borrow_mut() += 1
+            })
+            .detach();
+        });
+
+        pane.read_with(cx, |pane, _| {
+            assert!(!pane.can_navigate_backward());
+            assert!(!pane.can_navigate_forward());
+        });
+
+        item.update(cx, |item, cx| {
+            item.set_state("one".to_string(), cx);
+        });
+
+        // Toolbar must be notified to re-render the navigation buttons
+        assert_eq!(*toolbar_notify_count.borrow(), 1);
+
+        pane.read_with(cx, |pane, _| {
+            assert!(pane.can_navigate_backward());
+            assert!(!pane.can_navigate_forward());
+        });
+
+        workspace
+            .update(cx, |workspace, cx| {
+                Pane::go_back(workspace, Some(pane.clone()), cx)
+            })
+            .await;
+
+        assert_eq!(*toolbar_notify_count.borrow(), 3);
+        pane.read_with(cx, |pane, _| {
+            assert!(!pane.can_navigate_backward());
+            assert!(pane.can_navigate_forward());
+        });
     }
 
-    #[derive(Clone)]
     struct TestItem {
+        state: String,
         save_count: usize,
         save_as_count: usize,
         reload_count: usize,
         is_dirty: bool,
+        is_singleton: bool,
         has_conflict: bool,
         project_entry_ids: Vec<ProjectEntryId>,
         project_path: Option<ProjectPath>,
-        is_singleton: bool,
+        nav_history: Option<ItemNavHistory>,
     }
 
     enum TestItemEvent {
         Edit,
     }
 
+    impl Clone for TestItem {
+        fn clone(&self) -> Self {
+            Self {
+                state: self.state.clone(),
+                save_count: self.save_count,
+                save_as_count: self.save_as_count,
+                reload_count: self.reload_count,
+                is_dirty: self.is_dirty,
+                is_singleton: self.is_singleton,
+                has_conflict: self.has_conflict,
+                project_entry_ids: self.project_entry_ids.clone(),
+                project_path: self.project_path.clone(),
+                nav_history: None,
+            }
+        }
+    }
+
     impl TestItem {
         fn new() -> Self {
             Self {
+                state: String::new(),
                 save_count: 0,
                 save_as_count: 0,
                 reload_count: 0,
@@ -3157,6 +3248,18 @@ mod tests {
                 project_entry_ids: Vec::new(),
                 project_path: None,
                 is_singleton: true,
+                nav_history: None,
+            }
+        }
+
+        fn set_state(&mut self, state: String, cx: &mut ViewContext<Self>) {
+            self.push_to_nav_history(cx);
+            self.state = state;
+        }
+
+        fn push_to_nav_history(&mut self, cx: &mut ViewContext<Self>) {
+            if let Some(history) = &mut self.nav_history {
+                history.push(Some(Box::new(self.state.clone())), cx);
             }
         }
     }
@@ -3192,7 +3295,23 @@ mod tests {
             self.is_singleton
         }
 
-        fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>) {}
+        fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext<Self>) {
+            self.nav_history = Some(history);
+        }
+
+        fn navigate(&mut self, state: Box<dyn Any>, _: &mut ViewContext<Self>) -> bool {
+            let state = *state.downcast::<String>().unwrap_or_default();
+            if state != self.state {
+                self.state = state;
+                true
+            } else {
+                false
+            }
+        }
+
+        fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
+            self.push_to_nav_history(cx);
+        }
 
         fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
         where

crates/zed/Cargo.toml 🔗

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
 description = "The fast, collaborative code editor."
 edition = "2021"
 name = "zed"
-version = "0.44.1"
+version = "0.45.0"
 
 [lib]
 name = "zed"

crates/zed/src/zed.rs 🔗

@@ -554,7 +554,7 @@ mod tests {
         });
 
         let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
-        app_state.fs.as_fake().insert_dir("/root").await;
+        app_state.fs.create_dir(Path::new("/root")).await.unwrap();
         cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
         save_task.await.unwrap();
         editor.read_with(cx, |editor, cx| {
@@ -680,14 +680,25 @@ mod tests {
     async fn test_open_paths(cx: &mut TestAppContext) {
         let app_state = init(cx);
 
-        let fs = app_state.fs.as_fake();
-        fs.insert_dir("/dir1").await;
-        fs.insert_dir("/dir2").await;
-        fs.insert_dir("/dir3").await;
-        fs.insert_file("/dir1/a.txt", "".into()).await;
-        fs.insert_file("/dir2/b.txt", "".into()).await;
-        fs.insert_file("/dir3/c.txt", "".into()).await;
-        fs.insert_file("/d.txt", "".into()).await;
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/",
+                json!({
+                    "dir1": {
+                        "a.txt": ""
+                    },
+                    "dir2": {
+                        "b.txt": ""
+                    },
+                    "dir3": {
+                        "c.txt": ""
+                    },
+                    "d.txt": ""
+                }),
+            )
+            .await;
 
         let project = Project::test(app_state.fs.clone(), ["/dir1".as_ref()], cx).await;
         let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
@@ -891,7 +902,7 @@ mod tests {
     #[gpui::test]
     async fn test_open_and_save_new_file(cx: &mut TestAppContext) {
         let app_state = init(cx);
-        app_state.fs.as_fake().insert_dir("/root").await;
+        app_state.fs.create_dir(Path::new("/root")).await.unwrap();
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
         project.update(cx, |project, _| project.languages().add(rust_lang()));
@@ -980,7 +991,7 @@ mod tests {
     #[gpui::test]
     async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
         let app_state = init(cx);
-        app_state.fs.as_fake().insert_dir("/root").await;
+        app_state.fs.create_dir(Path::new("/root")).await.unwrap();
 
         let project = Project::test(app_state.fs.clone(), [], cx).await;
         project.update(cx, |project, _| project.languages().add(rust_lang()));

styles/package-lock.json 🔗

@@ -5,7 +5,6 @@
     "requires": true,
     "packages": {
         "": {
-            "name": "styles",
             "version": "1.0.0",
             "license": "ISC",
             "dependencies": {

styles/src/styleTree/workspace.ts 🔗

@@ -139,6 +139,19 @@ export default function workspace(theme: Theme) {
       background: backgroundColor(theme, 500),
       border: border(theme, "secondary", { bottom: true }),
       itemSpacing: 8,
+      navButton: {
+        color: iconColor(theme, "secondary"),
+        iconWidth: 8,
+        buttonWidth: 18,
+        cornerRadius: 6,
+        hover: {
+          color: iconColor(theme, "active"),
+          background: backgroundColor(theme, 300),
+        },
+        disabled: {
+          color: withOpacity(iconColor(theme, "muted"), 0.6),
+        },
+      },
       padding: { left: 16, right: 8, top: 4, bottom: 4 },
     },
     breadcrumbs: {