Detailed changes
@@ -1242,6 +1242,7 @@ dependencies = [
"project",
"serde_json",
"settings",
+ "smallvec",
"theme",
"unindent",
"util",
@@ -4137,6 +4138,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "smallvec",
"theme",
"unindent",
"util",
@@ -9,6 +9,7 @@ doctest = false
[dependencies]
anyhow = "1.0"
+smallvec = { version = "1.6", features = ["union"] }
collections = { path = "../collections" }
editor = { path = "../editor" }
language = { path = "../language" }
@@ -18,6 +18,7 @@ use language::{
use project::{DiagnosticSummary, Project, ProjectPath};
use serde_json::json;
use settings::Settings;
+use smallvec::SmallVec;
use std::{
any::{Any, TypeId},
cmp::Ordering,
@@ -479,8 +480,8 @@ impl workspace::Item for ProjectDiagnosticsEditor {
None
}
- fn project_entry_id(&self, _: &AppContext) -> Option<project::ProjectEntryId> {
- None
+ fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
+ self.editor.project_entry_ids(cx)
}
fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
@@ -9,6 +9,7 @@ use language::{Bias, Buffer, File as _, SelectionGoal};
use project::{File, Project, ProjectEntryId, ProjectPath};
use rpc::proto::{self, update_view};
use settings::Settings;
+use smallvec::SmallVec;
use std::{fmt::Write, path::PathBuf, time::Duration};
use text::{Point, Selection};
use util::TryFutureExt;
@@ -293,14 +294,21 @@ impl Item for Editor {
}
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),
path: file.path().clone(),
})
}
- fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> {
- File::from_dyn(self.buffer().read(cx).file(cx)).and_then(|file| file.project_entry_id(cx))
+ fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+ 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>
@@ -12,6 +12,7 @@ use language::{
ToPointUtf16 as _, TransactionId,
};
use settings::Settings;
+use smallvec::SmallVec;
use std::{
cell::{Ref, RefCell},
cmp, fmt, io,
@@ -1126,18 +1127,26 @@ impl MultiBuffer {
.and_then(|(buffer, _)| buffer.read(cx).language())
}
- pub fn file<'a>(&self, cx: &'a AppContext) -> Option<&'a dyn File> {
- self.as_singleton()?.read(cx).file()
+ pub fn files<'a>(&'a self, cx: &'a AppContext) -> SmallVec<[&'a dyn File; 2]> {
+ let buffers = self.buffers.borrow();
+ buffers
+ .values()
+ .filter_map(|buffer| buffer.buffer.read(cx).file())
+ .collect()
}
pub fn title(&self, cx: &AppContext) -> String {
if let Some(title) = self.title.clone() {
- title
- } else if let Some(file) = self.file(cx) {
- file.file_name(cx).to_string_lossy().into()
- } else {
- "untitled".into()
+ return title;
}
+
+ 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)]
@@ -521,12 +521,27 @@ impl TestAppContext {
.downcast_mut::<platform::test::Window>()
.unwrap();
let mut done_tx = test_window
- .last_prompt
- .take()
+ .pending_prompts
+ .borrow_mut()
+ .pop_front()
.expect("prompt was not called");
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"))]
pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> {
self.cx.borrow().leak_detector()
@@ -4,11 +4,12 @@ use crate::{
keymap, Action, ClipboardItem,
};
use anyhow::{anyhow, Result};
+use collections::VecDeque;
use parking_lot::Mutex;
use postage::oneshot;
use std::{
any::Any,
- cell::{Cell, RefCell},
+ cell::RefCell,
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
@@ -36,7 +37,7 @@ pub struct Window {
event_handlers: Vec<Box<dyn FnMut(super::Event)>>,
resize_handlers: Vec<Box<dyn FnMut()>>,
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"))]
@@ -188,7 +189,7 @@ impl Window {
close_handlers: Vec::new(),
scale_factor: 1.0,
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> {
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
}
@@ -21,6 +21,7 @@ anyhow = "1.0"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
postage = { version = "0.4.1", features = ["futures-traits"] }
serde = { version = "1", features = ["derive"] }
+smallvec = { version = "1.6", features = ["union"] }
[dev-dependencies]
editor = { path = "../editor", features = ["test-support"] }
@@ -11,6 +11,7 @@ use gpui::{
};
use project::{search::SearchQuery, Project};
use settings::Settings;
+use smallvec::SmallVec;
use std::{
any::{Any, TypeId},
ops::Range,
@@ -18,7 +19,8 @@ use std::{
};
use util::ResultExt as _;
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]);
@@ -234,8 +236,8 @@ impl Item for ProjectSearchView {
None
}
- fn project_entry_id(&self, _: &AppContext) -> Option<project::ProjectEntryId> {
- None
+ fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
+ self.results_editor.project_entry_ids(cx)
}
fn can_save(&self, _: &gpui::AppContext) -> bool {
@@ -9,10 +9,10 @@ use gpui::{
geometry::{rect::RectF, vector::vec2f},
impl_actions, impl_internal_actions,
platform::{CursorStyle, NavigationDirection},
- AppContext, Entity, MutableAppContext, PromptLevel, Quad, RenderContext, Task, View,
- ViewContext, ViewHandle, WeakViewHandle,
+ AppContext, AsyncAppContext, Entity, ModelHandle, MutableAppContext, PromptLevel, Quad,
+ RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
};
-use project::{ProjectEntryId, ProjectPath};
+use project::{Project, ProjectEntryId, ProjectPath};
use serde::Deserialize;
use settings::Settings;
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(|workspace: &mut Workspace, action: &CloseItem, 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| {
pane.split(action.0, cx);
@@ -294,7 +298,7 @@ impl Pane {
) -> Box<dyn ItemHandle> {
let existing_item = pane.update(cx, |pane, cx| {
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();
pane.activate_item(ix, true, focus_item, cx);
return Some(item);
@@ -351,27 +355,13 @@ impl Pane {
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(
&self,
entry_id: ProjectEntryId,
cx: &AppContext,
) -> Option<Box<dyn ItemHandle>> {
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())
} else {
None
@@ -445,12 +435,13 @@ impl Pane {
None
} else {
let item_id_to_close = pane.items[pane.active_item_index].id();
- Some(Self::close_items(
- workspace,
- pane_handle,
- cx,
- move |item_id| item_id == item_id_to_close,
- ))
+ let task = Self::close_items(workspace, pane_handle, cx, move |item_id| {
+ item_id == item_id_to_close
+ });
+ Some(cx.foreground().spawn(async move {
+ task.await?;
+ Ok(())
+ }))
}
}
@@ -465,8 +456,11 @@ impl Pane {
None
} else {
let active_item_id = pane.items[pane.active_item_index].id();
- Some(Self::close_items(workspace, pane_handle, cx, move |id| {
- id != active_item_id
+ let task =
+ 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>,
item_id_to_close: usize,
cx: &mut ViewContext<Workspace>,
- ) -> Task<Result<()>> {
+ ) -> Task<Result<bool>> {
Self::close_items(workspace, pane, cx, move |view_id| {
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(
workspace: &mut Workspace,
pane: ViewHandle<Pane>,
cx: &mut ViewContext<Workspace>,
should_close: impl 'static + Fn(usize) -> bool,
- ) -> Task<Result<()>> {
- 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?";
-
+ ) -> Task<Result<bool>> {
let project = workspace.project().clone();
+
+ // Find which items to close.
+ let mut items_to_close = Vec::new();
+ for item in &pane.read(cx).items {
+ if should_close(item.id()) {
+ items_to_close.push(item.boxed_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| {
- let project_entry_id = item.project_entry_id(cx);
- project_entry_id.is_none()
- || workspace
- .items(cx)
- .filter(|item| item.project_entry_id(cx) == project_entry_id)
- .count()
- == 1
+ 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 {
- if cx.read(|cx| item.has_conflict(cx) && item.can_save(cx)) {
- let mut answer = pane.update(&mut cx, |pane, cx| {
- pane.activate_item(item_to_close_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.clone(), cx)).await?;
- }
- Some(1) => {
- cx.update(|cx| item.reload(project.clone(), cx)).await?;
- }
- _ => break,
- }
- } else if cx.read(|cx| item.is_dirty(cx)) {
- if cx.read(|cx| item.can_save(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) => {
- 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,
- }
+ let item_ix = if let Some(ix) = item_ix {
+ ix
+ } else {
+ continue;
+ };
+
+ // An item should be saved if either it has *no* project entries, or if it
+ // has project entries that don't exist anywhere else in the workspace.
+ let mut should_save = project_entry_ids.is_empty();
+ let mut project_entry_ids_to_save = project_entry_ids;
+ workspace.read_with(&cx, |workspace, cx| {
+ for item in workspace.items(cx) {
+ if !items_to_close
+ .iter()
+ .any(|item_to_close| item_to_close.id() == item.id())
+ {
+ let project_entry_ids = item.project_entry_ids(cx);
+ project_entry_ids_to_save.retain(|id| !project_entry_ids.contains(&id));
}
}
+ });
+ 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| {
@@ -629,10 +565,88 @@ impl Pane {
}
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>) {
if let Some(active_item) = self.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(()))
- }
- }
-}
@@ -33,6 +33,7 @@ use postage::prelude::Stream;
use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree};
use settings::Settings;
use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem, ToggleSidebarItemFocus};
+use smallvec::SmallVec;
use status_bar::StatusBar;
pub use status_bar::StatusItemView;
use std::{
@@ -82,6 +83,7 @@ actions!(
Unfollow,
Save,
SaveAs,
+ SaveAll,
ActivatePreviousPane,
ActivateNextPane,
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::follow_next_collaborator);
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: &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 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 clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
where
@@ -369,7 +372,7 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
pub trait ItemHandle: 'static + fmt::Debug {
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
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 set_nav_history(&self, nav_history: Rc<RefCell<NavHistory>>, cx: &mut MutableAppContext);
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)
}
- fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> {
- self.read(cx).project_entry_id(cx)
+ fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+ self.read(cx).project_entry_ids(cx)
}
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<()>>> {
- let mut tasks = Vec::new();
- for pane in self.panes.clone() {
- tasks.push(Pane::close_all_items(self, pane, cx));
- }
+ let save_all = self.save_all_internal(true, cx);
Some(cx.spawn(|this, mut cx| async move {
- for task in tasks {
- task.await?;
- }
- this.update(&mut cx, |this, cx| {
- if this
- .panes
- .iter()
- .all(|pane| pane.read(cx).items().next().is_none())
- {
+ if save_all.await? {
+ this.update(&mut cx, |_, cx| {
let window_id = cx.window_id();
cx.remove_window(window_id);
- }
- });
+ });
+ }
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(
&mut self,
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);
}
+
+#[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(()))
+ }
+ }
+}
@@ -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),
+ }],
+ },
]
}