Save buffers after restoring hunks in the project diff (#25620)

This PR fixes a bug where using the project diff editor to restore hunks
from a file that's not open in its own buffer would cause those reverts
to be lost once the project diff drops its excerpts for that file.

The fix is to save the buffers after restoring them but before the
excerpts are (potentially) dropped. This is done for the project diff
editor only. If we fail to save the affected files, we add their buffers
to the active workspace, so that the reverted contents are preserved and
the user can try again to save them.

- [x] Get it working
- [x] Test
- [ ] ~~Clean up boolean soup~~

Co-authored-by: Max <max@zed.dev>

Release Notes:

- N/A
This commit is contained in:
Cole Miller 2025-02-26 15:16:17 -05:00 committed by GitHub
parent add7ae8052
commit 7a34dd9888
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 195 additions and 19 deletions

1
Cargo.lock generated
View file

@ -5422,6 +5422,7 @@ dependencies = [
"theme", "theme",
"time", "time",
"ui", "ui",
"unindent",
"util", "util",
"windows 0.58.0", "windows 0.58.0",
"workspace", "workspace",

View file

@ -6354,7 +6354,7 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
// Open item 1 as preview // Open item 1 as preview
workspace workspace
.update_in(cx, |workspace, window, cx| { .update_in(cx, |workspace, window, cx| {
workspace.open_path_preview(path_1.clone(), None, true, true, window, cx) workspace.open_path_preview(path_1.clone(), None, true, true, true, window, cx)
}) })
.await .await
.unwrap(); .unwrap();
@ -6375,7 +6375,7 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
// Open item 2 as preview // Open item 2 as preview
workspace workspace
.update_in(cx, |workspace, window, cx| { .update_in(cx, |workspace, window, cx| {
workspace.open_path_preview(path_2.clone(), None, true, true, window, cx) workspace.open_path_preview(path_2.clone(), None, true, true, true, window, cx)
}) })
.await .await
.unwrap(); .unwrap();
@ -6507,7 +6507,7 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
// Open item 2 as preview in right pane // Open item 2 as preview in right pane
workspace workspace
.update_in(cx, |workspace, window, cx| { .update_in(cx, |workspace, window, cx| {
workspace.open_path_preview(path_2.clone(), None, true, true, window, cx) workspace.open_path_preview(path_2.clone(), None, true, true, true, window, cx)
}) })
.await .await
.unwrap(); .unwrap();
@ -6545,7 +6545,7 @@ async fn test_preview_tabs(cx: &mut TestAppContext) {
// Open item 2 as preview in left pane // Open item 2 as preview in left pane
workspace workspace
.update_in(cx, |workspace, window, cx| { .update_in(cx, |workspace, window, cx| {
workspace.open_path_preview(path_2.clone(), None, true, true, window, cx) workspace.open_path_preview(path_2.clone(), None, true, true, true, window, cx)
}) })
.await .await
.unwrap(); .unwrap();

View file

@ -7722,7 +7722,7 @@ impl Editor {
drop(chunk_by); drop(chunk_by);
if !revert_changes.is_empty() { if !revert_changes.is_empty() {
self.transact(window, cx, |editor, window, cx| { self.transact(window, cx, |editor, window, cx| {
editor.revert(revert_changes, window, cx); editor.restore(revert_changes, window, cx);
}); });
} }
} }
@ -15886,13 +15886,16 @@ impl Editor {
FILE_HEADER_HEIGHT FILE_HEADER_HEIGHT
} }
pub fn revert( pub fn restore(
&mut self, &mut self,
revert_changes: HashMap<BufferId, Vec<(Range<text::Anchor>, Rope)>>, revert_changes: HashMap<BufferId, Vec<(Range<text::Anchor>, Rope)>>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.buffer().update(cx, |multi_buffer, cx| { let workspace = self.workspace();
let project = self.project.as_ref();
let save_tasks = self.buffer().update(cx, |multi_buffer, cx| {
let mut tasks = Vec::new();
for (buffer_id, changes) in revert_changes { for (buffer_id, changes) in revert_changes {
if let Some(buffer) = multi_buffer.buffer(buffer_id) { if let Some(buffer) = multi_buffer.buffer(buffer_id) {
buffer.update(cx, |buffer, cx| { buffer.update(cx, |buffer, cx| {
@ -15904,9 +15907,44 @@ impl Editor {
cx, cx,
); );
}); });
if let Some(project) =
project.filter(|_| multi_buffer.all_diff_hunks_expanded())
{
project.update(cx, |project, cx| {
tasks.push((buffer.clone(), project.save_buffer(buffer, cx)));
})
}
} }
} }
tasks
}); });
cx.spawn_in(window, |_, mut cx| async move {
for (buffer, task) in save_tasks {
let result = task.await;
if result.is_err() {
let Some(path) = buffer
.read_with(&cx, |buffer, cx| buffer.project_path(cx))
.ok()
else {
continue;
};
if let Some((workspace, path)) = workspace.as_ref().zip(path) {
let Some(task) = cx
.update_window_entity(&workspace, |workspace, window, cx| {
workspace
.open_path_preview(path, None, false, false, false, window, cx)
})
.ok()
else {
continue;
};
task.await.log_err();
}
}
}
})
.detach();
self.change_selections(None, window, cx, |selections| selections.refresh()); self.change_selections(None, window, cx, |selections| selections.refresh());
} }

View file

@ -1195,6 +1195,7 @@ impl PickerDelegate for FileFinderDelegate {
None, None,
true, true,
allow_preview, allow_preview,
true,
window, window,
cx, cx,
) )

View file

@ -47,6 +47,14 @@ zed_actions.workspace = true
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows.workspace = true windows.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
unindent.workspace = true
[features] [features]
default = [] default = []
test-support = ["multi_buffer/test-support"] test-support = ["multi_buffer/test-support"]

View file

@ -693,7 +693,7 @@ impl GitPanel {
self.workspace self.workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
workspace workspace
.open_path_preview(path, None, false, false, window, cx) .open_path_preview(path, None, false, false, true, window, cx)
.detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| { .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
Some(format!("{e}")) Some(format!("{e}"))
}); });

View file

@ -47,6 +47,7 @@ pub struct ProjectDiff {
_subscription: Subscription, _subscription: Subscription,
} }
#[derive(Debug)]
struct DiffBuffer { struct DiffBuffer {
path_key: PathKey, path_key: PathKey,
buffer: Entity<Buffer>, buffer: Entity<Buffer>,
@ -923,3 +924,93 @@ impl Render for ProjectDiffToolbar {
) )
} }
} }
#[cfg(test)]
mod tests {
use collections::HashMap;
use editor::test::editor_test_context::assert_state_with_diff;
use git::status::{StatusCode, TrackedStatus};
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use unindent::Unindent as _;
use util::path;
use super::*;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
language::init(cx);
Project::init_settings(cx);
workspace::init_settings(cx);
editor::init(cx);
crate::init(cx);
});
}
#[gpui::test]
async fn test_save_after_restore(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"foo": "FOO\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
fs.set_head_for_repo(
path!("/project/.git").as_ref(),
&[("foo".into(), "foo\n".into())],
);
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state.statuses = HashMap::from_iter([(
"foo".into(),
TrackedStatus {
index_status: StatusCode::Unmodified,
worktree_status: StatusCode::Modified,
}
.into(),
)]);
});
cx.run_until_parked();
let editor = diff.update(cx, |diff, _| diff.editor.clone());
assert_state_with_diff(
&editor,
cx,
&"
- foo
+ FOO
ˇ"
.unindent(),
);
editor.update_in(cx, |editor, window, cx| {
editor.restore_file(&Default::default(), window, cx);
});
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state.statuses = HashMap::default();
});
cx.run_until_parked();
assert_state_with_diff(&editor, cx, &"ˇ".unindent());
let text = String::from_utf8(fs.read_file_sync("/project/foo").unwrap()).unwrap();
assert_eq!(text, "foo\n");
}
}

View file

@ -485,6 +485,7 @@ impl ProjectPanel {
None, None,
focus_opened_item, focus_opened_item,
allow_preview, allow_preview,
true,
window, cx, window, cx,
) )
.detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| { .detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| {

View file

@ -849,6 +849,7 @@ impl Pane {
project_entry_id: Option<ProjectEntryId>, project_entry_id: Option<ProjectEntryId>,
focus_item: bool, focus_item: bool,
allow_preview: bool, allow_preview: bool,
activate: bool,
suggested_position: Option<usize>, suggested_position: Option<usize>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@ -876,7 +877,9 @@ impl Pane {
} }
} }
} }
self.activate_item(index, focus_item, focus_item, window, cx); if activate {
self.activate_item(index, focus_item, focus_item, window, cx);
}
existing_item existing_item
} else { } else {
// If the item is being opened as preview and we have an existing preview tab, // If the item is being opened as preview and we have an existing preview tab,
@ -892,10 +895,11 @@ impl Pane {
if allow_preview { if allow_preview {
self.set_preview_item_id(Some(new_item.item_id()), cx); self.set_preview_item_id(Some(new_item.item_id()), cx);
} }
self.add_item( self.add_item_inner(
new_item.clone(), new_item.clone(),
true, true,
focus_item, focus_item,
activate,
destination_index, destination_index,
window, window,
cx, cx,
@ -924,11 +928,13 @@ impl Pane {
} }
} }
pub fn add_item( #[allow(clippy::too_many_arguments)]
pub fn add_item_inner(
&mut self, &mut self,
item: Box<dyn ItemHandle>, item: Box<dyn ItemHandle>,
activate_pane: bool, activate_pane: bool,
focus_item: bool, focus_item: bool,
activate: bool,
destination_index: Option<usize>, destination_index: Option<usize>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
@ -1015,23 +1021,47 @@ impl Pane {
cx.notify(); cx.notify();
} }
self.activate_item(insertion_index, activate_pane, focus_item, window, cx); if activate {
self.activate_item(insertion_index, activate_pane, focus_item, window, cx);
}
} else { } else {
self.items.insert(insertion_index, item.clone()); self.items.insert(insertion_index, item.clone());
if insertion_index <= self.active_item_index if activate {
&& self.preview_item_idx() != Some(self.active_item_index) if insertion_index <= self.active_item_index
{ && self.preview_item_idx() != Some(self.active_item_index)
self.active_item_index += 1; {
} self.active_item_index += 1;
}
self.activate_item(insertion_index, activate_pane, focus_item, window, cx); self.activate_item(insertion_index, activate_pane, focus_item, window, cx);
}
cx.notify(); cx.notify();
} }
cx.emit(Event::AddItem { item }); cx.emit(Event::AddItem { item });
} }
pub fn add_item(
&mut self,
item: Box<dyn ItemHandle>,
activate_pane: bool,
focus_item: bool,
destination_index: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.add_item_inner(
item,
activate_pane,
focus_item,
true,
destination_index,
window,
cx,
)
}
pub fn items_len(&self) -> usize { pub fn items_len(&self) -> usize {
self.items.len() self.items.len()
} }
@ -2941,6 +2971,7 @@ impl Pane {
project_entry_id, project_entry_id,
true, true,
false, false,
true,
target, target,
window, window,
cx, cx,

View file

@ -1499,6 +1499,7 @@ impl Workspace {
project_entry_id, project_entry_id,
true, true,
entry.is_preview, entry.is_preview,
true,
None, None,
window, cx, window, cx,
build_item, build_item,
@ -2801,15 +2802,17 @@ impl Workspace {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> { ) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
self.open_path_preview(path, pane, focus_item, false, window, cx) self.open_path_preview(path, pane, focus_item, false, true, window, cx)
} }
#[allow(clippy::too_many_arguments)]
pub fn open_path_preview( pub fn open_path_preview(
&mut self, &mut self,
path: impl Into<ProjectPath>, path: impl Into<ProjectPath>,
pane: Option<WeakEntity<Pane>>, pane: Option<WeakEntity<Pane>>,
focus_item: bool, focus_item: bool,
allow_preview: bool, allow_preview: bool,
activate: bool,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> { ) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
@ -2830,6 +2833,7 @@ impl Workspace {
project_entry_id, project_entry_id,
focus_item, focus_item,
allow_preview, allow_preview,
activate,
None, None,
window, window,
cx, cx,
@ -2888,6 +2892,7 @@ impl Workspace {
project_entry_id, project_entry_id,
true, true,
allow_preview, allow_preview,
true,
None, None,
window, window,
cx, cx,