@@ -9,15 +9,10 @@ use collections::HashSet;
pub use connection::*;
pub use diff::*;
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
-use gpui::{
- AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
- WeakEntity,
-};
+use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
use itertools::Itertools;
use language::language_settings::FormatOnSave;
-use language::{
- Anchor, Buffer, BufferEvent, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff,
-};
+use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff};
use markdown::Markdown;
pub use mention::*;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
@@ -1021,12 +1016,6 @@ struct RunningTurn {
struct InferredEditCandidateReady {
nonce: u64,
buffer: Entity<Buffer>,
- baseline_snapshot: text::BufferSnapshot,
- existed_on_disk: bool,
- was_dirty: bool,
- observed_external_file_change: bool,
- has_inferred_edit: bool,
- _subscription: Subscription,
}
enum InferredEditCandidateState {
@@ -1774,20 +1763,32 @@ impl AcpThread {
tool_call_id: &acp::ToolCallId,
abs_path: &PathBuf,
nonce: u64,
+ cx: &mut Context<Self>,
) {
+ let mut buffer_to_end = None;
let remove_tool_call =
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 {
- candidates.remove(abs_path);
+ if let Some(InferredEditCandidateState::Ready(candidate)) =
+ candidates.remove(abs_path)
+ {
+ buffer_to_end = Some(candidate.buffer);
+ }
}
candidates.is_empty()
} else {
false
};
+ 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
@@ -1798,12 +1799,27 @@ impl AcpThread {
fn clear_inferred_edit_candidates_for_tool_calls(
&mut self,
tool_call_ids: impl IntoIterator<Item = acp::ToolCallId>,
+ cx: &mut Context<Self>,
) {
+ let mut buffers_to_end = Vec::new();
+
for tool_call_id in tool_call_ids {
- self.inferred_edit_candidates.remove(&tool_call_id);
+ 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);
}
+
+ for buffer in buffers_to_end {
+ self.action_log.update(cx, |action_log, cx| {
+ action_log.end_expected_external_edit(buffer, cx);
+ });
+ }
}
fn finalize_all_inferred_edit_tool_calls(&mut self, cx: &mut Context<Self>) {
@@ -1827,19 +1843,38 @@ impl AcpThread {
&mut self,
tool_call_id: &acp::ToolCallId,
locations: &[acp::ToolCallLocation],
+ cx: &mut Context<Self>,
) {
let mut current_paths = HashSet::default();
for location in locations {
current_paths.insert(location.path.clone());
}
- let remove_tool_call =
- if let Some(candidates) = self.inferred_edit_candidates.get_mut(tool_call_id) {
- candidates.retain(|path, _| current_paths.contains(path));
- candidates.is_empty()
- } else {
- false
- };
+ 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
+ };
+
+ for buffer in buffers_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);
@@ -1854,7 +1889,7 @@ impl AcpThread {
locations: &[acp::ToolCallLocation],
cx: &mut Context<Self>,
) {
- self.sync_inferred_edit_candidate_paths(&tool_call_id, locations);
+ self.sync_inferred_edit_candidate_paths(&tool_call_id, locations, cx);
let mut unique_paths = HashSet::default();
for location in locations {
@@ -1880,38 +1915,40 @@ impl AcpThread {
let open_buffer = self.project.update(cx, |project, cx| {
let project_path = project.project_path_for_absolute_path(&abs_path, cx)?;
if let Some(buffer) = project.get_open_buffer(&project_path, cx) {
- let (baseline_snapshot, existed_on_disk, was_dirty) =
- Self::inferred_edit_candidate_baseline(&buffer, cx);
- return Some((
- Some((buffer, baseline_snapshot, existed_on_disk, was_dirty)),
- None,
- ));
+ return Some((Some(buffer), None));
}
Some((None, Some(project.open_buffer(project_path, cx))))
});
- let Some((ready_candidate, open_buffer)) = open_buffer else {
- self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce);
+ let Some((ready_buffer, open_buffer)) = open_buffer else {
+ self.remove_inferred_edit_candidate_if_matching(
+ &tool_call_id,
+ &abs_path,
+ nonce,
+ cx,
+ );
continue;
};
- if let Some((buffer, baseline_snapshot, existed_on_disk, was_dirty)) = ready_candidate {
+ if let Some(buffer) = ready_buffer {
self.set_inferred_edit_candidate_ready(
tool_call_id.clone(),
abs_path,
nonce,
buffer,
- baseline_snapshot,
- existed_on_disk,
- was_dirty,
cx,
);
continue;
}
let Some(open_buffer) = open_buffer else {
- self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce);
+ self.remove_inferred_edit_candidate_if_matching(
+ &tool_call_id,
+ &abs_path,
+ nonce,
+ cx,
+ );
continue;
};
@@ -1920,11 +1957,12 @@ impl AcpThread {
let buffer = match open_buffer.await {
Ok(buffer) => buffer,
Err(_) => {
- this.update(cx, |this, _| {
+ this.update(cx, |this, cx| {
this.remove_inferred_edit_candidate_if_matching(
&tool_call_id,
&abs_path,
nonce,
+ cx,
);
})
.ok();
@@ -1932,18 +1970,12 @@ impl AcpThread {
}
};
- let (baseline_snapshot, existed_on_disk, was_dirty) =
- Self::inferred_edit_candidate_baseline(&buffer, cx);
-
this.update(cx, |this, cx| {
this.set_inferred_edit_candidate_ready(
tool_call_id.clone(),
abs_path.clone(),
nonce,
buffer,
- baseline_snapshot,
- existed_on_disk,
- was_dirty,
cx,
);
})
@@ -1955,67 +1987,14 @@ impl AcpThread {
}
}
- fn inferred_edit_candidate_baseline(
- buffer: &Entity<Buffer>,
- cx: &mut impl AppContext,
- ) -> (text::BufferSnapshot, bool, bool) {
- buffer.read_with(cx, |buffer, _| {
- (
- buffer.text_snapshot(),
- buffer.file().is_some_and(|file| file.disk_state().exists()),
- buffer.is_dirty(),
- )
- })
- }
-
- fn update_ready_inferred_edit_candidate_if_matching(
- &mut self,
- tool_call_id: &acp::ToolCallId,
- abs_path: &PathBuf,
- nonce: u64,
- update: impl FnOnce(&mut InferredEditCandidateReady),
- ) -> bool {
- let Some(candidates) = self.inferred_edit_candidates.get_mut(tool_call_id) else {
- return false;
- };
- let Some(InferredEditCandidateState::Ready(candidate)) = candidates.get_mut(abs_path)
- else {
- return false;
- };
- if candidate.nonce != nonce {
- return false;
- }
-
- update(candidate);
- true
- }
-
fn set_inferred_edit_candidate_ready(
&mut self,
tool_call_id: acp::ToolCallId,
abs_path: PathBuf,
nonce: u64,
buffer: Entity<Buffer>,
- baseline_snapshot: text::BufferSnapshot,
- existed_on_disk: bool,
- was_dirty: bool,
cx: &mut Context<Self>,
) {
- let subscription = cx.subscribe(&buffer, {
- let tool_call_id = tool_call_id.clone();
- let abs_path = abs_path.clone();
- move |this, buffer, event: &BufferEvent, cx| {
- this.handle_inferred_edit_candidate_buffer_event(
- tool_call_id.clone(),
- abs_path.clone(),
- nonce,
- buffer,
- event,
- cx,
- );
- }
- });
-
let Some(candidates) = self.inferred_edit_candidates.get_mut(&tool_call_id) else {
return;
};
@@ -2026,235 +2005,13 @@ impl AcpThread {
return;
}
- *candidate_state = InferredEditCandidateState::Ready(InferredEditCandidateReady {
- nonce,
- buffer,
- baseline_snapshot,
- existed_on_disk,
- was_dirty,
- observed_external_file_change: false,
- has_inferred_edit: false,
- _subscription: subscription,
- });
- }
-
- fn can_infer_initial_external_edit_for_candidate(
- &self,
- buffer: &Entity<Buffer>,
- was_dirty: bool,
- cx: &App,
- ) -> bool {
- if was_dirty {
- return false;
- }
-
- if buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
- return false;
- }
-
- !self.action_log.read_with(cx, |action_log, cx| {
- action_log.has_changed_buffer(buffer, cx)
- })
- }
+ let buffer_for_action_log = buffer.clone();
+ *candidate_state =
+ InferredEditCandidateState::Ready(InferredEditCandidateReady { nonce, buffer });
- fn inferred_edit_candidate_has_meaningful_change(
- existed_on_disk: bool,
- baseline_snapshot: &text::BufferSnapshot,
- buffer: &Entity<Buffer>,
- cx: &App,
- ) -> bool {
- let (current_snapshot, current_exists) = buffer.read_with(cx, |buffer, _| {
- (
- buffer.text_snapshot(),
- buffer.file().is_some_and(|file| file.disk_state().exists()),
- )
+ self.action_log.update(cx, |action_log, cx| {
+ action_log.begin_expected_external_edit(buffer_for_action_log, cx);
});
-
- if !existed_on_disk {
- current_exists || current_snapshot.text() != baseline_snapshot.text()
- } else {
- current_snapshot.text() != baseline_snapshot.text()
- }
- }
-
- fn infer_inferred_edit_candidate_from_baseline(
- &mut self,
- buffer: Entity<Buffer>,
- baseline_snapshot: text::BufferSnapshot,
- existed_on_disk: bool,
- cx: &mut Context<Self>,
- ) {
- if existed_on_disk {
- self.action_log.update(cx, |action_log, cx| {
- action_log.infer_buffer_edited_from_snapshot(buffer.clone(), baseline_snapshot, cx);
- });
- } else {
- self.action_log.update(cx, |action_log, cx| {
- action_log.infer_buffer_created(buffer.clone(), baseline_snapshot, cx);
- });
- }
- }
-
- fn handle_inferred_edit_candidate_buffer_event(
- &mut self,
- tool_call_id: acp::ToolCallId,
- abs_path: PathBuf,
- nonce: u64,
- buffer: Entity<Buffer>,
- event: &BufferEvent,
- cx: &mut Context<Self>,
- ) {
- let Some(InferredEditCandidateState::Ready(candidate)) = self
- .inferred_edit_candidates
- .get(&tool_call_id)
- .and_then(|candidates| candidates.get(&abs_path))
- else {
- return;
- };
- if candidate.nonce != nonce {
- return;
- }
-
- let baseline_snapshot = candidate.baseline_snapshot.clone();
- let existed_on_disk = candidate.existed_on_disk;
- let was_dirty = candidate.was_dirty;
- let observed_external_file_change = candidate.observed_external_file_change;
- let has_inferred_edit = candidate.has_inferred_edit;
-
- enum Action {
- None,
- Remove,
- SetObservedExternalFileChange,
- ClearObservedExternalFileChange,
- ClearObservedAndTrackEdited,
- ClearObservedAndInferCreatedOrEdited,
- RemoveAndWillDeleteBuffer,
- RemoveAndInferDeleted,
- }
-
- let action = match event {
- BufferEvent::Edited { is_local: true }
- if !has_inferred_edit && !observed_external_file_change =>
- {
- Action::Remove
- }
- BufferEvent::Saved if !has_inferred_edit => Action::Remove,
- BufferEvent::FileHandleChanged => {
- let is_deleted = buffer.read_with(cx, |buffer, _| {
- buffer
- .file()
- .is_some_and(|file| file.disk_state().is_deleted())
- });
-
- if is_deleted {
- if has_inferred_edit {
- Action::RemoveAndWillDeleteBuffer
- } else if self
- .can_infer_initial_external_edit_for_candidate(&buffer, was_dirty, cx)
- {
- Action::RemoveAndInferDeleted
- } else {
- Action::Remove
- }
- } else {
- Action::SetObservedExternalFileChange
- }
- }
- BufferEvent::Reloaded if observed_external_file_change => {
- if has_inferred_edit {
- Action::ClearObservedAndTrackEdited
- } else if self.can_infer_initial_external_edit_for_candidate(&buffer, was_dirty, cx)
- && Self::inferred_edit_candidate_has_meaningful_change(
- existed_on_disk,
- &baseline_snapshot,
- &buffer,
- cx,
- )
- {
- Action::ClearObservedAndInferCreatedOrEdited
- } else {
- Action::ClearObservedExternalFileChange
- }
- }
- _ => Action::None,
- };
-
- match action {
- Action::None => {}
- Action::Remove => {
- self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce);
- }
- Action::SetObservedExternalFileChange => {
- self.update_ready_inferred_edit_candidate_if_matching(
- &tool_call_id,
- &abs_path,
- nonce,
- |candidate| {
- candidate.observed_external_file_change = true;
- },
- );
- }
- Action::ClearObservedExternalFileChange => {
- self.update_ready_inferred_edit_candidate_if_matching(
- &tool_call_id,
- &abs_path,
- nonce,
- |candidate| {
- candidate.observed_external_file_change = false;
- },
- );
- }
- Action::ClearObservedAndTrackEdited => {
- let updated = self.update_ready_inferred_edit_candidate_if_matching(
- &tool_call_id,
- &abs_path,
- nonce,
- |candidate| {
- candidate.observed_external_file_change = false;
- },
- );
- if updated {
- self.action_log.update(cx, |action_log, cx| {
- action_log.buffer_edited(buffer.clone(), cx);
- });
- }
- }
- Action::ClearObservedAndInferCreatedOrEdited => {
- let updated = self.update_ready_inferred_edit_candidate_if_matching(
- &tool_call_id,
- &abs_path,
- nonce,
- |candidate| {
- candidate.observed_external_file_change = false;
- candidate.has_inferred_edit = true;
- },
- );
- if updated {
- self.infer_inferred_edit_candidate_from_baseline(
- buffer,
- baseline_snapshot,
- existed_on_disk,
- cx,
- );
- }
- }
- Action::RemoveAndWillDeleteBuffer => {
- self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce);
- self.action_log.update(cx, |action_log, cx| {
- action_log.will_delete_buffer(buffer.clone(), cx);
- });
- }
- Action::RemoveAndInferDeleted => {
- self.remove_inferred_edit_candidate_if_matching(&tool_call_id, &abs_path, nonce);
- self.action_log.update(cx, |action_log, cx| {
- action_log.infer_buffer_deleted_from_snapshot(
- buffer.clone(),
- baseline_snapshot,
- cx,
- );
- });
- }
- }
}
fn finalize_inferred_edit_tool_call(
@@ -2267,7 +2024,7 @@ impl AcpThread {
&& Self::is_inferred_edit_terminal_status(&tool_call.status)
});
if !should_finalize {
- self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id]);
+ self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id], cx);
return;
}
@@ -2284,42 +2041,34 @@ impl AcpThread {
const ATTEMPT_DELAY: Duration = Duration::from_millis(50);
for attempt in 0..MAX_ATTEMPTS {
- let (buffers_to_reload, has_pending, has_observed_external_file_change) = this
+ 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, false);
+ return (Vec::new(), false);
};
- let mut buffers_to_reload = HashSet::default();
+ let mut ready_buffers = Vec::new();
let mut has_pending = false;
- let mut has_observed_external_file_change = false;
for candidate_state in candidates.values() {
match candidate_state {
InferredEditCandidateState::Pending { .. } => has_pending = true,
InferredEditCandidateState::Ready(candidate) => {
- if candidate.observed_external_file_change {
- has_observed_external_file_change = true;
- buffers_to_reload.insert(candidate.buffer.clone());
- }
+ ready_buffers.push(candidate.buffer.clone());
}
}
}
- (
- buffers_to_reload.into_iter().collect::<Vec<_>>(),
- has_pending,
- has_observed_external_file_change,
- )
+ (ready_buffers, has_pending)
})
- .unwrap_or((Vec::new(), false, false));
+ .unwrap_or((Vec::new(), false));
- if buffers_to_reload.is_empty() && !has_pending {
+ if ready_buffers.is_empty() && !has_pending {
break;
}
- for buffer in buffers_to_reload {
+ for buffer in ready_buffers {
let should_reload = buffer.read_with(cx, |buffer, _| !buffer.is_dirty());
if !should_reload {
continue;
@@ -2333,17 +2082,15 @@ impl AcpThread {
reload.await.log_err();
}
- if (!has_pending && !has_observed_external_file_change)
- || attempt + 1 == MAX_ATTEMPTS
- {
+ if !has_pending || attempt + 1 == MAX_ATTEMPTS {
break;
}
cx.background_executor().timer(ATTEMPT_DELAY).await;
}
- this.update(cx, |this, _| {
- this.clear_inferred_edit_candidates_for_tool_calls([tool_call_id.clone()]);
+ this.update(cx, |this, cx| {
+ this.clear_inferred_edit_candidates_for_tool_calls([tool_call_id.clone()], cx);
})
.ok();
@@ -2366,7 +2113,7 @@ impl AcpThread {
let locations = tool_call.locations.clone();
if !should_track {
- self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id]);
+ self.clear_inferred_edit_candidates_for_tool_calls([tool_call_id], cx);
return;
}
@@ -2892,6 +2639,7 @@ impl AcpThread {
let canceled_tool_call_ids = this.mark_pending_tools_as_canceled();
this.clear_inferred_edit_candidates_for_tool_calls(
canceled_tool_call_ids,
+ cx,
);
this.finalize_all_inferred_edit_tool_calls(cx);
}
@@ -2934,6 +2682,7 @@ impl AcpThread {
.collect::<Vec<_>>();
this.clear_inferred_edit_candidates_for_tool_calls(
removed_tool_call_ids,
+ cx,
);
this.entries.truncate(user_msg_ix);
cx.emit(AcpThreadEvent::EntriesRemoved(range));
@@ -2973,7 +2722,7 @@ impl AcpThread {
Self::flush_streaming_text(&mut self.streaming_text_buffer, cx);
let canceled_tool_call_ids = self.mark_pending_tools_as_canceled();
- self.clear_inferred_edit_candidates_for_tool_calls(canceled_tool_call_ids);
+ self.clear_inferred_edit_candidates_for_tool_calls(canceled_tool_call_ids, cx);
self.finalize_all_inferred_edit_tool_calls(cx);
cx.background_spawn(turn.send_task)
@@ -3066,7 +2815,7 @@ impl AcpThread {
.collect::<Vec<_>>();
let range = ix..this.entries.len();
- this.clear_inferred_edit_candidates_for_tool_calls(removed_tool_call_ids);
+ this.clear_inferred_edit_candidates_for_tool_calls(removed_tool_call_ids, cx);
this.entries.truncate(ix);
cx.emit(AcpThreadEvent::EntriesRemoved(range));
@@ -183,6 +183,8 @@ impl ActionLog {
unreviewed_edits,
snapshot: text_snapshot,
status,
+ mode: TrackedBufferMode::Normal,
+ expected_external_edit: None,
version: buffer.read(cx).version(),
diff,
diff_update: diff_update_tx,
@@ -208,6 +210,10 @@ impl ActionLog {
event: &BufferEvent,
cx: &mut Context<Self>,
) {
+ if self.handle_expected_external_edit_event(buffer.clone(), event, cx) {
+ return;
+ }
+
match event {
BufferEvent::Edited { .. } => {
let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
@@ -268,6 +274,245 @@ impl ActionLog {
}
}
+ fn handle_expected_external_edit_event(
+ &mut self,
+ buffer: Entity<Buffer>,
+ 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))
+ })
+ else {
+ return false;
+ };
+
+ if expected_external_edit.is_disqualified {
+ return false;
+ }
+
+ match event {
+ BufferEvent::Saved
+ if expected_external_edit.observed_external_file_change
+ && !expected_external_edit.has_attributed_change =>
+ {
+ self.mark_expected_external_edit_disqualified(&buffer);
+ true
+ }
+ BufferEvent::Edited { is_local: true } => {
+ if expected_external_edit.pending_delete {
+ let (is_deleted, is_empty) = buffer.read_with(cx, |buffer, _| {
+ (
+ buffer
+ .file()
+ .is_some_and(|file| file.disk_state().is_deleted()),
+ buffer.text().is_empty(),
+ )
+ });
+
+ if is_deleted && is_empty {
+ self.apply_expected_external_delete_local(buffer, cx);
+ return true;
+ }
+ }
+
+ expected_external_edit.observed_external_file_change
+ }
+ BufferEvent::FileHandleChanged => {
+ let (is_deleted, is_empty, is_dirty) = buffer.read_with(cx, |buffer, _| {
+ (
+ buffer
+ .file()
+ .is_some_and(|file| file.disk_state().is_deleted()),
+ buffer.text().is_empty(),
+ buffer.is_dirty(),
+ )
+ });
+
+ if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) {
+ if let Some(expected_external_edit) =
+ tracked_buffer.expected_external_edit.as_mut()
+ {
+ if !is_dirty || is_deleted {
+ expected_external_edit.observed_external_file_change = true;
+ }
+ expected_external_edit.pending_delete = is_deleted;
+ }
+ }
+
+ if is_deleted {
+ if is_empty {
+ self.apply_expected_external_delete_local(buffer, cx);
+ } else if self.linked_action_log.is_none() {
+ let buffer = buffer.clone();
+ cx.defer(move |cx| {
+ buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
+ });
+ }
+ }
+
+ true
+ }
+ BufferEvent::Reloaded
+ if expected_external_edit.observed_external_file_change
+ && !expected_external_edit.pending_delete =>
+ {
+ if self.expected_external_edit_has_meaningful_change(&buffer, cx) {
+ self.apply_expected_external_reload_local(buffer, cx);
+ } else if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) {
+ if let Some(expected_external_edit) =
+ tracked_buffer.expected_external_edit.as_mut()
+ {
+ expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.pending_delete = false;
+ }
+ }
+
+ true
+ }
+ _ => false,
+ }
+ }
+
+ fn mark_expected_external_edit_disqualified(&mut self, buffer: &Entity<Buffer>) {
+ 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;
+ };
+
+ expected_external_edit.is_disqualified = true;
+ expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.pending_delete = false;
+ }
+
+ fn expected_external_edit_has_meaningful_change(
+ &self,
+ buffer: &Entity<Buffer>,
+ cx: &App,
+ ) -> bool {
+ let Some(tracked_buffer) = self.tracked_buffers.get(buffer) else {
+ return false;
+ };
+ let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_ref() else {
+ return false;
+ };
+
+ let (current_snapshot, current_exists) = buffer.read_with(cx, |buffer, _| {
+ (
+ buffer.text_snapshot(),
+ buffer.file().is_some_and(|file| file.disk_state().exists()),
+ )
+ });
+
+ if !expected_external_edit.initial_exists_on_disk {
+ current_exists || current_snapshot.text() != tracked_buffer.snapshot.text()
+ } else {
+ !current_exists || current_snapshot.text() != tracked_buffer.snapshot.text()
+ }
+ }
+
+ fn apply_expected_external_reload_local(
+ &mut self,
+ buffer: Entity<Buffer>,
+ cx: &mut Context<Self>,
+ ) {
+ let current_version = buffer.read(cx).version();
+ let (record_file_read_time, initial_exists_on_disk) = self
+ .tracked_buffers
+ .get(&buffer)
+ .and_then(|tracked_buffer| {
+ tracked_buffer
+ .expected_external_edit
+ .as_ref()
+ .map(|expected_external_edit| {
+ (
+ expected_external_edit.record_file_read_time_source_count > 0,
+ expected_external_edit.initial_exists_on_disk,
+ )
+ })
+ })
+ .unwrap_or((false, true));
+
+ if record_file_read_time {
+ self.update_file_read_time(&buffer, 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;
+ };
+
+ expected_external_edit.has_attributed_change = true;
+ expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.pending_delete = false;
+ tracked_buffer.mode = TrackedBufferMode::Normal;
+
+ if !initial_exists_on_disk {
+ let existing_file_content = if tracked_buffer.diff_base.len() == 0 {
+ None
+ } else {
+ Some(tracked_buffer.diff_base.clone())
+ };
+ tracked_buffer.status = TrackedBufferStatus::Created {
+ existing_file_content,
+ };
+ } else {
+ tracked_buffer.status = TrackedBufferStatus::Modified;
+ }
+
+ tracked_buffer.version = current_version;
+ tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
+ }
+
+ fn apply_expected_external_delete_local(
+ &mut self,
+ buffer: Entity<Buffer>,
+ cx: &mut Context<Self>,
+ ) {
+ let current_version = buffer.read(cx).version();
+ let remove_file_read_time = self
+ .tracked_buffers
+ .get(&buffer)
+ .and_then(|tracked_buffer| {
+ tracked_buffer
+ .expected_external_edit
+ .as_ref()
+ .map(|expected_external_edit| {
+ expected_external_edit.record_file_read_time_source_count > 0
+ })
+ })
+ .unwrap_or(false);
+
+ if remove_file_read_time {
+ self.remove_file_read_time(&buffer, 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;
+ };
+
+ expected_external_edit.has_attributed_change = true;
+ expected_external_edit.observed_external_file_change = false;
+ expected_external_edit.pending_delete = false;
+ tracked_buffer.mode = TrackedBufferMode::Normal;
+ tracked_buffer.status = TrackedBufferStatus::Deleted;
+ tracked_buffer.version = current_version;
+ tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
+ }
+
async fn maintain_diff(
this: WeakEntity<Self>,
buffer: Entity<Buffer>,
@@ -644,6 +889,111 @@ impl ActionLog {
.is_some_and(|tracked_buffer| tracked_buffer.has_edits(cx))
}
+ pub fn begin_expected_external_edit(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+ self.begin_expected_external_edit_impl(buffer, true, cx);
+ }
+
+ fn begin_expected_external_edit_impl(
+ &mut self,
+ buffer: Entity<Buffer>,
+ record_file_read_time: bool,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(linked_action_log) = &self.linked_action_log {
+ linked_action_log.update(cx, |log, cx| {
+ log.begin_expected_external_edit_impl(buffer.clone(), false, cx);
+ });
+ }
+
+ let initial_exists_on_disk = buffer
+ .read(cx)
+ .file()
+ .is_some_and(|file| file.disk_state().exists());
+ let had_tracked_buffer = self.tracked_buffers.contains_key(&buffer);
+ let has_attributed_change = self
+ .tracked_buffers
+ .get(&buffer)
+ .is_some_and(|tracked_buffer| tracked_buffer.has_edits(cx));
+
+ let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
+ if !had_tracked_buffer {
+ tracked_buffer.mode = TrackedBufferMode::ExpectationOnly;
+ tracked_buffer.status = TrackedBufferStatus::Modified;
+ tracked_buffer.diff_base = buffer.read(cx).as_rope().clone();
+ tracked_buffer.snapshot = buffer.read(cx).text_snapshot();
+ tracked_buffer.unreviewed_edits.clear();
+ }
+
+ let expected_external_edit =
+ tracked_buffer
+ .expected_external_edit
+ .get_or_insert_with(|| ExpectedExternalEdit {
+ active_source_count: 0,
+ record_file_read_time_source_count: 0,
+ initial_exists_on_disk,
+ observed_external_file_change: false,
+ has_attributed_change,
+ pending_delete: false,
+ is_disqualified: false,
+ });
+ expected_external_edit.active_source_count += 1;
+ if record_file_read_time {
+ expected_external_edit.record_file_read_time_source_count += 1;
+ }
+ }
+
+ pub fn end_expected_external_edit(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
+ self.end_expected_external_edit_impl(buffer, true, cx);
+ }
+
+ fn end_expected_external_edit_impl(
+ &mut self,
+ buffer: Entity<Buffer>,
+ record_file_read_time: bool,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(linked_action_log) = &self.linked_action_log {
+ linked_action_log.update(cx, |log, cx| {
+ log.end_expected_external_edit_impl(buffer.clone(), false, cx);
+ });
+ }
+
+ let remove_tracked_buffer = if let Some(tracked_buffer) =
+ self.tracked_buffers.get_mut(&buffer)
+ {
+ let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_mut()
+ else {
+ return;
+ };
+
+ expected_external_edit.active_source_count =
+ expected_external_edit.active_source_count.saturating_sub(1);
+ if record_file_read_time {
+ expected_external_edit.record_file_read_time_source_count = expected_external_edit
+ .record_file_read_time_source_count
+ .saturating_sub(1);
+ }
+
+ if expected_external_edit.active_source_count > 0 {
+ false
+ } else {
+ let remove_tracked_buffer = tracked_buffer.mode
+ == TrackedBufferMode::ExpectationOnly
+ && !expected_external_edit.has_attributed_change;
+ tracked_buffer.expected_external_edit = None;
+ tracked_buffer.mode = TrackedBufferMode::Normal;
+ remove_tracked_buffer
+ }
+ } else {
+ false
+ };
+
+ if remove_tracked_buffer {
+ self.tracked_buffers.remove(&buffer);
+ cx.notify();
+ }
+ }
+
pub fn infer_buffer_created(
&mut self,
buffer: Entity<Buffer>,
@@ -1223,7 +1573,8 @@ impl ActionLog {
.filter(|(buffer, tracked)| {
let buffer = buffer.read(cx);
- tracked.version != buffer.version
+ tracked.mode == TrackedBufferMode::Normal
+ && tracked.version != buffer.version
&& buffer
.file()
.is_some_and(|file| !file.disk_state().is_deleted())
@@ -1448,6 +1799,23 @@ enum ChangeAuthor {
Agent,
}
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum TrackedBufferMode {
+ Normal,
+ ExpectationOnly,
+}
+
+#[derive(Clone, Debug)]
+struct ExpectedExternalEdit {
+ active_source_count: usize,
+ record_file_read_time_source_count: usize,
+ initial_exists_on_disk: bool,
+ observed_external_file_change: bool,
+ has_attributed_change: bool,
+ pending_delete: bool,
+ is_disqualified: bool,
+}
+
#[derive(Debug)]
enum TrackedBufferStatus {
Created { existing_file_content: Option<Rope> },
@@ -1460,6 +1828,8 @@ pub struct TrackedBuffer {
diff_base: Rope,
unreviewed_edits: Patch<u32>,
status: TrackedBufferStatus,
+ mode: TrackedBufferMode,
+ expected_external_edit: Option<ExpectedExternalEdit>,
version: clock::Global,
diff: Entity<BufferDiff>,
snapshot: text::BufferSnapshot,
@@ -3774,6 +4144,219 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_expected_external_edit_does_not_mark_read_time_or_stale_before_first_agent_change(
+ 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();
+ let abs_path = PathBuf::from(path!("/dir/file"));
+
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| {
+ log.begin_expected_external_edit(buffer.clone(), cx);
+ });
+ });
+
+ assert!(
+ action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
+ "expected external edit should not record file_read_time before an agent change"
+ );
+ assert!(
+ action_log.read_with(cx, |log, cx| log.stale_buffers(cx).next().is_none()),
+ "expected external edit should not mark a synthetic tracker as stale"
+ );
+
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "zero\n")], None, cx).unwrap();
+ });
+ cx.run_until_parked();
+
+ assert!(
+ action_log.read_with(cx, |log, cx| log.changed_buffers(cx).is_empty()),
+ "local edits before the first external change should not become review hunks"
+ );
+ assert!(
+ action_log.read_with(cx, |log, cx| log.stale_buffers(cx).next().is_none()),
+ "expectation-only tracking should stay out of stale_buffers"
+ );
+ assert!(
+ action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
+ "local edits before the first external change should not record file_read_time"
+ );
+
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| {
+ log.end_expected_external_edit(buffer.clone(), cx);
+ });
+ });
+ cx.run_until_parked();
+
+ assert!(
+ action_log.read_with(cx, |log, cx| log.changed_buffers(cx).is_empty()),
+ "ending an expectation without an agent change should remove synthetic tracking"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_expected_external_edit_preserves_local_edits_before_first_agent_change(
+ 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);
+ });
+ });
+
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "zero\n")], None, cx).unwrap();
+ });
+ project
+ .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ fs.save(
+ path!("/dir/file").as_ref(),
+ &"zero\none\ntwo\nthree\n".into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ assert_eq!(
+ action_log.read_with(cx, |log, cx| log.changed_buffers(cx).len()),
+ 1,
+ "the first external change should be attributed relative to the local user baseline"
+ );
+
+ cx.update(|cx| {
+ action_log.update(cx, |log, cx| {
+ log.end_expected_external_edit(buffer.clone(), cx);
+ });
+ });
+ cx.run_until_parked();
+
+ action_log
+ .update(cx, |log, cx| log.reject_all_edits(None, cx))
+ .await;
+ cx.run_until_parked();
+
+ assert_eq!(
+ buffer.read_with(cx, |buffer, _| buffer.text()),
+ "zero\none\ntwo\n"
+ );
+ assert_eq!(
+ String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(),
+ "zero\none\ntwo\n"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_linked_expected_external_edit_tracks_review_without_parent_file_read_time(
+ 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 parent_log = cx.new(|_| ActionLog::new(project.clone()));
+ let child_log =
+ cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.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();
+ let abs_path = PathBuf::from(path!("/dir/file"));
+
+ cx.update(|cx| {
+ child_log.update(cx, |log, cx| {
+ log.begin_expected_external_edit(buffer.clone(), cx);
+ });
+ });
+
+ assert!(
+ child_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
+ "child should not record file_read_time until the first external agent change"
+ );
+ assert!(
+ parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
+ "parent should not inherit file_read_time from the child's pending expectation"
+ );
+
+ fs.save(
+ path!("/dir/file").as_ref(),
+ &"one\ntwo\nthree\n".into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ cx.update(|cx| {
+ child_log.update(cx, |log, cx| {
+ log.end_expected_external_edit(buffer.clone(), cx);
+ });
+ });
+ cx.run_until_parked();
+
+ let child_hunks = unreviewed_hunks(&child_log, cx);
+ assert!(
+ !child_hunks.is_empty(),
+ "child should track the expected external edit"
+ );
+ assert_eq!(
+ unreviewed_hunks(&parent_log, cx),
+ child_hunks,
+ "parent should also track the expected external edit"
+ );
+ assert!(
+ child_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
+ "child should record file_read_time once the expected external edit is attributed"
+ );
+ assert!(
+ parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
+ "parent should still not inherit file_read_time from the child's expected external edit"
+ );
+ }
+
#[gpui::test]
async fn test_infer_buffer_edited_from_snapshot(cx: &mut TestAppContext) {
init_test(cx);