Add save command

Max Brunsfeld and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

zed/src/editor/buffer/mod.rs           | 106 +++++++++++++++++++++++----
zed/src/editor/buffer_view.rs          |   9 +
zed/src/editor/display_map/fold_map.rs |   2 
zed/src/workspace/mod.rs               |   1 
zed/src/workspace/workspace_view.rs    |  33 ++++++++
zed/src/worktree/worktree.rs           |  67 ++++++++++++++++
6 files changed, 192 insertions(+), 26 deletions(-)

Detailed changes

zed/src/editor/buffer/mod.rs 🔗

@@ -14,7 +14,7 @@ use crate::{
     worktree::FileHandle,
 };
 use anyhow::{anyhow, Result};
-use gpui::{AppContext, Entity, ModelContext};
+use gpui::{executor::BackgroundTask, AppContext, Entity, ModelContext};
 use lazy_static::lazy_static;
 use rand::prelude::*;
 use std::{
@@ -46,6 +46,10 @@ pub struct Buffer {
     lamport_clock: time::Lamport,
 }
 
+pub struct Snapshot {
+    fragments: SumTree<Fragment>,
+}
+
 #[derive(Clone)]
 pub struct History {
     pub base_text: String,
@@ -59,11 +63,17 @@ pub struct Selection {
 }
 
 #[derive(Clone)]
-pub struct Chars<'a> {
+pub struct CharIter<'a> {
     fragments_cursor: Cursor<'a, Fragment, usize, usize>,
     fragment_chars: str::Chars<'a>,
 }
 
+#[derive(Clone)]
+pub struct FragmentIter<'a> {
+    cursor: Cursor<'a, Fragment, usize, usize>,
+    started: bool,
+}
+
 struct Edits<'a, F: Fn(&FragmentSummary) -> bool> {
     cursor: FilterCursor<'a, F, Fragment, usize>,
     since: time::Global,
@@ -225,6 +235,21 @@ impl Buffer {
         self.file.as_ref().map(|file| file.entry_id())
     }
 
+    pub fn snapshot(&self) -> Snapshot {
+        Snapshot {
+            fragments: self.fragments.clone(),
+        }
+    }
+
+    pub fn save(&self, ctx: &mut ModelContext<Self>) -> Option<BackgroundTask<Result<()>>> {
+        if let Some(file) = &self.file {
+            let snapshot = self.snapshot();
+            Some(file.save(snapshot, ctx.app()))
+        } else {
+            None
+        }
+    }
+
     pub fn is_modified(&self) -> bool {
         self.version != time::Global::new()
     }
@@ -325,25 +350,13 @@ impl Buffer {
         Ok(self.chars_at(start)?.take(end - start).collect())
     }
 
-    pub fn chars(&self) -> Chars {
+    pub fn chars(&self) -> CharIter {
         self.chars_at(0).unwrap()
     }
 
-    pub fn chars_at<T: ToOffset>(&self, position: T) -> Result<Chars> {
+    pub fn chars_at<T: ToOffset>(&self, position: T) -> Result<CharIter> {
         let offset = position.to_offset(self)?;
-
-        let mut fragments_cursor = self.fragments.cursor::<usize, usize>();
-        fragments_cursor.seek(&offset, SeekBias::Right);
-
-        let fragment_chars = fragments_cursor.item().map_or("".chars(), |fragment| {
-            let offset_in_fragment = offset - fragments_cursor.start();
-            fragment.text[offset_in_fragment..].chars()
-        });
-
-        Ok(Chars {
-            fragments_cursor,
-            fragment_chars,
-        })
+        Ok(CharIter::new(&self.fragments, offset))
     }
 
     pub fn selections_changed_since(&self, since: SelectionsVersion) -> bool {
@@ -1369,6 +1382,16 @@ impl Clone for Buffer {
     }
 }
 
+impl Snapshot {
+    pub fn fragments<'a>(&'a self) -> FragmentIter<'a> {
+        FragmentIter::new(&self.fragments)
+    }
+
+    pub fn text_summary(&self) -> TextSummary {
+        self.fragments.summary().text_summary
+    }
+}
+
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub enum Event {
     Edited(Vec<Edit>),
@@ -1384,7 +1407,22 @@ impl<'a> sum_tree::Dimension<'a, FragmentSummary> for Point {
     }
 }
 
-impl<'a> Iterator for Chars<'a> {
+impl<'a> CharIter<'a> {
+    fn new(fragments: &'a SumTree<Fragment>, offset: usize) -> Self {
+        let mut fragments_cursor = fragments.cursor::<usize, usize>();
+        fragments_cursor.seek(&offset, SeekBias::Right);
+        let fragment_chars = fragments_cursor.item().map_or("".chars(), |fragment| {
+            let offset_in_fragment = offset - fragments_cursor.start();
+            fragment.text[offset_in_fragment..].chars()
+        });
+        Self {
+            fragments_cursor,
+            fragment_chars,
+        }
+    }
+}
+
+impl<'a> Iterator for CharIter<'a> {
     type Item = char;
 
     fn next(&mut self) -> Option<Self::Item> {
@@ -1406,6 +1444,38 @@ impl<'a> Iterator for Chars<'a> {
     }
 }
 
+impl<'a> FragmentIter<'a> {
+    fn new(fragments: &'a SumTree<Fragment>) -> Self {
+        let mut cursor = fragments.cursor::<usize, usize>();
+        cursor.seek(&0, SeekBias::Right);
+        Self {
+            cursor,
+            started: false,
+        }
+    }
+}
+
+impl<'a> Iterator for FragmentIter<'a> {
+    type Item = &'a str;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        loop {
+            if self.started {
+                self.cursor.next();
+            } else {
+                self.started = true;
+            }
+            if let Some(fragment) = self.cursor.item() {
+                if fragment.is_visible() {
+                    return Some(fragment.text.as_str());
+                }
+            } else {
+                return None;
+            }
+        }
+    }
+}
+
 impl<'a, F: Fn(&FragmentSummary) -> bool> Iterator for Edits<'a, F> {
     type Item = Edit;
 

zed/src/editor/buffer_view.rs 🔗

@@ -5,8 +5,9 @@ use super::{
 use crate::{settings::Settings, watch, workspace};
 use anyhow::Result;
 use gpui::{
-    fonts::Properties as FontProperties, keymap::Binding, text_layout, App, AppContext, Element,
-    ElementBox, Entity, FontCache, ModelHandle, View, ViewContext, WeakViewHandle,
+    executor::BackgroundTask, fonts::Properties as FontProperties, keymap::Binding, text_layout,
+    App, AppContext, Element, ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, View,
+    ViewContext, WeakViewHandle,
 };
 use gpui::{geometry::vector::Vector2F, TextLayoutCache};
 use parking_lot::Mutex;
@@ -1178,6 +1179,10 @@ impl workspace::ItemView for BufferView {
         *clone.scroll_position.lock() = *self.scroll_position.lock();
         Some(clone)
     }
+
+    fn save(&self, ctx: &mut MutableAppContext) -> Option<BackgroundTask<Result<()>>> {
+        self.buffer.update(ctx, |buffer, ctx| buffer.save(ctx))
+    }
 }
 
 impl Selection {

zed/src/editor/display_map/fold_map.rs 🔗

@@ -403,7 +403,7 @@ pub struct Chars<'a> {
     cursor: Cursor<'a, Transform, DisplayOffset, TransformSummary>,
     offset: usize,
     buffer: &'a Buffer,
-    buffer_chars: Option<Take<buffer::Chars<'a>>>,
+    buffer_chars: Option<Take<buffer::CharIter<'a>>>,
 }
 
 impl<'a> Iterator for Chars<'a> {

zed/src/workspace/mod.rs 🔗

@@ -15,6 +15,7 @@ use std::path::PathBuf;
 pub fn init(app: &mut App) {
     app.add_global_action("workspace:open_paths", open_paths);
     pane::init(app);
+    workspace_view::init(app);
 }
 
 pub struct OpenParams {

zed/src/workspace/workspace_view.rs 🔗

@@ -1,12 +1,17 @@
 use super::{pane, Pane, PaneGroup, SplitDirection, Workspace};
 use crate::{settings::Settings, watch};
 use gpui::{
-    color::rgbu, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext,
-    View, ViewContext, ViewHandle,
+    color::rgbu, elements::*, executor::BackgroundTask, keymap::Binding, AnyViewHandle, App,
+    AppContext, Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle,
 };
 use log::{error, info};
 use std::{collections::HashSet, path::PathBuf};
 
+pub fn init(app: &mut App) {
+    app.add_action("workspace:save", WorkspaceView::save_active_item);
+    app.add_bindings(vec![Binding::new("cmd-s", "workspace:save", None)]);
+}
+
 pub trait ItemView: View {
     fn is_activate_event(event: &Self::Event) -> bool;
     fn title(&self, app: &AppContext) -> String;
@@ -17,6 +22,9 @@ pub trait ItemView: View {
     {
         None
     }
+    fn save(&self, _: &mut MutableAppContext) -> Option<BackgroundTask<anyhow::Result<()>>> {
+        None
+    }
 }
 
 pub trait ItemViewHandle: Send + Sync {
@@ -27,6 +35,7 @@ pub trait ItemViewHandle: Send + Sync {
     fn set_parent_pane(&self, pane: &ViewHandle<Pane>, app: &mut MutableAppContext);
     fn id(&self) -> usize;
     fn to_any(&self) -> AnyViewHandle;
+    fn save(&self, ctx: &mut MutableAppContext) -> Option<BackgroundTask<anyhow::Result<()>>>;
 }
 
 impl<T: ItemView> ItemViewHandle for ViewHandle<T> {
@@ -62,6 +71,10 @@ impl<T: ItemView> ItemViewHandle for ViewHandle<T> {
         })
     }
 
+    fn save(&self, ctx: &mut MutableAppContext) -> Option<BackgroundTask<anyhow::Result<()>>> {
+        self.update(ctx, |item, ctx| item.save(ctx.app_mut()))
+    }
+
     fn id(&self) -> usize {
         self.id()
     }
@@ -206,6 +219,22 @@ impl WorkspaceView {
         }
     }
 
+    pub fn save_active_item(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
+        self.active_pane.update(ctx, |pane, ctx| {
+            if let Some(item) = pane.active_item() {
+                if let Some(task) = item.save(ctx.app_mut()) {
+                    ctx.spawn(task, |_, result, _| {
+                        if let Err(e) = result {
+                            // TODO - present this error to the user
+                            error!("failed to save item: {:?}, ", e);
+                        }
+                    })
+                    .detach();
+                }
+            }
+        });
+    }
+
     fn workspace_updated(&mut self, _: ModelHandle<Workspace>, ctx: &mut ViewContext<Self>) {
         ctx.notify();
     }

zed/src/worktree/worktree.rs 🔗

@@ -3,18 +3,23 @@ use super::{
     char_bag::CharBag,
     fuzzy::{self, PathEntry},
 };
-use crate::{editor::History, timer, util::post_inc};
+use crate::{
+    editor::{History, Snapshot},
+    timer,
+    util::post_inc,
+};
 use anyhow::{anyhow, Result};
 use crossbeam_channel as channel;
 use easy_parallel::Parallel;
-use gpui::{AppContext, Entity, ModelContext, ModelHandle};
+use gpui::{executor::BackgroundTask, AppContext, Entity, ModelContext, ModelHandle};
 use ignore::dir::{Ignore, IgnoreBuilder};
 use parking_lot::RwLock;
 use smol::prelude::*;
 use std::{
     collections::HashMap,
     ffi::{OsStr, OsString},
-    fmt, fs, io,
+    fmt, fs,
+    io::{self, Write},
     os::unix::fs::MetadataExt,
     path::Path,
     path::PathBuf,
@@ -346,6 +351,25 @@ impl Worktree {
         }
     }
 
+    pub fn save<'a>(
+        &self,
+        entry_id: usize,
+        content: Snapshot,
+        ctx: &AppContext,
+    ) -> BackgroundTask<Result<()>> {
+        let path = self.abs_entry_path(entry_id);
+        ctx.background_executor().spawn(async move {
+            let buffer_size = content.text_summary().bytes.min(10 * 1024);
+            let file = std::fs::File::create(&path?)?;
+            let mut writer = std::io::BufWriter::with_capacity(buffer_size, file);
+            for chunk in content.fragments() {
+                writer.write(chunk.as_bytes())?;
+            }
+            writer.flush()?;
+            Ok(())
+        })
+    }
+
     fn scanning(&mut self, _: (), ctx: &mut ModelContext<Self>) {
         if self.0.read().scanning {
             ctx.notify();
@@ -444,6 +468,11 @@ impl FileHandle {
         self.worktree.as_ref(app).load_history(self.entry_id)
     }
 
+    pub fn save<'a>(&self, content: Snapshot, ctx: &AppContext) -> BackgroundTask<Result<()>> {
+        let worktree = self.worktree.as_ref(ctx);
+        worktree.save(self.entry_id, content, ctx)
+    }
+
     pub fn entry_id(&self) -> (usize, usize) {
         (self.worktree.id(), self.entry_id)
     }
@@ -611,6 +640,7 @@ pub fn match_paths(
 #[cfg(test)]
 mod test {
     use super::*;
+    use crate::editor::Buffer;
     use crate::test::*;
     use anyhow::Result;
     use gpui::App;
@@ -659,4 +689,35 @@ mod test {
             Ok(())
         })
     }
+
+    #[test]
+    fn test_save_file() {
+        App::test((), |mut app| async move {
+            let dir = temp_tree(json!({
+                "file1": "the old contents",
+            }));
+
+            let tree = app.add_model(|ctx| Worktree::new(1, dir.path(), Some(ctx)));
+            app.finish_pending_tasks().await;
+
+            let file_id = tree.read(&app, |tree, _| {
+                let entry = tree.files().next().unwrap();
+                assert_eq!(entry.path.file_name().unwrap(), "file1");
+                entry.entry_id
+            });
+
+            let buffer = Buffer::new(1, "a line of text.\n".repeat(10 * 1024));
+
+            tree.update(&mut app, |tree, ctx| {
+                smol::block_on(tree.save(file_id, buffer.snapshot(), ctx.app())).unwrap()
+            });
+
+            let history = tree
+                .read(&app, |tree, _| tree.load_history(file_id))
+                .await
+                .unwrap();
+
+            assert_eq!(history.base_text, buffer.text());
+        })
+    }
 }