Fix item closing overly triggering save dialogues (#21374)

Closes https://github.com/zed-industries/zed/issues/12029

Allows to introspect project items inside items more deeply, checking
them for being dirty.
For that:
* renames `project::Item` into `project::ProjectItem`
* adds an `is_dirty(&self) -> bool` method to the renamed trait
* changes the closing logic to only care about dirty project items when
checking for save prompts conditions
* save prompts are raised only if the item is singleton without a
project path; or if the item has dirty project items that are not open
elsewhere

Release Notes:

- Fixed item closing overly triggering save dialogues
This commit is contained in:
Kirill Bulatov 2024-12-01 01:48:31 +02:00 committed by GitHub
parent c2cd84a749
commit 28849dd2a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 600 additions and 85 deletions

View file

@ -716,7 +716,7 @@ impl Item for ProjectDiagnosticsEditor {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) { ) {
self.editor.for_each_project_item(cx, f) self.editor.for_each_project_item(cx, f)
} }

View file

@ -125,8 +125,8 @@ use parking_lot::{Mutex, RwLock};
use project::{ use project::{
lsp_store::{FormatTarget, FormatTrigger}, lsp_store::{FormatTarget, FormatTrigger},
project_settings::{GitGutterSetting, ProjectSettings}, project_settings::{GitGutterSetting, ProjectSettings},
CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Item, Location, CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink,
LocationLink, Project, ProjectTransaction, TaskSourceKind, Project, ProjectItem, ProjectTransaction, TaskSourceKind,
}; };
use rand::prelude::*; use rand::prelude::*;
use rpc::{proto::*, ErrorExt}; use rpc::{proto::*, ErrorExt};

View file

@ -10,7 +10,7 @@ use gpui::{Model, ModelContext, Subscription, Task};
use http_client::HttpClient; use http_client::HttpClient;
use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown}; use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
use multi_buffer::MultiBufferRow; use multi_buffer::MultiBufferRow;
use project::{Item, Project}; use project::{Project, ProjectItem};
use smallvec::SmallVec; use smallvec::SmallVec;
use sum_tree::SumTree; use sum_tree::SumTree;
use url::Url; use url::Url;

View file

@ -22,8 +22,8 @@ use language::{
use lsp::DiagnosticSeverity; use lsp::DiagnosticSeverity;
use multi_buffer::AnchorRangeExt; use multi_buffer::AnchorRangeExt;
use project::{ use project::{
lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Item as _, lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Project,
Project, ProjectPath, ProjectItem as _, ProjectPath,
}; };
use rpc::proto::{self, update_view, PeerId}; use rpc::proto::{self, update_view, PeerId};
use settings::Settings; use settings::Settings;
@ -665,7 +665,7 @@ impl Item for Editor {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
f: &mut dyn FnMut(EntityId, &dyn project::Item), f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
) { ) {
self.buffer self.buffer
.read(cx) .read(cx)

View file

@ -78,7 +78,7 @@ impl Item for ImageView {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) { ) {
f(self.image_item.entity_id(), self.image_item.read(cx)) f(self.image_item.entity_id(), self.image_item.read(cx))
} }

View file

@ -36,7 +36,7 @@ use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev};
use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides}; use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides};
use project::{File, Fs, Item, Project}; use project::{File, Fs, Project, ProjectItem};
use search::{BufferSearchBar, ProjectSearchView}; use search::{BufferSearchBar, ProjectSearchView};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};

View file

@ -1,7 +1,7 @@
use crate::{ use crate::{
search::SearchQuery, search::SearchQuery,
worktree_store::{WorktreeStore, WorktreeStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent},
Item, ProjectPath, ProjectItem as _, ProjectPath,
}; };
use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry}; use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};

View file

@ -1,6 +1,6 @@
use crate::{ use crate::{
worktree_store::{WorktreeStore, WorktreeStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent},
Project, ProjectEntryId, ProjectPath, Project, ProjectEntryId, ProjectItem, ProjectPath,
}; };
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use collections::{hash_map, HashMap, HashSet}; use collections::{hash_map, HashMap, HashSet};
@ -114,7 +114,7 @@ impl ImageItem {
} }
} }
impl crate::Item for ImageItem { impl ProjectItem for ImageItem {
fn try_open( fn try_open(
project: &Model<Project>, project: &Model<Project>,
path: &ProjectPath, path: &ProjectPath,
@ -151,6 +151,10 @@ impl crate::Item for ImageItem {
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> { fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
Some(self.project_path(cx).clone()) Some(self.project_path(cx).clone())
} }
fn is_dirty(&self) -> bool {
false
}
} }
trait ImageStoreImpl { trait ImageStoreImpl {

View file

@ -10,7 +10,7 @@ use crate::{
toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent}, toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent},
yarn::YarnPathStore, yarn::YarnPathStore,
CodeAction, Completion, CoreCompletion, Hover, InlayHint, Item as _, ProjectPath, CodeAction, Completion, CoreCompletion, Hover, InlayHint, ProjectItem as _, ProjectPath,
ProjectTransaction, ResolveState, Symbol, ToolchainStore, ProjectTransaction, ResolveState, Symbol, ToolchainStore,
}; };
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};

View file

@ -111,7 +111,7 @@ const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500;
const MAX_SEARCH_RESULT_FILES: usize = 5_000; const MAX_SEARCH_RESULT_FILES: usize = 5_000;
const MAX_SEARCH_RESULT_RANGES: usize = 10_000; const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
pub trait Item { pub trait ProjectItem {
fn try_open( fn try_open(
project: &Model<Project>, project: &Model<Project>,
path: &ProjectPath, path: &ProjectPath,
@ -121,6 +121,7 @@ pub trait Item {
Self: Sized; Self: Sized;
fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>; fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>; fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
fn is_dirty(&self) -> bool;
} }
#[derive(Clone)] #[derive(Clone)]
@ -4354,7 +4355,7 @@ impl ResolvedPath {
} }
} }
impl Item for Buffer { impl ProjectItem for Buffer {
fn try_open( fn try_open(
project: &Model<Project>, project: &Model<Project>,
path: &ProjectPath, path: &ProjectPath,
@ -4373,6 +4374,10 @@ impl Item for Buffer {
path: file.path().clone(), path: file.path().clone(),
}) })
} }
fn is_dirty(&self) -> bool {
self.is_dirty()
}
} }
impl Completion { impl Completion {

View file

@ -7511,7 +7511,7 @@ mod tests {
path: ProjectPath, path: ProjectPath,
} }
impl project::Item for TestProjectItem { impl project::ProjectItem for TestProjectItem {
fn try_open( fn try_open(
_project: &Model<Project>, _project: &Model<Project>,
path: &ProjectPath, path: &ProjectPath,
@ -7528,6 +7528,10 @@ mod tests {
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> { fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
Some(self.path.clone()) Some(self.path.clone())
} }
fn is_dirty(&self) -> bool {
false
}
} }
impl ProjectItem for TestProjectItemView { impl ProjectItem for TestProjectItemView {

View file

@ -158,16 +158,6 @@ impl NotebookEditor {
}) })
} }
fn is_dirty(&self, cx: &AppContext) -> bool {
self.cell_map.values().any(|cell| {
if let Cell::Code(code_cell) = cell {
code_cell.read(cx).is_dirty(cx)
} else {
false
}
})
}
fn clear_outputs(&mut self, cx: &mut ViewContext<Self>) { fn clear_outputs(&mut self, cx: &mut ViewContext<Self>) {
for cell in self.cell_map.values() { for cell in self.cell_map.values() {
if let Cell::Code(code_cell) = cell { if let Cell::Code(code_cell) = cell {
@ -500,7 +490,7 @@ pub struct NotebookItem {
id: ProjectEntryId, id: ProjectEntryId,
} }
impl project::Item for NotebookItem { impl project::ProjectItem for NotebookItem {
fn try_open( fn try_open(
project: &Model<Project>, project: &Model<Project>,
path: &ProjectPath, path: &ProjectPath,
@ -561,6 +551,10 @@ impl project::Item for NotebookItem {
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> { fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
Some(self.project_path.clone()) Some(self.project_path.clone())
} }
fn is_dirty(&self) -> bool {
false
}
} }
impl NotebookItem { impl NotebookItem {
@ -656,7 +650,7 @@ impl Item for NotebookEditor {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) { ) {
f(self.notebook_item.entity_id(), self.notebook_item.read(cx)) f(self.notebook_item.entity_id(), self.notebook_item.read(cx))
} }
@ -734,8 +728,13 @@ impl Item for NotebookEditor {
} }
fn is_dirty(&self, cx: &AppContext) -> bool { fn is_dirty(&self, cx: &AppContext) -> bool {
// self.is_dirty(cx) TODO self.cell_map.values().any(|cell| {
false if let Cell::Code(code_cell) = cell {
code_cell.read(cx).is_dirty(cx)
} else {
false
}
})
} }
} }

View file

@ -7,7 +7,7 @@ use anyhow::{Context, Result};
use editor::Editor; use editor::Editor;
use gpui::{prelude::*, Entity, View, WeakView, WindowContext}; use gpui::{prelude::*, Entity, View, WeakView, WindowContext};
use language::{BufferSnapshot, Language, LanguageName, Point}; use language::{BufferSnapshot, Language, LanguageName, Point};
use project::{Item as _, WorktreeId}; use project::{ProjectItem as _, WorktreeId};
use crate::repl_store::ReplStore; use crate::repl_store::ReplStore;
use crate::session::SessionEvent; use crate::session::SessionEvent;

View file

@ -3,7 +3,7 @@ use gpui::{
actions, prelude::*, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, actions, prelude::*, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView,
Subscription, View, Subscription, View,
}; };
use project::Item as _; use project::ProjectItem as _;
use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding};
use util::ResultExt as _; use util::ResultExt as _;
use workspace::item::ItemEvent; use workspace::item::ItemEvent;

View file

@ -449,7 +449,7 @@ impl Item for ProjectSearchView {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
f: &mut dyn FnMut(EntityId, &dyn project::Item), f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
) { ) {
self.results_editor.for_each_project_item(cx, f) self.results_editor.for_each_project_item(cx, f)
} }

View file

@ -208,7 +208,7 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
_: &AppContext, _: &AppContext,
_: &mut dyn FnMut(EntityId, &dyn project::Item), _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
) { ) {
} }
fn is_singleton(&self, _cx: &AppContext) -> bool { fn is_singleton(&self, _cx: &AppContext) -> bool {
@ -386,7 +386,7 @@ pub trait ItemHandle: 'static + Send {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
_: &AppContext, _: &AppContext,
_: &mut dyn FnMut(EntityId, &dyn project::Item), _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
); );
fn is_singleton(&self, cx: &AppContext) -> bool; fn is_singleton(&self, cx: &AppContext) -> bool;
fn boxed_clone(&self) -> Box<dyn ItemHandle>; fn boxed_clone(&self) -> Box<dyn ItemHandle>;
@ -563,7 +563,7 @@ impl<T: Item> ItemHandle for View<T> {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
f: &mut dyn FnMut(EntityId, &dyn project::Item), f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
) { ) {
self.read(cx).for_each_project_item(cx, f) self.read(cx).for_each_project_item(cx, f)
} }
@ -891,7 +891,7 @@ impl<T: Item> WeakItemHandle for WeakView<T> {
} }
pub trait ProjectItem: Item { pub trait ProjectItem: Item {
type Item: project::Item; type Item: project::ProjectItem;
fn for_project_item( fn for_project_item(
project: Model<Project>, project: Model<Project>,
@ -1045,6 +1045,7 @@ pub mod test {
pub struct TestProjectItem { pub struct TestProjectItem {
pub entry_id: Option<ProjectEntryId>, pub entry_id: Option<ProjectEntryId>,
pub project_path: Option<ProjectPath>, pub project_path: Option<ProjectPath>,
pub is_dirty: bool,
} }
pub struct TestItem { pub struct TestItem {
@ -1065,7 +1066,7 @@ pub mod test {
focus_handle: gpui::FocusHandle, focus_handle: gpui::FocusHandle,
} }
impl project::Item for TestProjectItem { impl project::ProjectItem for TestProjectItem {
fn try_open( fn try_open(
_project: &Model<Project>, _project: &Model<Project>,
_path: &ProjectPath, _path: &ProjectPath,
@ -1073,7 +1074,6 @@ pub mod test {
) -> Option<Task<gpui::Result<Model<Self>>>> { ) -> Option<Task<gpui::Result<Model<Self>>>> {
None None
} }
fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> { fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
self.entry_id self.entry_id
} }
@ -1081,6 +1081,10 @@ pub mod test {
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> { fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
self.project_path.clone() self.project_path.clone()
} }
fn is_dirty(&self) -> bool {
self.is_dirty
}
} }
pub enum TestItemEvent { pub enum TestItemEvent {
@ -1097,6 +1101,7 @@ pub mod test {
cx.new_model(|_| Self { cx.new_model(|_| Self {
entry_id, entry_id,
project_path, project_path,
is_dirty: false,
}) })
} }
@ -1104,6 +1109,7 @@ pub mod test {
cx.new_model(|_| Self { cx.new_model(|_| Self {
project_path: None, project_path: None,
entry_id: None, entry_id: None,
is_dirty: false,
}) })
} }
} }
@ -1225,7 +1231,7 @@ pub mod test {
fn for_each_project_item( fn for_each_project_item(
&self, &self,
cx: &AppContext, cx: &AppContext,
f: &mut dyn FnMut(EntityId, &dyn project::Item), f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
) { ) {
self.project_items self.project_items
.iter() .iter()

View file

@ -1295,10 +1295,12 @@ impl Pane {
) -> Task<Result<()>> { ) -> Task<Result<()>> {
// Find the items to close. // Find the items to close.
let mut items_to_close = Vec::new(); let mut items_to_close = Vec::new();
let mut item_ids_to_close = HashSet::default();
let mut dirty_items = Vec::new(); let mut dirty_items = Vec::new();
for item in &self.items { for item in &self.items {
if should_close(item.item_id()) { if should_close(item.item_id()) {
items_to_close.push(item.boxed_clone()); items_to_close.push(item.boxed_clone());
item_ids_to_close.insert(item.item_id());
if item.is_dirty(cx) { if item.is_dirty(cx) {
dirty_items.push(item.boxed_clone()); dirty_items.push(item.boxed_clone());
} }
@ -1339,16 +1341,23 @@ impl Pane {
} }
} }
let mut saved_project_items_ids = HashSet::default(); let mut saved_project_items_ids = HashSet::default();
for item in items_to_close.clone() { for item_to_close in items_to_close {
// Find the item's current index and its set of project item models. Avoid // Find the item's current index and its set of dirty project item models. Avoid
// storing these in advance, in case they have changed since this task // storing these in advance, in case they have changed since this task
// was started. // was started.
let (item_ix, mut project_item_ids) = pane.update(&mut cx, |pane, cx| { let mut dirty_project_item_ids = Vec::new();
(pane.index_for_item(&*item), item.project_item_model_ids(cx)) let Some(item_ix) = pane.update(&mut cx, |pane, cx| {
})?; item_to_close.for_each_project_item(
let item_ix = if let Some(ix) = item_ix { cx,
ix &mut |project_item_id, project_item| {
} else { if project_item.is_dirty() {
dirty_project_item_ids.push(project_item_id);
}
},
);
pane.index_for_item(&*item_to_close)
})?
else {
continue; continue;
}; };
@ -1356,27 +1365,34 @@ impl Pane {
// in the workspace, AND that the user has not already been prompted to save. // 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. // If there are any such project entries, prompt the user to save this item.
let project = workspace.update(&mut cx, |workspace, cx| { let project = workspace.update(&mut cx, |workspace, cx| {
for item in workspace.items(cx) { for open_item in workspace.items(cx) {
if !items_to_close let open_item_id = open_item.item_id();
.iter() if !item_ids_to_close.contains(&open_item_id) {
.any(|item_to_close| item_to_close.item_id() == item.item_id()) let other_project_item_ids = open_item.project_item_model_ids(cx);
{ dirty_project_item_ids
let other_project_item_ids = item.project_item_model_ids(cx); .retain(|id| !other_project_item_ids.contains(id));
project_item_ids.retain(|id| !other_project_item_ids.contains(id));
} }
} }
workspace.project().clone() workspace.project().clone()
})?; })?;
let should_save = project_item_ids let should_save = dirty_project_item_ids
.iter() .iter()
.any(|id| saved_project_items_ids.insert(*id)); .any(|id| saved_project_items_ids.insert(*id))
// Always propose to save singleton files without any project paths: those cannot be saved via multibuffer, as require a file path selection modal.
|| cx
.update(|cx| {
item_to_close.is_dirty(cx)
&& item_to_close.is_singleton(cx)
&& item_to_close.project_path(cx).is_none()
})
.unwrap_or(false);
if should_save if should_save
&& !Self::save_item( && !Self::save_item(
project.clone(), project.clone(),
&pane, &pane,
item_ix, item_ix,
&*item, &*item_to_close,
save_intent, save_intent,
&mut cx, &mut cx,
) )
@ -1390,7 +1406,7 @@ impl Pane {
if let Some(item_ix) = pane if let Some(item_ix) = pane
.items .items
.iter() .iter()
.position(|i| i.item_id() == item.item_id()) .position(|i| i.item_id() == item_to_close.item_id())
{ {
pane.remove_item(item_ix, false, true, cx); pane.remove_item(item_ix, false, true, cx);
} }
@ -3725,9 +3741,18 @@ mod tests {
assert_item_labels(&pane, [], cx); assert_item_labels(&pane, [], cx);
add_labeled_item(&pane, "A", true, cx); add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| {
add_labeled_item(&pane, "B", true, cx); item.project_items
add_labeled_item(&pane, "C", true, cx); .push(TestProjectItem::new(1, "A.txt", cx))
});
add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| {
item.project_items
.push(TestProjectItem::new(2, "B.txt", cx))
});
add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| {
item.project_items
.push(TestProjectItem::new(3, "C.txt", cx))
});
assert_item_labels(&pane, ["A^", "B^", "C*^"], cx); assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
let save = pane let save = pane
@ -3746,6 +3771,30 @@ mod tests {
cx.simulate_prompt_answer(2); cx.simulate_prompt_answer(2);
save.await.unwrap(); save.await.unwrap();
assert_item_labels(&pane, [], cx); assert_item_labels(&pane, [], cx);
add_labeled_item(&pane, "A", true, cx);
add_labeled_item(&pane, "B", true, cx);
add_labeled_item(&pane, "C", true, cx);
assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
let save = pane
.update(cx, |pane, cx| {
pane.close_all_items(
&CloseAllItems {
save_intent: None,
close_pinned: false,
},
cx,
)
})
.unwrap();
cx.executor().run_until_parked();
cx.simulate_prompt_answer(2);
cx.executor().run_until_parked();
cx.simulate_prompt_answer(2);
cx.executor().run_until_parked();
save.await.unwrap();
assert_item_labels(&pane, ["A*^", "B^", "C^"], cx);
} }
#[gpui::test] #[gpui::test]
@ -3833,14 +3882,14 @@ mod tests {
} }
// Assert the item label, with the active item label suffixed with a '*' // Assert the item label, with the active item label suffixed with a '*'
#[track_caller]
fn assert_item_labels<const COUNT: usize>( fn assert_item_labels<const COUNT: usize>(
pane: &View<Pane>, pane: &View<Pane>,
expected_states: [&str; COUNT], expected_states: [&str; COUNT],
cx: &mut VisualTestContext, cx: &mut VisualTestContext,
) { ) {
pane.update(cx, |pane, cx| { let actual_states = pane.update(cx, |pane, cx| {
let actual_states = pane pane.items
.items
.iter() .iter()
.enumerate() .enumerate()
.map(|(ix, item)| { .map(|(ix, item)| {
@ -3859,12 +3908,11 @@ mod tests {
} }
state state
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>()
});
assert_eq!( assert_eq!(
actual_states, expected_states, actual_states, expected_states,
"pane items do not match expectation" "pane items do not match expectation"
); );
})
} }
} }

View file

@ -391,12 +391,12 @@ impl Global for ProjectItemOpeners {}
pub fn register_project_item<I: ProjectItem>(cx: &mut AppContext) { pub fn register_project_item<I: ProjectItem>(cx: &mut AppContext) {
let builders = cx.default_global::<ProjectItemOpeners>(); let builders = cx.default_global::<ProjectItemOpeners>();
builders.push(|project, project_path, cx| { builders.push(|project, project_path, cx| {
let project_item = <I::Item as project::Item>::try_open(project, project_path, cx)?; let project_item = <I::Item as project::ProjectItem>::try_open(project, project_path, cx)?;
let project = project.clone(); let project = project.clone();
Some(cx.spawn(|cx| async move { Some(cx.spawn(|cx| async move {
let project_item = project_item.await?; let project_item = project_item.await?;
let project_entry_id: Option<ProjectEntryId> = let project_entry_id: Option<ProjectEntryId> =
project_item.read_with(&cx, project::Item::entry_id)?; project_item.read_with(&cx, project::ProjectItem::entry_id)?;
let build_workspace_item = Box::new(|cx: &mut ViewContext<Pane>| { let build_workspace_item = Box::new(|cx: &mut ViewContext<Pane>| {
Box::new(cx.new_view(|cx| I::for_project_item(project, project_item, cx))) Box::new(cx.new_view(|cx| I::for_project_item(project, project_item, cx)))
as Box<dyn ItemHandle> as Box<dyn ItemHandle>
@ -2721,7 +2721,7 @@ impl Workspace {
where where
T: ProjectItem, T: ProjectItem,
{ {
use project::Item as _; use project::ProjectItem as _;
let project_item = project_item.read(cx); let project_item = project_item.read(cx);
let entry_id = project_item.entry_id(cx); let entry_id = project_item.entry_id(cx);
let project_path = project_item.project_path(cx); let project_path = project_item.project_path(cx);
@ -6422,24 +6422,26 @@ mod tests {
let item1 = cx.new_view(|cx| { let item1 = cx.new_view(|cx| {
TestItem::new(cx) TestItem::new(cx)
.with_dirty(true) .with_dirty(true)
.with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) .with_project_items(&[dirty_project_item(1, "1.txt", cx)])
}); });
let item2 = cx.new_view(|cx| { let item2 = cx.new_view(|cx| {
TestItem::new(cx) TestItem::new(cx)
.with_dirty(true) .with_dirty(true)
.with_conflict(true) .with_conflict(true)
.with_project_items(&[TestProjectItem::new(2, "2.txt", cx)]) .with_project_items(&[dirty_project_item(2, "2.txt", cx)])
}); });
let item3 = cx.new_view(|cx| { let item3 = cx.new_view(|cx| {
TestItem::new(cx) TestItem::new(cx)
.with_dirty(true) .with_dirty(true)
.with_conflict(true) .with_conflict(true)
.with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) .with_project_items(&[dirty_project_item(3, "3.txt", cx)])
}); });
let item4 = cx.new_view(|cx| { let item4 = cx.new_view(|cx| {
TestItem::new(cx) TestItem::new(cx).with_dirty(true).with_project_items(&[{
.with_dirty(true) let project_item = TestProjectItem::new_untitled(cx);
.with_project_items(&[TestProjectItem::new_untitled(cx)]) project_item.update(cx, |project_item, _| project_item.is_dirty = true);
project_item
}])
}); });
let pane = workspace.update(cx, |workspace, cx| { let pane = workspace.update(cx, |workspace, cx| {
workspace.add_item_to_active_pane(Box::new(item1.clone()), None, true, cx); workspace.add_item_to_active_pane(Box::new(item1.clone()), None, true, cx);
@ -6531,7 +6533,7 @@ mod tests {
cx.new_view(|cx| { cx.new_view(|cx| {
TestItem::new(cx) TestItem::new(cx)
.with_dirty(true) .with_dirty(true)
.with_project_items(&[TestProjectItem::new( .with_project_items(&[dirty_project_item(
project_entry_id, project_entry_id,
&format!("{project_entry_id}.txt"), &format!("{project_entry_id}.txt"),
cx, cx,
@ -6713,6 +6715,9 @@ mod tests {
}) })
}); });
item.is_dirty = true; item.is_dirty = true;
for project_item in &mut item.project_items {
project_item.update(cx, |project_item, _| project_item.is_dirty = true);
}
}); });
pane.update(cx, |pane, cx| { pane.update(cx, |pane, cx| {
@ -7411,6 +7416,434 @@ mod tests {
}); });
} }
#[gpui::test]
async fn test_no_save_prompt_when_multi_buffer_dirty_items_closed(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
let dirty_regular_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("1.txt")
.with_project_items(&[dirty_project_item(1, "1.txt", cx)])
});
let dirty_regular_buffer_2 = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("2.txt")
.with_project_items(&[dirty_project_item(2, "2.txt", cx)])
});
let dirty_multi_buffer_with_both = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_singleton(false)
.with_label("Fake Project Search")
.with_project_items(&[
dirty_regular_buffer.read(cx).project_items[0].clone(),
dirty_regular_buffer_2.read(cx).project_items[0].clone(),
])
});
let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id();
workspace.update(cx, |workspace, cx| {
workspace.add_item(
pane.clone(),
Box::new(dirty_regular_buffer.clone()),
None,
false,
false,
cx,
);
workspace.add_item(
pane.clone(),
Box::new(dirty_regular_buffer_2.clone()),
None,
false,
false,
cx,
);
workspace.add_item(
pane.clone(),
Box::new(dirty_multi_buffer_with_both.clone()),
None,
false,
false,
cx,
);
});
pane.update(cx, |pane, cx| {
pane.activate_item(2, true, true, cx);
assert_eq!(
pane.active_item().unwrap().item_id(),
multi_buffer_with_both_files_id,
"Should select the multi buffer in the pane"
);
});
let close_all_but_multi_buffer_task = pane
.update(cx, |pane, cx| {
pane.close_inactive_items(
&CloseInactiveItems {
save_intent: Some(SaveIntent::Save),
close_pinned: true,
},
cx,
)
})
.expect("should have inactive files to close");
cx.background_executor.run_until_parked();
assert!(
!cx.has_pending_prompt(),
"Multi buffer still has the unsaved buffer inside, so no save prompt should be shown"
);
close_all_but_multi_buffer_task
.await
.expect("Closing all buffers but the multi buffer failed");
pane.update(cx, |pane, cx| {
assert_eq!(dirty_regular_buffer.read(cx).save_count, 0);
assert_eq!(dirty_multi_buffer_with_both.read(cx).save_count, 0);
assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0);
assert_eq!(pane.items_len(), 1);
assert_eq!(
pane.active_item().unwrap().item_id(),
multi_buffer_with_both_files_id,
"Should have only the multi buffer left in the pane"
);
assert!(
dirty_multi_buffer_with_both.read(cx).is_dirty,
"The multi buffer containing the unsaved buffer should still be dirty"
);
});
let close_multi_buffer_task = pane
.update(cx, |pane, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: Some(SaveIntent::Close),
},
cx,
)
})
.expect("should have the multi buffer to close");
cx.background_executor.run_until_parked();
assert!(
cx.has_pending_prompt(),
"Dirty multi buffer should prompt a save dialog"
);
cx.simulate_prompt_answer(0);
cx.background_executor.run_until_parked();
close_multi_buffer_task
.await
.expect("Closing the multi buffer failed");
pane.update(cx, |pane, cx| {
assert_eq!(
dirty_multi_buffer_with_both.read(cx).save_count,
1,
"Multi buffer item should get be saved"
);
// Test impl does not save inner items, so we do not assert them
assert_eq!(
pane.items_len(),
0,
"No more items should be left in the pane"
);
assert!(pane.active_item().is_none());
});
}
#[gpui::test]
async fn test_no_save_prompt_when_dirty_singleton_buffer_closed_with_a_multi_buffer_containing_it_present_in_the_pane(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
let dirty_regular_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("1.txt")
.with_project_items(&[dirty_project_item(1, "1.txt", cx)])
});
let dirty_regular_buffer_2 = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("2.txt")
.with_project_items(&[dirty_project_item(2, "2.txt", cx)])
});
let clear_regular_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_label("3.txt")
.with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
});
let dirty_multi_buffer_with_both = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_singleton(false)
.with_label("Fake Project Search")
.with_project_items(&[
dirty_regular_buffer.read(cx).project_items[0].clone(),
dirty_regular_buffer_2.read(cx).project_items[0].clone(),
clear_regular_buffer.read(cx).project_items[0].clone(),
])
});
workspace.update(cx, |workspace, cx| {
workspace.add_item(
pane.clone(),
Box::new(dirty_regular_buffer.clone()),
None,
false,
false,
cx,
);
workspace.add_item(
pane.clone(),
Box::new(dirty_multi_buffer_with_both.clone()),
None,
false,
false,
cx,
);
});
pane.update(cx, |pane, cx| {
pane.activate_item(0, true, true, cx);
assert_eq!(
pane.active_item().unwrap().item_id(),
dirty_regular_buffer.item_id(),
"Should select the dirty singleton buffer in the pane"
);
});
let close_singleton_buffer_task = pane
.update(cx, |pane, cx| {
pane.close_active_item(&CloseActiveItem { save_intent: None }, cx)
})
.expect("should have active singleton buffer to close");
cx.background_executor.run_until_parked();
assert!(
!cx.has_pending_prompt(),
"Multi buffer is still in the pane and has the unsaved buffer inside, so no save prompt should be shown"
);
close_singleton_buffer_task
.await
.expect("Should not fail closing the singleton buffer");
pane.update(cx, |pane, cx| {
assert_eq!(dirty_regular_buffer.read(cx).save_count, 0);
assert_eq!(
dirty_multi_buffer_with_both.read(cx).save_count,
0,
"Multi buffer itself should not be saved"
);
assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0);
assert_eq!(
pane.items_len(),
1,
"A dirty multi buffer should be present in the pane"
);
assert_eq!(
pane.active_item().unwrap().item_id(),
dirty_multi_buffer_with_both.item_id(),
"Should activate the only remaining item in the pane"
);
});
}
#[gpui::test]
async fn test_save_prompt_when_dirty_multi_buffer_closed_with_some_of_its_dirty_items_not_present_in_the_pane(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
let dirty_regular_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("1.txt")
.with_project_items(&[dirty_project_item(1, "1.txt", cx)])
});
let dirty_regular_buffer_2 = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("2.txt")
.with_project_items(&[dirty_project_item(2, "2.txt", cx)])
});
let clear_regular_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_label("3.txt")
.with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
});
let dirty_multi_buffer_with_both = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_singleton(false)
.with_label("Fake Project Search")
.with_project_items(&[
dirty_regular_buffer.read(cx).project_items[0].clone(),
dirty_regular_buffer_2.read(cx).project_items[0].clone(),
clear_regular_buffer.read(cx).project_items[0].clone(),
])
});
let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id();
workspace.update(cx, |workspace, cx| {
workspace.add_item(
pane.clone(),
Box::new(dirty_regular_buffer.clone()),
None,
false,
false,
cx,
);
workspace.add_item(
pane.clone(),
Box::new(dirty_multi_buffer_with_both.clone()),
None,
false,
false,
cx,
);
});
pane.update(cx, |pane, cx| {
pane.activate_item(1, true, true, cx);
assert_eq!(
pane.active_item().unwrap().item_id(),
multi_buffer_with_both_files_id,
"Should select the multi buffer in the pane"
);
});
let _close_multi_buffer_task = pane
.update(cx, |pane, cx| {
pane.close_active_item(&CloseActiveItem { save_intent: None }, cx)
})
.expect("should have active multi buffer to close");
cx.background_executor.run_until_parked();
assert!(
cx.has_pending_prompt(),
"With one dirty item from the multi buffer not being in the pane, a save prompt should be shown"
);
}
#[gpui::test]
async fn test_no_save_prompt_when_dirty_multi_buffer_closed_with_all_of_its_dirty_items_present_in_the_pane(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
let project = Project::test(fs, [], cx).await;
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
let dirty_regular_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("1.txt")
.with_project_items(&[dirty_project_item(1, "1.txt", cx)])
});
let dirty_regular_buffer_2 = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_label("2.txt")
.with_project_items(&[dirty_project_item(2, "2.txt", cx)])
});
let clear_regular_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_label("3.txt")
.with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
});
let dirty_multi_buffer = cx.new_view(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_singleton(false)
.with_label("Fake Project Search")
.with_project_items(&[
dirty_regular_buffer.read(cx).project_items[0].clone(),
dirty_regular_buffer_2.read(cx).project_items[0].clone(),
clear_regular_buffer.read(cx).project_items[0].clone(),
])
});
workspace.update(cx, |workspace, cx| {
workspace.add_item(
pane.clone(),
Box::new(dirty_regular_buffer.clone()),
None,
false,
false,
cx,
);
workspace.add_item(
pane.clone(),
Box::new(dirty_regular_buffer_2.clone()),
None,
false,
false,
cx,
);
workspace.add_item(
pane.clone(),
Box::new(dirty_multi_buffer.clone()),
None,
false,
false,
cx,
);
});
pane.update(cx, |pane, cx| {
pane.activate_item(2, true, true, cx);
assert_eq!(
pane.active_item().unwrap().item_id(),
dirty_multi_buffer.item_id(),
"Should select the multi buffer in the pane"
);
});
let close_multi_buffer_task = pane
.update(cx, |pane, cx| {
pane.close_active_item(&CloseActiveItem { save_intent: None }, cx)
})
.expect("should have active multi buffer to close");
cx.background_executor.run_until_parked();
assert!(
!cx.has_pending_prompt(),
"All dirty items from the multi buffer are in the pane still, no save prompts should be shown"
);
close_multi_buffer_task
.await
.expect("Closing multi buffer failed");
pane.update(cx, |pane, cx| {
assert_eq!(dirty_regular_buffer.read(cx).save_count, 0);
assert_eq!(dirty_multi_buffer.read(cx).save_count, 0);
assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0);
assert_eq!(
pane.items()
.map(|item| item.item_id())
.sorted()
.collect::<Vec<_>>(),
vec![
dirty_regular_buffer.item_id(),
dirty_regular_buffer_2.item_id(),
],
"Should have no multi buffer left in the pane"
);
assert!(dirty_regular_buffer.read(cx).is_dirty);
assert!(dirty_regular_buffer_2.read(cx).is_dirty);
});
}
mod register_project_item_tests { mod register_project_item_tests {
use ui::Context as _; use ui::Context as _;
@ -7423,7 +7856,7 @@ mod tests {
// Model // Model
struct TestPngItem {} struct TestPngItem {}
impl project::Item for TestPngItem { impl project::ProjectItem for TestPngItem {
fn try_open( fn try_open(
_project: &Model<Project>, _project: &Model<Project>,
path: &ProjectPath, path: &ProjectPath,
@ -7443,6 +7876,10 @@ mod tests {
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> { fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
None None
} }
fn is_dirty(&self) -> bool {
false
}
} }
impl Item for TestPngItemView { impl Item for TestPngItemView {
@ -7485,7 +7922,7 @@ mod tests {
// Model // Model
struct TestIpynbItem {} struct TestIpynbItem {}
impl project::Item for TestIpynbItem { impl project::ProjectItem for TestIpynbItem {
fn try_open( fn try_open(
_project: &Model<Project>, _project: &Model<Project>,
path: &ProjectPath, path: &ProjectPath,
@ -7505,6 +7942,10 @@ mod tests {
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> { fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
None None
} }
fn is_dirty(&self) -> bool {
false
}
} }
impl Item for TestIpynbItemView { impl Item for TestIpynbItemView {
@ -7702,4 +8143,12 @@ mod tests {
Project::init_settings(cx); Project::init_settings(cx);
}); });
} }
fn dirty_project_item(id: u64, path: &str, cx: &mut AppContext) -> Model<TestProjectItem> {
let item = TestProjectItem::new(id, path, cx);
item.update(cx, |item, _| {
item.is_dirty = true;
});
item
}
} }

View file

@ -29,7 +29,7 @@ use gpui::{
pub use open_listener::*; pub use open_listener::*;
use outline_panel::OutlinePanel; use outline_panel::OutlinePanel;
use paths::{local_settings_file_relative_path, local_tasks_file_relative_path}; use paths::{local_settings_file_relative_path, local_tasks_file_relative_path};
use project::{DirectoryLister, Item}; use project::{DirectoryLister, ProjectItem};
use project_panel::ProjectPanel; use project_panel::ProjectPanel;
use quick_action_bar::QuickActionBar; use quick_action_bar::QuickActionBar;
use recent_projects::open_ssh_project; use recent_projects::open_ssh_project;