@@ -1013,6 +1013,7 @@ struct RunningTurn {
send_task: Task<()>,
}
+#[derive(Clone)]
struct InferredEditCandidateReady {
nonce: u64,
buffer: Entity<Buffer>,
@@ -1021,13 +1022,21 @@ struct InferredEditCandidateReady {
enum InferredEditCandidateState {
Pending { nonce: u64 },
Ready(InferredEditCandidateReady),
+ Finalizing(InferredEditCandidateReady),
}
impl InferredEditCandidateState {
fn nonce(&self) -> u64 {
match self {
Self::Pending { nonce } => *nonce,
- Self::Ready(candidate) => candidate.nonce,
+ Self::Ready(candidate) | Self::Finalizing(candidate) => candidate.nonce,
+ }
+ }
+
+ fn into_buffer_to_end(self) -> Option<Entity<Buffer>> {
+ match self {
+ Self::Ready(candidate) | Self::Finalizing(candidate) => Some(candidate.buffer),
+ Self::Pending { .. } => None,
}
}
}
@@ -1744,6 +1753,7 @@ impl AcpThread {
fn should_infer_external_edits(tool_call: &ToolCall) -> bool {
tool_call.tool_name.is_none()
&& tool_call.kind == acp::ToolKind::Edit
+ && tool_call.diffs().next().is_none()
&& !tool_call.locations.is_empty()
}
@@ -1758,6 +1768,49 @@ impl AcpThread {
nonce
}
+ fn end_expected_external_edits(
+ &mut self,
+ buffers: impl IntoIterator<Item = Entity<Buffer>>,
+ cx: &mut Context<Self>,
+ ) {
+ for buffer in buffers {
+ self.action_log.update(cx, |action_log, cx| {
+ action_log.end_expected_external_edit(buffer, cx);
+ });
+ }
+ }
+
+ fn remove_inferred_edit_tool_call_if_empty(&mut self, tool_call_id: &acp::ToolCallId) {
+ let remove_tool_call = self
+ .inferred_edit_candidates
+ .get(tool_call_id)
+ .is_some_and(|candidates| candidates.is_empty());
+
+ if remove_tool_call {
+ self.inferred_edit_candidates.remove(tool_call_id);
+ self.finalizing_inferred_edit_tool_calls
+ .remove(tool_call_id);
+ }
+ }
+
+ fn clear_inferred_edit_tool_call_tracking(
+ &mut self,
+ tool_call_id: &acp::ToolCallId,
+ ) -> Vec<Entity<Buffer>> {
+ let buffers_to_end = self
+ .inferred_edit_candidates
+ .remove(tool_call_id)
+ .into_iter()
+ .flat_map(|candidates| candidates.into_values())
+ .filter_map(|candidate_state| candidate_state.into_buffer_to_end())
+ .collect::<Vec<_>>();
+
+ self.finalizing_inferred_edit_tool_calls
+ .remove(tool_call_id);
+
+ buffers_to_end
+ }
+
fn remove_inferred_edit_candidate_if_matching(
&mut self,
tool_call_id: &acp::ToolCallId,
@@ -1765,35 +1818,25 @@ impl AcpThread {
nonce: u64,
cx: &mut Context<Self>,
) {
- let mut buffer_to_end = None;
- let remove_tool_call =
+ let buffer_to_end =
if let Some(candidates) = self.inferred_edit_candidates.get_mut(tool_call_id) {
let should_remove = candidates
.get(abs_path)
.is_some_and(|candidate_state| candidate_state.nonce() == nonce);
+
if should_remove {
- if let Some(InferredEditCandidateState::Ready(candidate)) =
- candidates.remove(abs_path)
- {
- buffer_to_end = Some(candidate.buffer);
- }
+ candidates
+ .remove(abs_path)
+ .and_then(|candidate_state| candidate_state.into_buffer_to_end())
+ } else {
+ None
}
- candidates.is_empty()
} else {
- false
+ None
};
- if let Some(buffer) = buffer_to_end {
- self.action_log.update(cx, |action_log, cx| {
- action_log.end_expected_external_edit(buffer, cx);
- });
- }
-
- if remove_tool_call {
- self.inferred_edit_candidates.remove(tool_call_id);
- self.finalizing_inferred_edit_tool_calls
- .remove(tool_call_id);
- }
+ self.end_expected_external_edits(buffer_to_end, cx);
+ self.remove_inferred_edit_tool_call_if_empty(tool_call_id);
}
fn clear_inferred_edit_candidates_for_tool_calls(
@@ -1804,22 +1847,10 @@ impl AcpThread {
let mut buffers_to_end = Vec::new();
for tool_call_id in tool_call_ids {
- if let Some(candidates) = self.inferred_edit_candidates.remove(&tool_call_id) {
- for candidate_state in candidates.into_values() {
- if let InferredEditCandidateState::Ready(candidate) = candidate_state {
- buffers_to_end.push(candidate.buffer);
- }
- }
- }
- self.finalizing_inferred_edit_tool_calls
- .remove(&tool_call_id);
+ buffers_to_end.extend(self.clear_inferred_edit_tool_call_tracking(&tool_call_id));
}
- for buffer in buffers_to_end {
- self.action_log.update(cx, |action_log, cx| {
- action_log.end_expected_external_edit(buffer, cx);
- });
- }
+ self.end_expected_external_edits(buffers_to_end, cx);
}
fn finalize_all_inferred_edit_tool_calls(&mut self, cx: &mut Context<Self>) {
@@ -1850,37 +1881,28 @@ impl AcpThread {
current_paths.insert(location.path.clone());
}
- let mut buffers_to_end = Vec::new();
- let remove_tool_call = if let Some(candidates) =
- self.inferred_edit_candidates.get_mut(tool_call_id)
- {
- let removed_paths = candidates
- .keys()
- .filter(|path| !current_paths.contains(*path))
- .cloned()
- .collect::<Vec<_>>();
- for path in removed_paths {
- if let Some(InferredEditCandidateState::Ready(candidate)) = candidates.remove(&path)
- {
- buffers_to_end.push(candidate.buffer);
- }
- }
- candidates.is_empty()
- } else {
- false
- };
+ let buffers_to_end =
+ if let Some(candidates) = self.inferred_edit_candidates.get_mut(tool_call_id) {
+ let removed_paths = candidates
+ .keys()
+ .filter(|path| !current_paths.contains(*path))
+ .cloned()
+ .collect::<Vec<_>>();
- for buffer in buffers_to_end {
- self.action_log.update(cx, |action_log, cx| {
- action_log.end_expected_external_edit(buffer, cx);
- });
- }
+ removed_paths
+ .into_iter()
+ .filter_map(|path| {
+ candidates
+ .remove(&path)
+ .and_then(|candidate_state| candidate_state.into_buffer_to_end())
+ })
+ .collect::<Vec<_>>()
+ } else {
+ Vec::new()
+ };
- if remove_tool_call {
- self.inferred_edit_candidates.remove(tool_call_id);
- self.finalizing_inferred_edit_tool_calls
- .remove(tool_call_id);
- }
+ self.end_expected_external_edits(buffers_to_end, cx);
+ self.remove_inferred_edit_tool_call_if_empty(tool_call_id);
}
fn register_inferred_edit_locations(
@@ -1995,102 +2017,115 @@ impl AcpThread {
buffer: Entity<Buffer>,
cx: &mut Context<Self>,
) {
- let Some(candidates) = self.inferred_edit_candidates.get_mut(&tool_call_id) else {
- return;
- };
- let Some(candidate_state) = candidates.get_mut(&abs_path) else {
- return;
- };
- if candidate_state.nonce() != nonce {
- return;
- }
+ let buffer_for_action_log = {
+ let Some(candidates) = self.inferred_edit_candidates.get_mut(&tool_call_id) else {
+ return;
+ };
+ let Some(candidate_state) = candidates.get_mut(&abs_path) else {
+ return;
+ };
+ if candidate_state.nonce() != nonce {
+ return;
+ }
- let buffer_for_action_log = buffer.clone();
- *candidate_state =
- InferredEditCandidateState::Ready(InferredEditCandidateReady { nonce, buffer });
+ *candidate_state = InferredEditCandidateState::Ready(InferredEditCandidateReady {
+ nonce,
+ buffer: buffer.clone(),
+ });
+ buffer
+ };
self.action_log.update(cx, |action_log, cx| {
action_log.begin_expected_external_edit(buffer_for_action_log, cx);
});
+
+ if self
+ .finalizing_inferred_edit_tool_calls
+ .contains(&tool_call_id)
+ {
+ self.start_finalizing_ready_inferred_edit_candidates(tool_call_id, cx);
+ }
}
- fn finalize_inferred_edit_tool_call(
+ fn start_finalizing_ready_inferred_edit_candidates(
&mut self,
tool_call_id: acp::ToolCallId,
cx: &mut Context<Self>,
) {
- let should_finalize = self.tool_call(&tool_call_id).is_some_and(|(_, tool_call)| {
- Self::should_infer_external_edits(tool_call)
- && Self::is_inferred_edit_terminal_status(&tool_call.status)
- });
- if !should_finalize {
- self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id], cx);
- return;
- }
-
- if !self
- .finalizing_inferred_edit_tool_calls
- .insert(tool_call_id.clone())
- {
- return;
- }
-
- let project = self.project.clone();
- cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| {
- const MAX_ATTEMPTS: usize = 3;
- const ATTEMPT_DELAY: Duration = Duration::from_millis(50);
-
- for attempt in 0..MAX_ATTEMPTS {
- let (ready_buffers, has_pending) = this
- .read_with(cx, |this, _| {
- let Some(candidates) = this.inferred_edit_candidates.get(&tool_call_id)
- else {
- return (Vec::new(), false);
- };
-
- let mut ready_buffers = Vec::new();
- let mut has_pending = false;
-
- for candidate_state in candidates.values() {
- match candidate_state {
- InferredEditCandidateState::Pending { .. } => has_pending = true,
- InferredEditCandidateState::Ready(candidate) => {
- ready_buffers.push(candidate.buffer.clone());
- }
- }
+ let ready_candidates =
+ if let Some(candidates) = self.inferred_edit_candidates.get_mut(&tool_call_id) {
+ let ready_candidates = candidates
+ .iter()
+ .filter_map(|(abs_path, candidate_state)| match candidate_state {
+ InferredEditCandidateState::Ready(candidate) => {
+ Some((abs_path.clone(), candidate.clone()))
}
-
- (ready_buffers, has_pending)
+ InferredEditCandidateState::Pending { .. }
+ | InferredEditCandidateState::Finalizing(_) => None,
})
- .unwrap_or((Vec::new(), false));
-
- if ready_buffers.is_empty() && !has_pending {
- break;
- }
+ .collect::<Vec<_>>();
- for buffer in ready_buffers {
- let should_reload = buffer.read_with(cx, |buffer, _| !buffer.is_dirty());
- if !should_reload {
+ for (abs_path, candidate) in &ready_candidates {
+ let Some(candidate_state) = candidates.get_mut(abs_path) else {
+ continue;
+ };
+ if candidate_state.nonce() != candidate.nonce {
continue;
}
- let reload = project.update(cx, |project, cx| {
- let mut buffers = HashSet::default();
- buffers.insert(buffer.clone());
- project.reload_buffers(buffers, false, cx)
- });
- reload.await.log_err();
+ *candidate_state = InferredEditCandidateState::Finalizing(candidate.clone());
}
- if !has_pending || attempt + 1 == MAX_ATTEMPTS {
- break;
- }
+ ready_candidates
+ } else {
+ Vec::new()
+ };
- cx.background_executor().timer(ATTEMPT_DELAY).await;
- }
+ for (abs_path, candidate) in ready_candidates {
+ self.finalize_inferred_edit_candidate(tool_call_id.clone(), abs_path, candidate, cx);
+ }
+
+ if !self.inferred_edit_candidates.contains_key(&tool_call_id) {
+ self.finalizing_inferred_edit_tool_calls
+ .remove(&tool_call_id);
+ }
+ }
+
+ fn finalize_inferred_edit_candidate(
+ &mut self,
+ tool_call_id: acp::ToolCallId,
+ abs_path: PathBuf,
+ candidate: InferredEditCandidateReady,
+ cx: &mut Context<Self>,
+ ) {
+ let nonce = candidate.nonce;
+ let buffer = candidate.buffer;
+ let should_reload = buffer.read_with(cx, |buffer, _| !buffer.is_dirty());
+ if !should_reload {
+ self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce, cx);
+ return;
+ }
+
+ self.action_log.update(cx, |action_log, cx| {
+ action_log.arm_expected_external_reload(buffer.clone(), cx);
+ });
+
+ let project = self.project.clone();
+ cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| {
+ let reload = project.update(cx, |project, cx| {
+ let mut buffers = HashSet::default();
+ buffers.insert(buffer.clone());
+ project.reload_buffers(buffers, false, cx)
+ });
+ reload.await.log_err();
this.update(cx, |this, cx| {
- this.clear_inferred_edit_candidates_for_tool_calls([tool_call_id.clone()], cx);
+ this.remove_inferred_edit_candidate_if_matching(
+ &tool_call_id,
+ &abs_path,
+ nonce,
+ cx,
+ );
})
.ok();
@@ -2099,6 +2134,25 @@ impl AcpThread {
.detach_and_log_err(cx);
}
+ fn finalize_inferred_edit_tool_call(
+ &mut self,
+ tool_call_id: acp::ToolCallId,
+ cx: &mut Context<Self>,
+ ) {
+ let should_finalize = self.tool_call(&tool_call_id).is_some_and(|(_, tool_call)| {
+ Self::should_infer_external_edits(tool_call)
+ && Self::is_inferred_edit_terminal_status(&tool_call.status)
+ });
+ if !should_finalize {
+ self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id], cx);
+ return;
+ }
+
+ self.finalizing_inferred_edit_tool_calls
+ .insert(tool_call_id.clone());
+ self.start_finalizing_ready_inferred_edit_candidates(tool_call_id, cx);
+ }
+
fn refresh_inferred_edit_tool_call(
&mut self,
tool_call_id: acp::ToolCallId,
@@ -4450,19 +4504,30 @@ mod tests {
tool_call_id: &acp::ToolCallId,
locations: Vec<acp::ToolCallLocation>,
cx: &mut TestAppContext,
+ ) {
+ start_external_edit_tool_call_with_meta(thread, tool_call_id, locations, None, cx);
+ }
+
+ fn start_external_edit_tool_call_with_meta(
+ thread: &Entity<AcpThread>,
+ tool_call_id: &acp::ToolCallId,
+ locations: Vec<acp::ToolCallLocation>,
+ meta: Option<acp::Meta>,
+ cx: &mut TestAppContext,
) {
let tool_call_id = tool_call_id.clone();
thread
.update(cx, move |thread, cx| {
- thread.handle_session_update(
- acp::SessionUpdate::ToolCall(
- acp::ToolCall::new(tool_call_id, "Label")
- .kind(acp::ToolKind::Edit)
- .status(acp::ToolCallStatus::InProgress)
- .locations(locations),
- ),
- cx,
- )
+ let mut tool_call = acp::ToolCall::new(tool_call_id, "Label")
+ .kind(acp::ToolKind::Edit)
+ .status(acp::ToolCallStatus::InProgress)
+ .locations(locations);
+
+ if let Some(meta) = meta {
+ tool_call = tool_call.meta(meta);
+ }
+
+ thread.handle_session_update(acp::SessionUpdate::ToolCall(tool_call), cx)
})
.unwrap();
}
@@ -4521,6 +4586,18 @@ mod tests {
})
}
+ fn inferred_edit_tool_call_is_finalizing(
+ thread: &Entity<AcpThread>,
+ tool_call_id: &acp::ToolCallId,
+ cx: &TestAppContext,
+ ) -> bool {
+ thread.read_with(cx, |thread, _| {
+ thread
+ .finalizing_inferred_edit_tool_calls
+ .contains(tool_call_id)
+ })
+ }
+
fn inferred_edit_candidate_is_ready(
thread: &Entity<AcpThread>,
tool_call_id: &acp::ToolCallId,
@@ -4808,6 +4885,213 @@ mod tests {
assert_eq!(changed_buffer_count(&thread, cx), 0);
}
+ #[gpui::test]
+ async fn test_terminal_external_edit_candidates_remain_active_until_late_readiness(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"}))
+ .await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+ let connection = Rc::new(FakeAgentConnection::new());
+
+ let thread = cx
+ .update(|cx| {
+ connection.new_session(
+ project.clone(),
+ PathList::new(&[Path::new(path!("/test"))]),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ let abs_path = PathBuf::from(path!("/test/file.txt"));
+ let buffer = open_test_buffer(&project, Path::new(path!("/test/file.txt")), cx).await;
+
+ let tool_call_id = acp::ToolCallId::new("test");
+ start_external_edit_tool_call(&thread, &tool_call_id, Vec::new(), cx);
+
+ let nonce = thread.update(cx, |thread, _cx| {
+ let nonce = thread.allocate_inferred_edit_candidate_nonce();
+ {
+ let (_, tool_call) = thread.tool_call_mut(&tool_call_id).unwrap();
+ tool_call.locations = vec![acp::ToolCallLocation::new(abs_path.clone())];
+ tool_call.resolved_locations = vec![None];
+ }
+ thread
+ .inferred_edit_candidates
+ .entry(tool_call_id.clone())
+ .or_default()
+ .insert(
+ abs_path.clone(),
+ InferredEditCandidateState::Pending { nonce },
+ );
+ nonce
+ });
+
+ complete_external_edit_tool_call(&thread, &tool_call_id, cx);
+ cx.executor().advance_clock(Duration::from_millis(200));
+ cx.run_until_parked();
+
+ assert_eq!(inferred_edit_candidate_count(&thread, cx), 1);
+ assert!(inferred_edit_tool_call_is_finalizing(
+ &thread,
+ &tool_call_id,
+ cx
+ ));
+
+ thread.update(cx, |thread, cx| {
+ thread.set_inferred_edit_candidate_ready(
+ tool_call_id.clone(),
+ abs_path.clone(),
+ nonce,
+ buffer.clone(),
+ cx,
+ );
+ });
+ cx.run_until_parked();
+
+ assert_eq!(changed_buffer_count(&thread, cx), 0);
+ assert_eq!(inferred_edit_candidate_count(&thread, cx), 0);
+ assert!(!inferred_edit_tool_call_is_finalizing(
+ &thread,
+ &tool_call_id,
+ cx
+ ));
+ }
+
+ #[gpui::test]
+ async fn test_tool_name_disables_external_edit_inference_for_location_edit_calls(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"}))
+ .await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+ let connection = Rc::new(FakeAgentConnection::new());
+
+ let thread = cx
+ .update(|cx| {
+ connection.new_session(
+ project.clone(),
+ PathList::new(&[Path::new(path!("/test"))]),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ let tool_call_id = acp::ToolCallId::new("test");
+ start_external_edit_tool_call_with_meta(
+ &thread,
+ &tool_call_id,
+ vec![acp::ToolCallLocation::new(PathBuf::from(path!(
+ "/test/file.txt"
+ )))],
+ Some(meta_with_tool_name("edit_file")),
+ cx,
+ );
+ cx.run_until_parked();
+
+ assert_eq!(inferred_edit_candidate_count(&thread, cx), 0);
+ assert!(!cx.read(|cx| thread.read(cx).has_pending_edit_tool_calls()));
+
+ fs.save(
+ path!("/test/file.txt").as_ref(),
+ &"one\ntwo\nthree\n".into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ complete_external_edit_tool_call(&thread, &tool_call_id, cx);
+ cx.executor().advance_clock(Duration::from_millis(200));
+ cx.run_until_parked();
+
+ assert_eq!(changed_buffer_count(&thread, cx), 0);
+ }
+
+ #[gpui::test]
+ async fn test_explicit_diff_content_disables_external_edit_inference_for_location_edit_calls(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(path!("/test"), json!({"file.txt": "one\ntwo\n"}))
+ .await;
+ let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+ let connection = Rc::new(FakeAgentConnection::new());
+
+ let thread = cx
+ .update(|cx| {
+ connection.new_session(
+ project.clone(),
+ PathList::new(&[Path::new(path!("/test"))]),
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ let abs_path = PathBuf::from(path!("/test/file.txt"));
+ let tool_call_id = acp::ToolCallId::new("test");
+ start_external_edit_tool_call(
+ &thread,
+ &tool_call_id,
+ vec![acp::ToolCallLocation::new(abs_path.clone())],
+ cx,
+ );
+ cx.run_until_parked();
+
+ assert_eq!(inferred_edit_candidate_count(&thread, cx), 1);
+
+ let languages = project.read_with(cx, |project, _| project.languages().clone());
+ let diff = cx.new(|cx| {
+ Diff::finalized(
+ path!("/test/file.txt").to_string(),
+ Some("one\ntwo\n".into()),
+ "one\ntwo\nthree\n".into(),
+ languages,
+ cx,
+ )
+ });
+
+ thread
+ .update(cx, |thread, cx| {
+ thread.update_tool_call(
+ ToolCallUpdateDiff {
+ id: tool_call_id.clone(),
+ diff,
+ },
+ cx,
+ )
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ assert_eq!(inferred_edit_candidate_count(&thread, cx), 0);
+ assert!(cx.read(|cx| thread.read(cx).has_pending_edit_tool_calls()));
+
+ fs.save(
+ path!("/test/file.txt").as_ref(),
+ &"one\ntwo\nthree\n".into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ complete_external_edit_tool_call(&thread, &tool_call_id, cx);
+ cx.executor().advance_clock(Duration::from_millis(200));
+ cx.run_until_parked();
+
+ assert_eq!(changed_buffer_count(&thread, cx), 0);
+ }
+
#[gpui::test(iterations = 10)]
async fn test_checkpoints(cx: &mut TestAppContext) {
init_test(cx);
@@ -280,15 +280,10 @@ impl ActionLog {
event: &BufferEvent,
cx: &mut Context<Self>,
) -> bool {
- let Some((_, expected_external_edit)) =
- self.tracked_buffers
- .get(&buffer)
- .and_then(|tracked_buffer| {
- tracked_buffer
- .expected_external_edit
- .clone()
- .map(|expected_external_edit| (tracked_buffer.mode, expected_external_edit))
- })
+ let Some(expected_external_edit) = self
+ .tracked_buffers
+ .get(&buffer)
+ .and_then(|tracked_buffer| tracked_buffer.expected_external_edit.clone())
else {
return false;
};
@@ -299,7 +294,8 @@ impl ActionLog {
match event {
BufferEvent::Saved
- if expected_external_edit.observed_external_file_change
+ if (expected_external_edit.observed_external_file_change
+ || expected_external_edit.armed_explicit_reload)
&& !expected_external_edit.has_attributed_change =>
{
self.mark_expected_external_edit_disqualified(&buffer);
@@ -322,7 +318,11 @@ impl ActionLog {
}
}
+ // Reload applies its text changes through ordinary local edit events before
+ // emitting `Reloaded`, so an explicitly armed reload must suppress those edits
+ // to preserve the pre-reload baseline for attribution.
expected_external_edit.observed_external_file_change
+ || expected_external_edit.armed_explicit_reload
}
BufferEvent::FileHandleChanged => {
let (is_deleted, is_empty, is_dirty) = buffer.read_with(cx, |buffer, _| {
@@ -341,7 +341,14 @@ impl ActionLog {
{
if !is_dirty || is_deleted {
expected_external_edit.observed_external_file_change = true;
+ expected_external_edit.armed_explicit_reload = false;
}
+ // Non-delete external changes against dirty buffers stay unsupported for now.
+ // We do not mark them as observed here, so they are not automatically
+ // remembered for attribution once the buffer becomes clean. Later
+ // attribution only happens after a subsequent clean file change or an
+ // explicitly armed reload, which keeps conflicted reloads and local-save
+ // noise from becoming agent edits.
expected_external_edit.pending_delete = is_deleted;
}
}
@@ -360,7 +367,8 @@ impl ActionLog {
true
}
BufferEvent::Reloaded
- if expected_external_edit.observed_external_file_change
+ if (expected_external_edit.observed_external_file_change
+ || expected_external_edit.armed_explicit_reload)
&& !expected_external_edit.pending_delete =>
{
if self.expected_external_edit_has_meaningful_change(&buffer, cx) {
@@ -370,6 +378,7 @@ impl ActionLog {
tracked_buffer.expected_external_edit.as_mut()
{
expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.armed_explicit_reload = false;
expected_external_edit.pending_delete = false;
}
}
@@ -390,6 +399,7 @@ impl ActionLog {
expected_external_edit.is_disqualified = true;
expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.armed_explicit_reload = false;
expected_external_edit.pending_delete = false;
}
@@ -454,6 +464,7 @@ impl ActionLog {
expected_external_edit.has_attributed_change = true;
expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.armed_explicit_reload = false;
expected_external_edit.pending_delete = false;
tracked_buffer.mode = TrackedBufferMode::Normal;
@@ -506,6 +517,7 @@ impl ActionLog {
expected_external_edit.has_attributed_change = true;
expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.armed_explicit_reload = false;
expected_external_edit.pending_delete = false;
tracked_buffer.mode = TrackedBufferMode::Normal;
tracked_buffer.status = TrackedBufferStatus::Deleted;
@@ -932,6 +944,7 @@ impl ActionLog {
record_file_read_time_source_count: 0,
initial_exists_on_disk,
observed_external_file_change: false,
+ armed_explicit_reload: false,
has_attributed_change,
pending_delete: false,
is_disqualified: false,
@@ -942,6 +955,39 @@ impl ActionLog {
}
}
+ pub fn arm_expected_external_reload(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+ self.arm_expected_external_reload_impl(buffer, true, cx);
+ }
+
+ fn arm_expected_external_reload_impl(
+ &mut self,
+ buffer: Entity<Buffer>,
+ forward_to_linked_action_log: bool,
+ cx: &mut Context<Self>,
+ ) {
+ if forward_to_linked_action_log {
+ if let Some(linked_action_log) = &self.linked_action_log {
+ linked_action_log.update(cx, |log, cx| {
+ log.arm_expected_external_reload_impl(buffer.clone(), false, cx);
+ });
+ }
+ }
+
+ let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
+ return;
+ };
+ let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_mut() else {
+ return;
+ };
+ if expected_external_edit.is_disqualified || expected_external_edit.pending_delete {
+ return;
+ }
+
+ // Explicit reloads can observe on-disk contents before the worktree has delivered
+ // `FileHandleChanged`, so we arm the next reload for attribution ahead of time.
+ expected_external_edit.armed_explicit_reload = true;
+ }
+
pub fn end_expected_external_edit(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
self.end_expected_external_edit_impl(buffer, true, cx);
}
@@ -1000,139 +1046,104 @@ impl ActionLog {
baseline_snapshot: text::BufferSnapshot,
cx: &mut Context<Self>,
) {
- self.infer_buffer_created_impl(buffer, baseline_snapshot, true, cx);
+ self.infer_buffer_from_snapshot_impl(
+ buffer,
+ baseline_snapshot,
+ InferredSnapshotKind::Created,
+ true,
+ cx,
+ );
}
- fn infer_buffer_created_impl(
+ pub fn infer_buffer_edited_from_snapshot(
&mut self,
buffer: Entity<Buffer>,
baseline_snapshot: text::BufferSnapshot,
- record_file_read_time: bool,
cx: &mut Context<Self>,
) {
- if let Some(linked_action_log) = &self.linked_action_log {
- let linked_baseline_snapshot = baseline_snapshot.clone();
- if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) {
- linked_action_log.update(cx, |log, cx| {
- log.infer_buffer_created_impl(
- buffer.clone(),
- linked_baseline_snapshot,
- false,
- cx,
- );
- });
- }
- }
-
- if record_file_read_time {
- self.update_file_read_time(&buffer, cx);
- }
- self.prime_tracked_buffer_from_snapshot(
- buffer.clone(),
+ self.infer_buffer_from_snapshot_impl(
+ buffer,
baseline_snapshot,
- TrackedBufferStatus::Created {
- existing_file_content: None,
- },
+ InferredSnapshotKind::Edited,
+ true,
cx,
);
-
- if let Some(tracked_buffer) = self.tracked_buffers.get(&buffer) {
- tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
- }
}
- pub fn infer_buffer_edited_from_snapshot(
+ pub fn infer_buffer_deleted_from_snapshot(
&mut self,
buffer: Entity<Buffer>,
baseline_snapshot: text::BufferSnapshot,
cx: &mut Context<Self>,
) {
- self.infer_buffer_edited_from_snapshot_impl(buffer, baseline_snapshot, true, cx);
+ self.infer_buffer_from_snapshot_impl(
+ buffer,
+ baseline_snapshot,
+ InferredSnapshotKind::Deleted,
+ true,
+ cx,
+ );
}
- fn infer_buffer_edited_from_snapshot_impl(
+ fn forward_inferred_snapshot_to_linked_action_log(
&mut self,
- buffer: Entity<Buffer>,
- baseline_snapshot: text::BufferSnapshot,
- record_file_read_time: bool,
+ buffer: &Entity<Buffer>,
+ baseline_snapshot: &text::BufferSnapshot,
+ kind: InferredSnapshotKind,
cx: &mut Context<Self>,
) {
if let Some(linked_action_log) = &self.linked_action_log {
let linked_baseline_snapshot = baseline_snapshot.clone();
- if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) {
+ if !linked_action_log.read(cx).has_changed_buffer(buffer, cx) {
linked_action_log.update(cx, |log, cx| {
- log.infer_buffer_edited_from_snapshot_impl(
+ log.infer_buffer_from_snapshot_impl(
buffer.clone(),
linked_baseline_snapshot,
+ kind,
false,
cx,
);
});
}
}
-
- if record_file_read_time {
- self.update_file_read_time(&buffer, cx);
- }
- self.prime_tracked_buffer_from_snapshot(
- buffer.clone(),
- baseline_snapshot,
- TrackedBufferStatus::Modified,
- cx,
- );
-
- if let Some(tracked_buffer) = self.tracked_buffers.get(&buffer) {
- tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
- }
}
- pub fn infer_buffer_deleted_from_snapshot(
- &mut self,
- buffer: Entity<Buffer>,
- baseline_snapshot: text::BufferSnapshot,
- cx: &mut Context<Self>,
- ) {
- self.infer_buffer_deleted_from_snapshot_impl(buffer, baseline_snapshot, true, cx);
- }
-
- fn infer_buffer_deleted_from_snapshot_impl(
+ fn infer_buffer_from_snapshot_impl(
&mut self,
buffer: Entity<Buffer>,
baseline_snapshot: text::BufferSnapshot,
+ kind: InferredSnapshotKind,
record_file_read_time: bool,
cx: &mut Context<Self>,
) {
- if let Some(linked_action_log) = &self.linked_action_log {
- let linked_baseline_snapshot = baseline_snapshot.clone();
- if !linked_action_log.read(cx).has_changed_buffer(&buffer, cx) {
- linked_action_log.update(cx, |log, cx| {
- log.infer_buffer_deleted_from_snapshot_impl(
- buffer.clone(),
- linked_baseline_snapshot,
- false,
- cx,
- );
- });
- }
- }
+ self.forward_inferred_snapshot_to_linked_action_log(&buffer, &baseline_snapshot, kind, cx);
if record_file_read_time {
- self.remove_file_read_time(&buffer, cx);
+ match kind {
+ InferredSnapshotKind::Created | InferredSnapshotKind::Edited => {
+ self.update_file_read_time(&buffer, cx);
+ }
+ InferredSnapshotKind::Deleted => {
+ self.remove_file_read_time(&buffer, cx);
+ }
+ }
}
- let has_linked_action_log = self.linked_action_log.is_some();
+
self.prime_tracked_buffer_from_snapshot(
buffer.clone(),
baseline_snapshot,
- TrackedBufferStatus::Deleted,
+ kind.tracked_buffer_status(),
cx,
);
- if !has_linked_action_log {
+ if kind == InferredSnapshotKind::Deleted && self.linked_action_log.is_none() {
buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
}
if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) {
- tracked_buffer.version = buffer.read(cx).version();
+ if kind == InferredSnapshotKind::Deleted {
+ tracked_buffer.version = buffer.read(cx).version();
+ }
tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
}
}
@@ -1799,6 +1810,25 @@ enum ChangeAuthor {
Agent,
}
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum InferredSnapshotKind {
+ Created,
+ Edited,
+ Deleted,
+}
+
+impl InferredSnapshotKind {
+ fn tracked_buffer_status(self) -> TrackedBufferStatus {
+ match self {
+ Self::Created => TrackedBufferStatus::Created {
+ existing_file_content: None,
+ },
+ Self::Edited => TrackedBufferStatus::Modified,
+ Self::Deleted => TrackedBufferStatus::Deleted,
+ }
+ }
+}
+
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum TrackedBufferMode {
Normal,
@@ -1811,6 +1841,7 @@ struct ExpectedExternalEdit {
record_file_read_time_source_count: usize,
initial_exists_on_disk: bool,
observed_external_file_change: bool,
+ armed_explicit_reload: bool,
has_attributed_change: bool,
pending_delete: bool,
is_disqualified: bool,
@@ -4283,6 +4314,117 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_expected_external_edit_explicit_reload_arm_attributes_forced_reload(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
+ .await;
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+
+ let file_path = project
+ .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
+ .unwrap();
+ let buffer = project
+ .update(cx, |project, cx| project.open_buffer(file_path, cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| {
+ log.begin_expected_external_edit(buffer.clone(), cx);
+ });
+ });
+
+ fs.save(
+ path!("/dir/file").as_ref(),
+ &"one\ntwo\nthree\n".into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| {
+ log.arm_expected_external_reload(buffer.clone(), cx);
+ });
+ });
+
+ let reload = project.update(cx, |project, cx| {
+ let mut buffers = collections::HashSet::default();
+ buffers.insert(buffer.clone());
+ project.reload_buffers(buffers, false, cx)
+ });
+ reload.await.unwrap();
+ cx.run_until_parked();
+
+ assert_eq!(
+ action_log.read_with(cx, |log, cx| log.changed_buffers(cx).len()),
+ 1,
+ "arming an expected reload should attribute an explicit reload before file-handle updates arrive"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_expected_external_edit_does_not_attribute_dirty_non_delete_external_changes(
+ cx: &mut TestAppContext,
+ ) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
+ .await;
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ let abs_path = PathBuf::from(path!("/dir/file"));
+
+ let file_path = project
+ .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
+ .unwrap();
+ let buffer = project
+ .update(cx, |project, cx| project.open_buffer(file_path, cx))
+ .await
+ .unwrap();
+
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| {
+ log.begin_expected_external_edit(buffer.clone(), cx);
+ });
+ });
+
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "zero\n")], None, cx).unwrap();
+ });
+ cx.run_until_parked();
+
+ fs.save(
+ path!("/dir/file").as_ref(),
+ &"one\ntwo\nthree\n".into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ assert!(
+ action_log.read_with(cx, |log, cx| log.changed_buffers(cx).is_empty()),
+ "dirty non-delete external changes should stay out of review until the behavior is explicitly supported"
+ );
+ assert!(
+ action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
+ "unsupported dirty external changes should not record file_read_time"
+ );
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ "zero\none\ntwo\n",
+ "unsupported dirty external changes should preserve local buffer contents"
+ );
+ }
+
#[gpui::test]
async fn test_linked_expected_external_edit_tracks_review_without_parent_file_read_time(
cx: &mut TestAppContext,