Detailed changes
@@ -1245,6 +1245,7 @@ dependencies = [
"project",
"serde_json",
"settings",
+ "smallvec",
"theme",
"unindent",
"util",
@@ -4149,6 +4150,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "smallvec",
"theme",
"unindent",
"util",
@@ -14,13 +14,16 @@
"shift-cmd-{": "pane::ActivatePrevItem",
"shift-cmd-}": "pane::ActivateNextItem",
"cmd-w": "pane::CloseActiveItem",
+ "cmd-shift-W": "workspace::CloseWindow",
"alt-cmd-w": "pane::CloseInactiveItems",
"cmd-s": "workspace::Save",
+ "cmd-shift-S": "workspace::SaveAs",
"cmd-=": "zed::IncreaseBufferFontSize",
"cmd--": "zed::DecreaseBufferFontSize",
"cmd-,": "zed::OpenSettings",
"cmd-q": "zed::Quit",
- "cmd-n": "workspace::OpenNew",
+ "cmd-n": "workspace::NewFile",
+ "cmd-shift-N": "workspace::NewWindow",
"cmd-o": "workspace::Open"
}
},
@@ -217,7 +220,8 @@
"cmd-t": "project_symbols::Toggle",
"cmd-p": "file_finder::Toggle",
"cmd-shift-P": "command_palette::Toggle",
- "cmd-shift-M": "diagnostics::Deploy"
+ "cmd-shift-M": "diagnostics::Deploy",
+ "cmd-alt-s": "workspace::SaveAll"
}
},
// Bindings from Sublime Text
@@ -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,12 @@ 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 is_singleton(&self, _: &AppContext) -> bool {
+ false
}
fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
@@ -516,10 +521,6 @@ impl workspace::Item for ProjectDiagnosticsEditor {
self.editor.reload(project, cx)
}
- fn can_save_as(&self, _: &AppContext) -> bool {
- false
- }
-
fn save_as(
&mut self,
_: ModelHandle<Project>,
@@ -216,7 +216,7 @@ pub enum Direction {
}
pub fn init(cx: &mut MutableAppContext) {
- cx.add_action(Editor::open_new);
+ cx.add_action(Editor::new_file);
cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx));
cx.add_action(Editor::select);
cx.add_action(Editor::cancel);
@@ -1002,9 +1002,9 @@ impl Editor {
this
}
- pub fn open_new(
+ pub fn new_file(
workspace: &mut Workspace,
- _: &workspace::OpenNew,
+ _: &workspace::NewFile,
cx: &mut ViewContext<Workspace>,
) {
let project = workspace.project().clone();
@@ -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,25 @@ 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 is_singleton(&self, cx: &AppContext) -> bool {
+ self.buffer.read(cx).is_singleton()
}
fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
@@ -372,10 +384,6 @@ impl Item for Editor {
})
}
- fn can_save_as(&self, cx: &AppContext) -> bool {
- self.buffer().read(cx).is_singleton()
- }
-
fn save_as(
&mut self,
project: ModelHandle<Project>,
@@ -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,12 @@ 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 is_singleton(&self, _: &AppContext) -> bool {
+ false
}
fn can_save(&self, _: &gpui::AppContext) -> bool {
@@ -259,10 +265,6 @@ impl Item for ProjectSearchView {
.update(cx, |editor, cx| editor.save(project, cx))
}
- fn can_save_as(&self, _: &gpui::AppContext) -> bool {
- false
- }
-
fn save_as(
&mut self,
_: ModelHandle<Project>,
@@ -136,6 +136,14 @@ impl Settings {
}
}
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn test_async(cx: &mut gpui::TestAppContext) {
+ cx.update(|cx| {
+ let settings = Self::test(cx);
+ cx.set_global(settings.clone());
+ });
+ }
+
pub fn merge(
&mut self,
data: &SettingsFileContent,
@@ -1,7 +1,7 @@
use super::{ItemHandle, SplitDirection};
use crate::{toolbar::Toolbar, Item, WeakItemHandle, Workspace};
use anyhow::Result;
-use collections::{HashMap, VecDeque};
+use collections::{HashMap, HashSet, VecDeque};
use futures::StreamExt;
use gpui::{
actions,
@@ -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.is_singleton(cx) && 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,7 +470,7 @@ 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
})
@@ -487,108 +481,72 @@ impl Pane {
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();
- 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
- });
- 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"],
- )
- });
+ // Find the 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());
+ }
+ }
- 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,
+ // If a buffer is open both in a singleton editor and in a multibuffer, make sure
+ // to focus the singleton buffer when prompting to save that buffer, as opposed
+ // to focusing the multibuffer, because this gives the user a more clear idea
+ // of what content they would be saving.
+ items_to_close.sort_by_key(|item| !item.is_singleton(cx));
+
+ cx.spawn(|workspace, mut cx| async move {
+ let mut saved_project_entry_ids = HashSet::default();
+ for item in items_to_close.clone() {
+ // Find the item's current index and its set of project entries. Avoid
+ // storing these in advance, in case they have changed since this task
+ // was started.
+ let (item_ix, mut project_entry_ids) = pane.read_with(&cx, |pane, cx| {
+ (pane.index_for_item(&*item), item.project_entry_ids(cx))
+ });
+ let item_ix = if let Some(ix) = item_ix {
+ ix
+ } else {
+ continue;
+ };
+
+ // If an item hasn't yet been associated with a project entry, then always
+ // prompt to save it before closing it. Otherwise, check if the item has
+ // any project entries that are not open anywhere else in the workspace,
+ // AND that the user has not already been prompted to save. If there are
+ // any such project entries, prompt the user to save this item.
+ let should_save = if project_entry_ids.is_empty() {
+ true
+ } else {
+ 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 other_project_entry_ids = item.project_entry_ids(cx);
+ project_entry_ids
+ .retain(|id| !other_project_entry_ids.contains(&id));
}
}
+ });
+ project_entry_ids
+ .iter()
+ .any(|id| saved_project_entry_ids.insert(*id))
+ };
+
+ if should_save {
+ if !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx)
+ .await?
+ {
+ break;
}
}
+ // Remove the item from the pane.
pane.update(&mut cx, |pane, cx| {
if let Some(item_ix) = pane.items.iter().position(|i| i.id() == item.id()) {
if item_ix == pane.active_item_index {
@@ -621,10 +579,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, is_singleton) = cx.read(|cx| {
+ (
+ item.has_conflict(cx),
+ item.is_dirty(cx),
+ item.can_save(cx),
+ item.is_singleton(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 || is_singleton) {
+ 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 is_singleton {
+ 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);
@@ -916,253 +952,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::{
@@ -75,9 +76,14 @@ actions!(
workspace,
[
Open,
- OpenNew,
+ NewFile,
+ NewWindow,
+ CloseWindow,
+ AddFolderToProject,
Unfollow,
Save,
+ SaveAs,
+ SaveAll,
ActivatePreviousPane,
ActivateNextPane,
FollowNextCollaborator,
@@ -114,7 +120,15 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
});
cx.add_global_action({
let app_state = Arc::downgrade(&app_state);
- move |_: &OpenNew, cx: &mut MutableAppContext| {
+ move |_: &NewFile, cx: &mut MutableAppContext| {
+ if let Some(app_state) = app_state.upgrade() {
+ open_new(&app_state, cx)
+ }
+ }
+ });
+ cx.add_global_action({
+ let app_state = Arc::downgrade(&app_state);
+ move |_: &NewWindow, cx: &mut MutableAppContext| {
if let Some(app_state) = app_state.upgrade() {
open_new(&app_state, cx)
}
@@ -131,6 +145,9 @@ 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>| {
let pane = workspace.active_pane().clone();
@@ -139,7 +156,12 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
);
cx.add_action(
|workspace: &mut Workspace, _: &Save, cx: &mut ViewContext<Workspace>| {
- workspace.save_active_item(cx).detach_and_log_err(cx);
+ workspace.save_active_item(false, cx).detach_and_log_err(cx);
+ },
+ );
+ cx.add_action(
+ |workspace: &mut Workspace, _: &SaveAs, cx: &mut ViewContext<Workspace>| {
+ workspace.save_active_item(true, cx).detach_and_log_err(cx);
},
);
cx.add_action(Workspace::toggle_sidebar_item);
@@ -200,7 +222,8 @@ 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 is_singleton(&self, cx: &AppContext) -> bool;
fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>);
fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
where
@@ -220,7 +243,6 @@ pub trait Item: View {
project: ModelHandle<Project>,
cx: &mut ViewContext<Self>,
) -> Task<Result<()>>;
- fn can_save_as(&self, cx: &AppContext) -> bool;
fn save_as(
&mut self,
project: ModelHandle<Project>,
@@ -350,7 +372,8 @@ 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 is_singleton(&self, cx: &AppContext) -> bool;
fn boxed_clone(&self) -> Box<dyn ItemHandle>;
fn set_nav_history(&self, nav_history: Rc<RefCell<NavHistory>>, cx: &mut MutableAppContext);
fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemHandle>>;
@@ -367,7 +390,6 @@ pub trait ItemHandle: 'static + fmt::Debug {
fn is_dirty(&self, cx: &AppContext) -> bool;
fn has_conflict(&self, cx: &AppContext) -> bool;
fn can_save(&self, cx: &AppContext) -> bool;
- fn can_save_as(&self, cx: &AppContext) -> bool;
fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>>;
fn save_as(
&self,
@@ -411,8 +433,12 @@ 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 is_singleton(&self, cx: &AppContext) -> bool {
+ self.read(cx).is_singleton(cx)
}
fn boxed_clone(&self) -> Box<dyn ItemHandle> {
@@ -540,10 +566,6 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
self.read(cx).can_save(cx)
}
- fn can_save_as(&self, cx: &AppContext) -> bool {
- self.read(cx).can_save_as(cx)
- }
-
fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>> {
self.update(cx, |item, cx| item.save(project, cx))
}
@@ -864,6 +886,79 @@ impl Workspace {
}
}
+ fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
+ let prepare = self.prepare_to_close(cx);
+ Some(cx.spawn(|this, mut cx| async move {
+ if prepare.await? {
+ this.update(&mut cx, |_, cx| {
+ let window_id = cx.window_id();
+ cx.remove_window(window_id);
+ });
+ }
+ Ok(())
+ }))
+ }
+
+ fn prepare_to_close(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
+ self.save_all_internal(true, cx)
+ }
+
+ 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 (is_singl, project_entry_ids) =
+ cx.read(|cx| (item.is_singleton(cx), item.project_entry_ids(cx)));
+ if is_singl || !project_entry_ids.is_empty() {
+ 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>,
@@ -912,6 +1007,27 @@ impl Workspace {
})
}
+ fn add_folder_to_project(&mut self, _: &AddFolderToProject, cx: &mut ViewContext<Self>) {
+ let mut paths = cx.prompt_for_paths(PathPromptOptions {
+ files: false,
+ directories: true,
+ multiple: true,
+ });
+ cx.spawn(|this, mut cx| async move {
+ if let Some(paths) = paths.recv().await.flatten() {
+ let results = this
+ .update(&mut cx, |this, cx| this.open_paths(paths, cx))
+ .await;
+ for result in results {
+ if let Some(result) = result {
+ result.log_err();
+ }
+ }
+ }
+ })
+ .detach();
+ }
+
fn project_path_for_path(
&self,
abs_path: &Path,
@@ -1032,10 +1148,14 @@ impl Workspace {
self.active_item(cx).and_then(|item| item.project_path(cx))
}
- pub fn save_active_item(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
+ pub fn save_active_item(
+ &mut self,
+ force_name_change: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> Task<Result<()>> {
let project = self.project.clone();
if let Some(item) = self.active_item(cx) {
- if item.can_save(cx) {
+ if !force_name_change && item.can_save(cx) {
if item.has_conflict(cx.as_ref()) {
const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?";
@@ -1054,7 +1174,7 @@ impl Workspace {
} else {
item.save(project, cx)
}
- } else if item.can_save_as(cx) {
+ } else if item.is_singleton(cx) {
let worktree = self.worktrees(cx).next();
let start_abs_path = worktree
.and_then(|w| w.read(cx).as_local())
@@ -2287,5 +2407,361 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
(app_state.initialize_workspace)(&mut workspace, app_state, cx);
workspace
});
- cx.dispatch_action(window_id, vec![workspace.id()], &OpenNew);
+ cx.dispatch_action(window_id, vec![workspace.id()], &NewFile);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gpui::{ModelHandle, TestAppContext, ViewContext};
+ use project::{FakeFs, Project, ProjectEntryId};
+ use serde_json::json;
+
+ #[gpui::test]
+ async fn test_close_window(cx: &mut TestAppContext) {
+ cx.foreground().forbid_parking();
+ Settings::test_async(cx);
+ 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 task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+ assert_eq!(task.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.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
+ item
+ });
+ workspace.update(cx, |w, cx| {
+ w.add_item(Box::new(item2.clone()), cx);
+ w.add_item(Box::new(item3.clone()), cx);
+ });
+ let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+ cx.foreground().run_until_parked();
+ cx.simulate_prompt_answer(window_id, 2 /* cancel */);
+ cx.foreground().run_until_parked();
+ assert!(!cx.has_pending_prompt(window_id));
+ assert_eq!(task.await.unwrap(), false);
+
+ // If there are multiple dirty items representing the same project entry.
+ workspace.update(cx, |w, cx| {
+ w.add_item(Box::new(item2.clone()), cx);
+ w.add_item(Box::new(item3.clone()), cx);
+ });
+ let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+ cx.foreground().run_until_parked();
+ cx.simulate_prompt_answer(window_id, 2 /* cancel */);
+ cx.foreground().run_until_parked();
+ assert!(!cx.has_pending_prompt(window_id));
+ assert_eq!(task.await.unwrap(), false);
+ }
+
+ #[gpui::test]
+ async fn test_close_pane_items(cx: &mut TestAppContext) {
+ cx.foreground().forbid_parking();
+ Settings::test_async(cx);
+ let fs = FakeFs::new(cx.background());
+
+ let project = Project::test(fs, 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.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
+ item
+ });
+ let item2 = cx.add_view(window_id, |_| {
+ let mut item = TestItem::new();
+ item.is_dirty = true;
+ item.has_conflict = true;
+ item.project_entry_ids = vec![ProjectEntryId::from_proto(2)];
+ item
+ });
+ let item3 = cx.add_view(window_id, |_| {
+ let mut item = TestItem::new();
+ item.is_dirty = true;
+ item.has_conflict = true;
+ item.project_entry_ids = vec![ProjectEntryId::from_proto(3)];
+ item
+ });
+ let item4 = cx.add_view(window_id, |_| {
+ let mut item = TestItem::new();
+ item.is_dirty = true;
+ 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_to_save_only_on_last_item_for_entry(cx: &mut TestAppContext) {
+ cx.foreground().forbid_parking();
+ Settings::test_async(cx);
+ let fs = FakeFs::new(cx.background());
+
+ let project = Project::test(fs, [], cx).await;
+ let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
+
+ // Create several workspace items with single project entries, and two
+ // workspace items with multiple project entries.
+ let single_entry_items = (0..=4)
+ .map(|project_entry_id| {
+ let mut item = TestItem::new();
+ item.is_dirty = true;
+ item.project_entry_ids = vec![ProjectEntryId::from_proto(project_entry_id)];
+ item.is_singleton = true;
+ item
+ })
+ .collect::<Vec<_>>();
+ let item_2_3 = {
+ let mut item = TestItem::new();
+ item.is_dirty = true;
+ item.is_singleton = false;
+ item.project_entry_ids =
+ vec![ProjectEntryId::from_proto(2), ProjectEntryId::from_proto(3)];
+ item
+ };
+ let item_3_4 = {
+ let mut item = TestItem::new();
+ item.is_dirty = true;
+ item.is_singleton = false;
+ item.project_entry_ids =
+ vec![ProjectEntryId::from_proto(3), ProjectEntryId::from_proto(4)];
+ item
+ };
+
+ // Create two panes that contain the following project entries:
+ // left pane:
+ // multi-entry items: (2, 3)
+ // single-entry items: 0, 1, 2, 3, 4
+ // right pane:
+ // single-entry items: 1
+ // multi-entry items: (3, 4)
+ let left_pane = workspace.update(cx, |workspace, cx| {
+ let left_pane = workspace.active_pane().clone();
+ let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx);
+
+ workspace.activate_pane(left_pane.clone(), cx);
+ workspace.add_item(Box::new(cx.add_view(|_| item_2_3.clone())), cx);
+ for item in &single_entry_items {
+ workspace.add_item(Box::new(cx.add_view(|_| item.clone())), cx);
+ }
+
+ workspace.activate_pane(right_pane.clone(), cx);
+ workspace.add_item(Box::new(cx.add_view(|_| single_entry_items[1].clone())), cx);
+ workspace.add_item(Box::new(cx.add_view(|_| item_3_4.clone())), cx);
+
+ left_pane
+ });
+
+ // When closing all of the items in the left pane, we should be prompted twice:
+ // once for project entry 0, and once for project entry 2. After those two
+ // prompts, the task should complete.
+ let close = workspace.update(cx, |workspace, cx| {
+ workspace.activate_pane(left_pane.clone(), cx);
+ Pane::close_items(workspace, left_pane.clone(), cx, |_| true)
+ });
+
+ cx.foreground().run_until_parked();
+ left_pane.read_with(cx, |pane, cx| {
+ assert_eq!(
+ pane.active_item().unwrap().project_entry_ids(cx).as_slice(),
+ &[ProjectEntryId::from_proto(0)]
+ );
+ });
+ cx.simulate_prompt_answer(window_id, 0);
+
+ cx.foreground().run_until_parked();
+ left_pane.read_with(cx, |pane, cx| {
+ assert_eq!(
+ pane.active_item().unwrap().project_entry_ids(cx).as_slice(),
+ &[ProjectEntryId::from_proto(2)]
+ );
+ });
+ cx.simulate_prompt_answer(window_id, 0);
+
+ cx.foreground().run_until_parked();
+ close.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,
+ project_entry_ids: Vec<ProjectEntryId>,
+ is_singleton: bool,
+ }
+
+ impl TestItem {
+ fn new() -> Self {
+ Self {
+ save_count: 0,
+ save_as_count: 0,
+ reload_count: 0,
+ is_dirty: false,
+ has_conflict: false,
+ project_entry_ids: Vec::new(),
+ is_singleton: true,
+ }
+ }
+ }
+
+ 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_ids.iter().copied().collect()
+ }
+
+ fn is_singleton(&self, _: &AppContext) -> bool {
+ self.is_singleton
+ }
+
+ 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.project_entry_ids.len() > 0
+ }
+
+ fn save(
+ &mut self,
+ _: ModelHandle<Project>,
+ _: &mut ViewContext<Self>,
+ ) -> Task<anyhow::Result<()>> {
+ self.save_count += 1;
+ Task::ready(Ok(()))
+ }
+
+ 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(()))
+ }
+ }
}
@@ -38,7 +38,7 @@ use std::{
};
use theme::{ThemeRegistry, DEFAULT_THEME_NAME};
use util::{ResultExt, TryFutureExt};
-use workspace::{self, AppState, OpenNew, OpenPaths};
+use workspace::{self, AppState, NewFile, OpenPaths};
use zed::{
self, build_window_options,
fs::RealFs,
@@ -206,7 +206,7 @@ fn main() {
cx.platform().activate(true);
let paths = collect_path_args();
if paths.is_empty() {
- cx.dispatch_global_action(OpenNew);
+ cx.dispatch_global_action(NewFile);
} else {
cx.dispatch_global_action(OpenPaths { paths });
}
@@ -215,7 +215,7 @@ fn main() {
cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
.detach();
} else {
- cx.dispatch_global_action(OpenNew);
+ cx.dispatch_global_action(NewFile);
}
cx.spawn(|cx| async move {
while let Some(connection) = cli_connections_rx.next().await {
@@ -31,21 +31,41 @@ pub fn menus() -> Vec<Menu<'static>> {
items: vec![
MenuItem::Action {
name: "New",
- action: Box::new(workspace::OpenNew),
+ action: Box::new(workspace::NewFile),
+ },
+ MenuItem::Action {
+ name: "New Window",
+ action: Box::new(workspace::NewWindow),
},
MenuItem::Separator,
MenuItem::Action {
name: "Openβ¦",
action: Box::new(workspace::Open),
},
+ MenuItem::Action {
+ name: "Add Folder to Projectβ¦",
+ action: Box::new(workspace::AddFolderToProject),
+ },
MenuItem::Action {
name: "Save",
action: Box::new(workspace::Save),
},
+ MenuItem::Action {
+ name: "Save Asβ¦",
+ action: Box::new(workspace::SaveAs),
+ },
+ MenuItem::Action {
+ name: "Save All",
+ action: Box::new(workspace::SaveAll),
+ },
MenuItem::Action {
name: "Close Editor",
action: Box::new(workspace::CloseActiveItem),
},
+ MenuItem::Action {
+ name: "Close Window",
+ action: Box::new(workspace::CloseWindow),
+ },
],
},
Menu {
@@ -209,5 +229,12 @@ pub fn menus() -> Vec<Menu<'static>> {
},
],
},
+ Menu {
+ name: "Help",
+ items: vec![MenuItem::Action {
+ name: "Command Palette",
+ action: Box::new(command_palette::Toggle),
+ }],
+ },
]
}
@@ -314,7 +314,7 @@ mod tests {
};
use theme::{Theme, ThemeRegistry, DEFAULT_THEME_NAME};
use workspace::{
- open_paths, pane, Item, ItemHandle, OpenNew, Pane, SplitDirection, WorkspaceHandle,
+ open_paths, pane, Item, ItemHandle, NewFile, Pane, SplitDirection, WorkspaceHandle,
};
#[gpui::test]
@@ -376,7 +376,7 @@ mod tests {
#[gpui::test]
async fn test_new_empty_workspace(cx: &mut TestAppContext) {
let app_state = init(cx);
- cx.dispatch_global_action(workspace::OpenNew);
+ cx.dispatch_global_action(workspace::NewFile);
let window_id = *cx.window_ids().first().unwrap();
let workspace = cx.root_view::<Workspace>(window_id).unwrap();
let editor = workspace.update(cx, |workspace, cx| {
@@ -391,7 +391,7 @@ mod tests {
assert!(editor.text(cx).is_empty());
});
- let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+ let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
app_state.fs.as_fake().insert_dir("/root").await;
cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
save_task.await.unwrap();
@@ -666,7 +666,7 @@ mod tests {
.await;
cx.read(|cx| assert!(editor.is_dirty(cx)));
- let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+ let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
cx.simulate_prompt_answer(window_id, 0);
save_task.await.unwrap();
editor.read_with(cx, |editor, cx| {
@@ -686,7 +686,7 @@ mod tests {
let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
// Create a new untitled buffer
- cx.dispatch_action(window_id, OpenNew);
+ cx.dispatch_action(window_id, NewFile);
let editor = workspace.read_with(cx, |workspace, cx| {
workspace
.active_item(cx)
@@ -707,7 +707,7 @@ mod tests {
});
// Save the buffer. This prompts for a filename.
- let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+ let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
cx.simulate_new_path_selection(|parent_dir| {
assert_eq!(parent_dir, Path::new("/root"));
Some(parent_dir.join("the-new-name.rs"))
@@ -731,7 +731,7 @@ mod tests {
editor.handle_input(&editor::Input(" there".into()), cx);
assert_eq!(editor.is_dirty(cx.as_ref()), true);
});
- let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+ let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
save_task.await.unwrap();
assert!(!cx.did_prompt_for_new_path());
editor.read_with(cx, |editor, cx| {
@@ -741,7 +741,7 @@ mod tests {
// Open the same newly-created file in another pane item. The new editor should reuse
// the same buffer.
- cx.dispatch_action(window_id, OpenNew);
+ cx.dispatch_action(window_id, NewFile);
workspace
.update(cx, |workspace, cx| {
workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx);
@@ -774,7 +774,7 @@ mod tests {
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
// Create a new untitled buffer
- cx.dispatch_action(window_id, OpenNew);
+ cx.dispatch_action(window_id, NewFile);
let editor = workspace.read_with(cx, |workspace, cx| {
workspace
.active_item(cx)
@@ -793,7 +793,7 @@ mod tests {
});
// Save the buffer. This prompts for a filename.
- let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+ let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
save_task.await.unwrap();
// The buffer is not dirty anymore and the language is assigned based on the path.