Start work on handling multibuffers properly when closing unsaved buffers

This commit is contained in:
Max Brunsfeld 2022-05-22 16:48:33 -07:00
parent 21206800bc
commit fbd589b589
12 changed files with 581 additions and 421 deletions

2
Cargo.lock generated
View file

@ -1242,6 +1242,7 @@ dependencies = [
"project", "project",
"serde_json", "serde_json",
"settings", "settings",
"smallvec",
"theme", "theme",
"unindent", "unindent",
"util", "util",
@ -4137,6 +4138,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"settings", "settings",
"smallvec",
"theme", "theme",
"unindent", "unindent",
"util", "util",

View file

@ -9,6 +9,7 @@ doctest = false
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
smallvec = { version = "1.6", features = ["union"] }
collections = { path = "../collections" } collections = { path = "../collections" }
editor = { path = "../editor" } editor = { path = "../editor" }
language = { path = "../language" } language = { path = "../language" }

View file

@ -18,6 +18,7 @@ use language::{
use project::{DiagnosticSummary, Project, ProjectPath}; use project::{DiagnosticSummary, Project, ProjectPath};
use serde_json::json; use serde_json::json;
use settings::Settings; use settings::Settings;
use smallvec::SmallVec;
use std::{ use std::{
any::{Any, TypeId}, any::{Any, TypeId},
cmp::Ordering, cmp::Ordering,
@ -479,8 +480,8 @@ impl workspace::Item for ProjectDiagnosticsEditor {
None None
} }
fn project_entry_id(&self, _: &AppContext) -> Option<project::ProjectEntryId> { fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
None self.editor.project_entry_ids(cx)
} }
fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool { fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {

View file

@ -9,6 +9,7 @@ use language::{Bias, Buffer, File as _, SelectionGoal};
use project::{File, Project, ProjectEntryId, ProjectPath}; use project::{File, Project, ProjectEntryId, ProjectPath};
use rpc::proto::{self, update_view}; use rpc::proto::{self, update_view};
use settings::Settings; use settings::Settings;
use smallvec::SmallVec;
use std::{fmt::Write, path::PathBuf, time::Duration}; use std::{fmt::Write, path::PathBuf, time::Duration};
use text::{Point, Selection}; use text::{Point, Selection};
use util::TryFutureExt; use util::TryFutureExt;
@ -293,14 +294,21 @@ impl Item for Editor {
} }
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> { fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
File::from_dyn(self.buffer().read(cx).file(cx)).map(|file| ProjectPath { let buffer = self.buffer.read(cx).as_singleton()?;
let file = buffer.read(cx).file();
File::from_dyn(file).map(|file| ProjectPath {
worktree_id: file.worktree_id(cx), worktree_id: file.worktree_id(cx),
path: file.path().clone(), path: file.path().clone(),
}) })
} }
fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> { fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
File::from_dyn(self.buffer().read(cx).file(cx)).and_then(|file| file.project_entry_id(cx)) self.buffer
.read(cx)
.files(cx)
.into_iter()
.filter_map(|file| File::from_dyn(Some(file))?.project_entry_id(cx))
.collect()
} }
fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>

View file

@ -12,6 +12,7 @@ use language::{
ToPointUtf16 as _, TransactionId, ToPointUtf16 as _, TransactionId,
}; };
use settings::Settings; use settings::Settings;
use smallvec::SmallVec;
use std::{ use std::{
cell::{Ref, RefCell}, cell::{Ref, RefCell},
cmp, fmt, io, cmp, fmt, io,
@ -1126,18 +1127,26 @@ impl MultiBuffer {
.and_then(|(buffer, _)| buffer.read(cx).language()) .and_then(|(buffer, _)| buffer.read(cx).language())
} }
pub fn file<'a>(&self, cx: &'a AppContext) -> Option<&'a dyn File> { pub fn files<'a>(&'a self, cx: &'a AppContext) -> SmallVec<[&'a dyn File; 2]> {
self.as_singleton()?.read(cx).file() let buffers = self.buffers.borrow();
buffers
.values()
.filter_map(|buffer| buffer.buffer.read(cx).file())
.collect()
} }
pub fn title(&self, cx: &AppContext) -> String { pub fn title(&self, cx: &AppContext) -> String {
if let Some(title) = self.title.clone() { if let Some(title) = self.title.clone() {
title return title;
} else if let Some(file) = self.file(cx) {
file.file_name(cx).to_string_lossy().into()
} else {
"untitled".into()
} }
if let Some(buffer) = self.as_singleton() {
if let Some(file) = buffer.read(cx).file() {
return file.file_name(cx).to_string_lossy().into();
}
}
"untitled".into()
} }
#[cfg(test)] #[cfg(test)]

View file

@ -521,12 +521,27 @@ impl TestAppContext {
.downcast_mut::<platform::test::Window>() .downcast_mut::<platform::test::Window>()
.unwrap(); .unwrap();
let mut done_tx = test_window let mut done_tx = test_window
.last_prompt .pending_prompts
.take() .borrow_mut()
.pop_front()
.expect("prompt was not called"); .expect("prompt was not called");
let _ = done_tx.try_send(answer); let _ = done_tx.try_send(answer);
} }
pub fn has_pending_prompt(&self, window_id: usize) -> bool {
let mut state = self.cx.borrow_mut();
let (_, window) = state
.presenters_and_platform_windows
.get_mut(&window_id)
.unwrap();
let test_window = window
.as_any_mut()
.downcast_mut::<platform::test::Window>()
.unwrap();
let prompts = test_window.pending_prompts.borrow_mut();
!prompts.is_empty()
}
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> { pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> {
self.cx.borrow().leak_detector() self.cx.borrow().leak_detector()

View file

@ -4,11 +4,12 @@ use crate::{
keymap, Action, ClipboardItem, keymap, Action, ClipboardItem,
}; };
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use collections::VecDeque;
use parking_lot::Mutex; use parking_lot::Mutex;
use postage::oneshot; use postage::oneshot;
use std::{ use std::{
any::Any, any::Any,
cell::{Cell, RefCell}, cell::RefCell,
path::{Path, PathBuf}, path::{Path, PathBuf},
rc::Rc, rc::Rc,
sync::Arc, sync::Arc,
@ -36,7 +37,7 @@ pub struct Window {
event_handlers: Vec<Box<dyn FnMut(super::Event)>>, event_handlers: Vec<Box<dyn FnMut(super::Event)>>,
resize_handlers: Vec<Box<dyn FnMut()>>, resize_handlers: Vec<Box<dyn FnMut()>>,
close_handlers: Vec<Box<dyn FnOnce()>>, close_handlers: Vec<Box<dyn FnOnce()>>,
pub(crate) last_prompt: Cell<Option<oneshot::Sender<usize>>>, pub(crate) pending_prompts: RefCell<VecDeque<oneshot::Sender<usize>>>,
} }
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
@ -188,7 +189,7 @@ impl Window {
close_handlers: Vec::new(), close_handlers: Vec::new(),
scale_factor: 1.0, scale_factor: 1.0,
current_scene: None, current_scene: None,
last_prompt: Default::default(), pending_prompts: Default::default(),
} }
} }
} }
@ -242,7 +243,7 @@ impl super::Window for Window {
fn prompt(&self, _: crate::PromptLevel, _: &str, _: &[&str]) -> oneshot::Receiver<usize> { fn prompt(&self, _: crate::PromptLevel, _: &str, _: &[&str]) -> oneshot::Receiver<usize> {
let (done_tx, done_rx) = oneshot::channel(); let (done_tx, done_rx) = oneshot::channel();
self.last_prompt.replace(Some(done_tx)); self.pending_prompts.borrow_mut().push_back(done_tx);
done_rx done_rx
} }

View file

@ -21,6 +21,7 @@ anyhow = "1.0"
log = { version = "0.4.16", features = ["kv_unstable_serde"] } log = { version = "0.4.16", features = ["kv_unstable_serde"] }
postage = { version = "0.4.1", features = ["futures-traits"] } postage = { version = "0.4.1", features = ["futures-traits"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
smallvec = { version = "1.6", features = ["union"] }
[dev-dependencies] [dev-dependencies]
editor = { path = "../editor", features = ["test-support"] } editor = { path = "../editor", features = ["test-support"] }

View file

@ -11,6 +11,7 @@ use gpui::{
}; };
use project::{search::SearchQuery, Project}; use project::{search::SearchQuery, Project};
use settings::Settings; use settings::Settings;
use smallvec::SmallVec;
use std::{ use std::{
any::{Any, TypeId}, any::{Any, TypeId},
ops::Range, ops::Range,
@ -18,7 +19,8 @@ use std::{
}; };
use util::ResultExt as _; use util::ResultExt as _;
use workspace::{ use workspace::{
menu::Confirm, Item, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, menu::Confirm, Item, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView,
Workspace,
}; };
actions!(project_search, [Deploy, SearchInNew, ToggleFocus]); actions!(project_search, [Deploy, SearchInNew, ToggleFocus]);
@ -234,8 +236,8 @@ impl Item for ProjectSearchView {
None None
} }
fn project_entry_id(&self, _: &AppContext) -> Option<project::ProjectEntryId> { fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
None self.results_editor.project_entry_ids(cx)
} }
fn can_save(&self, _: &gpui::AppContext) -> bool { fn can_save(&self, _: &gpui::AppContext) -> bool {

View file

@ -9,10 +9,10 @@ use gpui::{
geometry::{rect::RectF, vector::vec2f}, geometry::{rect::RectF, vector::vec2f},
impl_actions, impl_internal_actions, impl_actions, impl_internal_actions,
platform::{CursorStyle, NavigationDirection}, platform::{CursorStyle, NavigationDirection},
AppContext, Entity, MutableAppContext, PromptLevel, Quad, RenderContext, Task, View, AppContext, AsyncAppContext, Entity, ModelHandle, MutableAppContext, PromptLevel, Quad,
ViewContext, ViewHandle, WeakViewHandle, RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
}; };
use project::{ProjectEntryId, ProjectPath}; use project::{Project, ProjectEntryId, ProjectPath};
use serde::Deserialize; use serde::Deserialize;
use settings::Settings; use settings::Settings;
use std::{any::Any, cell::RefCell, cmp, mem, path::Path, rc::Rc}; use std::{any::Any, cell::RefCell, cmp, mem, path::Path, rc::Rc};
@ -71,7 +71,11 @@ pub fn init(cx: &mut MutableAppContext) {
cx.add_async_action(Pane::close_inactive_items); cx.add_async_action(Pane::close_inactive_items);
cx.add_async_action(|workspace: &mut Workspace, action: &CloseItem, cx| { cx.add_async_action(|workspace: &mut Workspace, action: &CloseItem, cx| {
let pane = action.pane.upgrade(cx)?; let pane = action.pane.upgrade(cx)?;
Some(Pane::close_item(workspace, pane, action.item_id, cx)) let task = Pane::close_item(workspace, pane, action.item_id, cx);
Some(cx.foreground().spawn(async move {
task.await?;
Ok(())
}))
}); });
cx.add_action(|pane: &mut Pane, action: &Split, cx| { cx.add_action(|pane: &mut Pane, action: &Split, cx| {
pane.split(action.0, cx); pane.split(action.0, cx);
@ -294,7 +298,7 @@ impl Pane {
) -> Box<dyn ItemHandle> { ) -> Box<dyn ItemHandle> {
let existing_item = pane.update(cx, |pane, cx| { let existing_item = pane.update(cx, |pane, cx| {
for (ix, item) in pane.items.iter().enumerate() { for (ix, item) in pane.items.iter().enumerate() {
if item.project_entry_id(cx) == Some(project_entry_id) { if item.project_entry_ids(cx).as_slice() == &[project_entry_id] {
let item = item.boxed_clone(); let item = item.boxed_clone();
pane.activate_item(ix, true, focus_item, cx); pane.activate_item(ix, true, focus_item, cx);
return Some(item); return Some(item);
@ -351,27 +355,13 @@ impl Pane {
self.items.get(self.active_item_index).cloned() self.items.get(self.active_item_index).cloned()
} }
pub fn project_entry_id_for_item(
&self,
item: &dyn ItemHandle,
cx: &AppContext,
) -> Option<ProjectEntryId> {
self.items.iter().find_map(|existing| {
if existing.id() == item.id() {
existing.project_entry_id(cx)
} else {
None
}
})
}
pub fn item_for_entry( pub fn item_for_entry(
&self, &self,
entry_id: ProjectEntryId, entry_id: ProjectEntryId,
cx: &AppContext, cx: &AppContext,
) -> Option<Box<dyn ItemHandle>> { ) -> Option<Box<dyn ItemHandle>> {
self.items.iter().find_map(|item| { self.items.iter().find_map(|item| {
if item.project_entry_id(cx) == Some(entry_id) { if item.project_entry_ids(cx).as_slice() == &[entry_id] {
Some(item.boxed_clone()) Some(item.boxed_clone())
} else { } else {
None None
@ -445,12 +435,13 @@ impl Pane {
None None
} else { } else {
let item_id_to_close = pane.items[pane.active_item_index].id(); let item_id_to_close = pane.items[pane.active_item_index].id();
Some(Self::close_items( let task = Self::close_items(workspace, pane_handle, cx, move |item_id| {
workspace, item_id == item_id_to_close
pane_handle, });
cx, Some(cx.foreground().spawn(async move {
move |item_id| item_id == item_id_to_close, task.await?;
)) Ok(())
}))
} }
} }
@ -465,8 +456,11 @@ impl Pane {
None None
} else { } else {
let active_item_id = pane.items[pane.active_item_index].id(); let active_item_id = pane.items[pane.active_item_index].id();
Some(Self::close_items(workspace, pane_handle, cx, move |id| { let task =
id != active_item_id Self::close_items(workspace, pane_handle, cx, move |id| id != active_item_id);
Some(cx.foreground().spawn(async move {
task.await?;
Ok(())
})) }))
} }
} }
@ -476,125 +470,67 @@ impl Pane {
pane: ViewHandle<Pane>, pane: ViewHandle<Pane>,
item_id_to_close: usize, item_id_to_close: usize,
cx: &mut ViewContext<Workspace>, cx: &mut ViewContext<Workspace>,
) -> Task<Result<()>> { ) -> Task<Result<bool>> {
Self::close_items(workspace, pane, cx, move |view_id| { Self::close_items(workspace, pane, cx, move |view_id| {
view_id == item_id_to_close view_id == item_id_to_close
}) })
} }
pub fn close_all_items(
workspace: &mut Workspace,
pane: ViewHandle<Pane>,
cx: &mut ViewContext<Workspace>,
) -> Task<Result<()>> {
Self::close_items(workspace, pane, cx, |_| true)
}
pub fn close_items( pub fn close_items(
workspace: &mut Workspace, workspace: &mut Workspace,
pane: ViewHandle<Pane>, pane: ViewHandle<Pane>,
cx: &mut ViewContext<Workspace>, cx: &mut ViewContext<Workspace>,
should_close: impl 'static + Fn(usize) -> bool, should_close: impl 'static + Fn(usize) -> bool,
) -> Task<Result<()>> { ) -> Task<Result<bool>> {
const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?";
const DIRTY_MESSAGE: &'static str =
"This file contains unsaved edits. Do you want to save it?";
let project = workspace.project().clone(); let project = workspace.project().clone();
cx.spawn(|workspace, mut cx| async move {
while let Some(item_to_close_ix) = pane.read_with(&cx, |pane, _| {
pane.items.iter().position(|item| should_close(item.id()))
}) {
let item =
pane.read_with(&cx, |pane, _| pane.items[item_to_close_ix].boxed_clone());
let is_last_item_for_entry = workspace.read_with(&cx, |workspace, cx| { // Find which items to close.
let project_entry_id = item.project_entry_id(cx); let mut items_to_close = Vec::new();
project_entry_id.is_none() for item in &pane.read(cx).items {
|| workspace if should_close(item.id()) {
.items(cx) items_to_close.push(item.boxed_clone());
.filter(|item| item.project_entry_id(cx) == project_entry_id) }
.count() }
== 1
cx.spawn(|workspace, mut cx| async move {
for item in items_to_close.clone() {
let (item_ix, project_entry_ids) = pane.read_with(&cx, |pane, cx| {
(
pane.index_for_item(item.as_ref()),
item.project_entry_ids(cx),
)
}); });
if is_last_item_for_entry { let item_ix = if let Some(ix) = item_ix {
if cx.read(|cx| item.has_conflict(cx) && item.can_save(cx)) { ix
let mut answer = pane.update(&mut cx, |pane, cx| { } else {
pane.activate_item(item_to_close_ix, true, true, cx); continue;
cx.prompt( };
PromptLevel::Warning,
CONFLICT_MESSAGE,
&["Overwrite", "Discard", "Cancel"],
)
});
match answer.next().await { // An item should be saved if either it has *no* project entries, or if it
Some(0) => { // has project entries that don't exist anywhere else in the workspace.
cx.update(|cx| item.save(project.clone(), cx)).await?; let mut should_save = project_entry_ids.is_empty();
} let mut project_entry_ids_to_save = project_entry_ids;
Some(1) => { workspace.read_with(&cx, |workspace, cx| {
cx.update(|cx| item.reload(project.clone(), cx)).await?; for item in workspace.items(cx) {
} if !items_to_close
_ => break, .iter()
} .any(|item_to_close| item_to_close.id() == item.id())
} else if cx.read(|cx| item.is_dirty(cx)) { {
if cx.read(|cx| item.can_save(cx)) { let project_entry_ids = item.project_entry_ids(cx);
let mut answer = pane.update(&mut cx, |pane, cx| { project_entry_ids_to_save.retain(|id| !project_entry_ids.contains(&id));
pane.activate_item(item_to_close_ix, true, true, cx);
cx.prompt(
PromptLevel::Warning,
DIRTY_MESSAGE,
&["Save", "Don't Save", "Cancel"],
)
});
match answer.next().await {
Some(0) => {
cx.update(|cx| item.save(project.clone(), cx)).await?;
}
Some(1) => {}
_ => break,
}
} else if cx.read(|cx| item.can_save_as(cx)) {
let mut answer = pane.update(&mut cx, |pane, cx| {
pane.activate_item(item_to_close_ix, true, true, cx);
cx.prompt(
PromptLevel::Warning,
DIRTY_MESSAGE,
&["Save", "Don't Save", "Cancel"],
)
});
match answer.next().await {
Some(0) => {
let start_abs_path = project
.read_with(&cx, |project, cx| {
let worktree = project.visible_worktrees(cx).next()?;
Some(
worktree
.read(cx)
.as_local()?
.abs_path()
.to_path_buf(),
)
})
.unwrap_or(Path::new("").into());
let mut abs_path =
cx.update(|cx| cx.prompt_for_new_path(&start_abs_path));
if let Some(abs_path) = abs_path.next().await.flatten() {
cx.update(|cx| item.save_as(project.clone(), abs_path, cx))
.await?;
} else {
break;
}
}
Some(1) => {}
_ => break,
}
} }
} }
});
if !project_entry_ids_to_save.is_empty() {
should_save = true;
}
if should_save
&& !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx)
.await?
{
break;
} }
pane.update(&mut cx, |pane, cx| { pane.update(&mut cx, |pane, cx| {
@ -629,10 +565,88 @@ impl Pane {
} }
pane.update(&mut cx, |_, cx| cx.notify()); pane.update(&mut cx, |_, cx| cx.notify());
Ok(()) Ok(true)
}) })
} }
pub async fn save_item(
project: ModelHandle<Project>,
pane: &ViewHandle<Pane>,
item_ix: usize,
item: &Box<dyn ItemHandle>,
should_prompt_for_save: bool,
cx: &mut AsyncAppContext,
) -> Result<bool> {
const CONFLICT_MESSAGE: &'static str =
"This file has changed on disk since you started editing it. Do you want to overwrite it?";
const DIRTY_MESSAGE: &'static str =
"This file contains unsaved edits. Do you want to save it?";
let (has_conflict, is_dirty, can_save, can_save_as) = cx.read(|cx| {
(
item.has_conflict(cx),
item.is_dirty(cx),
item.can_save(cx),
item.can_save_as(cx),
)
});
if has_conflict && can_save {
let mut answer = pane.update(cx, |pane, cx| {
pane.activate_item(item_ix, true, true, cx);
cx.prompt(
PromptLevel::Warning,
CONFLICT_MESSAGE,
&["Overwrite", "Discard", "Cancel"],
)
});
match answer.next().await {
Some(0) => cx.update(|cx| item.save(project, cx)).await?,
Some(1) => cx.update(|cx| item.reload(project, cx)).await?,
_ => return Ok(false),
}
} else if is_dirty && (can_save || can_save_as) {
let should_save = if should_prompt_for_save {
let mut answer = pane.update(cx, |pane, cx| {
pane.activate_item(item_ix, true, true, cx);
cx.prompt(
PromptLevel::Warning,
DIRTY_MESSAGE,
&["Save", "Don't Save", "Cancel"],
)
});
match answer.next().await {
Some(0) => true,
Some(1) => false,
_ => return Ok(false),
}
} else {
true
};
if should_save {
if can_save {
cx.update(|cx| item.save(project, cx)).await?;
} else if can_save_as {
let start_abs_path = project
.read_with(cx, |project, cx| {
let worktree = project.visible_worktrees(cx).next()?;
Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
})
.unwrap_or(Path::new("").into());
let mut abs_path = cx.update(|cx| cx.prompt_for_new_path(&start_abs_path));
if let Some(abs_path) = abs_path.next().await.flatten() {
cx.update(|cx| item.save_as(project, abs_path, cx)).await?;
} else {
return Ok(false);
}
}
}
}
Ok(true)
}
pub fn focus_active_item(&mut self, cx: &mut ViewContext<Self>) { pub fn focus_active_item(&mut self, cx: &mut ViewContext<Self>) {
if let Some(active_item) = self.active_item() { if let Some(active_item) = self.active_item() {
cx.focus(active_item); cx.focus(active_item);
@ -924,253 +938,3 @@ impl NavHistory {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::AppState;
use gpui::{ModelHandle, TestAppContext, ViewContext};
use project::Project;
use std::sync::atomic::AtomicUsize;
#[gpui::test]
async fn test_close_items(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
let app_state = cx.update(AppState::test);
let project = Project::test(app_state.fs.clone(), None, cx).await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
let item1 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item
});
let item2 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item.has_conflict = true;
item
});
let item3 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item.has_conflict = true;
item
});
let item4 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item.can_save = false;
item
});
let pane = workspace.update(cx, |workspace, cx| {
workspace.add_item(Box::new(item1.clone()), cx);
workspace.add_item(Box::new(item2.clone()), cx);
workspace.add_item(Box::new(item3.clone()), cx);
workspace.add_item(Box::new(item4.clone()), cx);
workspace.active_pane().clone()
});
let close_items = workspace.update(cx, |workspace, cx| {
pane.update(cx, |pane, cx| {
pane.activate_item(1, true, true, cx);
assert_eq!(pane.active_item().unwrap().id(), item2.id());
});
let item1_id = item1.id();
let item3_id = item3.id();
let item4_id = item4.id();
Pane::close_items(workspace, pane.clone(), cx, move |id| {
[item1_id, item3_id, item4_id].contains(&id)
})
});
cx.foreground().run_until_parked();
pane.read_with(cx, |pane, _| {
assert_eq!(pane.items.len(), 4);
assert_eq!(pane.active_item().unwrap().id(), item1.id());
});
cx.simulate_prompt_answer(window_id, 0);
cx.foreground().run_until_parked();
pane.read_with(cx, |pane, cx| {
assert_eq!(item1.read(cx).save_count, 1);
assert_eq!(item1.read(cx).save_as_count, 0);
assert_eq!(item1.read(cx).reload_count, 0);
assert_eq!(pane.items.len(), 3);
assert_eq!(pane.active_item().unwrap().id(), item3.id());
});
cx.simulate_prompt_answer(window_id, 1);
cx.foreground().run_until_parked();
pane.read_with(cx, |pane, cx| {
assert_eq!(item3.read(cx).save_count, 0);
assert_eq!(item3.read(cx).save_as_count, 0);
assert_eq!(item3.read(cx).reload_count, 1);
assert_eq!(pane.items.len(), 2);
assert_eq!(pane.active_item().unwrap().id(), item4.id());
});
cx.simulate_prompt_answer(window_id, 0);
cx.foreground().run_until_parked();
cx.simulate_new_path_selection(|_| Some(Default::default()));
close_items.await.unwrap();
pane.read_with(cx, |pane, cx| {
assert_eq!(item4.read(cx).save_count, 0);
assert_eq!(item4.read(cx).save_as_count, 1);
assert_eq!(item4.read(cx).reload_count, 0);
assert_eq!(pane.items.len(), 1);
assert_eq!(pane.active_item().unwrap().id(), item2.id());
});
}
#[gpui::test]
async fn test_prompting_only_on_last_item_for_entry(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
let app_state = cx.update(AppState::test);
let project = Project::test(app_state.fs.clone(), [], 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.is_dirty = true;
item.project_entry_id = Some(ProjectEntryId::new(&AtomicUsize::new(1)));
item
});
let (left_pane, right_pane) = workspace.update(cx, |workspace, cx| {
workspace.add_item(Box::new(item.clone()), cx);
let left_pane = workspace.active_pane().clone();
let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx);
(left_pane, right_pane)
});
workspace
.update(cx, |workspace, cx| {
let item = right_pane.read(cx).active_item().unwrap();
Pane::close_item(workspace, right_pane.clone(), item.id(), cx)
})
.await
.unwrap();
workspace.read_with(cx, |workspace, _| {
assert_eq!(workspace.panes(), [left_pane.clone()]);
});
let close_item = workspace.update(cx, |workspace, cx| {
let item = left_pane.read(cx).active_item().unwrap();
Pane::close_item(workspace, left_pane.clone(), item.id(), cx)
});
cx.foreground().run_until_parked();
cx.simulate_prompt_answer(window_id, 0);
close_item.await.unwrap();
left_pane.read_with(cx, |pane, _| {
assert_eq!(pane.items.len(), 0);
});
}
#[derive(Clone)]
struct TestItem {
save_count: usize,
save_as_count: usize,
reload_count: usize,
is_dirty: bool,
has_conflict: bool,
can_save: bool,
project_entry_id: Option<ProjectEntryId>,
}
impl TestItem {
fn new() -> Self {
Self {
save_count: 0,
save_as_count: 0,
reload_count: 0,
is_dirty: false,
has_conflict: false,
can_save: true,
project_entry_id: None,
}
}
}
impl Entity for TestItem {
type Event = ();
}
impl View for TestItem {
fn ui_name() -> &'static str {
"TestItem"
}
fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
Empty::new().boxed()
}
}
impl Item for TestItem {
fn tab_content(&self, _: &theme::Tab, _: &AppContext) -> ElementBox {
Empty::new().boxed()
}
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
None
}
fn project_entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
self.project_entry_id
}
fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>) {}
fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
where
Self: Sized,
{
Some(self.clone())
}
fn is_dirty(&self, _: &AppContext) -> bool {
self.is_dirty
}
fn has_conflict(&self, _: &AppContext) -> bool {
self.has_conflict
}
fn can_save(&self, _: &AppContext) -> bool {
self.can_save
}
fn save(
&mut self,
_: ModelHandle<Project>,
_: &mut ViewContext<Self>,
) -> Task<anyhow::Result<()>> {
self.save_count += 1;
Task::ready(Ok(()))
}
fn can_save_as(&self, _: &AppContext) -> bool {
true
}
fn save_as(
&mut self,
_: ModelHandle<Project>,
_: std::path::PathBuf,
_: &mut ViewContext<Self>,
) -> Task<anyhow::Result<()>> {
self.save_as_count += 1;
Task::ready(Ok(()))
}
fn reload(
&mut self,
_: ModelHandle<Project>,
_: &mut ViewContext<Self>,
) -> Task<anyhow::Result<()>> {
self.reload_count += 1;
Task::ready(Ok(()))
}
}
}

View file

@ -33,6 +33,7 @@ use postage::prelude::Stream;
use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree}; use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree};
use settings::Settings; use settings::Settings;
use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem, ToggleSidebarItemFocus}; use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem, ToggleSidebarItemFocus};
use smallvec::SmallVec;
use status_bar::StatusBar; use status_bar::StatusBar;
pub use status_bar::StatusItemView; pub use status_bar::StatusItemView;
use std::{ use std::{
@ -82,6 +83,7 @@ actions!(
Unfollow, Unfollow,
Save, Save,
SaveAs, SaveAs,
SaveAll,
ActivatePreviousPane, ActivatePreviousPane,
ActivateNextPane, ActivateNextPane,
FollowNextCollaborator, FollowNextCollaborator,
@ -144,6 +146,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
cx.add_async_action(Workspace::toggle_follow); cx.add_async_action(Workspace::toggle_follow);
cx.add_async_action(Workspace::follow_next_collaborator); cx.add_async_action(Workspace::follow_next_collaborator);
cx.add_async_action(Workspace::close); cx.add_async_action(Workspace::close);
cx.add_async_action(Workspace::save_all);
cx.add_action(Workspace::add_folder_to_project); cx.add_action(Workspace::add_folder_to_project);
cx.add_action( cx.add_action(
|workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| { |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
@ -219,7 +222,7 @@ pub trait Item: View {
} }
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox; fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>; fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>; fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>); fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>);
fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self> fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
where where
@ -369,7 +372,7 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
pub trait ItemHandle: 'static + fmt::Debug { pub trait ItemHandle: 'static + fmt::Debug {
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox; fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>; fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>; fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
fn boxed_clone(&self) -> Box<dyn ItemHandle>; fn boxed_clone(&self) -> Box<dyn ItemHandle>;
fn set_nav_history(&self, nav_history: Rc<RefCell<NavHistory>>, cx: &mut MutableAppContext); 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 clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemHandle>>;
@ -430,8 +433,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
self.read(cx).project_path(cx) self.read(cx).project_path(cx)
} }
fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> { fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
self.read(cx).project_entry_id(cx) self.read(cx).project_entry_ids(cx)
} }
fn boxed_clone(&self) -> Box<dyn ItemHandle> { fn boxed_clone(&self) -> Box<dyn ItemHandle> {
@ -884,28 +887,76 @@ impl Workspace {
} }
fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> { fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
let mut tasks = Vec::new(); let save_all = self.save_all_internal(true, cx);
for pane in self.panes.clone() {
tasks.push(Pane::close_all_items(self, pane, cx));
}
Some(cx.spawn(|this, mut cx| async move { Some(cx.spawn(|this, mut cx| async move {
for task in tasks { if save_all.await? {
task.await?; this.update(&mut cx, |_, cx| {
}
this.update(&mut cx, |this, cx| {
if this
.panes
.iter()
.all(|pane| pane.read(cx).items().next().is_none())
{
let window_id = cx.window_id(); let window_id = cx.window_id();
cx.remove_window(window_id); cx.remove_window(window_id);
} });
}); }
Ok(()) Ok(())
})) }))
} }
fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
let save_all = self.save_all_internal(false, cx);
Some(cx.foreground().spawn(async move {
save_all.await?;
Ok(())
}))
}
fn save_all_internal(
&mut self,
should_prompt_to_save: bool,
cx: &mut ViewContext<Self>,
) -> Task<Result<bool>> {
let dirty_items = self
.panes
.iter()
.flat_map(|pane| {
pane.read(cx).items().filter_map(|item| {
if item.is_dirty(cx) {
Some((pane.clone(), item.boxed_clone()))
} else {
None
}
})
})
.collect::<Vec<_>>();
let project = self.project.clone();
cx.spawn_weak(|_, mut cx| async move {
let mut saved_project_entry_ids = HashSet::default();
for (pane, item) in dirty_items {
let project_entry_ids = cx.read(|cx| item.project_entry_ids(cx));
if project_entry_ids
.into_iter()
.any(|entry_id| saved_project_entry_ids.insert(entry_id))
{
if let Some(ix) =
pane.read_with(&cx, |pane, _| pane.index_for_item(item.as_ref()))
{
if !Pane::save_item(
project.clone(),
&pane,
ix,
&item,
should_prompt_to_save,
&mut cx,
)
.await?
{
return Ok(false);
}
}
}
}
Ok(true)
})
}
pub fn open_paths( pub fn open_paths(
&mut self, &mut self,
mut abs_paths: Vec<PathBuf>, mut abs_paths: Vec<PathBuf>,
@ -2356,3 +2407,301 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
}); });
cx.dispatch_action(window_id, vec![workspace.id()], &NewFile); cx.dispatch_action(window_id, vec![workspace.id()], &NewFile);
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::AppState;
use gpui::{ModelHandle, TestAppContext, ViewContext};
use project::{FakeFs, Project, ProjectEntryId};
use serde_json::json;
use std::sync::atomic::AtomicUsize;
#[gpui::test]
async fn test_save_all(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
cx.update(|cx| {
let settings = Settings::test(cx);
cx.set_global(settings);
});
let fs = FakeFs::new(cx.background());
fs.insert_tree("/root", json!({ "one": ""})).await;
let project = Project::test(fs, ["root".as_ref()], cx).await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
// When there are no dirty items, there's nothing to do.
let item1 = cx.add_view(window_id, |_| TestItem::new());
workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx));
let save_all = workspace.update(cx, |w, cx| w.save_all_internal(true, cx));
assert_eq!(save_all.await.unwrap(), true);
// When there are dirty untitled items, prompt to save each one. If the user
// cancels any prompt, then abort.
let item2 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item
});
let item3 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item
});
workspace.update(cx, |w, cx| {
w.add_item(Box::new(item1.clone()), cx);
w.add_item(Box::new(item2.clone()), cx);
w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
w.add_item(Box::new(item3.clone()), cx);
});
eprintln!("save_all 2");
let save_all = workspace.update(cx, |w, cx| w.save_all_internal(true, cx));
cx.foreground().run_until_parked();
cx.simulate_prompt_answer(window_id, 2);
cx.foreground().run_until_parked();
assert!(!cx.has_pending_prompt(window_id));
assert_eq!(save_all.await.unwrap(), false);
}
#[gpui::test]
async fn test_close_pane_items(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
let app_state = cx.update(AppState::test);
let project = Project::test(app_state.fs.clone(), None, cx).await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
let item1 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item
});
let item2 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item.has_conflict = true;
item
});
let item3 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item.has_conflict = true;
item
});
let item4 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.is_dirty = true;
item.can_save = false;
item
});
let pane = workspace.update(cx, |workspace, cx| {
workspace.add_item(Box::new(item1.clone()), cx);
workspace.add_item(Box::new(item2.clone()), cx);
workspace.add_item(Box::new(item3.clone()), cx);
workspace.add_item(Box::new(item4.clone()), cx);
workspace.active_pane().clone()
});
let close_items = workspace.update(cx, |workspace, cx| {
pane.update(cx, |pane, cx| {
pane.activate_item(1, true, true, cx);
assert_eq!(pane.active_item().unwrap().id(), item2.id());
});
let item1_id = item1.id();
let item3_id = item3.id();
let item4_id = item4.id();
Pane::close_items(workspace, pane.clone(), cx, move |id| {
[item1_id, item3_id, item4_id].contains(&id)
})
});
cx.foreground().run_until_parked();
pane.read_with(cx, |pane, _| {
assert_eq!(pane.items().count(), 4);
assert_eq!(pane.active_item().unwrap().id(), item1.id());
});
cx.simulate_prompt_answer(window_id, 0);
cx.foreground().run_until_parked();
pane.read_with(cx, |pane, cx| {
assert_eq!(item1.read(cx).save_count, 1);
assert_eq!(item1.read(cx).save_as_count, 0);
assert_eq!(item1.read(cx).reload_count, 0);
assert_eq!(pane.items().count(), 3);
assert_eq!(pane.active_item().unwrap().id(), item3.id());
});
cx.simulate_prompt_answer(window_id, 1);
cx.foreground().run_until_parked();
pane.read_with(cx, |pane, cx| {
assert_eq!(item3.read(cx).save_count, 0);
assert_eq!(item3.read(cx).save_as_count, 0);
assert_eq!(item3.read(cx).reload_count, 1);
assert_eq!(pane.items().count(), 2);
assert_eq!(pane.active_item().unwrap().id(), item4.id());
});
cx.simulate_prompt_answer(window_id, 0);
cx.foreground().run_until_parked();
cx.simulate_new_path_selection(|_| Some(Default::default()));
close_items.await.unwrap();
pane.read_with(cx, |pane, cx| {
assert_eq!(item4.read(cx).save_count, 0);
assert_eq!(item4.read(cx).save_as_count, 1);
assert_eq!(item4.read(cx).reload_count, 0);
assert_eq!(pane.items().count(), 1);
assert_eq!(pane.active_item().unwrap().id(), item2.id());
});
}
#[gpui::test]
async fn test_prompting_only_on_last_item_for_entry(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
let app_state = cx.update(AppState::test);
let project = Project::test(app_state.fs.clone(), [], 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.is_dirty = true;
item.project_entry_id = Some(ProjectEntryId::new(&AtomicUsize::new(1)));
item
});
let (left_pane, right_pane) = workspace.update(cx, |workspace, cx| {
workspace.add_item(Box::new(item.clone()), cx);
let left_pane = workspace.active_pane().clone();
let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx);
(left_pane, right_pane)
});
workspace
.update(cx, |workspace, cx| {
let item = right_pane.read(cx).active_item().unwrap();
Pane::close_item(workspace, right_pane.clone(), item.id(), cx)
})
.await
.unwrap();
workspace.read_with(cx, |workspace, _| {
assert_eq!(workspace.panes(), [left_pane.clone()]);
});
let close_item = workspace.update(cx, |workspace, cx| {
let item = left_pane.read(cx).active_item().unwrap();
Pane::close_item(workspace, left_pane.clone(), item.id(), cx)
});
cx.foreground().run_until_parked();
cx.simulate_prompt_answer(window_id, 0);
close_item.await.unwrap();
left_pane.read_with(cx, |pane, _| {
assert_eq!(pane.items().count(), 0);
});
}
#[derive(Clone)]
struct TestItem {
save_count: usize,
save_as_count: usize,
reload_count: usize,
is_dirty: bool,
has_conflict: bool,
can_save: bool,
project_entry_id: Option<ProjectEntryId>,
}
impl TestItem {
fn new() -> Self {
Self {
save_count: 0,
save_as_count: 0,
reload_count: 0,
is_dirty: false,
has_conflict: false,
can_save: true,
project_entry_id: None,
}
}
}
impl Entity for TestItem {
type Event = ();
}
impl View for TestItem {
fn ui_name() -> &'static str {
"TestItem"
}
fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
Empty::new().boxed()
}
}
impl Item for TestItem {
fn tab_content(&self, _: &theme::Tab, _: &AppContext) -> ElementBox {
Empty::new().boxed()
}
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
None
}
fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
self.project_entry_id.into_iter().collect()
}
fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>) {}
fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
where
Self: Sized,
{
Some(self.clone())
}
fn is_dirty(&self, _: &AppContext) -> bool {
self.is_dirty
}
fn has_conflict(&self, _: &AppContext) -> bool {
self.has_conflict
}
fn can_save(&self, _: &AppContext) -> bool {
self.can_save
}
fn save(
&mut self,
_: ModelHandle<Project>,
_: &mut ViewContext<Self>,
) -> Task<anyhow::Result<()>> {
self.save_count += 1;
Task::ready(Ok(()))
}
fn can_save_as(&self, _: &AppContext) -> bool {
true
}
fn save_as(
&mut self,
_: ModelHandle<Project>,
_: std::path::PathBuf,
_: &mut ViewContext<Self>,
) -> Task<anyhow::Result<()>> {
self.save_as_count += 1;
Task::ready(Ok(()))
}
fn reload(
&mut self,
_: ModelHandle<Project>,
_: &mut ViewContext<Self>,
) -> Task<anyhow::Result<()>> {
self.reload_count += 1;
Task::ready(Ok(()))
}
}
}

View file

@ -225,5 +225,12 @@ pub fn menus() -> Vec<Menu<'static>> {
}, },
], ],
}, },
Menu {
name: "Help",
items: vec![MenuItem::Action {
name: "Command Palette",
action: Box::new(command_palette::Toggle),
}],
},
] ]
} }