diff --git a/Cargo.lock b/Cargo.lock index ba63f85d85..21a08332c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2451,6 +2451,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec" + [[package]] name = "simplecss" version = "0.2.0" @@ -2976,6 +2982,7 @@ dependencies = [ "seahash", "serde 1.0.125", "serde_json 1.0.64", + "similar", "simplelog", "smallvec", "smol", diff --git a/gpui/src/app.rs b/gpui/src/app.rs index c4df05ffb8..099bf2ec6c 100644 --- a/gpui/src/app.rs +++ b/gpui/src/app.rs @@ -2,7 +2,7 @@ use crate::{ elements::ElementBox, executor, keymap::{self, Keystroke}, - platform::{self, WindowOptions}, + platform::{self, PromptLevel, WindowOptions}, presenter::Presenter, util::{post_inc, timeout}, AssetCache, AssetSource, ClipboardItem, FontCache, PathPromptOptions, TextLayoutCache, @@ -361,6 +361,23 @@ impl TestAppContext { pub fn did_prompt_for_new_path(&self) -> bool { self.1.as_ref().did_prompt_for_new_path() } + + pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) { + let mut state = self.0.borrow_mut(); + let (_, window) = state + .presenters_and_platform_windows + .get_mut(&window_id) + .unwrap(); + let test_window = window + .as_any_mut() + .downcast_mut::() + .unwrap(); + let callback = test_window + .last_prompt + .take() + .expect("prompt was not called"); + (callback)(answer); + } } impl AsyncAppContext { @@ -383,6 +400,10 @@ impl AsyncAppContext { { self.update(|ctx| ctx.add_model(build_model)) } + + pub fn background_executor(&self) -> Arc { + self.0.borrow().ctx.background.clone() + } } impl UpdateModel for AsyncAppContext { @@ -688,6 +709,31 @@ impl MutableAppContext { self.platform.set_menus(menus); } + fn prompt( + &self, + window_id: usize, + level: PromptLevel, + msg: &str, + answers: &[&str], + done_fn: F, + ) where + F: 'static + FnOnce(usize, &mut MutableAppContext), + { + let app = self.weak_self.as_ref().unwrap().upgrade().unwrap(); + let foreground = self.foreground.clone(); + let (_, window) = &self.presenters_and_platform_windows[&window_id]; + window.prompt( + level, + msg, + answers, + Box::new(move |answer| { + foreground + .spawn(async move { (done_fn)(answer, &mut *app.borrow_mut()) }) + .detach(); + }), + ); + } + pub fn prompt_for_paths(&self, options: PathPromptOptions, done_fn: F) where F: 'static + FnOnce(Option>, &mut MutableAppContext), @@ -1731,6 +1777,14 @@ impl<'a, T: View> ViewContext<'a, T> { &self.app.ctx.background } + pub fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str], done_fn: F) + where + F: 'static + FnOnce(usize, &mut MutableAppContext), + { + self.app + .prompt(self.window_id, level, msg, answers, done_fn) + } + pub fn prompt_for_paths(&self, options: PathPromptOptions, done_fn: F) where F: 'static + FnOnce(Option>, &mut MutableAppContext), diff --git a/gpui/src/lib.rs b/gpui/src/lib.rs index 19aac02c40..eef55742cb 100644 --- a/gpui/src/lib.rs +++ b/gpui/src/lib.rs @@ -25,7 +25,7 @@ pub mod json; pub mod keymap; mod platform; pub use gpui_macros::test; -pub use platform::{Event, PathPromptOptions}; +pub use platform::{Event, PathPromptOptions, PromptLevel}; pub use presenter::{ AfterLayoutContext, Axis, DebugContext, EventContext, LayoutContext, PaintContext, SizeConstraint, Vector2FExt, diff --git a/gpui/src/platform/mac/window.rs b/gpui/src/platform/mac/window.rs index 0c81ca623c..b1d86acb12 100644 --- a/gpui/src/platform/mac/window.rs +++ b/gpui/src/platform/mac/window.rs @@ -5,6 +5,7 @@ use crate::{ platform::{self, Event, WindowContext}, Scene, }; +use block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSBackingStoreBuffered, NSScreen, NSView, NSViewHeightSizable, @@ -26,7 +27,9 @@ use objc::{ use pathfinder_geometry::vector::vec2f; use smol::Timer; use std::{ - cell::RefCell, + any::Any, + cell::{Cell, RefCell}, + convert::TryInto, ffi::c_void, mem, ptr, rc::{Rc, Weak}, @@ -261,6 +264,10 @@ impl Drop for Window { } impl platform::Window for Window { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn on_event(&mut self, callback: Box) { self.0.as_ref().borrow_mut().event_callback = Some(callback); } @@ -272,6 +279,42 @@ impl platform::Window for Window { fn on_close(&mut self, callback: Box) { self.0.as_ref().borrow_mut().close_callback = Some(callback); } + + fn prompt( + &self, + level: platform::PromptLevel, + msg: &str, + answers: &[&str], + done_fn: Box, + ) { + unsafe { + let alert: id = msg_send![class!(NSAlert), alloc]; + let alert: id = msg_send![alert, init]; + let alert_style = match level { + platform::PromptLevel::Info => 1, + platform::PromptLevel::Warning => 0, + platform::PromptLevel::Critical => 2, + }; + let _: () = msg_send![alert, setAlertStyle: alert_style]; + let _: () = msg_send![alert, setMessageText: ns_string(msg)]; + for (ix, answer) in answers.into_iter().enumerate() { + let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)]; + let _: () = msg_send![button, setTag: ix as NSInteger]; + } + let done_fn = Cell::new(Some(done_fn)); + let block = ConcreteBlock::new(move |answer: NSInteger| { + if let Some(done_fn) = done_fn.take() { + (done_fn)(answer.try_into().unwrap()); + } + }); + let block = block.copy(); + let _: () = msg_send![ + alert, + beginSheetModalForWindow: self.0.borrow().native_window + completionHandler: block + ]; + } + } } impl platform::WindowContext for Window { @@ -515,3 +558,7 @@ async fn synthetic_drag( } } } + +unsafe fn ns_string(string: &str) -> id { + NSString::alloc(nil).init_str(string).autorelease() +} diff --git a/gpui/src/platform/mod.rs b/gpui/src/platform/mod.rs index 2ad5ce4202..fd62770c12 100644 --- a/gpui/src/platform/mod.rs +++ b/gpui/src/platform/mod.rs @@ -68,9 +68,17 @@ pub trait Dispatcher: Send + Sync { } pub trait Window: WindowContext { + fn as_any_mut(&mut self) -> &mut dyn Any; fn on_event(&mut self, callback: Box); fn on_resize(&mut self, callback: Box); fn on_close(&mut self, callback: Box); + fn prompt( + &self, + level: PromptLevel, + msg: &str, + answers: &[&str], + done_fn: Box, + ); } pub trait WindowContext { @@ -90,6 +98,12 @@ pub struct PathPromptOptions { pub multiple: bool, } +pub enum PromptLevel { + Info, + Warning, + Critical, +} + pub trait FontSystem: Send + Sync { fn load_family(&self, name: &str) -> anyhow::Result>; fn select_font( diff --git a/gpui/src/platform/test.rs b/gpui/src/platform/test.rs index 255484e2a6..d3a0c0d3f1 100644 --- a/gpui/src/platform/test.rs +++ b/gpui/src/platform/test.rs @@ -24,6 +24,7 @@ pub struct Window { event_handlers: Vec>, resize_handlers: Vec>, close_handlers: Vec>, + pub(crate) last_prompt: RefCell>>, } impl Platform { @@ -123,6 +124,7 @@ impl Window { close_handlers: Vec::new(), scale_factor: 1.0, current_scene: None, + last_prompt: RefCell::new(None), } } } @@ -152,6 +154,10 @@ impl super::WindowContext for Window { } impl super::Window for Window { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + fn on_event(&mut self, callback: Box) { self.event_handlers.push(callback); } @@ -163,6 +169,10 @@ impl super::Window for Window { fn on_close(&mut self, callback: Box) { self.close_handlers.push(callback); } + + fn prompt(&self, _: crate::PromptLevel, _: &str, _: &[&str], f: Box) { + self.last_prompt.replace(Some(f)); + } } pub(crate) fn platform() -> Platform { diff --git a/zed/Cargo.toml b/zed/Cargo.toml index d146387752..2302fc6509 100644 --- a/zed/Cargo.toml +++ b/zed/Cargo.toml @@ -34,6 +34,7 @@ rand = "0.8.3" rust-embed = "5.9.0" seahash = "4.1" serde = {version = "1", features = ["derive"]} +similar = "1.3" simplelog = "0.9" smallvec = "1.6.1" smol = "1.2.5" diff --git a/zed/src/editor/buffer/mod.rs b/zed/src/editor/buffer/mod.rs index 2ad1574498..b1f04f6786 100644 --- a/zed/src/editor/buffer/mod.rs +++ b/zed/src/editor/buffer/mod.rs @@ -7,6 +7,7 @@ pub use anchor::*; pub use point::*; use seahash::SeaHasher; pub use selection::*; +use similar::{ChangeTag, TextDiff}; pub use text::*; use crate::{ @@ -27,7 +28,7 @@ use std::{ ops::{AddAssign, Range}, str, sync::Arc, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; const UNDO_GROUP_INTERVAL: Duration = Duration::from_millis(300); @@ -60,6 +61,7 @@ pub struct Buffer { insertion_splits: HashMap>, pub version: time::Global, saved_version: time::Global, + saved_mtime: SystemTime, last_edit: time::Local, undo_map: UndoMap, history: History, @@ -374,13 +376,42 @@ impl Buffer { file: Option, ctx: &mut ModelContext, ) -> Self { + let saved_mtime; if let Some(file) = file.as_ref() { + saved_mtime = file.mtime(); file.observe_from_model(ctx, |this, file, ctx| { - if this.version == this.saved_version && file.is_deleted() { - ctx.emit(Event::Dirtied); + let version = this.version.clone(); + if this.version == this.saved_version { + if file.is_deleted() { + ctx.emit(Event::Dirtied); + } else { + ctx.spawn(|handle, mut ctx| async move { + let (current_version, history) = handle.read_with(&ctx, |this, ctx| { + (this.version.clone(), file.load_history(ctx.as_ref())) + }); + if let (Ok(history), true) = (history.await, current_version == version) + { + let operations = handle + .update(&mut ctx, |this, ctx| { + this.set_text_via_diff(history.base_text, ctx) + }) + .await; + if operations.is_some() { + handle.update(&mut ctx, |this, ctx| { + this.saved_version = this.version.clone(); + this.saved_mtime = file.mtime(); + ctx.emit(Event::Reloaded); + }); + } + } + }) + .detach(); + } } ctx.emit(Event::FileHandleChanged); }); + } else { + saved_mtime = UNIX_EPOCH; } let mut insertion_splits = HashMap::default(); @@ -449,6 +480,7 @@ impl Buffer { undo_map: Default::default(), history, file, + saved_mtime, selections: HashMap::default(), selections_last_update: 0, deferred_ops: OperationQueue::new(), @@ -500,14 +532,75 @@ impl Buffer { if file.is_some() { self.file = file; } + if let Some(file) = &self.file { + self.saved_mtime = file.mtime(); + } self.saved_version = version; ctx.emit(Event::Saved); } + fn set_text_via_diff( + &mut self, + new_text: Arc, + ctx: &mut ModelContext, + ) -> Task>> { + let version = self.version.clone(); + let old_text = self.text(); + ctx.spawn(|handle, mut ctx| async move { + let diff = ctx + .background_executor() + .spawn({ + let new_text = new_text.clone(); + async move { + TextDiff::from_lines(old_text.as_str(), new_text.as_ref()) + .iter_all_changes() + .map(|c| (c.tag(), c.value().len())) + .collect::>() + } + }) + .await; + handle.update(&mut ctx, |this, ctx| { + if this.version == version { + this.start_transaction(None).unwrap(); + let mut operations = Vec::new(); + let mut offset = 0; + for (tag, len) in diff { + let range = offset..(offset + len); + match tag { + ChangeTag::Equal => offset += len, + ChangeTag::Delete => operations + .extend_from_slice(&this.edit(Some(range), "", Some(ctx)).unwrap()), + ChangeTag::Insert => { + operations.extend_from_slice( + &this + .edit(Some(offset..offset), &new_text[range], Some(ctx)) + .unwrap(), + ); + offset += len; + } + } + } + this.end_transaction(None, Some(ctx)).unwrap(); + Some(operations) + } else { + None + } + }) + }) + } + pub fn is_dirty(&self) -> bool { self.version > self.saved_version || self.file.as_ref().map_or(false, |f| f.is_deleted()) } + pub fn has_conflict(&self) -> bool { + self.version > self.saved_version + && self + .file + .as_ref() + .map_or(false, |f| f.mtime() > self.saved_mtime) + } + pub fn version(&self) -> time::Global { self.version.clone() } @@ -1818,6 +1911,7 @@ impl Clone for Buffer { insertion_splits: self.insertion_splits.clone(), version: self.version.clone(), saved_version: self.saved_version.clone(), + saved_mtime: self.saved_mtime, last_edit: self.last_edit.clone(), undo_map: self.undo_map.clone(), history: self.history.clone(), @@ -1849,6 +1943,7 @@ pub enum Event { Dirtied, Saved, FileHandleChanged, + Reloaded, } impl Entity for Buffer { @@ -2380,7 +2475,10 @@ impl ToPoint for usize { #[cfg(test)] mod tests { use super::*; - use crate::{test::temp_tree, worktree::Worktree}; + use crate::{ + test::temp_tree, + worktree::{Worktree, WorktreeHandle}, + }; use cmp::Ordering; use gpui::App; use serde_json::json; @@ -2969,8 +3067,6 @@ mod tests { #[test] fn test_is_dirty() { - use crate::worktree::WorktreeHandle; - App::test_async((), |mut app| async move { let dir = temp_tree(json!({ "file1": "", @@ -2978,9 +3074,10 @@ mod tests { "file3": "", })); let tree = app.add_model(|ctx| Worktree::new(dir.path(), ctx)); + tree.flush_fs_events(&app).await; app.read(|ctx| tree.read(ctx).scan_complete()).await; - let file1 = app.read(|ctx| tree.file("file1", ctx)); + let file1 = app.update(|ctx| tree.file("file1", ctx)).await; let buffer1 = app.add_model(|ctx| { Buffer::from_history(0, History::new("abc".into()), Some(file1), ctx) }); @@ -3040,7 +3137,7 @@ mod tests { // When a file is deleted, the buffer is considered dirty. let events = Rc::new(RefCell::new(Vec::new())); - let file2 = app.read(|ctx| tree.file("file2", ctx)); + let file2 = app.update(|ctx| tree.file("file2", ctx)).await; let buffer2 = app.add_model(|ctx: &mut ModelContext| { ctx.subscribe(&ctx.handle(), { let events = events.clone(); @@ -3050,7 +3147,6 @@ mod tests { Buffer::from_history(0, History::new("abc".into()), Some(file2), ctx) }); - tree.flush_fs_events(&app).await; fs::remove_file(dir.path().join("file2")).unwrap(); tree.update(&mut app, |tree, ctx| tree.next_scan_complete(ctx)) .await; @@ -3062,7 +3158,7 @@ mod tests { // When a file is already dirty when deleted, we don't emit a Dirtied event. let events = Rc::new(RefCell::new(Vec::new())); - let file3 = app.read(|ctx| tree.file("file3", ctx)); + let file3 = app.update(|ctx| tree.file("file3", ctx)).await; let buffer3 = app.add_model(|ctx: &mut ModelContext| { ctx.subscribe(&ctx.handle(), { let events = events.clone(); @@ -3085,6 +3181,116 @@ mod tests { }); } + #[gpui::test] + async fn test_file_changes_on_disk(mut app: gpui::TestAppContext) { + let initial_contents = "aaa\nbbbbb\nc\n"; + let dir = temp_tree(json!({ "the-file": initial_contents })); + let tree = app.add_model(|ctx| Worktree::new(dir.path(), ctx)); + app.read(|ctx| tree.read(ctx).scan_complete()).await; + + let abs_path = dir.path().join("the-file"); + let file = app.update(|ctx| tree.file("the-file", ctx)).await; + let buffer = app.add_model(|ctx| { + Buffer::from_history(0, History::new(initial_contents.into()), Some(file), ctx) + }); + + // Add a cursor at the start of each row. + let (selection_set_id, _) = buffer.update(&mut app, |buffer, ctx| { + assert!(!buffer.is_dirty()); + buffer.add_selection_set( + (0..3) + .map(|row| { + let anchor = buffer + .anchor_at(Point::new(row, 0), AnchorBias::Right) + .unwrap(); + Selection { + id: row as usize, + start: anchor.clone(), + end: anchor, + reversed: false, + goal: SelectionGoal::None, + } + }) + .collect::>(), + Some(ctx), + ) + }); + + // Change the file on disk, adding two new lines of text, and removing + // one line. + buffer.update(&mut app, |buffer, _| { + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + tree.flush_fs_events(&app).await; + let new_contents = "AAAA\naaa\nBB\nbbbbb\n"; + + fs::write(&abs_path, new_contents).unwrap(); + + // Because the buffer was not modified, it is reloaded from disk. Its + // contents are edited according to the diff between the old and new + // file contents. + buffer + .condition_with_duration(Duration::from_millis(500), &app, |buffer, _| { + buffer.text() != initial_contents + }) + .await; + + buffer.update(&mut app, |buffer, _| { + assert_eq!(buffer.text(), new_contents); + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + + let selections = buffer.selections(selection_set_id).unwrap(); + let cursor_positions = selections + .iter() + .map(|selection| { + assert_eq!(selection.start, selection.end); + selection.start.to_point(&buffer).unwrap() + }) + .collect::>(); + assert_eq!( + cursor_positions, + &[Point::new(1, 0), Point::new(3, 0), Point::new(4, 0),] + ); + }); + + // Modify the buffer + buffer.update(&mut app, |buffer, ctx| { + buffer.edit(vec![0..0], " ", Some(ctx)).unwrap(); + assert!(buffer.is_dirty()); + }); + + // Change the file on disk again, adding blank lines to the beginning. + fs::write(&abs_path, "\n\n\nAAAA\naaa\nBB\nbbbbb\n").unwrap(); + + // Becaues the buffer is modified, it doesn't reload from disk, but is + // marked as having a conflict. + buffer + .condition_with_duration(Duration::from_millis(500), &app, |buffer, _| { + buffer.has_conflict() + }) + .await; + } + + #[gpui::test] + async fn test_set_text_via_diff(mut app: gpui::TestAppContext) { + let text = "a\nbb\nccc\ndddd\neeeee\nffffff\n"; + let buffer = app.add_model(|ctx| Buffer::new(0, text, ctx)); + + let text = "a\nccc\ndddd\nffffff\n"; + buffer + .update(&mut app, |b, ctx| b.set_text_via_diff(text.into(), ctx)) + .await; + app.read(|ctx| assert_eq!(buffer.read(ctx).text(), text)); + + let text = "a\n1\n\nccc\ndd2dd\nffffff\n"; + buffer + .update(&mut app, |b, ctx| b.set_text_via_diff(text.into(), ctx)) + .await; + app.read(|ctx| assert_eq!(buffer.read(ctx).text(), text)); + } + #[gpui::test] fn test_undo_redo(app: &mut gpui::MutableAppContext) { app.add_model(|ctx| { diff --git a/zed/src/editor/buffer_view.rs b/zed/src/editor/buffer_view.rs index df8d447a4a..3319e81f41 100644 --- a/zed/src/editor/buffer_view.rs +++ b/zed/src/editor/buffer_view.rs @@ -2396,6 +2396,7 @@ impl BufferView { buffer::Event::Dirtied => ctx.emit(Event::Dirtied), buffer::Event::Saved => ctx.emit(Event::Saved), buffer::Event::FileHandleChanged => ctx.emit(Event::FileHandleChanged), + buffer::Event::Reloaded => ctx.emit(Event::FileHandleChanged), } } } @@ -2500,6 +2501,10 @@ impl workspace::ItemView for BufferView { fn is_dirty(&self, ctx: &AppContext) -> bool { self.buffer.read(ctx).is_dirty() } + + fn has_conflict(&self, ctx: &AppContext) -> bool { + self.buffer.read(ctx).has_conflict() + } } #[cfg(test)] diff --git a/zed/src/workspace.rs b/zed/src/workspace.rs index 87f52e486a..5c9dc16c92 100644 --- a/zed/src/workspace.rs +++ b/zed/src/workspace.rs @@ -9,8 +9,8 @@ use crate::{ use futures_core::Future; use gpui::{ color::rgbu, elements::*, json::to_string_pretty, keymap::Binding, AnyViewHandle, AppContext, - ClipboardItem, Entity, ModelHandle, MutableAppContext, PathPromptOptions, Task, View, - ViewContext, ViewHandle, WeakModelHandle, + ClipboardItem, Entity, ModelHandle, MutableAppContext, PathPromptOptions, PromptLevel, Task, + View, ViewContext, ViewHandle, WeakModelHandle, }; use log::error; pub use pane::*; @@ -119,6 +119,9 @@ pub trait ItemView: View { fn is_dirty(&self, _: &AppContext) -> bool { false } + fn has_conflict(&self, _: &AppContext) -> bool { + false + } fn save( &mut self, _: Option, @@ -157,6 +160,7 @@ pub trait ItemViewHandle: Send + Sync { fn id(&self) -> usize; fn to_any(&self) -> AnyViewHandle; fn is_dirty(&self, ctx: &AppContext) -> bool; + fn has_conflict(&self, ctx: &AppContext) -> bool; fn save( &self, file: Option, @@ -247,6 +251,10 @@ impl ItemViewHandle for ViewHandle { self.read(ctx).is_dirty(ctx) } + fn has_conflict(&self, ctx: &AppContext) -> bool { + self.read(ctx).has_conflict(ctx) + } + fn id(&self) -> usize { self.id() } @@ -361,6 +369,7 @@ impl Workspace { .map(|(abs_path, file)| { let is_file = bg.spawn(async move { abs_path.is_file() }); ctx.spawn(|this, mut ctx| async move { + let file = file.await; let is_file = is_file.await; this.update(&mut ctx, |this, ctx| { if is_file { @@ -381,14 +390,14 @@ impl Workspace { } } - fn file_for_path(&mut self, abs_path: &Path, ctx: &mut ViewContext) -> FileHandle { + fn file_for_path(&mut self, abs_path: &Path, ctx: &mut ViewContext) -> Task { for tree in self.worktrees.iter() { if let Ok(relative_path) = abs_path.strip_prefix(tree.read(ctx).abs_path()) { - return tree.file(relative_path, ctx.as_ref()); + return tree.file(relative_path, ctx.as_mut()); } } let worktree = self.add_worktree(&abs_path, ctx); - worktree.file(Path::new(""), ctx.as_ref()) + worktree.file(Path::new(""), ctx.as_mut()) } pub fn add_worktree( @@ -489,18 +498,19 @@ impl Workspace { } }; - let file = worktree.file(path.clone(), ctx.as_ref()); + let file = worktree.file(path.clone(), ctx.as_mut()); if let Entry::Vacant(entry) = self.loading_items.entry(entry.clone()) { let (mut tx, rx) = postage::watch::channel(); entry.insert(rx); let replica_id = self.replica_id; - let history = ctx - .background_executor() - .spawn(file.load_history(ctx.as_ref())); ctx.as_mut() .spawn(|mut ctx| async move { - *tx.borrow_mut() = Some(match history.await { + let file = file.await; + let history = ctx.read(|ctx| file.load_history(ctx)); + let history = ctx.background_executor().spawn(history).await; + + *tx.borrow_mut() = Some(match history { Ok(history) => Ok(Box::new(ctx.add_model(|ctx| { Buffer::from_history(replica_id, history, Some(file), ctx) }))), @@ -545,8 +555,8 @@ impl Workspace { pub fn save_active_item(&mut self, _: &(), ctx: &mut ViewContext) { if let Some(item) = self.active_item(ctx) { + let handle = ctx.handle(); if item.entry_id(ctx.as_ref()).is_none() { - let handle = ctx.handle(); let start_path = self .worktrees .iter() @@ -556,8 +566,9 @@ impl Workspace { ctx.prompt_for_new_path(&start_path, move |path, ctx| { if let Some(path) = path { ctx.spawn(|mut ctx| async move { - let file = - handle.update(&mut ctx, |me, ctx| me.file_for_path(&path, ctx)); + let file = handle + .update(&mut ctx, |me, ctx| me.file_for_path(&path, ctx)) + .await; if let Err(error) = ctx.update(|ctx| item.save(Some(file), ctx)).await { error!("failed to save item: {:?}, ", error); } @@ -566,16 +577,32 @@ impl Workspace { } }); return; - } + } else if item.has_conflict(ctx.as_ref()) { + const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?"; - let save = item.save(None, ctx.as_mut()); - ctx.foreground() - .spawn(async move { - if let Err(e) = save.await { - error!("failed to save item: {:?}, ", e); + ctx.prompt( + PromptLevel::Warning, + CONFLICT_MESSAGE, + &["Overwrite", "Cancel"], + move |answer, ctx| { + if answer == 0 { + ctx.spawn(|mut ctx| async move { + if let Err(error) = ctx.update(|ctx| item.save(None, ctx)).await { + error!("failed to save item: {:?}, ", error); + } + }) + .detach(); + } + }, + ); + } else { + ctx.spawn(|_, mut ctx| async move { + if let Err(error) = ctx.update(|ctx| item.save(None, ctx)).await { + error!("failed to save item: {:?}, ", error); } }) .detach(); + } } } @@ -732,7 +759,7 @@ mod tests { use super::*; use crate::{editor::BufferView, settings, test::temp_tree}; use serde_json::json; - use std::collections::HashSet; + use std::{collections::HashSet, fs}; use tempdir::TempDir; #[gpui::test] @@ -970,6 +997,55 @@ mod tests { }); } + #[gpui::test] + async fn test_save_conflicting_item(mut app: gpui::TestAppContext) { + let dir = temp_tree(json!({ + "a.txt": "", + })); + + let settings = settings::channel(&app.font_cache()).unwrap().1; + let (window_id, workspace) = app.add_window(|ctx| { + let mut workspace = Workspace::new(0, settings, ctx); + workspace.add_worktree(dir.path(), ctx); + workspace + }); + let tree = app.read(|ctx| { + let mut trees = workspace.read(ctx).worktrees().iter(); + trees.next().unwrap().clone() + }); + tree.flush_fs_events(&app).await; + + // Open a file within an existing worktree. + app.update(|ctx| { + workspace.update(ctx, |view, ctx| { + view.open_paths(&[dir.path().join("a.txt")], ctx) + }) + }) + .await; + let editor = app.read(|ctx| { + let pane = workspace.read(ctx).active_pane().read(ctx); + let item = pane.active_item().unwrap(); + item.to_any().downcast::().unwrap() + }); + + app.update(|ctx| editor.update(ctx, |editor, ctx| editor.insert(&"x".to_string(), ctx))); + fs::write(dir.path().join("a.txt"), "changed").unwrap(); + tree.flush_fs_events(&app).await; + app.read(|ctx| { + assert!(editor.is_dirty(ctx)); + assert!(editor.has_conflict(ctx)); + }); + + app.update(|ctx| workspace.update(ctx, |w, ctx| w.save_active_item(&(), ctx))); + app.simulate_prompt_answer(window_id, 0); + tree.update(&mut app, |tree, ctx| tree.next_scan_complete(ctx)) + .await; + app.read(|ctx| { + assert!(!editor.is_dirty(ctx)); + assert!(!editor.has_conflict(ctx)); + }); + } + #[gpui::test] async fn test_open_and_save_new_file(mut app: gpui::TestAppContext) { let dir = TempDir::new("test-new-file").unwrap(); diff --git a/zed/src/workspace/pane.rs b/zed/src/workspace/pane.rs index e7957ec948..4108ac67a5 100644 --- a/zed/src/workspace/pane.rs +++ b/zed/src/workspace/pane.rs @@ -228,6 +228,7 @@ impl Pane { line_height - 2., mouse_state.hovered, item.is_dirty(ctx), + item.has_conflict(ctx), ctx, )) .right() @@ -296,15 +297,25 @@ impl Pane { item_id: usize, close_icon_size: f32, tab_hovered: bool, - is_modified: bool, + is_dirty: bool, + has_conflict: bool, ctx: &AppContext, ) -> ElementBox { enum TabCloseButton {} - let modified_color = ColorU::from_u32(0x556de8ff); - let mut clicked_color = modified_color; + let dirty_color = ColorU::from_u32(0x556de8ff); + let conflict_color = ColorU::from_u32(0xe45349ff); + let mut clicked_color = dirty_color; clicked_color.a = 180; + let current_color = if has_conflict { + Some(conflict_color) + } else if is_dirty { + Some(dirty_color) + } else { + None + }; + let icon = if tab_hovered { let mut icon = Svg::new("icons/x.svg"); @@ -314,13 +325,13 @@ impl Pane { .with_background_color(if mouse_state.clicked { clicked_color } else { - modified_color + dirty_color }) .with_corner_radius(close_icon_size / 2.) .boxed() } else { - if is_modified { - icon = icon.with_color(modified_color); + if let Some(current_color) = current_color { + icon = icon.with_color(current_color); } icon.boxed() } @@ -331,11 +342,11 @@ impl Pane { let diameter = 8.; ConstrainedBox::new( Canvas::new(move |bounds, ctx| { - if is_modified { + if let Some(current_color) = current_color { let square = RectF::new(bounds.origin(), vec2f(diameter, diameter)); ctx.scene.push_quad(Quad { bounds: square, - background: Some(modified_color), + background: Some(current_color), border: Default::default(), corner_radius: diameter / 2., }); diff --git a/zed/src/worktree.rs b/zed/src/worktree.rs index 80da089cf5..59171e115d 100644 --- a/zed/src/worktree.rs +++ b/zed/src/worktree.rs @@ -9,7 +9,7 @@ use crate::{ use ::ignore::gitignore::Gitignore; use anyhow::{Context, Result}; pub use fuzzy::{match_paths, PathMatch}; -use gpui::{scoped_pool, AppContext, Entity, ModelContext, ModelHandle, Task}; +use gpui::{scoped_pool, AppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task}; use lazy_static::lazy_static; use parking_lot::Mutex; use postage::{ @@ -28,7 +28,7 @@ use std::{ os::unix::{ffi::OsStrExt, fs::MetadataExt}, path::{Path, PathBuf}, sync::{Arc, Weak}, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use self::{char_bag::CharBag, ignore::IgnoreStack}; @@ -63,6 +63,7 @@ pub struct FileHandle { struct FileHandleState { path: Arc, is_deleted: bool, + mtime: SystemTime, } impl Worktree { @@ -201,9 +202,10 @@ impl Worktree { path: &Path, ctx: &AppContext, ) -> impl Future> { - let abs_path = self.absolutize(path); + let path = path.to_path_buf(); + let abs_path = self.absolutize(&path); ctx.background_executor().spawn(async move { - let mut file = std::fs::File::open(&abs_path)?; + let mut file = fs::File::open(&abs_path)?; let mut base_text = String::new(); file.read_to_string(&mut base_text)?; Ok(History::new(Arc::from(base_text))) @@ -221,20 +223,29 @@ impl Worktree { let abs_path = self.absolutize(&path); ctx.background_executor().spawn(async move { let buffer_size = content.text_summary().bytes.min(10 * 1024); - let file = std::fs::File::create(&abs_path)?; - let mut writer = std::io::BufWriter::with_capacity(buffer_size, file); + let file = fs::File::create(&abs_path)?; + let mut writer = io::BufWriter::with_capacity(buffer_size, &file); for chunk in content.fragments() { writer.write(chunk.as_bytes())?; } writer.flush()?; - - if let Some(handle) = handles.lock().get(path.as_path()).and_then(Weak::upgrade) { - handle.lock().is_deleted = false; - } - + Self::update_file_handle(&file, &path, &handles)?; Ok(()) }) } + + fn update_file_handle( + file: &fs::File, + path: &Path, + handles: &Mutex, Weak>>>, + ) -> Result<()> { + if let Some(handle) = handles.lock().get(path).and_then(Weak::upgrade) { + let mut handle = handle.lock(); + handle.mtime = file.metadata()?.modified()?; + handle.is_deleted = false; + } + Ok(()) + } } impl Entity for Worktree { @@ -457,6 +468,10 @@ impl FileHandle { self.state.lock().is_deleted } + pub fn mtime(&self) -> SystemTime { + self.state.lock().mtime + } + pub fn exists(&self) -> bool { !self.is_deleted() } @@ -927,41 +942,63 @@ impl BackgroundScanner { }; let mut renamed_paths: HashMap = HashMap::new(); + let mut handles = self.handles.lock(); let mut updated_handles = HashMap::new(); for event in &events { + let path = if let Ok(path) = event.path.strip_prefix(&root_abs_path) { + path + } else { + continue; + }; + + let metadata = fs::metadata(&event.path); if event.flags.contains(fsevent::StreamFlags::ITEM_RENAMED) { - if let Ok(path) = event.path.strip_prefix(&root_abs_path) { - if let Some(inode) = snapshot.inode_for_path(path) { - renamed_paths.insert(inode, path.to_path_buf()); - } else if let Ok(metadata) = fs::metadata(&event.path) { - let new_path = path; - let mut handles = self.handles.lock(); - if let Some(old_path) = renamed_paths.get(&metadata.ino()) { - handles.retain(|handle_path, handle_state| { - if let Ok(path_suffix) = handle_path.strip_prefix(&old_path) { - let new_handle_path: Arc = - if path_suffix.file_name().is_some() { - new_path.join(path_suffix) - } else { - new_path.to_path_buf() - } - .into(); - if let Some(handle_state) = Weak::upgrade(&handle_state) { - handle_state.lock().path = new_handle_path.clone(); - updated_handles - .insert(new_handle_path, Arc::downgrade(&handle_state)); + if let Some(inode) = snapshot.inode_for_path(path) { + renamed_paths.insert(inode, path.to_path_buf()); + } else if let Ok(metadata) = &metadata { + let new_path = path; + if let Some(old_path) = renamed_paths.get(&metadata.ino()) { + handles.retain(|handle_path, handle_state| { + if let Ok(path_suffix) = handle_path.strip_prefix(&old_path) { + let new_handle_path: Arc = + if path_suffix.file_name().is_some() { + new_path.join(path_suffix) + } else { + new_path.to_path_buf() } - false - } else { - true + .into(); + if let Some(handle_state) = Weak::upgrade(&handle_state) { + let mut state = handle_state.lock(); + state.path = new_handle_path.clone(); + updated_handles + .insert(new_handle_path, Arc::downgrade(&handle_state)); } - }); - handles.extend(updated_handles.drain()); + false + } else { + true + } + }); + handles.extend(updated_handles.drain()); + } + } + } + + for state in handles.values_mut() { + if let Some(state) = Weak::upgrade(&state) { + let mut state = state.lock(); + if state.path.as_ref() == path { + if let Ok(metadata) = &metadata { + state.mtime = metadata.modified().unwrap(); + } + } else if state.path.starts_with(path) { + if let Ok(metadata) = fs::metadata(state.path.as_ref()) { + state.mtime = metadata.modified().unwrap(); } } } } } + drop(handles); events.sort_unstable_by(|a, b| a.path.cmp(&b.path)); let mut abs_paths = events.into_iter().map(|e| e.path).peekable(); @@ -1189,7 +1226,7 @@ struct UpdateIgnoreStatusJob { } pub trait WorktreeHandle { - fn file(&self, path: impl AsRef, app: &AppContext) -> FileHandle; + fn file(&self, path: impl AsRef, app: &mut MutableAppContext) -> Task; #[cfg(test)] fn flush_fs_events<'a>( @@ -1199,34 +1236,51 @@ pub trait WorktreeHandle { } impl WorktreeHandle for ModelHandle { - fn file(&self, path: impl AsRef, app: &AppContext) -> FileHandle { - let path = path.as_ref(); + fn file(&self, path: impl AsRef, app: &mut MutableAppContext) -> Task { + let path = Arc::from(path.as_ref()); + let handle = self.clone(); let tree = self.read(app); - let mut handles = tree.handles.lock(); - let state = if let Some(state) = handles.get(path).and_then(Weak::upgrade) { - state - } else { - let handle_state = if let Some(entry) = tree.entry_for_path(path) { - FileHandleState { - path: entry.path().clone(), - is_deleted: false, - } - } else { - FileHandleState { - path: path.into(), - is_deleted: !tree.path_is_pending(path), - } - }; + let abs_path = tree.absolutize(&path); + app.spawn(|ctx| async move { + let mtime = ctx + .background_executor() + .spawn(async move { + if let Ok(metadata) = fs::metadata(&abs_path) { + metadata.modified().unwrap() + } else { + UNIX_EPOCH + } + }) + .await; + let state = handle.read_with(&ctx, |tree, _| { + let mut handles = tree.handles.lock(); + if let Some(state) = handles.get(&path).and_then(Weak::upgrade) { + state + } else { + let handle_state = if let Some(entry) = tree.entry_for_path(&path) { + FileHandleState { + path: entry.path().clone(), + is_deleted: false, + mtime, + } + } else { + FileHandleState { + path: path.clone(), + is_deleted: !tree.path_is_pending(path), + mtime, + } + }; - let state = Arc::new(Mutex::new(handle_state.clone())); - handles.insert(handle_state.path, Arc::downgrade(&state)); - state - }; - - FileHandle { - worktree: self.clone(), - state, - } + let state = Arc::new(Mutex::new(handle_state.clone())); + handles.insert(handle_state.path, Arc::downgrade(&state)); + state + } + }); + FileHandle { + worktree: handle.clone(), + state, + } + }) } // When the worktree's FS event stream sometimes delivers "redundant" events for FS changes that @@ -1484,7 +1538,7 @@ mod tests { let buffer = app.add_model(|ctx| Buffer::new(1, "a line of text.\n".repeat(10 * 1024), ctx)); - let file = app.read(|ctx| tree.file("", ctx)); + let file = app.update(|ctx| tree.file("", ctx)).await; app.update(|ctx| { assert_eq!(file.path().file_name(), None); smol::block_on(file.save(buffer.read(ctx).snapshot(), ctx.as_ref())).unwrap(); @@ -1511,15 +1565,11 @@ mod tests { })); let tree = app.add_model(|ctx| Worktree::new(dir.path(), ctx)); - let (file2, file3, file4, file5, non_existent_file) = app.read(|ctx| { - ( - tree.file("a/file2", ctx), - tree.file("a/file3", ctx), - tree.file("b/c/file4", ctx), - tree.file("b/c/file5", ctx), - tree.file("a/filex", ctx), - ) - }); + let file2 = app.update(|ctx| tree.file("a/file2", ctx)).await; + let file3 = app.update(|ctx| tree.file("a/file3", ctx)).await; + let file4 = app.update(|ctx| tree.file("b/c/file4", ctx)).await; + let file5 = app.update(|ctx| tree.file("b/c/file5", ctx)).await; + let non_existent_file = app.update(|ctx| tree.file("a/file_x", ctx)).await; // The worktree hasn't scanned the directories containing these paths, // so it can't determine that the paths are deleted.