mirror of
https://github.com/zed-industries/zed.git
synced 2025-01-12 05:15:00 +00:00
workspace: Improve save prompt. (#3025)
Add buffer path to the prompt. Z-2903 Release Notes: - Added a "Save all/Discard all" prompt when closing a pane with multiple edited buffers.
This commit is contained in:
parent
0697d08e54
commit
0a491e773b
3 changed files with 148 additions and 33 deletions
|
@ -41,6 +41,8 @@ pub fn truncate(s: &str, max_chars: usize) -> &str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes characters from the end of the string if it's length is greater than `max_chars` and
|
||||||
|
/// appends "..." to the string. Returns string unchanged if it's length is smaller than max_chars.
|
||||||
pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String {
|
pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String {
|
||||||
debug_assert!(max_chars >= 5);
|
debug_assert!(max_chars >= 5);
|
||||||
|
|
||||||
|
@ -51,6 +53,18 @@ pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes characters from the front of the string if it's length is greater than `max_chars` and
|
||||||
|
/// prepends the string with "...". Returns string unchanged if it's length is smaller than max_chars.
|
||||||
|
pub fn truncate_and_remove_front(s: &str, max_chars: usize) -> String {
|
||||||
|
debug_assert!(max_chars >= 5);
|
||||||
|
|
||||||
|
let truncation_ix = s.char_indices().map(|(i, _)| i).nth_back(max_chars);
|
||||||
|
match truncation_ix {
|
||||||
|
Some(length) => "…".to_string() + &s[length..],
|
||||||
|
None => s.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn post_inc<T: From<u8> + AddAssign<T> + Copy>(value: &mut T) -> T {
|
pub fn post_inc<T: From<u8> + AddAssign<T> + Copy>(value: &mut T) -> T {
|
||||||
let prev = *value;
|
let prev = *value;
|
||||||
*value += T::from(1);
|
*value += T::from(1);
|
||||||
|
|
|
@ -42,6 +42,7 @@ use std::{
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use theme::{Theme, ThemeSettings};
|
use theme::{Theme, ThemeSettings};
|
||||||
|
use util::truncate_and_remove_front;
|
||||||
|
|
||||||
#[derive(PartialEq, Clone, Copy, Deserialize, Debug)]
|
#[derive(PartialEq, Clone, Copy, Deserialize, Debug)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
@ -839,10 +840,45 @@ impl Pane {
|
||||||
Some(self.close_items(cx, SaveBehavior::PromptOnWrite, |_| true))
|
Some(self.close_items(cx, SaveBehavior::PromptOnWrite, |_| true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn file_names_for_prompt(
|
||||||
|
items: &mut dyn Iterator<Item = &Box<dyn ItemHandle>>,
|
||||||
|
all_dirty_items: usize,
|
||||||
|
cx: &AppContext,
|
||||||
|
) -> String {
|
||||||
|
/// Quantity of item paths displayed in prompt prior to cutoff..
|
||||||
|
const FILE_NAMES_CUTOFF_POINT: usize = 10;
|
||||||
|
let mut file_names: Vec<_> = items
|
||||||
|
.filter_map(|item| {
|
||||||
|
item.project_path(cx).and_then(|project_path| {
|
||||||
|
project_path
|
||||||
|
.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str().map(ToOwned::to_owned))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(FILE_NAMES_CUTOFF_POINT)
|
||||||
|
.collect();
|
||||||
|
let should_display_followup_text =
|
||||||
|
all_dirty_items > FILE_NAMES_CUTOFF_POINT || file_names.len() != all_dirty_items;
|
||||||
|
if should_display_followup_text {
|
||||||
|
let not_shown_files = all_dirty_items - file_names.len();
|
||||||
|
if not_shown_files == 1 {
|
||||||
|
file_names.push(".. 1 file not shown".into());
|
||||||
|
} else {
|
||||||
|
file_names.push(format!(".. {} files not shown", not_shown_files).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let file_names = file_names.join("\n");
|
||||||
|
format!(
|
||||||
|
"Do you want to save changes to the following {} files?\n{file_names}",
|
||||||
|
all_dirty_items
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn close_items(
|
pub fn close_items(
|
||||||
&mut self,
|
&mut self,
|
||||||
cx: &mut ViewContext<Pane>,
|
cx: &mut ViewContext<Pane>,
|
||||||
save_behavior: SaveBehavior,
|
mut save_behavior: SaveBehavior,
|
||||||
should_close: impl 'static + Fn(usize) -> bool,
|
should_close: impl 'static + Fn(usize) -> bool,
|
||||||
) -> Task<Result<()>> {
|
) -> Task<Result<()>> {
|
||||||
// Find the items to close.
|
// Find the items to close.
|
||||||
|
@ -861,6 +897,25 @@ impl Pane {
|
||||||
|
|
||||||
let workspace = self.workspace.clone();
|
let workspace = self.workspace.clone();
|
||||||
cx.spawn(|pane, mut cx| async move {
|
cx.spawn(|pane, mut cx| async move {
|
||||||
|
if save_behavior == SaveBehavior::PromptOnWrite && items_to_close.len() > 1 {
|
||||||
|
let mut answer = pane.update(&mut cx, |_, cx| {
|
||||||
|
let prompt = Self::file_names_for_prompt(
|
||||||
|
&mut items_to_close.iter(),
|
||||||
|
items_to_close.len(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
cx.prompt(
|
||||||
|
PromptLevel::Warning,
|
||||||
|
&prompt,
|
||||||
|
&["Save all", "Discard all", "Cancel"],
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match answer.next().await {
|
||||||
|
Some(0) => save_behavior = SaveBehavior::PromptOnConflict,
|
||||||
|
Some(1) => save_behavior = SaveBehavior::DontSave,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 in items_to_close.clone() {
|
||||||
// Find the item's current index and its set of project item models. Avoid
|
// Find the item's current index and its set of project item models. Avoid
|
||||||
|
@ -1003,7 +1058,6 @@ impl Pane {
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
const CONFLICT_MESSAGE: &str =
|
const CONFLICT_MESSAGE: &str =
|
||||||
"This file has changed on disk since you started editing it. Do you want to overwrite it?";
|
"This file has changed on disk since you started editing it. Do you want to overwrite it?";
|
||||||
const DIRTY_MESSAGE: &str = "This file contains unsaved edits. Do you want to save it?";
|
|
||||||
|
|
||||||
if save_behavior == SaveBehavior::DontSave {
|
if save_behavior == SaveBehavior::DontSave {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
|
@ -1046,9 +1100,10 @@ impl Pane {
|
||||||
let should_save = if save_behavior == SaveBehavior::PromptOnWrite && !will_autosave {
|
let should_save = if save_behavior == SaveBehavior::PromptOnWrite && !will_autosave {
|
||||||
let mut answer = pane.update(cx, |pane, cx| {
|
let mut answer = pane.update(cx, |pane, cx| {
|
||||||
pane.activate_item(item_ix, true, true, cx);
|
pane.activate_item(item_ix, true, true, cx);
|
||||||
|
let prompt = dirty_message_for(item.project_path(cx));
|
||||||
cx.prompt(
|
cx.prompt(
|
||||||
PromptLevel::Warning,
|
PromptLevel::Warning,
|
||||||
DIRTY_MESSAGE,
|
&prompt,
|
||||||
&["Save", "Don't Save", "Cancel"],
|
&["Save", "Don't Save", "Cancel"],
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
@ -2135,6 +2190,15 @@ impl<V: 'static> Element<V> for PaneBackdrop<V> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dirty_message_for(buffer_path: Option<ProjectPath>) -> String {
|
||||||
|
let path = buffer_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.path.to_str())
|
||||||
|
.unwrap_or(&"Untitled buffer");
|
||||||
|
let path = truncate_and_remove_front(path, 80);
|
||||||
|
format!("{path} contains unsaved edits. Do you want to save it?")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
@ -2479,12 +2543,14 @@ mod tests {
|
||||||
|
|
||||||
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
|
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
|
||||||
|
|
||||||
pane.update(cx, |pane, cx| {
|
let task = pane
|
||||||
pane.close_inactive_items(&CloseInactiveItems, cx)
|
.update(cx, |pane, cx| {
|
||||||
})
|
pane.close_inactive_items(&CloseInactiveItems, cx)
|
||||||
.unwrap()
|
})
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
cx.foreground().run_until_parked();
|
||||||
|
window.simulate_prompt_answer(2, cx);
|
||||||
|
task.await.unwrap();
|
||||||
assert_item_labels(&pane, ["C*"], cx);
|
assert_item_labels(&pane, ["C*"], cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2505,10 +2571,12 @@ mod tests {
|
||||||
add_labeled_item(&pane, "E", false, cx);
|
add_labeled_item(&pane, "E", false, cx);
|
||||||
assert_item_labels(&pane, ["A^", "B", "C^", "D", "E*"], cx);
|
assert_item_labels(&pane, ["A^", "B", "C^", "D", "E*"], cx);
|
||||||
|
|
||||||
pane.update(cx, |pane, cx| pane.close_clean_items(&CloseCleanItems, cx))
|
let task = pane
|
||||||
.unwrap()
|
.update(cx, |pane, cx| pane.close_clean_items(&CloseCleanItems, cx))
|
||||||
.await
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
cx.foreground().run_until_parked();
|
||||||
|
window.simulate_prompt_answer(2, cx);
|
||||||
|
task.await.unwrap();
|
||||||
assert_item_labels(&pane, ["A^", "C*^"], cx);
|
assert_item_labels(&pane, ["A^", "C*^"], cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2524,12 +2592,14 @@ mod tests {
|
||||||
|
|
||||||
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
|
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
|
||||||
|
|
||||||
pane.update(cx, |pane, cx| {
|
let task = pane
|
||||||
pane.close_items_to_the_left(&CloseItemsToTheLeft, cx)
|
.update(cx, |pane, cx| {
|
||||||
})
|
pane.close_items_to_the_left(&CloseItemsToTheLeft, cx)
|
||||||
.unwrap()
|
})
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
cx.foreground().run_until_parked();
|
||||||
|
window.simulate_prompt_answer(2, cx);
|
||||||
|
task.await.unwrap();
|
||||||
assert_item_labels(&pane, ["C*", "D", "E"], cx);
|
assert_item_labels(&pane, ["C*", "D", "E"], cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2545,12 +2615,14 @@ mod tests {
|
||||||
|
|
||||||
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
|
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
|
||||||
|
|
||||||
pane.update(cx, |pane, cx| {
|
let task = pane
|
||||||
pane.close_items_to_the_right(&CloseItemsToTheRight, cx)
|
.update(cx, |pane, cx| {
|
||||||
})
|
pane.close_items_to_the_right(&CloseItemsToTheRight, cx)
|
||||||
.unwrap()
|
})
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
cx.foreground().run_until_parked();
|
||||||
|
window.simulate_prompt_answer(2, cx);
|
||||||
|
task.await.unwrap();
|
||||||
assert_item_labels(&pane, ["A", "B", "C*"], cx);
|
assert_item_labels(&pane, ["A", "B", "C*"], cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2569,10 +2641,12 @@ mod tests {
|
||||||
add_labeled_item(&pane, "C", false, cx);
|
add_labeled_item(&pane, "C", false, cx);
|
||||||
assert_item_labels(&pane, ["A", "B", "C*"], cx);
|
assert_item_labels(&pane, ["A", "B", "C*"], cx);
|
||||||
|
|
||||||
pane.update(cx, |pane, cx| pane.close_all_items(&CloseAllItems, cx))
|
let t = pane
|
||||||
.unwrap()
|
.update(cx, |pane, cx| pane.close_all_items(&CloseAllItems, cx))
|
||||||
.await
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
cx.foreground().run_until_parked();
|
||||||
|
window.simulate_prompt_answer(2, cx);
|
||||||
|
t.await.unwrap();
|
||||||
assert_item_labels(&pane, [], cx);
|
assert_item_labels(&pane, [], cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1333,13 +1333,12 @@ impl Workspace {
|
||||||
|
|
||||||
fn save_all_internal(
|
fn save_all_internal(
|
||||||
&mut self,
|
&mut self,
|
||||||
save_behaviour: SaveBehavior,
|
mut save_behaviour: SaveBehavior,
|
||||||
cx: &mut ViewContext<Self>,
|
cx: &mut ViewContext<Self>,
|
||||||
) -> Task<Result<bool>> {
|
) -> Task<Result<bool>> {
|
||||||
if self.project.read(cx).is_read_only() {
|
if self.project.read(cx).is_read_only() {
|
||||||
return Task::ready(Ok(true));
|
return Task::ready(Ok(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
let dirty_items = self
|
let dirty_items = self
|
||||||
.panes
|
.panes
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -1355,7 +1354,27 @@ impl Workspace {
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let project = self.project.clone();
|
let project = self.project.clone();
|
||||||
cx.spawn(|_, mut cx| async move {
|
cx.spawn(|workspace, mut cx| async move {
|
||||||
|
// Override save mode and display "Save all files" prompt
|
||||||
|
if save_behaviour == SaveBehavior::PromptOnWrite && dirty_items.len() > 1 {
|
||||||
|
let mut answer = workspace.update(&mut cx, |_, cx| {
|
||||||
|
let prompt = Pane::file_names_for_prompt(
|
||||||
|
&mut dirty_items.iter().map(|(_, handle)| handle),
|
||||||
|
dirty_items.len(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
cx.prompt(
|
||||||
|
PromptLevel::Warning,
|
||||||
|
&prompt,
|
||||||
|
&["Save all", "Discard all", "Cancel"],
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match answer.next().await {
|
||||||
|
Some(0) => save_behaviour = SaveBehavior::PromptOnConflict,
|
||||||
|
Some(1) => save_behaviour = SaveBehavior::DontSave,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (pane, item) in dirty_items {
|
for (pane, item) in dirty_items {
|
||||||
let (singleton, project_entry_ids) =
|
let (singleton, project_entry_ids) =
|
||||||
cx.read(|cx| (item.is_singleton(cx), item.project_entry_ids(cx)));
|
cx.read(|cx| (item.is_singleton(cx), item.project_entry_ids(cx)));
|
||||||
|
@ -4320,7 +4339,9 @@ mod tests {
|
||||||
});
|
});
|
||||||
let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx));
|
let task = workspace.update(cx, |w, cx| w.prepare_to_close(false, cx));
|
||||||
cx.foreground().run_until_parked();
|
cx.foreground().run_until_parked();
|
||||||
window.simulate_prompt_answer(2, cx); // cancel
|
window.simulate_prompt_answer(2, cx); // cancel save all
|
||||||
|
cx.foreground().run_until_parked();
|
||||||
|
window.simulate_prompt_answer(2, cx); // cancel save all
|
||||||
cx.foreground().run_until_parked();
|
cx.foreground().run_until_parked();
|
||||||
assert!(!window.has_pending_prompt(cx));
|
assert!(!window.has_pending_prompt(cx));
|
||||||
assert!(!task.await.unwrap());
|
assert!(!task.await.unwrap());
|
||||||
|
@ -4378,13 +4399,15 @@ mod tests {
|
||||||
});
|
});
|
||||||
cx.foreground().run_until_parked();
|
cx.foreground().run_until_parked();
|
||||||
|
|
||||||
|
assert!(window.has_pending_prompt(cx));
|
||||||
|
// Ignore "Save all" prompt
|
||||||
|
window.simulate_prompt_answer(2, cx);
|
||||||
|
cx.foreground().run_until_parked();
|
||||||
// There's a prompt to save item 1.
|
// There's a prompt to save item 1.
|
||||||
pane.read_with(cx, |pane, _| {
|
pane.read_with(cx, |pane, _| {
|
||||||
assert_eq!(pane.items_len(), 4);
|
assert_eq!(pane.items_len(), 4);
|
||||||
assert_eq!(pane.active_item().unwrap().id(), item1.id());
|
assert_eq!(pane.active_item().unwrap().id(), item1.id());
|
||||||
});
|
});
|
||||||
assert!(window.has_pending_prompt(cx));
|
|
||||||
|
|
||||||
// Confirm saving item 1.
|
// Confirm saving item 1.
|
||||||
window.simulate_prompt_answer(0, cx);
|
window.simulate_prompt_answer(0, cx);
|
||||||
cx.foreground().run_until_parked();
|
cx.foreground().run_until_parked();
|
||||||
|
@ -4512,6 +4535,10 @@ mod tests {
|
||||||
let close = left_pane.update(cx, |pane, cx| {
|
let close = left_pane.update(cx, |pane, cx| {
|
||||||
pane.close_items(cx, SaveBehavior::PromptOnWrite, move |_| true)
|
pane.close_items(cx, SaveBehavior::PromptOnWrite, move |_| true)
|
||||||
});
|
});
|
||||||
|
cx.foreground().run_until_parked();
|
||||||
|
// Discard "Save all" prompt
|
||||||
|
window.simulate_prompt_answer(2, cx);
|
||||||
|
|
||||||
cx.foreground().run_until_parked();
|
cx.foreground().run_until_parked();
|
||||||
left_pane.read_with(cx, |pane, cx| {
|
left_pane.read_with(cx, |pane, cx| {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
Loading…
Reference in a new issue