Merge branch 'main' into gpui-changes

Mikayla Maki created

Change summary

Cargo.lock                             |   5 
assets/icons/arrow-left.svg            |   3 
assets/icons/arrow-right.svg           |   3 
crates/client/src/client.rs            |   2 
crates/collab/src/integration_tests.rs |  34 ++
crates/diagnostics/src/diagnostics.rs  |   9 
crates/editor/src/editor.rs            | 186 +++-------------
crates/editor/src/items.rs             |  11 
crates/gpui/src/app.rs                 | 149 +++++++++---
crates/language/src/buffer.rs          | 115 ++--------
crates/language/src/proto.rs           |  14 +
crates/language/src/tests.rs           |  25 ++
crates/project/src/fs.rs               |  26 --
crates/project/src/project.rs          | 258 ++++++++++++++++------
crates/project/src/project_tests.rs    |  53 ++++
crates/project/src/worktree.rs         |   4 
crates/search/src/project_search.rs    |  20 +
crates/settings/src/settings.rs        |  25 +
crates/terminal/Cargo.toml             |   4 
crates/terminal/src/terminal.rs        | 113 +++++++++
crates/text/Cargo.toml                 |   1 
crates/text/src/random_char_iter.rs    |   2 
crates/text/src/rope.rs                |   9 
crates/text/src/tests.rs               |  62 +++-
crates/text/src/text.rs                | 104 ++++++++
crates/theme/src/theme.rs              |  30 +-
crates/workspace/src/pane.rs           | 158 +++++++++----
crates/workspace/src/toolbar.rs        | 107 ++++++++
crates/workspace/src/workspace.rs      | 317 ++++++++++++++++++++++++++-
crates/zed/Cargo.toml                  |   2 
crates/zed/src/zed.rs                  |  33 +
styles/src/styleTree/workspace.ts      |  13 +
32 files changed, 1,349 insertions(+), 548 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",
@@ -4910,6 +4912,7 @@ dependencies = [
  "parking_lot 0.11.2",
  "postage",
  "rand 0.8.5",
+ "regex",
  "smallvec",
  "sum_tree",
  "util",
@@ -6158,7 +6161,7 @@ dependencies = [
 
 [[package]]
 name = "zed"
-version = "0.43.0"
+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>

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/diagnostics/src/diagnostics.rs 🔗

@@ -568,10 +568,11 @@ impl workspace::Item for ProjectDiagnosticsEditor {
     }
 
     fn should_update_tab_on_event(event: &Event) -> bool {
-        matches!(
-            event,
-            Event::Saved | Event::DirtyChanged | Event::TitleChanged
-        )
+        Editor::should_update_tab_on_event(event)
+    }
+
+    fn is_edit_event(event: &Self::Event) -> bool {
+        Editor::is_edit_event(event)
     }
 
     fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {

crates/editor/src/editor.rs 🔗

@@ -18,7 +18,6 @@ use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
 pub use display_map::DisplayPoint;
 use display_map::*;
 pub use element::*;
-use futures::{channel::oneshot, FutureExt};
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     actions,
@@ -51,7 +50,7 @@ use ordered_float::OrderedFloat;
 use project::{LocationLink, Project, ProjectPath, ProjectTransaction};
 use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection};
 use serde::{Deserialize, Serialize};
-use settings::{Autosave, Settings};
+use settings::Settings;
 use smallvec::SmallVec;
 use smol::Timer;
 use snippet::Snippet;
@@ -439,8 +438,6 @@ pub struct Editor {
     leader_replica_id: Option<u16>,
     hover_state: HoverState,
     link_go_to_definition_state: LinkGoToDefinitionState,
-    pending_autosave: Option<Task<Option<()>>>,
-    cancel_pending_autosave: Option<oneshot::Sender<()>>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -1028,13 +1025,10 @@ impl Editor {
             leader_replica_id: None,
             hover_state: Default::default(),
             link_go_to_definition_state: Default::default(),
-            pending_autosave: Default::default(),
-            cancel_pending_autosave: Default::default(),
             _subscriptions: vec![
                 cx.observe(&buffer, Self::on_buffer_changed),
                 cx.subscribe(&buffer, Self::on_buffer_event),
                 cx.observe(&display_map, Self::on_display_map_changed),
-                cx.observe_window_activation(Self::on_window_activation_changed),
             ],
         };
         this.end_selection(cx);
@@ -4071,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,
+            );
         }
     }
 
@@ -4675,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
@@ -4687,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());
             });
         }
     }
@@ -5584,33 +5581,6 @@ impl Editor {
                 self.refresh_active_diagnostics(cx);
                 self.refresh_code_actions(cx);
                 cx.emit(Event::BufferEdited);
-                if let Autosave::AfterDelay { milliseconds } = cx.global::<Settings>().autosave {
-                    let pending_autosave =
-                        self.pending_autosave.take().unwrap_or(Task::ready(None));
-                    if let Some(cancel_pending_autosave) = self.cancel_pending_autosave.take() {
-                        let _ = cancel_pending_autosave.send(());
-                    }
-
-                    let (cancel_tx, mut cancel_rx) = oneshot::channel();
-                    self.cancel_pending_autosave = Some(cancel_tx);
-                    self.pending_autosave = Some(cx.spawn_weak(|this, mut cx| async move {
-                        let mut timer = cx
-                            .background()
-                            .timer(Duration::from_millis(milliseconds))
-                            .fuse();
-                        pending_autosave.await;
-                        futures::select_biased! {
-                            _ = cancel_rx => return None,
-                            _ = timer => {}
-                        }
-
-                        this.upgrade(&cx)?
-                            .update(&mut cx, |this, cx| this.autosave(cx))
-                            .await
-                            .log_err();
-                        None
-                    }));
-                }
             }
             language::Event::Reparsed => cx.emit(Event::Reparsed),
             language::Event::DirtyChanged => cx.emit(Event::DirtyChanged),
@@ -5629,25 +5599,6 @@ impl Editor {
         cx.notify();
     }
 
-    fn on_window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
-        if !active && cx.global::<Settings>().autosave == Autosave::OnWindowChange {
-            self.autosave(cx).detach_and_log_err(cx);
-        }
-    }
-
-    fn autosave(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
-        if let Some(project) = self.project.clone() {
-            if self.buffer.read(cx).is_dirty(cx)
-                && !self.buffer.read(cx).has_conflict(cx)
-                && workspace::Item::can_save(self, cx)
-            {
-                return workspace::Item::save(self, project, cx);
-            }
-        }
-
-        Task::ready(Ok(()))
-    }
-
     pub fn set_searchable(&mut self, searchable: bool) {
         self.searchable = searchable;
     }
@@ -5693,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,
@@ -5709,7 +5660,7 @@ impl Editor {
                 });
             }
 
-            nav_history.borrow_mut().enable();
+            pane.update(cx, |pane, _| pane.enable_history());
         });
     }
 
@@ -5865,10 +5816,6 @@ impl View for Editor {
         hide_hover(self, cx);
         cx.emit(Event::Blurred);
         cx.notify();
-
-        if cx.global::<Settings>().autosave == Autosave::OnFocusChange {
-            self.autosave(cx).detach_and_log_err(cx);
-        }
     }
 
     fn keymap_context(&self, _: &AppContext) -> gpui::keymap::Context {
@@ -6282,23 +6229,22 @@ mod tests {
     use super::*;
     use futures::StreamExt;
     use gpui::{
-        executor::Deterministic,
         geometry::rect::RectF,
         platform::{WindowBounds, WindowOptions},
     };
     use indoc::indoc;
     use language::{FakeLspAdapter, LanguageConfig};
     use lsp::FakeLanguageServer;
-    use project::{FakeFs, Fs};
+    use project::FakeFs;
     use settings::LanguageSettings;
-    use std::{cell::RefCell, path::Path, rc::Rc, time::Instant};
+    use std::{cell::RefCell, rc::Rc, time::Instant};
     use text::Point;
     use unindent::Unindent;
     use util::{
         assert_set_eq,
         test::{marked_text_by, marked_text_ranges, marked_text_ranges_by, sample_text},
     };
-    use workspace::{FollowableItem, Item, ItemHandle};
+    use workspace::{FollowableItem, ItemHandle, NavigationEntry, Pane};
 
     #[gpui::test]
     fn test_edit_events(cx: &mut MutableAppContext) {
@@ -6646,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.
@@ -6661,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.
@@ -6685,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.
@@ -6695,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);
@@ -6715,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);
@@ -9562,72 +9516,6 @@ mod tests {
         save.await.unwrap();
     }
 
-    #[gpui::test]
-    async fn test_autosave(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
-        deterministic.forbid_parking();
-
-        let fs = FakeFs::new(cx.background().clone());
-        fs.insert_file("/file.rs", Default::default()).await;
-
-        let project = Project::test(fs.clone(), ["/file.rs".as_ref()], cx).await;
-        let buffer = project
-            .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
-            .await
-            .unwrap();
-
-        let (_, editor) = cx.add_window(|cx| Editor::for_buffer(buffer, Some(project), cx));
-
-        // Autosave on window change.
-        editor.update(cx, |editor, cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::OnWindowChange;
-            });
-            editor.insert("X", cx);
-            assert!(editor.is_dirty(cx))
-        });
-
-        // Deactivating the window saves the file.
-        cx.simulate_window_activation(None);
-        deterministic.run_until_parked();
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "X");
-        editor.read_with(cx, |editor, cx| assert!(!editor.is_dirty(cx)));
-
-        // Autosave on focus change.
-        editor.update(cx, |editor, cx| {
-            cx.focus_self();
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::OnFocusChange;
-            });
-            editor.insert("X", cx);
-            assert!(editor.is_dirty(cx))
-        });
-
-        // Blurring the editor saves the file.
-        editor.update(cx, |_, cx| cx.blur());
-        deterministic.run_until_parked();
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "XX");
-        editor.read_with(cx, |editor, cx| assert!(!editor.is_dirty(cx)));
-
-        // Autosave after delay.
-        editor.update(cx, |editor, cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::AfterDelay { milliseconds: 500 };
-            });
-            editor.insert("X", cx);
-            assert!(editor.is_dirty(cx))
-        });
-
-        // Delay hasn't fully expired, so the file is still dirty and unsaved.
-        deterministic.advance_clock(Duration::from_millis(250));
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "XX");
-        editor.read_with(cx, |editor, cx| assert!(editor.is_dirty(cx)));
-
-        // After delay expires, the file is saved.
-        deterministic.advance_clock(Duration::from_millis(250));
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "XXX");
-        editor.read_with(cx, |editor, cx| assert!(!editor.is_dirty(cx)));
-    }
-
     #[gpui::test]
     async fn test_completion(cx: &mut gpui::TestAppContext) {
         let mut language = Language::new(

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 {
@@ -445,6 +440,10 @@ impl Item for Editor {
             Event::Saved | Event::DirtyChanged | Event::TitleChanged
         )
     }
+
+    fn is_edit_event(event: &Self::Event) -> bool {
+        matches!(event, Event::BufferEdited)
+    }
 }
 
 impl ProjectItem for Editor {

crates/gpui/src/app.rs 🔗

@@ -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
@@ -612,6 +614,19 @@ 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)
+            }
+        })
+    }
 }
 
 impl AsyncAppContext {
@@ -811,7 +826,7 @@ type GlobalActionCallback = dyn FnMut(&dyn Action, &mut MutableAppContext);
 type SubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext) -> bool>;
 type GlobalSubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext)>;
 type ObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
-type FocusObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
+type FocusObservationCallback = Box<dyn FnMut(bool, &mut MutableAppContext) -> bool>;
 type GlobalObservationCallback = Box<dyn FnMut(&mut MutableAppContext)>;
 type ReleaseObservationCallback = Box<dyn FnOnce(&dyn Any, &mut MutableAppContext)>;
 type ActionObservationCallback = Box<dyn FnMut(TypeId, &mut MutableAppContext)>;
@@ -1305,7 +1320,7 @@ impl MutableAppContext {
 
     fn observe_focus<F, V>(&mut self, handle: &ViewHandle<V>, mut callback: F) -> Subscription
     where
-        F: 'static + FnMut(ViewHandle<V>, &mut MutableAppContext) -> bool,
+        F: 'static + FnMut(ViewHandle<V>, bool, &mut MutableAppContext) -> bool,
         V: View,
     {
         let subscription_id = post_inc(&mut self.next_subscription_id);
@@ -1314,9 +1329,9 @@ impl MutableAppContext {
         self.pending_effects.push_back(Effect::FocusObservation {
             view_id,
             subscription_id,
-            callback: Box::new(move |cx| {
+            callback: Box::new(move |focused, cx| {
                 if let Some(observed) = observed.upgrade(cx) {
-                    callback(observed, cx)
+                    callback(observed, focused, cx)
                 } else {
                     false
                 }
@@ -2525,6 +2540,31 @@ impl MutableAppContext {
                 if let Some(mut blurred_view) = this.cx.views.remove(&(window_id, blurred_id)) {
                     blurred_view.on_blur(this, window_id, blurred_id);
                     this.cx.views.insert((window_id, blurred_id), blurred_view);
+
+                    let callbacks = this.focus_observations.lock().remove(&blurred_id);
+                    if let Some(callbacks) = callbacks {
+                        for (id, callback) in callbacks {
+                            if let Some(mut callback) = callback {
+                                let alive = callback(false, this);
+                                if alive {
+                                    match this
+                                        .focus_observations
+                                        .lock()
+                                        .entry(blurred_id)
+                                        .or_default()
+                                        .entry(id)
+                                    {
+                                        btree_map::Entry::Vacant(entry) => {
+                                            entry.insert(Some(callback));
+                                        }
+                                        btree_map::Entry::Occupied(entry) => {
+                                            entry.remove();
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
                 }
             }
 
@@ -2537,7 +2577,7 @@ impl MutableAppContext {
                     if let Some(callbacks) = callbacks {
                         for (id, callback) in callbacks {
                             if let Some(mut callback) = callback {
-                                let alive = callback(this);
+                                let alive = callback(true, this);
                                 if alive {
                                     match this
                                         .focus_observations
@@ -3598,20 +3638,21 @@ impl<'a, T: View> ViewContext<'a, T> {
 
     pub fn observe_focus<F, V>(&mut self, handle: &ViewHandle<V>, mut callback: F) -> Subscription
     where
-        F: 'static + FnMut(&mut T, ViewHandle<V>, &mut ViewContext<T>),
+        F: 'static + FnMut(&mut T, ViewHandle<V>, bool, &mut ViewContext<T>),
         V: View,
     {
         let observer = self.weak_handle();
-        self.app.observe_focus(handle, move |observed, cx| {
-            if let Some(observer) = observer.upgrade(cx) {
-                observer.update(cx, |observer, cx| {
-                    callback(observer, observed, cx);
-                });
-                true
-            } else {
-                false
-            }
-        })
+        self.app
+            .observe_focus(handle, move |observed, focused, cx| {
+                if let Some(observer) = observer.upgrade(cx) {
+                    observer.update(cx, |observer, cx| {
+                        callback(observer, observed, focused, cx);
+                    });
+                    true
+                } else {
+                    false
+                }
+            })
     }
 
     pub fn observe_release<E, F, H>(&mut self, handle: &H, mut callback: F) -> Subscription
@@ -4398,6 +4439,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| {
@@ -4419,14 +4461,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();
@@ -6448,11 +6485,13 @@ mod tests {
         view_1.update(cx, |_, cx| {
             cx.observe_focus(&view_2, {
                 let observed_events = observed_events.clone();
-                move |this, view, cx| {
+                move |this, view, focused, cx| {
+                    let label = if focused { "focus" } else { "blur" };
                     observed_events.lock().push(format!(
-                        "{} observed {}'s focus",
+                        "{} observed {}'s {}",
                         this.name,
-                        view.read(cx).name
+                        view.read(cx).name,
+                        label
                     ))
                 }
             })
@@ -6461,16 +6500,20 @@ mod tests {
         view_2.update(cx, |_, cx| {
             cx.observe_focus(&view_1, {
                 let observed_events = observed_events.clone();
-                move |this, view, cx| {
+                move |this, view, focused, cx| {
+                    let label = if focused { "focus" } else { "blur" };
                     observed_events.lock().push(format!(
-                        "{} observed {}'s focus",
+                        "{} observed {}'s {}",
                         this.name,
-                        view.read(cx).name
+                        view.read(cx).name,
+                        label
                     ))
                 }
             })
             .detach();
         });
+        assert_eq!(mem::take(&mut *view_events.lock()), ["view 1 focused"]);
+        assert_eq!(mem::take(&mut *observed_events.lock()), Vec::<&str>::new());
 
         view_1.update(cx, |_, cx| {
             // Ensure only the latest focus is honored.
@@ -6478,31 +6521,47 @@ mod tests {
             cx.focus(&view_1);
             cx.focus(&view_2);
         });
-        view_1.update(cx, |_, cx| cx.focus(&view_1));
-        view_1.update(cx, |_, cx| cx.focus(&view_2));
-        view_1.update(cx, |_, _| drop(view_2));
+        assert_eq!(
+            mem::take(&mut *view_events.lock()),
+            ["view 1 blurred", "view 2 focused"],
+        );
+        assert_eq!(
+            mem::take(&mut *observed_events.lock()),
+            [
+                "view 2 observed view 1's blur",
+                "view 1 observed view 2's focus"
+            ]
+        );
 
+        view_1.update(cx, |_, cx| cx.focus(&view_1));
         assert_eq!(
-            *view_events.lock(),
+            mem::take(&mut *view_events.lock()),
+            ["view 2 blurred", "view 1 focused"],
+        );
+        assert_eq!(
+            mem::take(&mut *observed_events.lock()),
             [
-                "view 1 focused".to_string(),
-                "view 1 blurred".to_string(),
-                "view 2 focused".to_string(),
-                "view 2 blurred".to_string(),
-                "view 1 focused".to_string(),
-                "view 1 blurred".to_string(),
-                "view 2 focused".to_string(),
-                "view 1 focused".to_string(),
-            ],
+                "view 1 observed view 2's blur",
+                "view 2 observed view 1's focus"
+            ]
+        );
+
+        view_1.update(cx, |_, cx| cx.focus(&view_2));
+        assert_eq!(
+            mem::take(&mut *view_events.lock()),
+            ["view 1 blurred", "view 2 focused"],
         );
         assert_eq!(
-            *observed_events.lock(),
+            mem::take(&mut *observed_events.lock()),
             [
-                "view 1 observed view 2's focus".to_string(),
-                "view 2 observed view 1's focus".to_string(),
-                "view 1 observed view 2's focus".to_string(),
+                "view 2 observed view 1's blur",
+                "view 1 observed view 2's focus"
             ]
         );
+
+        view_1.update(cx, |_, _| drop(view_2));
+        assert_eq!(mem::take(&mut *view_events.lock()), ["view 1 focused"]);
+        assert_eq!(mem::take(&mut *observed_events.lock()), Vec::<&str>::new());
     }
 
     #[crate::test(self)]

crates/language/src/buffer.rs 🔗

@@ -53,7 +53,6 @@ pub struct Buffer {
     saved_version: clock::Global,
     saved_version_fingerprint: String,
     saved_mtime: SystemTime,
-    line_ending: LineEnding,
     transaction_depth: usize,
     was_dirty_before_starting_transaction: Option<bool>,
     language: Option<Arc<Language>>,
@@ -98,12 +97,6 @@ pub enum IndentKind {
     Tab,
 }
 
-#[derive(Copy, Debug, Clone, PartialEq, Eq)]
-pub enum LineEnding {
-    Unix,
-    Windows,
-}
-
 #[derive(Clone, Debug)]
 struct SelectionSet {
     line_mode: bool,
@@ -280,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)>,
@@ -314,32 +307,26 @@ impl CharKind {
 }
 
 impl Buffer {
-    pub fn new<T: Into<Arc<str>>>(
+    pub fn new<T: Into<String>>(
         replica_id: ReplicaId,
         base_text: T,
         cx: &mut ModelContext<Self>,
     ) -> Self {
-        let history = History::new(base_text.into());
-        let line_ending = LineEnding::detect(&history.base_text);
         Self::build(
-            TextBuffer::new(replica_id, cx.model_id() as u64, history),
+            TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
             None,
-            line_ending,
         )
     }
 
-    pub fn from_file<T: Into<Arc<str>>>(
+    pub fn from_file<T: Into<String>>(
         replica_id: ReplicaId,
         base_text: T,
         file: Arc<dyn File>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
-        let history = History::new(base_text.into());
-        let line_ending = LineEnding::detect(&history.base_text);
         Self::build(
-            TextBuffer::new(replica_id, cx.model_id() as u64, history),
+            TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
             Some(file),
-            line_ending,
         )
     }
 
@@ -349,14 +336,12 @@ impl Buffer {
         file: Option<Arc<dyn File>>,
         cx: &mut ModelContext<Self>,
     ) -> Result<Self> {
-        let buffer = TextBuffer::new(
-            replica_id,
-            message.id,
-            History::new(Arc::from(message.base_text)),
-        );
-        let line_ending = proto::LineEnding::from_i32(message.line_ending)
-            .ok_or_else(|| anyhow!("missing line_ending"))?;
-        let mut this = Self::build(buffer, file, LineEnding::from_proto(line_ending));
+        let buffer = TextBuffer::new(replica_id, message.id, message.base_text);
+        let mut this = Self::build(buffer, file);
+        this.text.set_line_ending(proto::deserialize_line_ending(
+            proto::LineEnding::from_i32(message.line_ending)
+                .ok_or_else(|| anyhow!("missing line_ending"))?,
+        ));
         let ops = message
             .operations
             .into_iter()
@@ -421,7 +406,7 @@ impl Buffer {
             diagnostics: proto::serialize_diagnostics(self.diagnostics.iter()),
             diagnostics_timestamp: self.diagnostics_timestamp.value,
             completion_triggers: self.completion_triggers.clone(),
-            line_ending: self.line_ending.to_proto() as i32,
+            line_ending: proto::serialize_line_ending(self.line_ending()) as i32,
         }
     }
 
@@ -430,7 +415,7 @@ impl Buffer {
         self
     }
 
-    fn build(buffer: TextBuffer, file: Option<Arc<dyn File>>, line_ending: LineEnding) -> Self {
+    fn build(buffer: TextBuffer, file: Option<Arc<dyn File>>) -> Self {
         let saved_mtime;
         if let Some(file) = file.as_ref() {
             saved_mtime = file.mtime();
@@ -446,7 +431,6 @@ impl Buffer {
             was_dirty_before_starting_transaction: None,
             text: buffer,
             file,
-            line_ending,
             syntax_tree: Mutex::new(None),
             parsing_in_background: false,
             parse_count: 0,
@@ -507,7 +491,7 @@ impl Buffer {
             self.remote_id(),
             text,
             version,
-            self.line_ending,
+            self.line_ending(),
             cx.as_mut(),
         );
         cx.spawn(|this, mut cx| async move {
@@ -563,7 +547,7 @@ impl Buffer {
                         this.did_reload(
                             this.version(),
                             this.as_rope().fingerprint(),
-                            this.line_ending,
+                            this.line_ending(),
                             new_mtime,
                             cx,
                         );
@@ -588,14 +572,14 @@ impl Buffer {
     ) {
         self.saved_version = version;
         self.saved_version_fingerprint = fingerprint;
-        self.line_ending = line_ending;
+        self.text.set_line_ending(line_ending);
         self.saved_mtime = mtime;
         if let Some(file) = self.file.as_ref().and_then(|f| f.as_local()) {
             file.buffer_reloaded(
                 self.remote_id(),
                 &self.saved_version,
                 self.saved_version_fingerprint.clone(),
-                self.line_ending,
+                self.line_ending(),
                 self.saved_mtime,
                 cx,
             );
@@ -974,13 +958,13 @@ impl Buffer {
         }
     }
 
-    pub(crate) fn diff(&self, 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 {
             let old_text = old_text.to_string();
             let line_ending = LineEnding::detect(&new_text);
-            let new_text = new_text.replace("\r\n", "\n").replace('\r', "\n");
+            LineEnding::normalize(&mut new_text);
             let changes = TextDiff::from_lines(old_text.as_str(), new_text.as_str())
                 .iter_all_changes()
                 .map(|c| (c.tag(), c.value().len()))
@@ -995,15 +979,11 @@ 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();
-            self.line_ending = diff.line_ending;
+            self.text.set_line_ending(diff.line_ending);
             let mut offset = diff.start_offset;
             for (tag, len) in diff.changes {
                 let range = offset..(offset + len);
@@ -1249,7 +1229,8 @@ impl Buffer {
 
             let inserted_ranges = edits
                 .into_iter()
-                .filter_map(|(range, new_text)| {
+                .zip(&edit_operation.as_edit().unwrap().new_text)
+                .filter_map(|((range, _), new_text)| {
                     let first_newline_ix = new_text.find('\n')?;
                     let new_text_len = new_text.len();
                     let start = (delta + range.start as isize) as usize + first_newline_ix + 1;
@@ -1518,10 +1499,6 @@ impl Buffer {
     pub fn completion_triggers(&self) -> &[String] {
         &self.completion_triggers
     }
-
-    pub fn line_ending(&self) -> LineEnding {
-        self.line_ending
-    }
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -2542,52 +2519,6 @@ impl std::ops::SubAssign for IndentSize {
     }
 }
 
-impl LineEnding {
-    pub fn from_proto(style: proto::LineEnding) -> Self {
-        match style {
-            proto::LineEnding::Unix => Self::Unix,
-            proto::LineEnding::Windows => Self::Windows,
-        }
-    }
-
-    fn detect(text: &str) -> Self {
-        let text = &text[..cmp::min(text.len(), 1000)];
-        if let Some(ix) = text.find('\n') {
-            if ix == 0 || text.as_bytes()[ix - 1] != b'\r' {
-                Self::Unix
-            } else {
-                Self::Windows
-            }
-        } else {
-            Default::default()
-        }
-    }
-
-    pub fn as_str(self) -> &'static str {
-        match self {
-            LineEnding::Unix => "\n",
-            LineEnding::Windows => "\r\n",
-        }
-    }
-
-    pub fn to_proto(self) -> proto::LineEnding {
-        match self {
-            LineEnding::Unix => proto::LineEnding::Unix,
-            LineEnding::Windows => proto::LineEnding::Windows,
-        }
-    }
-}
-
-impl Default for LineEnding {
-    fn default() -> Self {
-        #[cfg(unix)]
-        return Self::Unix;
-
-        #[cfg(not(unix))]
-        return Self::Windows;
-    }
-}
-
 impl Completion {
     pub fn sort_key(&self) -> (usize, &str) {
         let kind_key = match self.lsp_completion.kind {

crates/language/src/proto.rs 🔗

@@ -11,6 +11,20 @@ use text::*;
 
 pub use proto::{Buffer, BufferState, LineEnding, SelectionSet};
 
+pub fn deserialize_line_ending(message: proto::LineEnding) -> text::LineEnding {
+    match message {
+        LineEnding::Unix => text::LineEnding::Unix,
+        LineEnding::Windows => text::LineEnding::Windows,
+    }
+}
+
+pub fn serialize_line_ending(message: text::LineEnding) -> proto::LineEnding {
+    match message {
+        text::LineEnding::Unix => proto::LineEnding::Unix,
+        text::LineEnding::Windows => proto::LineEnding::Windows,
+    }
+}
+
 pub fn serialize_operation(operation: &Operation) -> proto::Operation {
     proto::Operation {
         variant: Some(match operation {

crates/language/src/tests.rs 🔗

@@ -22,6 +22,29 @@ fn init_logger() {
     }
 }
 
+#[gpui::test]
+fn test_line_endings(cx: &mut gpui::MutableAppContext) {
+    cx.add_model(|cx| {
+        let mut buffer =
+            Buffer::new(0, "one\r\ntwo\rthree", cx).with_language(Arc::new(rust_lang()), cx);
+        assert_eq!(buffer.text(), "one\ntwo\nthree");
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+
+        buffer.check_invariants();
+        buffer.edit_with_autoindent(
+            [(buffer.len()..buffer.len(), "\r\nfour")],
+            IndentSize::spaces(2),
+            cx,
+        );
+        buffer.edit([(0..0, "zero\r\n")], cx);
+        assert_eq!(buffer.text(), "zero\none\ntwo\nthree\nfour");
+        assert_eq!(buffer.line_ending(), LineEnding::Windows);
+        buffer.check_invariants();
+
+        buffer
+    });
+}
+
 #[gpui::test]
 fn test_select_language() {
     let registry = LanguageRegistry::test();
@@ -421,7 +444,7 @@ async fn test_outline(cx: &mut gpui::TestAppContext) {
     async fn search<'a>(
         outline: &'a Outline<Anchor>,
         query: &str,
-        cx: &gpui::TestAppContext,
+        cx: &'a gpui::TestAppContext,
     ) -> Vec<(&'a str, Vec<usize>)> {
         let matches = cx
             .read(|cx| outline.search(query, cx.background().clone()))

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,
@@ -20,7 +20,10 @@ use gpui::{
 };
 use language::{
     point_to_lsp,
-    proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version},
+    proto::{
+        deserialize_anchor, deserialize_line_ending, deserialize_version, serialize_anchor,
+        serialize_version,
+    },
     range_from_lsp, range_to_lsp, Anchor, Bias, Buffer, CharKind, CodeAction, CodeLabel,
     Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Event as BufferEvent, File as _,
     Language, LanguageRegistry, LanguageServerName, LineEnding, LocalFile, LspAdapter,
@@ -48,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,
@@ -3022,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);
                 }
             }
 
@@ -3101,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>,
@@ -3379,7 +3491,8 @@ impl Project {
                                 return None;
                             }
 
-                            let (old_range, new_text) = match lsp_completion.text_edit.as_ref() {
+                            let (old_range, mut new_text) = match lsp_completion.text_edit.as_ref()
+                            {
                                 // If the language server provides a range to overwrite, then
                                 // check that the range is valid.
                                 Some(lsp::CompletionTextEdit::Edit(edit)) => {
@@ -3429,6 +3542,7 @@ impl Project {
                                 }
                             };
 
+                            LineEnding::normalize(&mut new_text);
                             Some(Completion {
                                 old_range,
                                 new_text,
@@ -5542,7 +5656,7 @@ impl Project {
     ) -> Result<()> {
         let payload = envelope.payload;
         let version = deserialize_version(payload.version);
-        let line_ending = LineEnding::from_proto(
+        let line_ending = deserialize_line_ending(
             proto::LineEnding::from_i32(payload.line_ending)
                 .ok_or_else(|| anyhow!("missing line ending"))?,
         );

crates/project/src/project_tests.rs 🔗

@@ -1850,6 +1850,59 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) {
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "TypeScript".into(),
+            path_suffixes: vec!["ts".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_typescript::language_typescript()),
+    );
+    let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default());
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            "a.ts": "",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, ["/dir".as_ref()], cx).await;
+    project.update(cx, |project, _| project.languages.add(Arc::new(language)));
+    let buffer = project
+        .update(cx, |p, cx| p.open_local_buffer("/dir/a.ts", cx))
+        .await
+        .unwrap();
+
+    let fake_server = fake_language_servers.next().await.unwrap();
+
+    let text = "let a = b.fqn";
+    buffer.update(cx, |buffer, cx| buffer.set_text(text, cx));
+    let completions = project.update(cx, |project, cx| {
+        project.completions(&buffer, text.len(), cx)
+    });
+
+    fake_server
+        .handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
+            Ok(Some(lsp::CompletionResponse::Array(vec![
+                lsp::CompletionItem {
+                    label: "fullyQualifiedName?".into(),
+                    insert_text: Some("fully\rQualified\r\nName".into()),
+                    ..Default::default()
+                },
+            ])))
+        })
+        .next()
+        .await;
+    let completions = completions.await.unwrap();
+    assert_eq!(completions.len(), 1);
+    assert_eq!(completions[0].new_text, "fully\nQualified\nName");
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) {
     let mut language = Language::new(

crates/project/src/worktree.rs 🔗

@@ -23,7 +23,7 @@ use gpui::{
     Task,
 };
 use language::{
-    proto::{deserialize_version, serialize_version},
+    proto::{deserialize_version, serialize_line_ending, serialize_version},
     Buffer, DiagnosticEntry, LineEnding, PointUtf16, Rope,
 };
 use lazy_static::lazy_static;
@@ -1750,7 +1750,7 @@ impl language::LocalFile for File {
                     version: serialize_version(&version),
                     mtime: Some(mtime.into()),
                     fingerprint,
-                    line_ending: line_ending.to_proto() as i32,
+                    line_ending: serialize_line_ending(line_ending) as i32,
                 })
                 .log_err();
         }

crates/search/src/project_search.rs 🔗

@@ -329,6 +329,14 @@ impl Item for ProjectSearchView {
     fn should_update_tab_on_event(event: &ViewEvent) -> bool {
         matches!(event, ViewEvent::UpdateTab)
     }
+
+    fn is_edit_event(event: &Self::Event) -> bool {
+        if let ViewEvent::EditorEvent(editor_event) = event {
+            Editor::is_edit_event(editor_event)
+        } else {
+            false
+        }
+    }
 }
 
 impl ProjectSearchView {
@@ -365,8 +373,10 @@ impl ProjectSearchView {
             cx.emit(ViewEvent::EditorEvent(event.clone()))
         })
         .detach();
-        cx.observe_focus(&query_editor, |this, _, _| {
-            this.results_editor_was_focused = false;
+        cx.observe_focus(&query_editor, |this, _, focused, _| {
+            if focused {
+                this.results_editor_was_focused = false;
+            }
         })
         .detach();
 
@@ -377,8 +387,10 @@ impl ProjectSearchView {
         });
         cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
             .detach();
-        cx.observe_focus(&results_editor, |this, _, _| {
-            this.results_editor_was_focused = true;
+        cx.observe_focus(&results_editor, |this, _, focused, _| {
+            if focused {
+                this.results_editor_was_focused = true;
+            }
         })
         .detach();
         cx.subscribe(&results_editor, |this, _, event, cx| {

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/terminal.rs 🔗

@@ -1,5 +1,5 @@
 use alacritty_terminal::{
-    config::{Config, Program, PtyConfig},
+    config::{Config, PtyConfig},
     event::{Event as AlacTermEvent, EventListener, Notify},
     event_loop::{EventLoop, Msg, Notifier},
     grid::Scroll,
@@ -9,6 +9,7 @@ use alacritty_terminal::{
     Term,
 };
 
+use dirs::home_dir;
 use futures::{
     channel::mpsc::{unbounded, UnboundedSender},
     StreamExt,
@@ -17,7 +18,7 @@ use gpui::{
     actions, color::Color, elements::*, impl_internal_actions, platform::CursorStyle,
     ClipboardItem, Entity, MutableAppContext, View, ViewContext,
 };
-use project::{Project, ProjectPath};
+use project::{LocalWorktree, Project, ProjectPath};
 use settings::Settings;
 use smallvec::SmallVec;
 use std::{collections::HashMap, path::PathBuf, sync::Arc};
@@ -90,6 +91,7 @@ pub struct Terminal {
     has_new_content: bool,
     has_bell: bool, //Currently using iTerm bell, show bell emoji in tab until input is received
     cur_size: SizeInfo,
+    associated_directory: Option<PathBuf>,
 }
 
 ///Upward flowing events, for changing the title and such
@@ -124,8 +126,8 @@ impl Terminal {
         .detach();
 
         let pty_config = PtyConfig {
-            shell: Some(Program::Just("zsh".to_string())),
-            working_directory,
+            shell: None,
+            working_directory: working_directory.clone(),
             hold: false,
         };
 
@@ -172,6 +174,7 @@ impl Terminal {
             has_new_content: false,
             has_bell: false,
             cur_size: size_info,
+            associated_directory: working_directory,
         }
     }
 
@@ -268,11 +271,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))), cx);
     }
@@ -408,6 +412,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
     }
@@ -477,18 +488,29 @@ fn to_alac_rgb(color: Color) -> AlacRgb {
     }
 }
 
+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 std::{path::Path, sync::atomic::AtomicUsize, time::Duration};
+
     use super::*;
     use alacritty_terminal::{grid::GridIterator, term::cell::Cell};
     use gpui::TestAppContext;
     use itertools::Itertools;
+    use project::{FakeFs, Fs, RealFs, RemoveOptions, Worktree};
 
     ///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));
+        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);
@@ -512,4 +534,85 @@ mod tests {
             .collect::<Vec<String>>()
             .join("\n")
     }
+
+    #[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");
+    }
 }

crates/text/Cargo.toml 🔗

@@ -23,6 +23,7 @@ log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 parking_lot = "0.11"
 postage = { version = "0.4.1", features = ["futures-traits"] }
 rand = { version = "0.8.3", optional = true }
+regex = "1.5"
 smallvec = { version = "1.6", features = ["union"] }
 
 [dev-dependencies]

crates/text/src/random_char_iter.rs 🔗

@@ -22,7 +22,7 @@ impl<T: Rng> Iterator for RandomCharIter<T> {
 
         match self.0.gen_range(0..100) {
             // whitespace
-            0..=19 => [' ', '\n', '\t'].choose(&mut self.0).copied(),
+            0..=19 => [' ', '\n', '\r', '\t'].choose(&mut self.0).copied(),
             // two-byte greek letters
             20..=32 => char::from_u32(self.0.gen_range(('α' as u32)..('ω' as u32 + 1))),
             // // three-byte characters

crates/text/src/rope.rs 🔗

@@ -58,19 +58,12 @@ impl Rope {
     pub fn push(&mut self, text: &str) {
         let mut new_chunks = SmallVec::<[_; 16]>::new();
         let mut new_chunk = ArrayString::new();
-        let mut chars = text.chars().peekable();
-        while let Some(mut ch) = chars.next() {
+        for ch in text.chars() {
             if new_chunk.len() + ch.len_utf8() > 2 * CHUNK_BASE {
                 new_chunks.push(Chunk(new_chunk));
                 new_chunk = ArrayString::new();
             }
 
-            if ch == '\r' {
-                ch = '\n';
-                if chars.peek().copied() == Some('\n') {
-                    chars.next();
-                }
-            }
             new_chunk.push(ch);
         }
         if !new_chunk.is_empty() {

crates/text/src/tests.rs 🔗

@@ -18,7 +18,7 @@ fn init_logger() {
 
 #[test]
 fn test_edit() {
-    let mut buffer = Buffer::new(0, 0, History::new("abc".into()));
+    let mut buffer = Buffer::new(0, 0, "abc".into());
     assert_eq!(buffer.text(), "abc");
     buffer.edit([(3..3, "def")]);
     assert_eq!(buffer.text(), "abcdef");
@@ -42,7 +42,9 @@ fn test_random_edits(mut rng: StdRng) {
     let mut reference_string = RandomCharIter::new(&mut rng)
         .take(reference_string_len)
         .collect::<String>();
-    let mut buffer = Buffer::new(0, 0, History::new(reference_string.clone().into()));
+    let mut buffer = Buffer::new(0, 0, reference_string.clone().into());
+    LineEnding::normalize(&mut reference_string);
+
     buffer.history.group_interval = Duration::from_millis(rng.gen_range(0..=200));
     let mut buffer_versions = Vec::new();
     log::info!(
@@ -56,6 +58,7 @@ fn test_random_edits(mut rng: StdRng) {
         for (old_range, new_text) in edits.iter().rev() {
             reference_string.replace_range(old_range.clone(), &new_text);
         }
+
         assert_eq!(buffer.text(), reference_string);
         log::info!(
             "buffer text {:?}, version: {:?}",
@@ -148,9 +151,34 @@ fn test_random_edits(mut rng: StdRng) {
     }
 }
 
+#[test]
+fn test_line_endings() {
+    assert_eq!(LineEnding::detect(&"🍐✅\n".repeat(1000)), LineEnding::Unix);
+    assert_eq!(LineEnding::detect(&"abcd\n".repeat(1000)), LineEnding::Unix);
+    assert_eq!(
+        LineEnding::detect(&"🍐✅\r\n".repeat(1000)),
+        LineEnding::Windows
+    );
+    assert_eq!(
+        LineEnding::detect(&"abcd\r\n".repeat(1000)),
+        LineEnding::Windows
+    );
+
+    let mut buffer = Buffer::new(0, 0, "one\r\ntwo\rthree".into());
+    assert_eq!(buffer.text(), "one\ntwo\nthree");
+    assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    buffer.check_invariants();
+
+    buffer.edit([(buffer.len()..buffer.len(), "\r\nfour")]);
+    buffer.edit([(0..0, "zero\r\n")]);
+    assert_eq!(buffer.text(), "zero\none\ntwo\nthree\nfour");
+    assert_eq!(buffer.line_ending(), LineEnding::Windows);
+    buffer.check_invariants();
+}
+
 #[test]
 fn test_line_len() {
-    let mut buffer = Buffer::new(0, 0, History::new("".into()));
+    let mut buffer = Buffer::new(0, 0, "".into());
     buffer.edit([(0..0, "abcd\nefg\nhij")]);
     buffer.edit([(12..12, "kl\nmno")]);
     buffer.edit([(18..18, "\npqrs\n")]);
@@ -167,7 +195,7 @@ fn test_line_len() {
 #[test]
 fn test_common_prefix_at_positionn() {
     let text = "a = str; b = δα";
-    let buffer = Buffer::new(0, 0, History::new(text.into()));
+    let buffer = Buffer::new(0, 0, text.into());
 
     let offset1 = offset_after(text, "str");
     let offset2 = offset_after(text, "δα");
@@ -215,7 +243,7 @@ fn test_common_prefix_at_positionn() {
 
 #[test]
 fn test_text_summary_for_range() {
-    let buffer = Buffer::new(0, 0, History::new("ab\nefg\nhklm\nnopqrs\ntuvwxyz".into()));
+    let buffer = Buffer::new(0, 0, "ab\nefg\nhklm\nnopqrs\ntuvwxyz".into());
     assert_eq!(
         buffer.text_summary_for_range::<TextSummary, _>(1..3),
         TextSummary {
@@ -280,7 +308,7 @@ fn test_text_summary_for_range() {
 
 #[test]
 fn test_chars_at() {
-    let mut buffer = Buffer::new(0, 0, History::new("".into()));
+    let mut buffer = Buffer::new(0, 0, "".into());
     buffer.edit([(0..0, "abcd\nefgh\nij")]);
     buffer.edit([(12..12, "kl\nmno")]);
     buffer.edit([(18..18, "\npqrs")]);
@@ -302,7 +330,7 @@ fn test_chars_at() {
     assert_eq!(chars.collect::<String>(), "PQrs");
 
     // Regression test:
-    let mut buffer = Buffer::new(0, 0, History::new("".into()));
+    let mut buffer = Buffer::new(0, 0, "".into());
     buffer.edit([(0..0, "[workspace]\nmembers = [\n    \"xray_core\",\n    \"xray_server\",\n    \"xray_cli\",\n    \"xray_wasm\",\n]\n")]);
     buffer.edit([(60..60, "\n")]);
 
@@ -312,7 +340,7 @@ fn test_chars_at() {
 
 #[test]
 fn test_anchors() {
-    let mut buffer = Buffer::new(0, 0, History::new("".into()));
+    let mut buffer = Buffer::new(0, 0, "".into());
     buffer.edit([(0..0, "abc")]);
     let left_anchor = buffer.anchor_before(2);
     let right_anchor = buffer.anchor_after(2);
@@ -430,7 +458,7 @@ fn test_anchors() {
 
 #[test]
 fn test_anchors_at_start_and_end() {
-    let mut buffer = Buffer::new(0, 0, History::new("".into()));
+    let mut buffer = Buffer::new(0, 0, "".into());
     let before_start_anchor = buffer.anchor_before(0);
     let after_end_anchor = buffer.anchor_after(0);
 
@@ -453,7 +481,7 @@ fn test_anchors_at_start_and_end() {
 
 #[test]
 fn test_undo_redo() {
-    let mut buffer = Buffer::new(0, 0, History::new("1234".into()));
+    let mut buffer = Buffer::new(0, 0, "1234".into());
     // Set group interval to zero so as to not group edits in the undo stack.
     buffer.history.group_interval = Duration::from_secs(0);
 
@@ -490,7 +518,7 @@ fn test_undo_redo() {
 #[test]
 fn test_history() {
     let mut now = Instant::now();
-    let mut buffer = Buffer::new(0, 0, History::new("123456".into()));
+    let mut buffer = Buffer::new(0, 0, "123456".into());
 
     buffer.start_transaction_at(now);
     buffer.edit([(2..4, "cd")]);
@@ -544,7 +572,7 @@ fn test_history() {
 #[test]
 fn test_finalize_last_transaction() {
     let now = Instant::now();
-    let mut buffer = Buffer::new(0, 0, History::new("123456".into()));
+    let mut buffer = Buffer::new(0, 0, "123456".into());
 
     buffer.start_transaction_at(now);
     buffer.edit([(2..4, "cd")]);
@@ -579,7 +607,7 @@ fn test_finalize_last_transaction() {
 #[test]
 fn test_edited_ranges_for_transaction() {
     let now = Instant::now();
-    let mut buffer = Buffer::new(0, 0, History::new("1234567".into()));
+    let mut buffer = Buffer::new(0, 0, "1234567".into());
 
     buffer.start_transaction_at(now);
     buffer.edit([(2..4, "cd")]);
@@ -618,9 +646,9 @@ fn test_edited_ranges_for_transaction() {
 fn test_concurrent_edits() {
     let text = "abcdef";
 
-    let mut buffer1 = Buffer::new(1, 0, History::new(text.into()));
-    let mut buffer2 = Buffer::new(2, 0, History::new(text.into()));
-    let mut buffer3 = Buffer::new(3, 0, History::new(text.into()));
+    let mut buffer1 = Buffer::new(1, 0, text.into());
+    let mut buffer2 = Buffer::new(2, 0, text.into());
+    let mut buffer3 = Buffer::new(3, 0, text.into());
 
     let buf1_op = buffer1.edit([(1..2, "12")]);
     assert_eq!(buffer1.text(), "a12cdef");
@@ -659,7 +687,7 @@ fn test_random_concurrent_edits(mut rng: StdRng) {
     let mut network = Network::new(rng.clone());
 
     for i in 0..peers {
-        let mut buffer = Buffer::new(i as ReplicaId, 0, History::new(base_text.clone().into()));
+        let mut buffer = Buffer::new(i as ReplicaId, 0, base_text.clone().into());
         buffer.history.group_interval = Duration::from_millis(rng.gen_range(0..=200));
         buffers.push(buffer);
         replica_ids.push(i as u16);

crates/text/src/text.rs 🔗

@@ -18,6 +18,7 @@ pub use anchor::*;
 use anyhow::Result;
 use clock::ReplicaId;
 use collections::{HashMap, HashSet};
+use lazy_static::lazy_static;
 use locator::Locator;
 use operation_queue::OperationQueue;
 pub use patch::Patch;
@@ -26,10 +27,12 @@ pub use point_utf16::*;
 use postage::{barrier, oneshot, prelude::*};
 #[cfg(any(test, feature = "test-support"))]
 pub use random_char_iter::*;
+use regex::Regex;
 use rope::TextDimension;
 pub use rope::{Chunks, Rope, TextSummary};
 pub use selection::*;
 use std::{
+    borrow::Cow,
     cmp::{self, Ordering},
     future::Future,
     iter::Iterator,
@@ -42,6 +45,10 @@ pub use subscription::*;
 pub use sum_tree::Bias;
 use sum_tree::{FilterCursor, SumTree};
 
+lazy_static! {
+    static ref CARRIAGE_RETURNS_REGEX: Regex = Regex::new("\r\n|\r").unwrap();
+}
+
 pub type TransactionId = clock::Local;
 
 pub struct Buffer {
@@ -63,6 +70,7 @@ pub struct BufferSnapshot {
     remote_id: u64,
     visible_text: Rope,
     deleted_text: Rope,
+    line_ending: LineEnding,
     undo_map: UndoMap,
     fragments: SumTree<Fragment>,
     insertions: SumTree<InsertionFragment>,
@@ -86,6 +94,12 @@ pub struct Transaction {
     pub ranges: Vec<Range<FullOffset>>,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum LineEnding {
+    Unix,
+    Windows,
+}
+
 impl HistoryEntry {
     pub fn transaction_id(&self) -> TransactionId {
         self.transaction.id
@@ -148,9 +162,9 @@ impl HistoryEntry {
 }
 
 #[derive(Clone)]
-pub struct History {
+struct History {
     // TODO: Turn this into a String or Rope, maybe.
-    pub base_text: Arc<str>,
+    base_text: Arc<str>,
     operations: HashMap<clock::Local, Operation>,
     undo_stack: Vec<HistoryEntry>,
     redo_stack: Vec<HistoryEntry>,
@@ -539,13 +553,18 @@ pub struct UndoOperation {
 }
 
 impl Buffer {
-    pub fn new(replica_id: u16, remote_id: u64, history: History) -> Buffer {
+    pub fn new(replica_id: u16, remote_id: u64, mut base_text: String) -> Buffer {
+        let line_ending = LineEnding::detect(&base_text);
+        LineEnding::normalize(&mut base_text);
+
+        let history = History::new(base_text.into());
         let mut fragments = SumTree::new();
         let mut insertions = SumTree::new();
 
         let mut local_clock = clock::Local::new(replica_id);
         let mut lamport_clock = clock::Lamport::new(replica_id);
         let mut version = clock::Global::new();
+
         let visible_text = Rope::from(history.base_text.as_ref());
         if visible_text.len() > 0 {
             let insertion_timestamp = InsertionTimestamp {
@@ -576,6 +595,7 @@ impl Buffer {
                 remote_id,
                 visible_text,
                 deleted_text: Rope::new(),
+                line_ending,
                 fragments,
                 insertions,
                 version,
@@ -658,7 +678,7 @@ impl Buffer {
         let mut new_insertions = Vec::new();
         let mut insertion_offset = 0;
 
-        let mut ranges = edits
+        let mut edits = edits
             .map(|(range, new_text)| (range.to_offset(&*self), new_text))
             .peekable();
 
@@ -666,12 +686,12 @@ impl Buffer {
             RopeBuilder::new(self.visible_text.cursor(0), self.deleted_text.cursor(0));
         let mut old_fragments = self.fragments.cursor::<FragmentTextSummary>();
         let mut new_fragments =
-            old_fragments.slice(&ranges.peek().unwrap().0.start, Bias::Right, &None);
+            old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right, &None);
         new_ropes.push_tree(new_fragments.summary().text);
 
         let mut fragment_start = old_fragments.start().visible;
-        for (range, new_text) in ranges {
-            let new_text = new_text.into();
+        for (range, new_text) in edits {
+            let new_text = LineEnding::normalize_arc(new_text.into());
             let fragment_end = old_fragments.end(&None).visible;
 
             // If the current fragment ends before this range, then jump ahead to the first fragment
@@ -714,6 +734,7 @@ impl Buffer {
             // Insert the new text before any existing fragments within the range.
             if !new_text.is_empty() {
                 let new_start = new_fragments.summary().text.visible;
+
                 edits_patch.push(Edit {
                     old: fragment_start..fragment_start,
                     new: new_start..new_start + new_text.len(),
@@ -805,6 +826,10 @@ impl Buffer {
         edit_op
     }
 
+    pub fn set_line_ending(&mut self, line_ending: LineEnding) {
+        self.snapshot.line_ending = line_ending;
+    }
+
     pub fn apply_ops<I: IntoIterator<Item = Operation>>(&mut self, ops: I) -> Result<()> {
         let mut deferred_ops = Vec::new();
         for op in ops {
@@ -1412,6 +1437,8 @@ impl Buffer {
             fragment_summary.text.deleted,
             self.snapshot.deleted_text.len()
         );
+
+        assert!(!self.text().contains("\r\n"));
     }
 
     pub fn set_group_interval(&mut self, group_interval: Duration) {
@@ -1452,6 +1479,15 @@ impl Buffer {
 
         log::info!("mutating buffer {} with {:?}", self.replica_id, edits);
         let op = self.edit(edits.iter().cloned());
+        if let Operation::Edit(edit) = &op {
+            assert_eq!(edits.len(), edit.new_text.len());
+            for (edit, new_text) in edits.iter_mut().zip(&edit.new_text) {
+                edit.1 = new_text.clone();
+            }
+        } else {
+            unreachable!()
+        }
+
         (edits, op)
     }
 
@@ -1549,6 +1585,10 @@ impl BufferSnapshot {
         self.visible_text.to_string()
     }
 
+    pub fn line_ending(&self) -> LineEnding {
+        self.line_ending
+    }
+
     pub fn deleted_text(&self) -> String {
         self.deleted_text.to_string()
     }
@@ -2310,6 +2350,56 @@ impl operation_queue::Operation for Operation {
     }
 }
 
+impl Default for LineEnding {
+    fn default() -> Self {
+        #[cfg(unix)]
+        return Self::Unix;
+
+        #[cfg(not(unix))]
+        return Self::CRLF;
+    }
+}
+
+impl LineEnding {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            LineEnding::Unix => "\n",
+            LineEnding::Windows => "\r\n",
+        }
+    }
+
+    pub fn detect(text: &str) -> Self {
+        let mut max_ix = cmp::min(text.len(), 1000);
+        while !text.is_char_boundary(max_ix) {
+            max_ix -= 1;
+        }
+
+        if let Some(ix) = text[..max_ix].find(&['\n']) {
+            if ix > 0 && text.as_bytes()[ix - 1] == b'\r' {
+                Self::Windows
+            } else {
+                Self::Unix
+            }
+        } else {
+            Self::default()
+        }
+    }
+
+    pub fn normalize(text: &mut String) {
+        if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(text, "\n") {
+            *text = replaced;
+        }
+    }
+
+    fn normalize_arc(text: Arc<str>) -> Arc<str> {
+        if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(&text, "\n") {
+            replaced.into()
+        } else {
+            text
+        }
+    }
+}
+
 pub trait ToOffset {
     fn to_offset<'a>(&self, snapshot: &BufferSnapshot) -> 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/workspace/src/pane.rs 🔗

@@ -14,7 +14,7 @@ use gpui::{
 };
 use project::{Project, ProjectEntryId, ProjectPath};
 use serde::Deserialize;
-use settings::Settings;
+use settings::{Autosave, Settings};
 use std::{any::Any, cell::RefCell, mem, path::Path, rc::Rc};
 use util::ResultExt;
 
@@ -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());
                         }
                     }
                 });
@@ -677,7 +712,13 @@ impl Pane {
                 _ => return Ok(false),
             }
         } else if is_dirty && (can_save || is_singleton) {
-            let should_save = if should_prompt_for_save {
+            let will_autosave = cx.read(|cx| {
+                matches!(
+                    cx.global::<Settings>().autosave,
+                    Autosave::OnFocusChange | Autosave::OnWindowChange
+                ) && Self::can_autosave_item(item.as_ref(), cx)
+            });
+            let should_save = if should_prompt_for_save && !will_autosave {
                 let mut answer = pane.update(cx, |pane, cx| {
                     pane.activate_item(item_ix, true, true, cx);
                     cx.prompt(
@@ -718,6 +759,23 @@ impl Pane {
         Ok(true)
     }
 
+    fn can_autosave_item(item: &dyn ItemHandle, cx: &AppContext) -> bool {
+        let is_deleted = item.project_entry_ids(cx).is_empty();
+        item.is_dirty(cx) && !item.has_conflict(cx) && item.can_save(cx) && !is_deleted
+    }
+
+    pub fn autosave_item(
+        item: &dyn ItemHandle,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>> {
+        if Self::can_autosave_item(item, cx) {
+            item.save(project, cx)
+        } else {
+            Task::ready(Ok(()))
+        }
+    }
+
     pub fn focus_active_item(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(active_item) = self.active_item() {
             cx.focus(active_item);
@@ -930,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 => {
@@ -1021,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/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 🔗

@@ -11,6 +11,7 @@ use client::{
 };
 use clock::ReplicaId;
 use collections::{hash_map, HashMap, HashSet};
+use futures::{channel::oneshot, FutureExt};
 use gpui::{
     actions,
     color::Color,
@@ -30,7 +31,7 @@ pub use pane_group::*;
 use postage::prelude::Stream;
 use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId};
 use serde::Deserialize;
-use settings::Settings;
+use settings::{Autosave, Settings};
 use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem};
 use smallvec::SmallVec;
 use status_bar::StatusBar;
@@ -41,12 +42,14 @@ use std::{
     cell::RefCell,
     fmt,
     future::Future,
+    mem,
     path::{Path, PathBuf},
     rc::Rc,
     sync::{
         atomic::{AtomicBool, Ordering::SeqCst},
         Arc,
     },
+    time::Duration,
 };
 use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
@@ -296,6 +299,9 @@ pub trait Item: View {
     fn should_update_tab_on_event(_: &Self::Event) -> bool {
         false
     }
+    fn is_edit_event(_: &Self::Event) -> bool {
+        false
+    }
     fn act_as_type(
         &self,
         type_id: TypeId,
@@ -408,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,
@@ -478,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))
@@ -497,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(
@@ -510,6 +512,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
             }
         }
 
+        let mut pending_autosave = None;
+        let mut cancel_pending_autosave = oneshot::channel::<()>().0;
         let pending_update = Rc::new(RefCell::new(None));
         let pending_update_scheduled = Rc::new(AtomicBool::new(false));
         let pane = pane.downgrade();
@@ -570,6 +574,40 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                     cx.notify();
                 });
             }
+
+            if T::is_edit_event(event) {
+                if let Autosave::AfterDelay { milliseconds } = cx.global::<Settings>().autosave {
+                    let prev_autosave = pending_autosave.take().unwrap_or(Task::ready(Some(())));
+                    let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
+                    let prev_cancel_tx = mem::replace(&mut cancel_pending_autosave, cancel_tx);
+                    let project = workspace.project.downgrade();
+                    let _ = prev_cancel_tx.send(());
+                    pending_autosave = Some(cx.spawn_weak(|_, mut cx| async move {
+                        let mut timer = cx
+                            .background()
+                            .timer(Duration::from_millis(milliseconds))
+                            .fuse();
+                        prev_autosave.await;
+                        futures::select_biased! {
+                            _ = cancel_rx => return None,
+                            _ = timer => {}
+                        }
+
+                        let project = project.upgrade(&cx)?;
+                        cx.update(|cx| Pane::autosave_item(&item, project, cx))
+                            .await
+                            .log_err();
+                        None
+                    }));
+                }
+            }
+        })
+        .detach();
+
+        cx.observe_focus(self, move |workspace, item, focused, cx| {
+            if !focused && cx.global::<Settings>().autosave == Autosave::OnFocusChange {
+                Pane::autosave_item(&item, workspace.project.clone(), cx).detach_and_log_err(cx);
+            }
         })
         .detach();
     }
@@ -774,6 +812,8 @@ impl Workspace {
             cx.notify()
         })
         .detach();
+        cx.observe_window_activation(Self::on_window_activation_changed)
+            .detach();
 
         cx.subscribe(&project, move |this, project, event, cx| {
             match event {
@@ -2314,6 +2354,24 @@ impl Workspace {
         }
         None
     }
+
+    fn on_window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        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() {
+                        Pane::autosave_item(item.as_ref(), self.project.clone(), cx)
+                            .detach_and_log_err(cx);
+                    }
+                });
+            }
+        }
+    }
 }
 
 impl Entity for Workspace {
@@ -2631,7 +2689,7 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::{ModelHandle, TestAppContext, ViewContext};
+    use gpui::{executor::Deterministic, ModelHandle, TestAppContext, ViewContext};
     use project::{FakeFs, Project, ProjectEntryId};
     use serde_json::json;
 
@@ -2969,21 +3027,219 @@ mod tests {
         });
     }
 
-    #[derive(Clone)]
+    #[gpui::test]
+    async fn test_autosave(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 item_id = item.id();
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(Box::new(item.clone()), cx);
+        });
+
+        // Autosave on window change.
+        item.update(cx, |item, cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.autosave = Autosave::OnWindowChange;
+            });
+            item.is_dirty = true;
+        });
+
+        // Deactivating the window saves the file.
+        cx.simulate_window_activation(None);
+        deterministic.run_until_parked();
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 1));
+
+        // Autosave on focus change.
+        item.update(cx, |item, cx| {
+            cx.focus_self();
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.autosave = Autosave::OnFocusChange;
+            });
+            item.is_dirty = true;
+        });
+
+        // Blurring the item saves the file.
+        item.update(cx, |_, cx| cx.blur());
+        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, _| {
+                settings.autosave = Autosave::AfterDelay { milliseconds: 500 };
+            });
+            item.is_dirty = true;
+            cx.emit(TestItemEvent::Edit);
+        });
+
+        // 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, 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, 4));
+
+        // Autosave on focus change, ensuring closing the tab counts as such.
+        item.update(cx, |item, cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.autosave = Autosave::OnFocusChange;
+            });
+            item.is_dirty = true;
+        });
+
+        workspace
+            .update(cx, |workspace, cx| {
+                let pane = workspace.active_pane().clone();
+                Pane::close_items(workspace, pane, cx, move |id| id == item_id)
+            })
+            .await
+            .unwrap();
+        assert!(!cx.has_pending_prompt(window_id));
+        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| {
+            workspace.add_item(Box::new(item.clone()), cx);
+        });
+        item.update(cx, |item, cx| {
+            item.project_entry_ids = Default::default();
+            item.is_dirty = true;
+            cx.blur();
+        });
+        deterministic.run_until_parked();
+        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| {
+            let pane = workspace.active_pane().clone();
+            Pane::close_items(workspace, pane, cx, move |id| id == item_id)
+        });
+        deterministic.run_until_parked();
+        assert!(cx.has_pending_prompt(window_id));
+        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());
+        });
+    }
+
     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,
@@ -2992,12 +3248,24 @@ 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);
             }
         }
     }
 
     impl Entity for TestItem {
-        type Event = ();
+        type Event = TestItemEvent;
     }
 
     impl View for TestItem {
@@ -3027,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
@@ -3054,6 +3338,7 @@ mod tests {
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {
             self.save_count += 1;
+            self.is_dirty = false;
             Task::ready(Ok(()))
         }
 
@@ -3064,6 +3349,7 @@ mod tests {
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {
             self.save_as_count += 1;
+            self.is_dirty = false;
             Task::ready(Ok(()))
         }
 
@@ -3073,11 +3359,16 @@ mod tests {
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {
             self.reload_count += 1;
+            self.is_dirty = false;
             Task::ready(Ok(()))
         }
 
         fn should_update_tab_on_event(_: &Self::Event) -> bool {
             true
         }
+
+        fn is_edit_event(event: &Self::Event) -> bool {
+            matches!(event, TestItemEvent::Edit)
+        }
     }
 }

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.43.0"
+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/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: {