1use anyhow::{Context as _, Result};
2use buffer_diff::BufferDiff;
3use clock;
4use collections::{BTreeMap, HashMap};
5use fs::MTime;
6use futures::{FutureExt, StreamExt, channel::mpsc};
7use gpui::{
8 App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
9};
10use language::{Anchor, Buffer, BufferEvent, Point, ToOffset, ToPoint};
11use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
12use std::{
13 cmp,
14 ops::Range,
15 path::{Path, PathBuf},
16 sync::Arc,
17};
18use text::{Edit, Patch, Rope};
19use util::{RangeExt, ResultExt as _};
20
21/// Stores undo information for a single buffer's rejected edits
22#[derive(Clone)]
23pub struct PerBufferUndo {
24 pub buffer: WeakEntity<Buffer>,
25 pub edits_to_restore: Vec<(Range<Anchor>, String)>,
26 pub status: UndoBufferStatus,
27}
28
29/// Tracks the buffer status for undo purposes
30#[derive(Clone, Debug)]
31pub enum UndoBufferStatus {
32 Modified,
33 /// Buffer was created by the agent.
34 /// - `had_existing_content: true` - Agent overwrote an existing file. On reject, the
35 /// original content was restored. Undo is supported: we restore the agent's content.
36 /// - `had_existing_content: false` - Agent created a new file that didn't exist before.
37 /// On reject, the file was deleted. Undo is NOT currently supported (would require
38 /// recreating the file). Future TODO.
39 Created {
40 had_existing_content: bool,
41 },
42}
43
44/// Stores undo information for the most recent reject operation
45#[derive(Clone)]
46pub struct LastRejectUndo {
47 /// Per-buffer undo information
48 pub buffers: Vec<PerBufferUndo>,
49}
50
51/// Tracks actions performed by tools in a thread
52pub struct ActionLog {
53 /// Buffers that we want to notify the model about when they change.
54 tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
55 /// The project this action log is associated with
56 project: Entity<Project>,
57 /// An action log to forward all public methods to
58 /// Useful in cases like subagents, where we want to track individual diffs for this subagent,
59 /// but also want to associate the reads/writes with a parent review experience
60 linked_action_log: Option<Entity<ActionLog>>,
61 /// Stores undo information for the most recent reject operation
62 last_reject_undo: Option<LastRejectUndo>,
63 /// Tracks the last time files were read by the agent, to detect external modifications
64 file_read_times: HashMap<PathBuf, MTime>,
65}
66
67impl ActionLog {
68 /// Creates a new, empty action log associated with the given project.
69 pub fn new(project: Entity<Project>) -> Self {
70 Self {
71 tracked_buffers: BTreeMap::default(),
72 project,
73 linked_action_log: None,
74 last_reject_undo: None,
75 file_read_times: HashMap::default(),
76 }
77 }
78
79 pub fn with_linked_action_log(mut self, linked_action_log: Entity<ActionLog>) -> Self {
80 self.linked_action_log = Some(linked_action_log);
81 self
82 }
83
84 pub fn project(&self) -> &Entity<Project> {
85 &self.project
86 }
87
88 pub fn file_read_time(&self, path: &Path) -> Option<MTime> {
89 self.file_read_times.get(path).copied()
90 }
91
92 fn update_file_read_time(&mut self, buffer: &Entity<Buffer>, cx: &App) {
93 let buffer = buffer.read(cx);
94 if let Some(file) = buffer.file() {
95 if let Some(local_file) = file.as_local() {
96 if let Some(mtime) = file.disk_state().mtime() {
97 let abs_path = local_file.abs_path(cx);
98 self.file_read_times.insert(abs_path, mtime);
99 }
100 }
101 }
102 }
103
104 fn remove_file_read_time(&mut self, buffer: &Entity<Buffer>, cx: &App) {
105 let buffer = buffer.read(cx);
106 if let Some(file) = buffer.file() {
107 if let Some(local_file) = file.as_local() {
108 let abs_path = local_file.abs_path(cx);
109 self.file_read_times.remove(&abs_path);
110 }
111 }
112 }
113
114 fn track_buffer_internal(
115 &mut self,
116 buffer: Entity<Buffer>,
117 is_created: bool,
118 cx: &mut Context<Self>,
119 ) -> &mut TrackedBuffer {
120 let status = if is_created {
121 if let Some(tracked) = self.tracked_buffers.remove(&buffer) {
122 match tracked.status {
123 TrackedBufferStatus::Created {
124 existing_file_content,
125 } => TrackedBufferStatus::Created {
126 existing_file_content,
127 },
128 TrackedBufferStatus::Modified | TrackedBufferStatus::Deleted => {
129 TrackedBufferStatus::Created {
130 existing_file_content: Some(tracked.diff_base),
131 }
132 }
133 }
134 } else if buffer
135 .read(cx)
136 .file()
137 .is_some_and(|file| file.disk_state().exists())
138 {
139 TrackedBufferStatus::Created {
140 existing_file_content: Some(buffer.read(cx).as_rope().clone()),
141 }
142 } else {
143 TrackedBufferStatus::Created {
144 existing_file_content: None,
145 }
146 }
147 } else {
148 TrackedBufferStatus::Modified
149 };
150
151 let tracked_buffer = self
152 .tracked_buffers
153 .entry(buffer.clone())
154 .or_insert_with(|| {
155 let open_lsp_handle = self.project.update(cx, |project, cx| {
156 project.register_buffer_with_language_servers(&buffer, cx)
157 });
158
159 let text_snapshot = buffer.read(cx).text_snapshot();
160 let language = buffer.read(cx).language().cloned();
161 let language_registry = buffer.read(cx).language_registry();
162 let diff = cx.new(|cx| {
163 let mut diff = BufferDiff::new(&text_snapshot, cx);
164 diff.language_changed(language, language_registry, cx);
165 diff
166 });
167 let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
168 let diff_base;
169 let unreviewed_edits;
170 if is_created {
171 diff_base = Rope::default();
172 unreviewed_edits = Patch::new(vec![Edit {
173 old: 0..1,
174 new: 0..text_snapshot.max_point().row + 1,
175 }])
176 } else {
177 diff_base = buffer.read(cx).as_rope().clone();
178 unreviewed_edits = Patch::default();
179 }
180 TrackedBuffer {
181 buffer: buffer.clone(),
182 diff_base,
183 unreviewed_edits,
184 snapshot: text_snapshot,
185 status,
186 mode: TrackedBufferMode::Normal,
187 expected_external_edit: None,
188 version: buffer.read(cx).version(),
189 diff,
190 diff_update: diff_update_tx,
191 _open_lsp_handle: open_lsp_handle,
192 _maintain_diff: cx.spawn({
193 let buffer = buffer.clone();
194 async move |this, cx| {
195 Self::maintain_diff(this, buffer, diff_update_rx, cx)
196 .await
197 .ok();
198 }
199 }),
200 _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
201 }
202 });
203 tracked_buffer.version = buffer.read(cx).version();
204 tracked_buffer
205 }
206
207 fn handle_buffer_event(
208 &mut self,
209 buffer: Entity<Buffer>,
210 event: &BufferEvent,
211 cx: &mut Context<Self>,
212 ) {
213 if self.handle_expected_external_edit_event(buffer.clone(), event, cx) {
214 return;
215 }
216
217 match event {
218 BufferEvent::Edited { .. } => {
219 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
220 return;
221 };
222 let buffer_version = buffer.read(cx).version();
223 if !buffer_version.changed_since(&tracked_buffer.version) {
224 return;
225 }
226 self.handle_buffer_edited(buffer, cx);
227 }
228 BufferEvent::FileHandleChanged => {
229 self.handle_buffer_file_changed(buffer, cx);
230 }
231 _ => {}
232 };
233 }
234
235 fn handle_buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
236 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
237 return;
238 };
239 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
240 }
241
242 fn handle_buffer_file_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
243 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
244 return;
245 };
246
247 match tracked_buffer.status {
248 TrackedBufferStatus::Created { .. } | TrackedBufferStatus::Modified => {
249 if buffer
250 .read(cx)
251 .file()
252 .is_some_and(|file| file.disk_state().is_deleted())
253 {
254 // If the buffer had been edited by a tool, but it got
255 // deleted externally, we want to stop tracking it.
256 self.tracked_buffers.remove(&buffer);
257 }
258 cx.notify();
259 }
260 TrackedBufferStatus::Deleted => {
261 if buffer
262 .read(cx)
263 .file()
264 .is_some_and(|file| !file.disk_state().is_deleted())
265 {
266 // If the buffer had been deleted by a tool, but it got
267 // resurrected externally, we want to clear the edits we
268 // were tracking and reset the buffer's state.
269 self.tracked_buffers.remove(&buffer);
270 self.track_buffer_internal(buffer, false, cx);
271 }
272 cx.notify();
273 }
274 }
275 }
276
277 fn handle_expected_external_edit_event(
278 &mut self,
279 buffer: Entity<Buffer>,
280 event: &BufferEvent,
281 cx: &mut Context<Self>,
282 ) -> bool {
283 let Some(expected_external_edit) = self
284 .tracked_buffers
285 .get(&buffer)
286 .and_then(|tracked_buffer| tracked_buffer.expected_external_edit.clone())
287 else {
288 return false;
289 };
290
291 if expected_external_edit.is_disqualified {
292 return false;
293 }
294
295 match event {
296 BufferEvent::Saved
297 if (expected_external_edit.observed_external_file_change
298 || expected_external_edit.armed_explicit_reload)
299 && !expected_external_edit.has_attributed_change =>
300 {
301 self.mark_expected_external_edit_disqualified(&buffer);
302 true
303 }
304 BufferEvent::Edited { is_local: true } => {
305 if expected_external_edit.pending_delete {
306 let (is_deleted, is_empty) = buffer.read_with(cx, |buffer, _| {
307 (
308 buffer
309 .file()
310 .is_some_and(|file| file.disk_state().is_deleted()),
311 buffer.text().is_empty(),
312 )
313 });
314
315 if is_deleted && is_empty {
316 self.apply_expected_external_delete_local(buffer, cx);
317 return true;
318 }
319 }
320
321 // Reload applies its text changes through ordinary local edit events before
322 // emitting `Reloaded`, so an explicitly armed reload must suppress those edits
323 // to preserve the pre-reload baseline for attribution.
324 expected_external_edit.observed_external_file_change
325 || expected_external_edit.armed_explicit_reload
326 }
327 BufferEvent::FileHandleChanged => {
328 let (is_deleted, is_empty, is_dirty) = buffer.read_with(cx, |buffer, _| {
329 (
330 buffer
331 .file()
332 .is_some_and(|file| file.disk_state().is_deleted()),
333 buffer.text().is_empty(),
334 buffer.is_dirty(),
335 )
336 });
337
338 if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) {
339 if let Some(expected_external_edit) =
340 tracked_buffer.expected_external_edit.as_mut()
341 {
342 if !is_dirty || is_deleted {
343 expected_external_edit.observed_external_file_change = true;
344 expected_external_edit.armed_explicit_reload = false;
345 }
346 // Non-delete external changes against dirty buffers stay unsupported for now.
347 // We do not mark them as observed here, so they are not automatically
348 // remembered for attribution once the buffer becomes clean. Later
349 // attribution only happens after a subsequent clean file change or an
350 // explicitly armed reload, which keeps conflicted reloads and local-save
351 // noise from becoming agent edits.
352 expected_external_edit.pending_delete = is_deleted;
353 }
354 }
355
356 if is_deleted {
357 if is_empty {
358 self.apply_expected_external_delete_local(buffer, cx);
359 } else if self.linked_action_log.is_none() {
360 let buffer = buffer.clone();
361 cx.defer(move |cx| {
362 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
363 });
364 }
365 }
366
367 true
368 }
369 BufferEvent::Reloaded
370 if (expected_external_edit.observed_external_file_change
371 || expected_external_edit.armed_explicit_reload)
372 && !expected_external_edit.pending_delete =>
373 {
374 if self.expected_external_edit_has_meaningful_change(&buffer, cx) {
375 self.apply_expected_external_reload_local(buffer, cx);
376 } else if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) {
377 if let Some(expected_external_edit) =
378 tracked_buffer.expected_external_edit.as_mut()
379 {
380 expected_external_edit.observed_external_file_change = false;
381 expected_external_edit.armed_explicit_reload = false;
382 expected_external_edit.pending_delete = false;
383 }
384 }
385
386 true
387 }
388 _ => false,
389 }
390 }
391
392 fn mark_expected_external_edit_disqualified(&mut self, buffer: &Entity<Buffer>) {
393 let Some(tracked_buffer) = self.tracked_buffers.get_mut(buffer) else {
394 return;
395 };
396 let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_mut() else {
397 return;
398 };
399
400 expected_external_edit.is_disqualified = true;
401 expected_external_edit.observed_external_file_change = false;
402 expected_external_edit.armed_explicit_reload = false;
403 expected_external_edit.pending_delete = false;
404 }
405
406 fn expected_external_edit_has_meaningful_change(
407 &self,
408 buffer: &Entity<Buffer>,
409 cx: &App,
410 ) -> bool {
411 let Some(tracked_buffer) = self.tracked_buffers.get(buffer) else {
412 return false;
413 };
414 let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_ref() else {
415 return false;
416 };
417
418 let (current_snapshot, current_exists) = buffer.read_with(cx, |buffer, _| {
419 (
420 buffer.text_snapshot(),
421 buffer.file().is_some_and(|file| file.disk_state().exists()),
422 )
423 });
424
425 if !expected_external_edit.initial_exists_on_disk {
426 current_exists || current_snapshot.text() != tracked_buffer.snapshot.text()
427 } else {
428 !current_exists || current_snapshot.text() != tracked_buffer.snapshot.text()
429 }
430 }
431
432 fn apply_expected_external_reload_local(
433 &mut self,
434 buffer: Entity<Buffer>,
435 cx: &mut Context<Self>,
436 ) {
437 let current_version = buffer.read(cx).version();
438 let (record_file_read_time, initial_exists_on_disk) = self
439 .tracked_buffers
440 .get(&buffer)
441 .and_then(|tracked_buffer| {
442 tracked_buffer
443 .expected_external_edit
444 .as_ref()
445 .map(|expected_external_edit| {
446 (
447 expected_external_edit.record_file_read_time_source_count > 0,
448 expected_external_edit.initial_exists_on_disk,
449 )
450 })
451 })
452 .unwrap_or((false, true));
453
454 if record_file_read_time {
455 self.update_file_read_time(&buffer, cx);
456 }
457
458 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
459 return;
460 };
461 let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_mut() else {
462 return;
463 };
464
465 expected_external_edit.has_attributed_change = true;
466 expected_external_edit.observed_external_file_change = false;
467 expected_external_edit.armed_explicit_reload = false;
468 expected_external_edit.pending_delete = false;
469 tracked_buffer.mode = TrackedBufferMode::Normal;
470
471 if !initial_exists_on_disk {
472 let existing_file_content = if tracked_buffer.diff_base.len() == 0 {
473 None
474 } else {
475 Some(tracked_buffer.diff_base.clone())
476 };
477 tracked_buffer.status = TrackedBufferStatus::Created {
478 existing_file_content,
479 };
480 } else {
481 tracked_buffer.status = TrackedBufferStatus::Modified;
482 }
483
484 tracked_buffer.version = current_version;
485 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
486 }
487
488 fn apply_expected_external_delete_local(
489 &mut self,
490 buffer: Entity<Buffer>,
491 cx: &mut Context<Self>,
492 ) {
493 let current_version = buffer.read(cx).version();
494 let remove_file_read_time = self
495 .tracked_buffers
496 .get(&buffer)
497 .and_then(|tracked_buffer| {
498 tracked_buffer
499 .expected_external_edit
500 .as_ref()
501 .map(|expected_external_edit| {
502 expected_external_edit.record_file_read_time_source_count > 0
503 })
504 })
505 .unwrap_or(false);
506
507 if remove_file_read_time {
508 self.remove_file_read_time(&buffer, cx);
509 }
510
511 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
512 return;
513 };
514 let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_mut() else {
515 return;
516 };
517
518 expected_external_edit.has_attributed_change = true;
519 expected_external_edit.observed_external_file_change = false;
520 expected_external_edit.armed_explicit_reload = false;
521 expected_external_edit.pending_delete = false;
522 tracked_buffer.mode = TrackedBufferMode::Normal;
523 tracked_buffer.status = TrackedBufferStatus::Deleted;
524 tracked_buffer.version = current_version;
525 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
526 }
527
528 async fn maintain_diff(
529 this: WeakEntity<Self>,
530 buffer: Entity<Buffer>,
531 mut buffer_updates: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
532 cx: &mut AsyncApp,
533 ) -> Result<()> {
534 let git_store = this.read_with(cx, |this, cx| this.project.read(cx).git_store().clone())?;
535 let git_diff = this
536 .update(cx, |this, cx| {
537 this.project.update(cx, |project, cx| {
538 project.open_uncommitted_diff(buffer.clone(), cx)
539 })
540 })?
541 .await
542 .ok();
543 let buffer_repo = git_store.read_with(cx, |git_store, cx| {
544 git_store.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
545 });
546
547 let (mut git_diff_updates_tx, mut git_diff_updates_rx) = watch::channel(());
548 let _repo_subscription =
549 if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) {
550 cx.update(|cx| {
551 let mut old_head = buffer_repo.read(cx).head_commit.clone();
552 Some(cx.subscribe(git_diff, move |_, event, cx| {
553 if let buffer_diff::BufferDiffEvent::DiffChanged { .. } = event {
554 let new_head = buffer_repo.read(cx).head_commit.clone();
555 if new_head != old_head {
556 old_head = new_head;
557 git_diff_updates_tx.send(()).ok();
558 }
559 }
560 }))
561 })
562 } else {
563 None
564 };
565
566 loop {
567 futures::select_biased! {
568 buffer_update = buffer_updates.next() => {
569 if let Some((author, buffer_snapshot)) = buffer_update {
570 Self::track_edits(&this, &buffer, author, buffer_snapshot, cx).await?;
571 } else {
572 break;
573 }
574 }
575 _ = git_diff_updates_rx.changed().fuse() => {
576 if let Some(git_diff) = git_diff.as_ref() {
577 Self::keep_committed_edits(&this, &buffer, git_diff, cx).await?;
578 }
579 }
580 }
581 }
582
583 Ok(())
584 }
585
586 async fn track_edits(
587 this: &WeakEntity<ActionLog>,
588 buffer: &Entity<Buffer>,
589 author: ChangeAuthor,
590 buffer_snapshot: text::BufferSnapshot,
591 cx: &mut AsyncApp,
592 ) -> Result<()> {
593 let rebase = this.update(cx, |this, cx| {
594 let tracked_buffer = this
595 .tracked_buffers
596 .get_mut(buffer)
597 .context("buffer not tracked")?;
598
599 let rebase = cx.background_spawn({
600 let mut base_text = tracked_buffer.diff_base.clone();
601 let old_snapshot = tracked_buffer.snapshot.clone();
602 let new_snapshot = buffer_snapshot.clone();
603 let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
604 let edits = diff_snapshots(&old_snapshot, &new_snapshot);
605 async move {
606 if let ChangeAuthor::User = author {
607 apply_non_conflicting_edits(
608 &unreviewed_edits,
609 edits,
610 &mut base_text,
611 new_snapshot.as_rope(),
612 );
613 }
614
615 (Arc::from(base_text.to_string().as_str()), base_text)
616 }
617 });
618
619 anyhow::Ok(rebase)
620 })??;
621 let (new_base_text, new_diff_base) = rebase.await;
622
623 Self::update_diff(
624 this,
625 buffer,
626 buffer_snapshot,
627 new_base_text,
628 new_diff_base,
629 cx,
630 )
631 .await
632 }
633
634 async fn keep_committed_edits(
635 this: &WeakEntity<ActionLog>,
636 buffer: &Entity<Buffer>,
637 git_diff: &Entity<BufferDiff>,
638 cx: &mut AsyncApp,
639 ) -> Result<()> {
640 let buffer_snapshot = this.read_with(cx, |this, _cx| {
641 let tracked_buffer = this
642 .tracked_buffers
643 .get(buffer)
644 .context("buffer not tracked")?;
645 anyhow::Ok(tracked_buffer.snapshot.clone())
646 })??;
647 let (new_base_text, new_diff_base) = this
648 .read_with(cx, |this, cx| {
649 let tracked_buffer = this
650 .tracked_buffers
651 .get(buffer)
652 .context("buffer not tracked")?;
653 let old_unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
654 let agent_diff_base = tracked_buffer.diff_base.clone();
655 let git_diff_base = git_diff.read(cx).base_text(cx).as_rope().clone();
656 let buffer_text = tracked_buffer.snapshot.as_rope().clone();
657 anyhow::Ok(cx.background_spawn(async move {
658 let mut old_unreviewed_edits = old_unreviewed_edits.into_iter().peekable();
659 let committed_edits = language::line_diff(
660 &agent_diff_base.to_string(),
661 &git_diff_base.to_string(),
662 )
663 .into_iter()
664 .map(|(old, new)| Edit { old, new });
665
666 let mut new_agent_diff_base = agent_diff_base.clone();
667 let mut row_delta = 0i32;
668 for committed in committed_edits {
669 while let Some(unreviewed) = old_unreviewed_edits.peek() {
670 // If the committed edit matches the unreviewed
671 // edit, assume the user wants to keep it.
672 if committed.old == unreviewed.old {
673 let unreviewed_new =
674 buffer_text.slice_rows(unreviewed.new.clone()).to_string();
675 let committed_new =
676 git_diff_base.slice_rows(committed.new.clone()).to_string();
677 if unreviewed_new == committed_new {
678 let old_byte_start =
679 new_agent_diff_base.point_to_offset(Point::new(
680 (unreviewed.old.start as i32 + row_delta) as u32,
681 0,
682 ));
683 let old_byte_end =
684 new_agent_diff_base.point_to_offset(cmp::min(
685 Point::new(
686 (unreviewed.old.end as i32 + row_delta) as u32,
687 0,
688 ),
689 new_agent_diff_base.max_point(),
690 ));
691 new_agent_diff_base
692 .replace(old_byte_start..old_byte_end, &unreviewed_new);
693 row_delta +=
694 unreviewed.new_len() as i32 - unreviewed.old_len() as i32;
695 }
696 } else if unreviewed.old.start >= committed.old.end {
697 break;
698 }
699
700 old_unreviewed_edits.next().unwrap();
701 }
702 }
703
704 (
705 Arc::from(new_agent_diff_base.to_string().as_str()),
706 new_agent_diff_base,
707 )
708 }))
709 })??
710 .await;
711
712 Self::update_diff(
713 this,
714 buffer,
715 buffer_snapshot,
716 new_base_text,
717 new_diff_base,
718 cx,
719 )
720 .await
721 }
722
723 async fn update_diff(
724 this: &WeakEntity<ActionLog>,
725 buffer: &Entity<Buffer>,
726 buffer_snapshot: text::BufferSnapshot,
727 new_base_text: Arc<str>,
728 new_diff_base: Rope,
729 cx: &mut AsyncApp,
730 ) -> Result<()> {
731 let (diff, language) = this.read_with(cx, |this, cx| {
732 let tracked_buffer = this
733 .tracked_buffers
734 .get(buffer)
735 .context("buffer not tracked")?;
736 anyhow::Ok((
737 tracked_buffer.diff.clone(),
738 buffer.read(cx).language().cloned(),
739 ))
740 })??;
741 let update = diff
742 .update(cx, |diff, cx| {
743 diff.update_diff(
744 buffer_snapshot.clone(),
745 Some(new_base_text),
746 Some(true),
747 language,
748 cx,
749 )
750 })
751 .await;
752 diff.update(cx, |diff, cx| {
753 diff.set_snapshot(update.clone(), &buffer_snapshot, cx)
754 })
755 .await;
756 let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx));
757
758 let unreviewed_edits = cx
759 .background_spawn({
760 let buffer_snapshot = buffer_snapshot.clone();
761 let new_diff_base = new_diff_base.clone();
762 async move {
763 let mut unreviewed_edits = Patch::default();
764 for hunk in diff_snapshot.hunks_intersecting_range(
765 Anchor::min_for_buffer(buffer_snapshot.remote_id())
766 ..Anchor::max_for_buffer(buffer_snapshot.remote_id()),
767 &buffer_snapshot,
768 ) {
769 let old_range = new_diff_base
770 .offset_to_point(hunk.diff_base_byte_range.start)
771 ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end);
772 let new_range = hunk.range.start..hunk.range.end;
773 unreviewed_edits.push(point_to_row_edit(
774 Edit {
775 old: old_range,
776 new: new_range,
777 },
778 &new_diff_base,
779 buffer_snapshot.as_rope(),
780 ));
781 }
782 unreviewed_edits
783 }
784 })
785 .await;
786 this.update(cx, |this, cx| {
787 let tracked_buffer = this
788 .tracked_buffers
789 .get_mut(buffer)
790 .context("buffer not tracked")?;
791 tracked_buffer.diff_base = new_diff_base;
792 tracked_buffer.snapshot = buffer_snapshot;
793 tracked_buffer.unreviewed_edits = unreviewed_edits;
794 cx.notify();
795 anyhow::Ok(())
796 })?
797 }
798
799 /// Track a buffer as read by agent, so we can notify the model about user edits.
800 pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
801 self.buffer_read_impl(buffer, true, cx);
802 }
803
804 fn buffer_read_impl(
805 &mut self,
806 buffer: Entity<Buffer>,
807 record_file_read_time: bool,
808 cx: &mut Context<Self>,
809 ) {
810 if let Some(linked_action_log) = &self.linked_action_log {
811 // We don't want to share read times since the other agent hasn't read it necessarily
812 linked_action_log.update(cx, |log, cx| {
813 log.buffer_read_impl(buffer.clone(), false, cx);
814 });
815 }
816 if record_file_read_time {
817 self.update_file_read_time(&buffer, cx);
818 }
819 self.track_buffer_internal(buffer, false, cx);
820 }
821
822 /// Mark a buffer as created by agent, so we can refresh it in the context
823 pub fn buffer_created(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
824 self.buffer_created_impl(buffer, true, cx);
825 }
826
827 fn buffer_created_impl(
828 &mut self,
829 buffer: Entity<Buffer>,
830 record_file_read_time: bool,
831 cx: &mut Context<Self>,
832 ) {
833 if let Some(linked_action_log) = &self.linked_action_log {
834 // We don't want to share read times since the other agent hasn't read it necessarily
835 linked_action_log.update(cx, |log, cx| {
836 log.buffer_created_impl(buffer.clone(), false, cx);
837 });
838 }
839 if record_file_read_time {
840 self.update_file_read_time(&buffer, cx);
841 }
842 self.track_buffer_internal(buffer, true, cx);
843 }
844
845 /// Mark a buffer as edited by agent, so we can refresh it in the context
846 pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
847 self.buffer_edited_impl(buffer, true, cx);
848 }
849
850 fn buffer_edited_impl(
851 &mut self,
852 buffer: Entity<Buffer>,
853 record_file_read_time: bool,
854 cx: &mut Context<Self>,
855 ) {
856 if let Some(linked_action_log) = &self.linked_action_log {
857 // We don't want to share read times since the other agent hasn't read it necessarily
858 linked_action_log.update(cx, |log, cx| {
859 log.buffer_edited_impl(buffer.clone(), false, cx);
860 });
861 }
862 if record_file_read_time {
863 self.update_file_read_time(&buffer, cx);
864 }
865 let new_version = buffer.read(cx).version();
866 let tracked_buffer = self.track_buffer_internal(buffer, false, cx);
867 if let TrackedBufferStatus::Deleted = tracked_buffer.status {
868 tracked_buffer.status = TrackedBufferStatus::Modified;
869 }
870
871 tracked_buffer.version = new_version;
872 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
873 }
874
875 fn prime_tracked_buffer_from_snapshot(
876 &mut self,
877 buffer: Entity<Buffer>,
878 baseline_snapshot: text::BufferSnapshot,
879 status: TrackedBufferStatus,
880 cx: &mut Context<Self>,
881 ) {
882 let version = buffer.read(cx).version();
883 let diff_base = match &status {
884 TrackedBufferStatus::Created {
885 existing_file_content: Some(existing_file_content),
886 } => existing_file_content.clone(),
887 TrackedBufferStatus::Created {
888 existing_file_content: None,
889 } => Rope::default(),
890 TrackedBufferStatus::Modified | TrackedBufferStatus::Deleted => {
891 baseline_snapshot.as_rope().clone()
892 }
893 };
894
895 let tracked_buffer = self.track_buffer_internal(buffer, false, cx);
896 tracked_buffer.diff_base = diff_base;
897 tracked_buffer.snapshot = baseline_snapshot;
898 tracked_buffer.unreviewed_edits.clear();
899 tracked_buffer.status = status;
900 tracked_buffer.version = version;
901 }
902
903 pub fn has_changed_buffer(&self, buffer: &Entity<Buffer>, cx: &App) -> bool {
904 self.tracked_buffers
905 .get(buffer)
906 .is_some_and(|tracked_buffer| tracked_buffer.has_edits(cx))
907 }
908
909 pub fn begin_expected_external_edit(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
910 self.begin_expected_external_edit_impl(buffer, true, cx);
911 }
912
913 fn begin_expected_external_edit_impl(
914 &mut self,
915 buffer: Entity<Buffer>,
916 record_file_read_time: bool,
917 cx: &mut Context<Self>,
918 ) {
919 if let Some(linked_action_log) = &self.linked_action_log {
920 linked_action_log.update(cx, |log, cx| {
921 log.begin_expected_external_edit_impl(buffer.clone(), false, cx);
922 });
923 }
924
925 let initial_exists_on_disk = buffer
926 .read(cx)
927 .file()
928 .is_some_and(|file| file.disk_state().exists());
929 let had_tracked_buffer = self.tracked_buffers.contains_key(&buffer);
930
931 if !had_tracked_buffer {
932 let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
933 tracked_buffer.mode = TrackedBufferMode::ExpectationOnly;
934 tracked_buffer.status = TrackedBufferStatus::Modified;
935 tracked_buffer.diff_base = buffer.read(cx).as_rope().clone();
936 tracked_buffer.snapshot = buffer.read(cx).text_snapshot();
937 tracked_buffer.unreviewed_edits.clear();
938 }
939
940 // Reusing an existing tracked buffer must preserve its prior version so stale-buffer
941 // detection continues to reflect any user edits that predate the expectation.
942 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
943 return;
944 };
945
946 let expected_external_edit =
947 tracked_buffer
948 .expected_external_edit
949 .get_or_insert_with(|| ExpectedExternalEdit {
950 active_source_count: 0,
951 record_file_read_time_source_count: 0,
952 initial_exists_on_disk,
953 observed_external_file_change: false,
954 armed_explicit_reload: false,
955 has_attributed_change: false,
956 pending_delete: false,
957 is_disqualified: false,
958 });
959 expected_external_edit.active_source_count += 1;
960 if record_file_read_time {
961 expected_external_edit.record_file_read_time_source_count += 1;
962 }
963 }
964
965 pub fn arm_expected_external_reload(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
966 self.arm_expected_external_reload_impl(buffer, true, cx);
967 }
968
969 fn arm_expected_external_reload_impl(
970 &mut self,
971 buffer: Entity<Buffer>,
972 forward_to_linked_action_log: bool,
973 cx: &mut Context<Self>,
974 ) {
975 if forward_to_linked_action_log {
976 if let Some(linked_action_log) = &self.linked_action_log {
977 linked_action_log.update(cx, |log, cx| {
978 log.arm_expected_external_reload_impl(buffer.clone(), false, cx);
979 });
980 }
981 }
982
983 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
984 return;
985 };
986 let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_mut() else {
987 return;
988 };
989 if expected_external_edit.is_disqualified || expected_external_edit.pending_delete {
990 return;
991 }
992
993 // Explicit reloads can observe on-disk contents before the worktree has delivered
994 // `FileHandleChanged`, so we arm the next reload for attribution ahead of time.
995 expected_external_edit.armed_explicit_reload = true;
996 }
997
998 pub fn end_expected_external_edit(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
999 self.end_expected_external_edit_impl(buffer, true, cx);
1000 }
1001
1002 fn end_expected_external_edit_impl(
1003 &mut self,
1004 buffer: Entity<Buffer>,
1005 record_file_read_time: bool,
1006 cx: &mut Context<Self>,
1007 ) {
1008 if let Some(linked_action_log) = &self.linked_action_log {
1009 linked_action_log.update(cx, |log, cx| {
1010 log.end_expected_external_edit_impl(buffer.clone(), false, cx);
1011 });
1012 }
1013
1014 let remove_tracked_buffer = if let Some(tracked_buffer) =
1015 self.tracked_buffers.get_mut(&buffer)
1016 {
1017 let Some(expected_external_edit) = tracked_buffer.expected_external_edit.as_mut()
1018 else {
1019 return;
1020 };
1021
1022 expected_external_edit.active_source_count =
1023 expected_external_edit.active_source_count.saturating_sub(1);
1024 if record_file_read_time {
1025 expected_external_edit.record_file_read_time_source_count = expected_external_edit
1026 .record_file_read_time_source_count
1027 .saturating_sub(1);
1028 }
1029
1030 if expected_external_edit.active_source_count > 0 {
1031 false
1032 } else {
1033 let remove_tracked_buffer = tracked_buffer.mode
1034 == TrackedBufferMode::ExpectationOnly
1035 && !expected_external_edit.has_attributed_change;
1036 tracked_buffer.expected_external_edit = None;
1037 tracked_buffer.mode = TrackedBufferMode::Normal;
1038 remove_tracked_buffer
1039 }
1040 } else {
1041 false
1042 };
1043
1044 if remove_tracked_buffer {
1045 self.tracked_buffers.remove(&buffer);
1046 cx.notify();
1047 }
1048 }
1049
1050 pub fn infer_buffer_created(
1051 &mut self,
1052 buffer: Entity<Buffer>,
1053 baseline_snapshot: text::BufferSnapshot,
1054 cx: &mut Context<Self>,
1055 ) {
1056 self.infer_buffer_from_snapshot_impl(
1057 buffer,
1058 baseline_snapshot,
1059 InferredSnapshotKind::Created,
1060 true,
1061 cx,
1062 );
1063 }
1064
1065 pub fn infer_buffer_edited_from_snapshot(
1066 &mut self,
1067 buffer: Entity<Buffer>,
1068 baseline_snapshot: text::BufferSnapshot,
1069 cx: &mut Context<Self>,
1070 ) {
1071 self.infer_buffer_from_snapshot_impl(
1072 buffer,
1073 baseline_snapshot,
1074 InferredSnapshotKind::Edited,
1075 true,
1076 cx,
1077 );
1078 }
1079
1080 pub fn infer_buffer_deleted_from_snapshot(
1081 &mut self,
1082 buffer: Entity<Buffer>,
1083 baseline_snapshot: text::BufferSnapshot,
1084 cx: &mut Context<Self>,
1085 ) {
1086 self.infer_buffer_from_snapshot_impl(
1087 buffer,
1088 baseline_snapshot,
1089 InferredSnapshotKind::Deleted,
1090 true,
1091 cx,
1092 );
1093 }
1094
1095 fn forward_inferred_snapshot_to_linked_action_log(
1096 &mut self,
1097 buffer: &Entity<Buffer>,
1098 baseline_snapshot: &text::BufferSnapshot,
1099 kind: InferredSnapshotKind,
1100 cx: &mut Context<Self>,
1101 ) {
1102 if let Some(linked_action_log) = &self.linked_action_log {
1103 let linked_baseline_snapshot = baseline_snapshot.clone();
1104 // Later inferred snapshots must keep refreshing linked logs for the same buffer so
1105 // parent and child review state do not diverge after the first forwarded hunk.
1106 linked_action_log.update(cx, |log, cx| {
1107 log.infer_buffer_from_snapshot_impl(
1108 buffer.clone(),
1109 linked_baseline_snapshot,
1110 kind,
1111 false,
1112 cx,
1113 );
1114 });
1115 }
1116 }
1117
1118 fn infer_buffer_from_snapshot_impl(
1119 &mut self,
1120 buffer: Entity<Buffer>,
1121 baseline_snapshot: text::BufferSnapshot,
1122 kind: InferredSnapshotKind,
1123 record_file_read_time: bool,
1124 cx: &mut Context<Self>,
1125 ) {
1126 self.forward_inferred_snapshot_to_linked_action_log(&buffer, &baseline_snapshot, kind, cx);
1127
1128 if record_file_read_time {
1129 match kind {
1130 InferredSnapshotKind::Created | InferredSnapshotKind::Edited => {
1131 self.update_file_read_time(&buffer, cx);
1132 }
1133 InferredSnapshotKind::Deleted => {
1134 self.remove_file_read_time(&buffer, cx);
1135 }
1136 }
1137 }
1138
1139 let tracked_buffer_status = kind.tracked_buffer_status(&baseline_snapshot);
1140 self.prime_tracked_buffer_from_snapshot(
1141 buffer.clone(),
1142 baseline_snapshot,
1143 tracked_buffer_status,
1144 cx,
1145 );
1146
1147 if kind == InferredSnapshotKind::Deleted && self.linked_action_log.is_none() {
1148 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
1149 }
1150
1151 if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) {
1152 if kind == InferredSnapshotKind::Deleted {
1153 tracked_buffer.version = buffer.read(cx).version();
1154 }
1155 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
1156 }
1157 }
1158
1159 pub fn will_delete_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
1160 // Ok to propagate file read time removal to linked action log
1161 self.remove_file_read_time(&buffer, cx);
1162 let has_linked_action_log = self.linked_action_log.is_some();
1163 let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
1164 match tracked_buffer.status {
1165 TrackedBufferStatus::Created { .. } => {
1166 self.tracked_buffers.remove(&buffer);
1167 cx.notify();
1168 }
1169 TrackedBufferStatus::Modified => {
1170 tracked_buffer.status = TrackedBufferStatus::Deleted;
1171 if !has_linked_action_log {
1172 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
1173 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
1174 }
1175 }
1176
1177 TrackedBufferStatus::Deleted => {}
1178 }
1179
1180 if let Some(linked_action_log) = &mut self.linked_action_log {
1181 linked_action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
1182 }
1183
1184 if let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) {
1185 tracked_buffer.version = buffer.read(cx).version();
1186 if has_linked_action_log {
1187 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
1188 }
1189 }
1190
1191 cx.notify();
1192 }
1193
1194 pub fn keep_edits_in_range(
1195 &mut self,
1196 buffer: Entity<Buffer>,
1197 buffer_range: Range<impl language::ToPoint>,
1198 telemetry: Option<ActionLogTelemetry>,
1199 cx: &mut Context<Self>,
1200 ) {
1201 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
1202 return;
1203 };
1204
1205 let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx));
1206 match tracked_buffer.status {
1207 TrackedBufferStatus::Deleted => {
1208 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
1209 self.tracked_buffers.remove(&buffer);
1210 cx.notify();
1211 }
1212 _ => {
1213 let buffer = buffer.read(cx);
1214 let buffer_range =
1215 buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
1216 let mut delta = 0i32;
1217 tracked_buffer.unreviewed_edits.retain_mut(|edit| {
1218 edit.old.start = (edit.old.start as i32 + delta) as u32;
1219 edit.old.end = (edit.old.end as i32 + delta) as u32;
1220
1221 if buffer_range.end.row < edit.new.start
1222 || buffer_range.start.row > edit.new.end
1223 {
1224 true
1225 } else {
1226 let old_range = tracked_buffer
1227 .diff_base
1228 .point_to_offset(Point::new(edit.old.start, 0))
1229 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
1230 Point::new(edit.old.end, 0),
1231 tracked_buffer.diff_base.max_point(),
1232 ));
1233 let new_range = tracked_buffer
1234 .snapshot
1235 .point_to_offset(Point::new(edit.new.start, 0))
1236 ..tracked_buffer.snapshot.point_to_offset(cmp::min(
1237 Point::new(edit.new.end, 0),
1238 tracked_buffer.snapshot.max_point(),
1239 ));
1240 tracked_buffer.diff_base.replace(
1241 old_range,
1242 &tracked_buffer
1243 .snapshot
1244 .text_for_range(new_range)
1245 .collect::<String>(),
1246 );
1247 delta += edit.new_len() as i32 - edit.old_len() as i32;
1248 metrics.add_edit(edit);
1249 false
1250 }
1251 });
1252 if tracked_buffer.unreviewed_edits.is_empty()
1253 && let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status
1254 {
1255 tracked_buffer.status = TrackedBufferStatus::Modified;
1256 }
1257 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
1258 }
1259 }
1260 if let Some(telemetry) = telemetry {
1261 telemetry_report_accepted_edits(&telemetry, metrics);
1262 }
1263 }
1264
1265 pub fn reject_edits_in_ranges(
1266 &mut self,
1267 buffer: Entity<Buffer>,
1268 buffer_ranges: Vec<Range<impl language::ToPoint>>,
1269 telemetry: Option<ActionLogTelemetry>,
1270 cx: &mut Context<Self>,
1271 ) -> (Task<Result<()>>, Option<PerBufferUndo>) {
1272 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
1273 return (Task::ready(Ok(())), None);
1274 };
1275
1276 let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx));
1277 let mut undo_info: Option<PerBufferUndo> = None;
1278 let task = match &tracked_buffer.status {
1279 TrackedBufferStatus::Created {
1280 existing_file_content,
1281 } => {
1282 let task = if let Some(existing_file_content) = existing_file_content {
1283 // Capture the agent's content before restoring existing file content
1284 let agent_content = buffer.read(cx).text();
1285
1286 buffer.update(cx, |buffer, cx| {
1287 buffer.start_transaction();
1288 buffer.set_text("", cx);
1289 for chunk in existing_file_content.chunks() {
1290 buffer.append(chunk, cx);
1291 }
1292 buffer.end_transaction(cx);
1293 });
1294
1295 undo_info = Some(PerBufferUndo {
1296 buffer: buffer.downgrade(),
1297 edits_to_restore: vec![(Anchor::MIN..Anchor::MAX, agent_content)],
1298 status: UndoBufferStatus::Created {
1299 had_existing_content: true,
1300 },
1301 });
1302
1303 self.project
1304 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1305 } else {
1306 // For a file created by AI with no pre-existing content,
1307 // only delete the file if we're certain it contains only AI content
1308 // with no edits from the user.
1309
1310 let initial_version = tracked_buffer.version.clone();
1311 let current_version = buffer.read(cx).version();
1312
1313 let current_content = buffer.read(cx).text();
1314 let tracked_content = tracked_buffer.snapshot.text();
1315
1316 let is_ai_only_content =
1317 initial_version == current_version && current_content == tracked_content;
1318
1319 if is_ai_only_content {
1320 buffer
1321 .read(cx)
1322 .entry_id(cx)
1323 .and_then(|entry_id| {
1324 self.project.update(cx, |project, cx| {
1325 project.delete_entry(entry_id, false, cx)
1326 })
1327 })
1328 .unwrap_or(Task::ready(Ok(())))
1329 } else {
1330 // Not sure how to disentangle edits made by the user
1331 // from edits made by the AI at this point.
1332 // For now, preserve both to avoid data loss.
1333 //
1334 // TODO: Better solution (disable "Reject" after user makes some
1335 // edit or find a way to differentiate between AI and user edits)
1336 Task::ready(Ok(()))
1337 }
1338 };
1339
1340 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
1341 self.tracked_buffers.remove(&buffer);
1342 cx.notify();
1343 task
1344 }
1345 TrackedBufferStatus::Deleted => {
1346 let current_version = buffer.read(cx).version();
1347 if current_version != tracked_buffer.version {
1348 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
1349 self.tracked_buffers.remove(&buffer);
1350 cx.notify();
1351 Task::ready(Ok(()))
1352 } else {
1353 buffer.update(cx, |buffer, cx| {
1354 buffer.set_text(tracked_buffer.diff_base.to_string(), cx)
1355 });
1356 let save = self
1357 .project
1358 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
1359
1360 // Clear all tracked edits for this buffer and start over as if we just read it.
1361 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
1362 self.tracked_buffers.remove(&buffer);
1363 self.buffer_read(buffer.clone(), cx);
1364 cx.notify();
1365 save
1366 }
1367 }
1368 TrackedBufferStatus::Modified => {
1369 let edits_to_restore = buffer.update(cx, |buffer, cx| {
1370 let mut buffer_row_ranges = buffer_ranges
1371 .into_iter()
1372 .map(|range| {
1373 range.start.to_point(buffer).row..range.end.to_point(buffer).row
1374 })
1375 .peekable();
1376
1377 let mut edits_to_revert = Vec::new();
1378 let mut edits_for_undo = Vec::new();
1379 for edit in tracked_buffer.unreviewed_edits.edits() {
1380 let new_range = tracked_buffer
1381 .snapshot
1382 .anchor_before(Point::new(edit.new.start, 0))
1383 ..tracked_buffer.snapshot.anchor_after(cmp::min(
1384 Point::new(edit.new.end, 0),
1385 tracked_buffer.snapshot.max_point(),
1386 ));
1387 let new_row_range = new_range.start.to_point(buffer).row
1388 ..new_range.end.to_point(buffer).row;
1389
1390 let mut revert = false;
1391 while let Some(buffer_row_range) = buffer_row_ranges.peek() {
1392 if buffer_row_range.end < new_row_range.start {
1393 buffer_row_ranges.next();
1394 } else if buffer_row_range.start > new_row_range.end {
1395 break;
1396 } else {
1397 revert = true;
1398 break;
1399 }
1400 }
1401
1402 if revert {
1403 metrics.add_edit(edit);
1404 let old_range = tracked_buffer
1405 .diff_base
1406 .point_to_offset(Point::new(edit.old.start, 0))
1407 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
1408 Point::new(edit.old.end, 0),
1409 tracked_buffer.diff_base.max_point(),
1410 ));
1411 let old_text = tracked_buffer
1412 .diff_base
1413 .chunks_in_range(old_range)
1414 .collect::<String>();
1415
1416 // Capture the agent's text before we revert it (for undo)
1417 let new_range_offset =
1418 new_range.start.to_offset(buffer)..new_range.end.to_offset(buffer);
1419 let agent_text =
1420 buffer.text_for_range(new_range_offset).collect::<String>();
1421 edits_for_undo.push((new_range.clone(), agent_text));
1422
1423 edits_to_revert.push((new_range, old_text));
1424 }
1425 }
1426
1427 buffer.edit(edits_to_revert, None, cx);
1428 edits_for_undo
1429 });
1430
1431 if !edits_to_restore.is_empty() {
1432 undo_info = Some(PerBufferUndo {
1433 buffer: buffer.downgrade(),
1434 edits_to_restore,
1435 status: UndoBufferStatus::Modified,
1436 });
1437 }
1438
1439 self.project
1440 .update(cx, |project, cx| project.save_buffer(buffer, cx))
1441 }
1442 };
1443 if let Some(telemetry) = telemetry {
1444 telemetry_report_rejected_edits(&telemetry, metrics);
1445 }
1446 (task, undo_info)
1447 }
1448
1449 pub fn keep_all_edits(
1450 &mut self,
1451 telemetry: Option<ActionLogTelemetry>,
1452 cx: &mut Context<Self>,
1453 ) {
1454 self.tracked_buffers.retain(|buffer, tracked_buffer| {
1455 let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx));
1456 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
1457 if let Some(telemetry) = telemetry.as_ref() {
1458 telemetry_report_accepted_edits(telemetry, metrics);
1459 }
1460 match tracked_buffer.status {
1461 TrackedBufferStatus::Deleted => false,
1462 _ => {
1463 if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status {
1464 tracked_buffer.status = TrackedBufferStatus::Modified;
1465 }
1466 tracked_buffer.unreviewed_edits.clear();
1467 tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
1468 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
1469 true
1470 }
1471 }
1472 });
1473
1474 cx.notify();
1475 }
1476
1477 pub fn reject_all_edits(
1478 &mut self,
1479 telemetry: Option<ActionLogTelemetry>,
1480 cx: &mut Context<Self>,
1481 ) -> Task<()> {
1482 // Clear any previous undo state before starting a new reject operation
1483 self.last_reject_undo = None;
1484
1485 let mut undo_buffers = Vec::new();
1486 let mut futures = Vec::new();
1487
1488 for buffer in self.changed_buffers(cx).into_keys() {
1489 let buffer_ranges = vec![Anchor::min_max_range_for_buffer(
1490 buffer.read(cx).remote_id(),
1491 )];
1492 let (reject_task, undo_info) =
1493 self.reject_edits_in_ranges(buffer, buffer_ranges, telemetry.clone(), cx);
1494
1495 if let Some(undo) = undo_info {
1496 undo_buffers.push(undo);
1497 }
1498
1499 futures.push(async move {
1500 reject_task.await.log_err();
1501 });
1502 }
1503
1504 // Store the undo information if we have any
1505 if !undo_buffers.is_empty() {
1506 self.last_reject_undo = Some(LastRejectUndo {
1507 buffers: undo_buffers,
1508 });
1509 }
1510
1511 let task = futures::future::join_all(futures);
1512 cx.background_spawn(async move {
1513 task.await;
1514 })
1515 }
1516
1517 pub fn has_pending_undo(&self) -> bool {
1518 self.last_reject_undo.is_some()
1519 }
1520
1521 pub fn set_last_reject_undo(&mut self, undo: LastRejectUndo) {
1522 self.last_reject_undo = Some(undo);
1523 }
1524
1525 /// Undoes the most recent reject operation, restoring the rejected agent changes.
1526 /// This is a best-effort operation: if buffers have been closed or modified externally,
1527 /// those buffers will be skipped.
1528 pub fn undo_last_reject(&mut self, cx: &mut Context<Self>) -> Task<()> {
1529 let Some(undo) = self.last_reject_undo.take() else {
1530 return Task::ready(());
1531 };
1532
1533 let mut save_tasks = Vec::with_capacity(undo.buffers.len());
1534
1535 for per_buffer_undo in undo.buffers {
1536 // Skip if the buffer entity has been deallocated
1537 let Some(buffer) = per_buffer_undo.buffer.upgrade() else {
1538 continue;
1539 };
1540
1541 buffer.update(cx, |buffer, cx| {
1542 let mut valid_edits = Vec::new();
1543
1544 for (anchor_range, text_to_restore) in per_buffer_undo.edits_to_restore {
1545 if anchor_range.start.buffer_id == Some(buffer.remote_id())
1546 && anchor_range.end.buffer_id == Some(buffer.remote_id())
1547 {
1548 valid_edits.push((anchor_range, text_to_restore));
1549 }
1550 }
1551
1552 if !valid_edits.is_empty() {
1553 buffer.edit(valid_edits, None, cx);
1554 }
1555 });
1556
1557 if !self.tracked_buffers.contains_key(&buffer) {
1558 self.buffer_edited(buffer.clone(), cx);
1559 }
1560
1561 let save = self
1562 .project
1563 .update(cx, |project, cx| project.save_buffer(buffer, cx));
1564 save_tasks.push(save);
1565 }
1566
1567 cx.notify();
1568
1569 cx.background_spawn(async move {
1570 futures::future::join_all(save_tasks).await;
1571 })
1572 }
1573
1574 /// Returns the set of buffers that contain edits that haven't been reviewed by the user.
1575 pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, Entity<BufferDiff>> {
1576 self.tracked_buffers
1577 .iter()
1578 .filter(|(_, tracked)| tracked.has_edits(cx))
1579 .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
1580 .collect()
1581 }
1582
1583 /// Returns the total number of lines added and removed across all unreviewed buffers.
1584 pub fn diff_stats(&self, cx: &App) -> DiffStats {
1585 DiffStats::all_files(&self.changed_buffers(cx), cx)
1586 }
1587
1588 /// Iterate over buffers changed since last read or edited by the model
1589 pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
1590 self.tracked_buffers
1591 .iter()
1592 .filter(|(buffer, tracked)| {
1593 let buffer = buffer.read(cx);
1594
1595 tracked.mode == TrackedBufferMode::Normal
1596 && tracked.version != buffer.version
1597 && buffer
1598 .file()
1599 .is_some_and(|file| !file.disk_state().is_deleted())
1600 })
1601 .map(|(buffer, _)| buffer)
1602 }
1603}
1604
1605#[derive(Default, Debug, Clone, Copy)]
1606pub struct DiffStats {
1607 pub lines_added: u32,
1608 pub lines_removed: u32,
1609}
1610
1611impl DiffStats {
1612 pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
1613 let mut stats = DiffStats::default();
1614 let diff_snapshot = diff.snapshot(cx);
1615 let buffer_snapshot = buffer.snapshot();
1616 let base_text = diff_snapshot.base_text();
1617
1618 for hunk in diff_snapshot.hunks(&buffer_snapshot) {
1619 let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
1620 stats.lines_added += added_rows;
1621
1622 let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
1623 let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
1624 let removed_rows = base_end.saturating_sub(base_start);
1625 stats.lines_removed += removed_rows;
1626 }
1627
1628 stats
1629 }
1630
1631 pub fn all_files(
1632 changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
1633 cx: &App,
1634 ) -> Self {
1635 let mut total = DiffStats::default();
1636 for (buffer, diff) in changed_buffers {
1637 let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
1638 total.lines_added += stats.lines_added;
1639 total.lines_removed += stats.lines_removed;
1640 }
1641 total
1642 }
1643}
1644
1645#[derive(Clone)]
1646pub struct ActionLogTelemetry {
1647 pub agent_telemetry_id: SharedString,
1648 pub session_id: Arc<str>,
1649}
1650
1651struct ActionLogMetrics {
1652 lines_removed: u32,
1653 lines_added: u32,
1654 language: Option<SharedString>,
1655}
1656
1657impl ActionLogMetrics {
1658 fn for_buffer(buffer: &Buffer) -> Self {
1659 Self {
1660 language: buffer.language().map(|l| l.name().0),
1661 lines_removed: 0,
1662 lines_added: 0,
1663 }
1664 }
1665
1666 fn add_edits(&mut self, edits: &[Edit<u32>]) {
1667 for edit in edits {
1668 self.add_edit(edit);
1669 }
1670 }
1671
1672 fn add_edit(&mut self, edit: &Edit<u32>) {
1673 self.lines_added += edit.new_len();
1674 self.lines_removed += edit.old_len();
1675 }
1676}
1677
1678fn telemetry_report_accepted_edits(telemetry: &ActionLogTelemetry, metrics: ActionLogMetrics) {
1679 telemetry::event!(
1680 "Agent Edits Accepted",
1681 agent = telemetry.agent_telemetry_id,
1682 session = telemetry.session_id,
1683 language = metrics.language,
1684 lines_added = metrics.lines_added,
1685 lines_removed = metrics.lines_removed
1686 );
1687}
1688
1689fn telemetry_report_rejected_edits(telemetry: &ActionLogTelemetry, metrics: ActionLogMetrics) {
1690 telemetry::event!(
1691 "Agent Edits Rejected",
1692 agent = telemetry.agent_telemetry_id,
1693 session = telemetry.session_id,
1694 language = metrics.language,
1695 lines_added = metrics.lines_added,
1696 lines_removed = metrics.lines_removed
1697 );
1698}
1699
1700fn apply_non_conflicting_edits(
1701 patch: &Patch<u32>,
1702 edits: Vec<Edit<u32>>,
1703 old_text: &mut Rope,
1704 new_text: &Rope,
1705) -> bool {
1706 let mut old_edits = patch.edits().iter().cloned().peekable();
1707 let mut new_edits = edits.into_iter().peekable();
1708 let mut applied_delta = 0i32;
1709 let mut rebased_delta = 0i32;
1710 let mut has_made_changes = false;
1711
1712 while let Some(mut new_edit) = new_edits.next() {
1713 let mut conflict = false;
1714
1715 // Push all the old edits that are before this new edit or that intersect with it.
1716 while let Some(old_edit) = old_edits.peek() {
1717 if new_edit.old.end < old_edit.new.start
1718 || (!old_edit.new.is_empty() && new_edit.old.end == old_edit.new.start)
1719 {
1720 break;
1721 } else if new_edit.old.start > old_edit.new.end
1722 || (!old_edit.new.is_empty() && new_edit.old.start == old_edit.new.end)
1723 {
1724 let old_edit = old_edits.next().unwrap();
1725 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
1726 } else {
1727 conflict = true;
1728 if new_edits
1729 .peek()
1730 .is_some_and(|next_edit| next_edit.old.overlaps(&old_edit.new))
1731 {
1732 new_edit = new_edits.next().unwrap();
1733 } else {
1734 let old_edit = old_edits.next().unwrap();
1735 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
1736 }
1737 }
1738 }
1739
1740 if !conflict {
1741 // This edit doesn't intersect with any old edit, so we can apply it to the old text.
1742 new_edit.old.start = (new_edit.old.start as i32 + applied_delta - rebased_delta) as u32;
1743 new_edit.old.end = (new_edit.old.end as i32 + applied_delta - rebased_delta) as u32;
1744 let old_bytes = old_text.point_to_offset(Point::new(new_edit.old.start, 0))
1745 ..old_text.point_to_offset(cmp::min(
1746 Point::new(new_edit.old.end, 0),
1747 old_text.max_point(),
1748 ));
1749 let new_bytes = new_text.point_to_offset(Point::new(new_edit.new.start, 0))
1750 ..new_text.point_to_offset(cmp::min(
1751 Point::new(new_edit.new.end, 0),
1752 new_text.max_point(),
1753 ));
1754
1755 old_text.replace(
1756 old_bytes,
1757 &new_text.chunks_in_range(new_bytes).collect::<String>(),
1758 );
1759 applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
1760 has_made_changes = true;
1761 }
1762 }
1763 has_made_changes
1764}
1765
1766fn diff_snapshots(
1767 old_snapshot: &text::BufferSnapshot,
1768 new_snapshot: &text::BufferSnapshot,
1769) -> Vec<Edit<u32>> {
1770 let mut edits = new_snapshot
1771 .edits_since::<Point>(&old_snapshot.version)
1772 .map(|edit| point_to_row_edit(edit, old_snapshot.as_rope(), new_snapshot.as_rope()))
1773 .peekable();
1774 let mut row_edits = Vec::new();
1775 while let Some(mut edit) = edits.next() {
1776 while let Some(next_edit) = edits.peek() {
1777 if edit.old.end >= next_edit.old.start {
1778 edit.old.end = next_edit.old.end;
1779 edit.new.end = next_edit.new.end;
1780 edits.next();
1781 } else {
1782 break;
1783 }
1784 }
1785 row_edits.push(edit);
1786 }
1787 row_edits
1788}
1789
1790fn point_to_row_edit(edit: Edit<Point>, old_text: &Rope, new_text: &Rope) -> Edit<u32> {
1791 if edit.old.start.column == old_text.line_len(edit.old.start.row)
1792 && new_text
1793 .chars_at(new_text.point_to_offset(edit.new.start))
1794 .next()
1795 == Some('\n')
1796 && edit.old.start != old_text.max_point()
1797 {
1798 Edit {
1799 old: edit.old.start.row + 1..edit.old.end.row + 1,
1800 new: edit.new.start.row + 1..edit.new.end.row + 1,
1801 }
1802 } else if edit.old.start.column == 0 && edit.old.end.column == 0 && edit.new.end.column == 0 {
1803 Edit {
1804 old: edit.old.start.row..edit.old.end.row,
1805 new: edit.new.start.row..edit.new.end.row,
1806 }
1807 } else {
1808 Edit {
1809 old: edit.old.start.row..edit.old.end.row + 1,
1810 new: edit.new.start.row..edit.new.end.row + 1,
1811 }
1812 }
1813}
1814
1815#[derive(Copy, Clone, Debug)]
1816enum ChangeAuthor {
1817 User,
1818 Agent,
1819}
1820
1821#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1822enum InferredSnapshotKind {
1823 Created,
1824 Edited,
1825 Deleted,
1826}
1827
1828impl InferredSnapshotKind {
1829 fn tracked_buffer_status(
1830 self,
1831 baseline_snapshot: &text::BufferSnapshot,
1832 ) -> TrackedBufferStatus {
1833 match self {
1834 Self::Created => TrackedBufferStatus::Created {
1835 existing_file_content: if baseline_snapshot.text().is_empty() {
1836 None
1837 } else {
1838 Some(baseline_snapshot.as_rope().clone())
1839 },
1840 },
1841 Self::Edited => TrackedBufferStatus::Modified,
1842 Self::Deleted => TrackedBufferStatus::Deleted,
1843 }
1844 }
1845}
1846
1847#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1848enum TrackedBufferMode {
1849 Normal,
1850 ExpectationOnly,
1851}
1852
1853#[derive(Clone, Debug)]
1854struct ExpectedExternalEdit {
1855 active_source_count: usize,
1856 record_file_read_time_source_count: usize,
1857 initial_exists_on_disk: bool,
1858 observed_external_file_change: bool,
1859 armed_explicit_reload: bool,
1860 has_attributed_change: bool,
1861 pending_delete: bool,
1862 is_disqualified: bool,
1863}
1864
1865#[derive(Debug)]
1866enum TrackedBufferStatus {
1867 Created { existing_file_content: Option<Rope> },
1868 Modified,
1869 Deleted,
1870}
1871
1872pub struct TrackedBuffer {
1873 buffer: Entity<Buffer>,
1874 diff_base: Rope,
1875 unreviewed_edits: Patch<u32>,
1876 status: TrackedBufferStatus,
1877 mode: TrackedBufferMode,
1878 expected_external_edit: Option<ExpectedExternalEdit>,
1879 version: clock::Global,
1880 diff: Entity<BufferDiff>,
1881 snapshot: text::BufferSnapshot,
1882 diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
1883 _open_lsp_handle: OpenLspBufferHandle,
1884 _maintain_diff: Task<()>,
1885 _subscription: Subscription,
1886}
1887
1888impl TrackedBuffer {
1889 #[cfg(any(test, feature = "test-support"))]
1890 pub fn diff(&self) -> &Entity<BufferDiff> {
1891 &self.diff
1892 }
1893
1894 #[cfg(any(test, feature = "test-support"))]
1895 pub fn diff_base_len(&self) -> usize {
1896 self.diff_base.len()
1897 }
1898
1899 fn has_edits(&self, cx: &App) -> bool {
1900 self.diff
1901 .read(cx)
1902 .snapshot(cx)
1903 .hunks(self.buffer.read(cx))
1904 .next()
1905 .is_some()
1906 }
1907
1908 fn schedule_diff_update(&self, author: ChangeAuthor, cx: &App) {
1909 self.diff_update
1910 .unbounded_send((author, self.buffer.read(cx).text_snapshot()))
1911 .ok();
1912 }
1913}
1914
1915pub struct ChangedBuffer {
1916 pub diff: Entity<BufferDiff>,
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921 use super::*;
1922 use buffer_diff::DiffHunkStatusKind;
1923 use gpui::TestAppContext;
1924 use language::Point;
1925 use project::{FakeFs, Fs, Project, RemoveOptions};
1926 use rand::prelude::*;
1927 use serde_json::json;
1928 use settings::SettingsStore;
1929 use std::env;
1930 use util::{RandomCharIter, path};
1931
1932 #[ctor::ctor]
1933 fn init_logger() {
1934 zlog::init_test();
1935 }
1936
1937 fn init_test(cx: &mut TestAppContext) {
1938 cx.update(|cx| {
1939 let settings_store = SettingsStore::test(cx);
1940 cx.set_global(settings_store);
1941 });
1942 }
1943
1944 #[gpui::test(iterations = 10)]
1945 async fn test_keep_edits(cx: &mut TestAppContext) {
1946 init_test(cx);
1947
1948 let fs = FakeFs::new(cx.executor());
1949 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1950 .await;
1951 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1952 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1953 let file_path = project
1954 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1955 .unwrap();
1956 let buffer = project
1957 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1958 .await
1959 .unwrap();
1960
1961 cx.update(|cx| {
1962 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1963 buffer.update(cx, |buffer, cx| {
1964 buffer
1965 .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx)
1966 .unwrap()
1967 });
1968 buffer.update(cx, |buffer, cx| {
1969 buffer
1970 .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx)
1971 .unwrap()
1972 });
1973 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1974 });
1975 cx.run_until_parked();
1976 assert_eq!(
1977 buffer.read_with(cx, |buffer, _| buffer.text()),
1978 "abc\ndEf\nghi\njkl\nmnO"
1979 );
1980 assert_eq!(
1981 unreviewed_hunks(&action_log, cx),
1982 vec![(
1983 buffer.clone(),
1984 vec![
1985 HunkStatus {
1986 range: Point::new(1, 0)..Point::new(2, 0),
1987 diff_status: DiffHunkStatusKind::Modified,
1988 old_text: "def\n".into(),
1989 },
1990 HunkStatus {
1991 range: Point::new(4, 0)..Point::new(4, 3),
1992 diff_status: DiffHunkStatusKind::Modified,
1993 old_text: "mno".into(),
1994 }
1995 ],
1996 )]
1997 );
1998
1999 action_log.update(cx, |log, cx| {
2000 log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), None, cx)
2001 });
2002 cx.run_until_parked();
2003 assert_eq!(
2004 unreviewed_hunks(&action_log, cx),
2005 vec![(
2006 buffer.clone(),
2007 vec![HunkStatus {
2008 range: Point::new(1, 0)..Point::new(2, 0),
2009 diff_status: DiffHunkStatusKind::Modified,
2010 old_text: "def\n".into(),
2011 }],
2012 )]
2013 );
2014
2015 action_log.update(cx, |log, cx| {
2016 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), None, cx)
2017 });
2018 cx.run_until_parked();
2019 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2020 }
2021
2022 #[gpui::test(iterations = 10)]
2023 async fn test_deletions(cx: &mut TestAppContext) {
2024 init_test(cx);
2025
2026 let fs = FakeFs::new(cx.executor());
2027 fs.insert_tree(
2028 path!("/dir"),
2029 json!({"file": "abc\ndef\nghi\njkl\nmno\npqr"}),
2030 )
2031 .await;
2032 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2033 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2034 let file_path = project
2035 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2036 .unwrap();
2037 let buffer = project
2038 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2039 .await
2040 .unwrap();
2041
2042 cx.update(|cx| {
2043 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2044 buffer.update(cx, |buffer, cx| {
2045 buffer
2046 .edit([(Point::new(1, 0)..Point::new(2, 0), "")], None, cx)
2047 .unwrap();
2048 buffer.finalize_last_transaction();
2049 });
2050 buffer.update(cx, |buffer, cx| {
2051 buffer
2052 .edit([(Point::new(3, 0)..Point::new(4, 0), "")], None, cx)
2053 .unwrap();
2054 buffer.finalize_last_transaction();
2055 });
2056 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2057 });
2058 cx.run_until_parked();
2059 assert_eq!(
2060 buffer.read_with(cx, |buffer, _| buffer.text()),
2061 "abc\nghi\njkl\npqr"
2062 );
2063 assert_eq!(
2064 unreviewed_hunks(&action_log, cx),
2065 vec![(
2066 buffer.clone(),
2067 vec![
2068 HunkStatus {
2069 range: Point::new(1, 0)..Point::new(1, 0),
2070 diff_status: DiffHunkStatusKind::Deleted,
2071 old_text: "def\n".into(),
2072 },
2073 HunkStatus {
2074 range: Point::new(3, 0)..Point::new(3, 0),
2075 diff_status: DiffHunkStatusKind::Deleted,
2076 old_text: "mno\n".into(),
2077 }
2078 ],
2079 )]
2080 );
2081
2082 buffer.update(cx, |buffer, cx| buffer.undo(cx));
2083 cx.run_until_parked();
2084 assert_eq!(
2085 buffer.read_with(cx, |buffer, _| buffer.text()),
2086 "abc\nghi\njkl\nmno\npqr"
2087 );
2088 assert_eq!(
2089 unreviewed_hunks(&action_log, cx),
2090 vec![(
2091 buffer.clone(),
2092 vec![HunkStatus {
2093 range: Point::new(1, 0)..Point::new(1, 0),
2094 diff_status: DiffHunkStatusKind::Deleted,
2095 old_text: "def\n".into(),
2096 }],
2097 )]
2098 );
2099
2100 action_log.update(cx, |log, cx| {
2101 log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), None, cx)
2102 });
2103 cx.run_until_parked();
2104 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2105 }
2106
2107 #[gpui::test(iterations = 10)]
2108 async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
2109 init_test(cx);
2110
2111 let fs = FakeFs::new(cx.executor());
2112 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
2113 .await;
2114 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2115 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2116 let file_path = project
2117 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2118 .unwrap();
2119 let buffer = project
2120 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2121 .await
2122 .unwrap();
2123
2124 cx.update(|cx| {
2125 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2126 buffer.update(cx, |buffer, cx| {
2127 buffer
2128 .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
2129 .unwrap()
2130 });
2131 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2132 });
2133 cx.run_until_parked();
2134 assert_eq!(
2135 buffer.read_with(cx, |buffer, _| buffer.text()),
2136 "abc\ndeF\nGHI\njkl\nmno"
2137 );
2138 assert_eq!(
2139 unreviewed_hunks(&action_log, cx),
2140 vec![(
2141 buffer.clone(),
2142 vec![HunkStatus {
2143 range: Point::new(1, 0)..Point::new(3, 0),
2144 diff_status: DiffHunkStatusKind::Modified,
2145 old_text: "def\nghi\n".into(),
2146 }],
2147 )]
2148 );
2149
2150 buffer.update(cx, |buffer, cx| {
2151 buffer.edit(
2152 [
2153 (Point::new(0, 2)..Point::new(0, 2), "X"),
2154 (Point::new(3, 0)..Point::new(3, 0), "Y"),
2155 ],
2156 None,
2157 cx,
2158 )
2159 });
2160 cx.run_until_parked();
2161 assert_eq!(
2162 buffer.read_with(cx, |buffer, _| buffer.text()),
2163 "abXc\ndeF\nGHI\nYjkl\nmno"
2164 );
2165 assert_eq!(
2166 unreviewed_hunks(&action_log, cx),
2167 vec![(
2168 buffer.clone(),
2169 vec![HunkStatus {
2170 range: Point::new(1, 0)..Point::new(3, 0),
2171 diff_status: DiffHunkStatusKind::Modified,
2172 old_text: "def\nghi\n".into(),
2173 }],
2174 )]
2175 );
2176
2177 buffer.update(cx, |buffer, cx| {
2178 buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "Z")], None, cx)
2179 });
2180 cx.run_until_parked();
2181 assert_eq!(
2182 buffer.read_with(cx, |buffer, _| buffer.text()),
2183 "abXc\ndZeF\nGHI\nYjkl\nmno"
2184 );
2185 assert_eq!(
2186 unreviewed_hunks(&action_log, cx),
2187 vec![(
2188 buffer.clone(),
2189 vec![HunkStatus {
2190 range: Point::new(1, 0)..Point::new(3, 0),
2191 diff_status: DiffHunkStatusKind::Modified,
2192 old_text: "def\nghi\n".into(),
2193 }],
2194 )]
2195 );
2196
2197 action_log.update(cx, |log, cx| {
2198 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), None, cx)
2199 });
2200 cx.run_until_parked();
2201 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2202 }
2203
2204 #[gpui::test(iterations = 10)]
2205 async fn test_creating_files(cx: &mut TestAppContext) {
2206 init_test(cx);
2207
2208 let fs = FakeFs::new(cx.executor());
2209 fs.insert_tree(path!("/dir"), json!({})).await;
2210 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2211 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2212 let file_path = project
2213 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
2214 .unwrap();
2215
2216 let buffer = project
2217 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2218 .await
2219 .unwrap();
2220 cx.update(|cx| {
2221 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2222 buffer.update(cx, |buffer, cx| buffer.set_text("lorem", cx));
2223 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2224 });
2225 project
2226 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2227 .await
2228 .unwrap();
2229 cx.run_until_parked();
2230 assert_eq!(
2231 unreviewed_hunks(&action_log, cx),
2232 vec![(
2233 buffer.clone(),
2234 vec![HunkStatus {
2235 range: Point::new(0, 0)..Point::new(0, 5),
2236 diff_status: DiffHunkStatusKind::Added,
2237 old_text: "".into(),
2238 }],
2239 )]
2240 );
2241
2242 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "X")], None, cx));
2243 cx.run_until_parked();
2244 assert_eq!(
2245 unreviewed_hunks(&action_log, cx),
2246 vec![(
2247 buffer.clone(),
2248 vec![HunkStatus {
2249 range: Point::new(0, 0)..Point::new(0, 6),
2250 diff_status: DiffHunkStatusKind::Added,
2251 old_text: "".into(),
2252 }],
2253 )]
2254 );
2255
2256 action_log.update(cx, |log, cx| {
2257 log.keep_edits_in_range(buffer.clone(), 0..5, None, cx)
2258 });
2259 cx.run_until_parked();
2260 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2261 }
2262
2263 #[gpui::test(iterations = 10)]
2264 async fn test_overwriting_files(cx: &mut TestAppContext) {
2265 init_test(cx);
2266
2267 let fs = FakeFs::new(cx.executor());
2268 fs.insert_tree(
2269 path!("/dir"),
2270 json!({
2271 "file1": "Lorem ipsum dolor"
2272 }),
2273 )
2274 .await;
2275 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2276 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2277 let file_path = project
2278 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
2279 .unwrap();
2280
2281 let buffer = project
2282 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2283 .await
2284 .unwrap();
2285 cx.update(|cx| {
2286 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2287 buffer.update(cx, |buffer, cx| buffer.set_text("sit amet consecteur", cx));
2288 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2289 });
2290 project
2291 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2292 .await
2293 .unwrap();
2294 cx.run_until_parked();
2295 assert_eq!(
2296 unreviewed_hunks(&action_log, cx),
2297 vec![(
2298 buffer.clone(),
2299 vec![HunkStatus {
2300 range: Point::new(0, 0)..Point::new(0, 19),
2301 diff_status: DiffHunkStatusKind::Added,
2302 old_text: "".into(),
2303 }],
2304 )]
2305 );
2306
2307 action_log
2308 .update(cx, |log, cx| {
2309 let (task, _) = log.reject_edits_in_ranges(buffer.clone(), vec![2..5], None, cx);
2310 task
2311 })
2312 .await
2313 .unwrap();
2314 cx.run_until_parked();
2315 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2316 assert_eq!(
2317 buffer.read_with(cx, |buffer, _cx| buffer.text()),
2318 "Lorem ipsum dolor"
2319 );
2320 }
2321
2322 #[gpui::test(iterations = 10)]
2323 async fn test_overwriting_previously_edited_files(cx: &mut TestAppContext) {
2324 init_test(cx);
2325
2326 let fs = FakeFs::new(cx.executor());
2327 fs.insert_tree(
2328 path!("/dir"),
2329 json!({
2330 "file1": "Lorem ipsum dolor"
2331 }),
2332 )
2333 .await;
2334 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2335 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2336 let file_path = project
2337 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
2338 .unwrap();
2339
2340 let buffer = project
2341 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2342 .await
2343 .unwrap();
2344 cx.update(|cx| {
2345 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2346 buffer.update(cx, |buffer, cx| buffer.append(" sit amet consecteur", cx));
2347 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2348 });
2349 project
2350 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2351 .await
2352 .unwrap();
2353 cx.run_until_parked();
2354 assert_eq!(
2355 unreviewed_hunks(&action_log, cx),
2356 vec![(
2357 buffer.clone(),
2358 vec![HunkStatus {
2359 range: Point::new(0, 0)..Point::new(0, 37),
2360 diff_status: DiffHunkStatusKind::Modified,
2361 old_text: "Lorem ipsum dolor".into(),
2362 }],
2363 )]
2364 );
2365
2366 cx.update(|cx| {
2367 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2368 buffer.update(cx, |buffer, cx| buffer.set_text("rewritten", cx));
2369 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2370 });
2371 project
2372 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2373 .await
2374 .unwrap();
2375 cx.run_until_parked();
2376 assert_eq!(
2377 unreviewed_hunks(&action_log, cx),
2378 vec![(
2379 buffer.clone(),
2380 vec![HunkStatus {
2381 range: Point::new(0, 0)..Point::new(0, 9),
2382 diff_status: DiffHunkStatusKind::Added,
2383 old_text: "".into(),
2384 }],
2385 )]
2386 );
2387
2388 action_log
2389 .update(cx, |log, cx| {
2390 let (task, _) = log.reject_edits_in_ranges(buffer.clone(), vec![2..5], None, cx);
2391 task
2392 })
2393 .await
2394 .unwrap();
2395 cx.run_until_parked();
2396 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2397 assert_eq!(
2398 buffer.read_with(cx, |buffer, _cx| buffer.text()),
2399 "Lorem ipsum dolor"
2400 );
2401 }
2402
2403 #[gpui::test(iterations = 10)]
2404 async fn test_deleting_files(cx: &mut TestAppContext) {
2405 init_test(cx);
2406
2407 let fs = FakeFs::new(cx.executor());
2408 fs.insert_tree(
2409 path!("/dir"),
2410 json!({"file1": "lorem\n", "file2": "ipsum\n"}),
2411 )
2412 .await;
2413
2414 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2415 let file1_path = project
2416 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
2417 .unwrap();
2418 let file2_path = project
2419 .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
2420 .unwrap();
2421
2422 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2423 let buffer1 = project
2424 .update(cx, |project, cx| {
2425 project.open_buffer(file1_path.clone(), cx)
2426 })
2427 .await
2428 .unwrap();
2429 let buffer2 = project
2430 .update(cx, |project, cx| {
2431 project.open_buffer(file2_path.clone(), cx)
2432 })
2433 .await
2434 .unwrap();
2435
2436 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx));
2437 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx));
2438 project
2439 .update(cx, |project, cx| {
2440 project.delete_file(file1_path.clone(), false, cx)
2441 })
2442 .unwrap()
2443 .await
2444 .unwrap();
2445 project
2446 .update(cx, |project, cx| {
2447 project.delete_file(file2_path.clone(), false, cx)
2448 })
2449 .unwrap()
2450 .await
2451 .unwrap();
2452 cx.run_until_parked();
2453 assert_eq!(
2454 unreviewed_hunks(&action_log, cx),
2455 vec![
2456 (
2457 buffer1.clone(),
2458 vec![HunkStatus {
2459 range: Point::new(0, 0)..Point::new(0, 0),
2460 diff_status: DiffHunkStatusKind::Deleted,
2461 old_text: "lorem\n".into(),
2462 }]
2463 ),
2464 (
2465 buffer2.clone(),
2466 vec![HunkStatus {
2467 range: Point::new(0, 0)..Point::new(0, 0),
2468 diff_status: DiffHunkStatusKind::Deleted,
2469 old_text: "ipsum\n".into(),
2470 }],
2471 )
2472 ]
2473 );
2474
2475 // Simulate file1 being recreated externally.
2476 fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec())
2477 .await;
2478
2479 // Simulate file2 being recreated by a tool.
2480 let buffer2 = project
2481 .update(cx, |project, cx| project.open_buffer(file2_path, cx))
2482 .await
2483 .unwrap();
2484 action_log.update(cx, |log, cx| log.buffer_created(buffer2.clone(), cx));
2485 buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx));
2486 action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx));
2487 project
2488 .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx))
2489 .await
2490 .unwrap();
2491
2492 cx.run_until_parked();
2493 assert_eq!(
2494 unreviewed_hunks(&action_log, cx),
2495 vec![(
2496 buffer2.clone(),
2497 vec![HunkStatus {
2498 range: Point::new(0, 0)..Point::new(0, 5),
2499 diff_status: DiffHunkStatusKind::Added,
2500 old_text: "".into(),
2501 }],
2502 )]
2503 );
2504
2505 // Simulate file2 being deleted externally.
2506 fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default())
2507 .await
2508 .unwrap();
2509 cx.run_until_parked();
2510 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2511 }
2512
2513 #[gpui::test(iterations = 10)]
2514 async fn test_reject_edits(cx: &mut TestAppContext) {
2515 init_test(cx);
2516
2517 let fs = FakeFs::new(cx.executor());
2518 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
2519 .await;
2520 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2521 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2522 let file_path = project
2523 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2524 .unwrap();
2525 let buffer = project
2526 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2527 .await
2528 .unwrap();
2529
2530 cx.update(|cx| {
2531 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2532 buffer.update(cx, |buffer, cx| {
2533 buffer
2534 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
2535 .unwrap()
2536 });
2537 buffer.update(cx, |buffer, cx| {
2538 buffer
2539 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
2540 .unwrap()
2541 });
2542 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2543 });
2544 cx.run_until_parked();
2545 assert_eq!(
2546 buffer.read_with(cx, |buffer, _| buffer.text()),
2547 "abc\ndE\nXYZf\nghi\njkl\nmnO"
2548 );
2549 assert_eq!(
2550 unreviewed_hunks(&action_log, cx),
2551 vec![(
2552 buffer.clone(),
2553 vec![
2554 HunkStatus {
2555 range: Point::new(1, 0)..Point::new(3, 0),
2556 diff_status: DiffHunkStatusKind::Modified,
2557 old_text: "def\n".into(),
2558 },
2559 HunkStatus {
2560 range: Point::new(5, 0)..Point::new(5, 3),
2561 diff_status: DiffHunkStatusKind::Modified,
2562 old_text: "mno".into(),
2563 }
2564 ],
2565 )]
2566 );
2567
2568 // If the rejected range doesn't overlap with any hunk, we ignore it.
2569 action_log
2570 .update(cx, |log, cx| {
2571 let (task, _) = log.reject_edits_in_ranges(
2572 buffer.clone(),
2573 vec![Point::new(4, 0)..Point::new(4, 0)],
2574 None,
2575 cx,
2576 );
2577 task
2578 })
2579 .await
2580 .unwrap();
2581 cx.run_until_parked();
2582 assert_eq!(
2583 buffer.read_with(cx, |buffer, _| buffer.text()),
2584 "abc\ndE\nXYZf\nghi\njkl\nmnO"
2585 );
2586 assert_eq!(
2587 unreviewed_hunks(&action_log, cx),
2588 vec![(
2589 buffer.clone(),
2590 vec![
2591 HunkStatus {
2592 range: Point::new(1, 0)..Point::new(3, 0),
2593 diff_status: DiffHunkStatusKind::Modified,
2594 old_text: "def\n".into(),
2595 },
2596 HunkStatus {
2597 range: Point::new(5, 0)..Point::new(5, 3),
2598 diff_status: DiffHunkStatusKind::Modified,
2599 old_text: "mno".into(),
2600 }
2601 ],
2602 )]
2603 );
2604
2605 action_log
2606 .update(cx, |log, cx| {
2607 let (task, _) = log.reject_edits_in_ranges(
2608 buffer.clone(),
2609 vec![Point::new(0, 0)..Point::new(1, 0)],
2610 None,
2611 cx,
2612 );
2613 task
2614 })
2615 .await
2616 .unwrap();
2617 cx.run_until_parked();
2618 assert_eq!(
2619 buffer.read_with(cx, |buffer, _| buffer.text()),
2620 "abc\ndef\nghi\njkl\nmnO"
2621 );
2622 assert_eq!(
2623 unreviewed_hunks(&action_log, cx),
2624 vec![(
2625 buffer.clone(),
2626 vec![HunkStatus {
2627 range: Point::new(4, 0)..Point::new(4, 3),
2628 diff_status: DiffHunkStatusKind::Modified,
2629 old_text: "mno".into(),
2630 }],
2631 )]
2632 );
2633
2634 action_log
2635 .update(cx, |log, cx| {
2636 let (task, _) = log.reject_edits_in_ranges(
2637 buffer.clone(),
2638 vec![Point::new(4, 0)..Point::new(4, 0)],
2639 None,
2640 cx,
2641 );
2642 task
2643 })
2644 .await
2645 .unwrap();
2646 cx.run_until_parked();
2647 assert_eq!(
2648 buffer.read_with(cx, |buffer, _| buffer.text()),
2649 "abc\ndef\nghi\njkl\nmno"
2650 );
2651 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2652 }
2653
2654 #[gpui::test(iterations = 10)]
2655 async fn test_reject_multiple_edits(cx: &mut TestAppContext) {
2656 init_test(cx);
2657
2658 let fs = FakeFs::new(cx.executor());
2659 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
2660 .await;
2661 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2662 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2663 let file_path = project
2664 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2665 .unwrap();
2666 let buffer = project
2667 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2668 .await
2669 .unwrap();
2670
2671 cx.update(|cx| {
2672 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2673 buffer.update(cx, |buffer, cx| {
2674 buffer
2675 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
2676 .unwrap()
2677 });
2678 buffer.update(cx, |buffer, cx| {
2679 buffer
2680 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
2681 .unwrap()
2682 });
2683 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2684 });
2685 cx.run_until_parked();
2686 assert_eq!(
2687 buffer.read_with(cx, |buffer, _| buffer.text()),
2688 "abc\ndE\nXYZf\nghi\njkl\nmnO"
2689 );
2690 assert_eq!(
2691 unreviewed_hunks(&action_log, cx),
2692 vec![(
2693 buffer.clone(),
2694 vec![
2695 HunkStatus {
2696 range: Point::new(1, 0)..Point::new(3, 0),
2697 diff_status: DiffHunkStatusKind::Modified,
2698 old_text: "def\n".into(),
2699 },
2700 HunkStatus {
2701 range: Point::new(5, 0)..Point::new(5, 3),
2702 diff_status: DiffHunkStatusKind::Modified,
2703 old_text: "mno".into(),
2704 }
2705 ],
2706 )]
2707 );
2708
2709 action_log.update(cx, |log, cx| {
2710 let range_1 = buffer.read(cx).anchor_before(Point::new(0, 0))
2711 ..buffer.read(cx).anchor_before(Point::new(1, 0));
2712 let range_2 = buffer.read(cx).anchor_before(Point::new(5, 0))
2713 ..buffer.read(cx).anchor_before(Point::new(5, 3));
2714
2715 let (task, _) =
2716 log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], None, cx);
2717 task.detach();
2718 assert_eq!(
2719 buffer.read_with(cx, |buffer, _| buffer.text()),
2720 "abc\ndef\nghi\njkl\nmno"
2721 );
2722 });
2723 cx.run_until_parked();
2724 assert_eq!(
2725 buffer.read_with(cx, |buffer, _| buffer.text()),
2726 "abc\ndef\nghi\njkl\nmno"
2727 );
2728 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2729 }
2730
2731 #[gpui::test(iterations = 10)]
2732 async fn test_reject_deleted_file(cx: &mut TestAppContext) {
2733 init_test(cx);
2734
2735 let fs = FakeFs::new(cx.executor());
2736 fs.insert_tree(path!("/dir"), json!({"file": "content"}))
2737 .await;
2738 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2739 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2740 let file_path = project
2741 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2742 .unwrap();
2743 let buffer = project
2744 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
2745 .await
2746 .unwrap();
2747
2748 cx.update(|cx| {
2749 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
2750 });
2751 project
2752 .update(cx, |project, cx| {
2753 project.delete_file(file_path.clone(), false, cx)
2754 })
2755 .unwrap()
2756 .await
2757 .unwrap();
2758 cx.run_until_parked();
2759 assert!(!fs.is_file(path!("/dir/file").as_ref()).await);
2760 assert_eq!(
2761 unreviewed_hunks(&action_log, cx),
2762 vec![(
2763 buffer.clone(),
2764 vec![HunkStatus {
2765 range: Point::new(0, 0)..Point::new(0, 0),
2766 diff_status: DiffHunkStatusKind::Deleted,
2767 old_text: "content".into(),
2768 }]
2769 )]
2770 );
2771
2772 action_log
2773 .update(cx, |log, cx| {
2774 let (task, _) = log.reject_edits_in_ranges(
2775 buffer.clone(),
2776 vec![Point::new(0, 0)..Point::new(0, 0)],
2777 None,
2778 cx,
2779 );
2780 task
2781 })
2782 .await
2783 .unwrap();
2784 cx.run_until_parked();
2785 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content");
2786 assert!(fs.is_file(path!("/dir/file").as_ref()).await);
2787 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2788 }
2789
2790 #[gpui::test(iterations = 10)]
2791 async fn test_reject_created_file(cx: &mut TestAppContext) {
2792 init_test(cx);
2793
2794 let fs = FakeFs::new(cx.executor());
2795 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2796 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2797 let file_path = project
2798 .read_with(cx, |project, cx| {
2799 project.find_project_path("dir/new_file", cx)
2800 })
2801 .unwrap();
2802 let buffer = project
2803 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2804 .await
2805 .unwrap();
2806 cx.update(|cx| {
2807 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2808 buffer.update(cx, |buffer, cx| buffer.set_text("content", cx));
2809 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2810 });
2811 project
2812 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2813 .await
2814 .unwrap();
2815 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2816 cx.run_until_parked();
2817 assert_eq!(
2818 unreviewed_hunks(&action_log, cx),
2819 vec![(
2820 buffer.clone(),
2821 vec![HunkStatus {
2822 range: Point::new(0, 0)..Point::new(0, 7),
2823 diff_status: DiffHunkStatusKind::Added,
2824 old_text: "".into(),
2825 }],
2826 )]
2827 );
2828
2829 action_log
2830 .update(cx, |log, cx| {
2831 let (task, _) = log.reject_edits_in_ranges(
2832 buffer.clone(),
2833 vec![Point::new(0, 0)..Point::new(0, 11)],
2834 None,
2835 cx,
2836 );
2837 task
2838 })
2839 .await
2840 .unwrap();
2841 cx.run_until_parked();
2842 assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await);
2843 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2844 }
2845
2846 #[gpui::test]
2847 async fn test_reject_created_file_with_user_edits(cx: &mut TestAppContext) {
2848 init_test(cx);
2849
2850 let fs = FakeFs::new(cx.executor());
2851 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2852 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2853
2854 let file_path = project
2855 .read_with(cx, |project, cx| {
2856 project.find_project_path("dir/new_file", cx)
2857 })
2858 .unwrap();
2859 let buffer = project
2860 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2861 .await
2862 .unwrap();
2863
2864 // AI creates file with initial content
2865 cx.update(|cx| {
2866 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2867 buffer.update(cx, |buffer, cx| buffer.set_text("ai content", cx));
2868 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2869 });
2870
2871 project
2872 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2873 .await
2874 .unwrap();
2875
2876 cx.run_until_parked();
2877
2878 // User makes additional edits
2879 cx.update(|cx| {
2880 buffer.update(cx, |buffer, cx| {
2881 buffer.edit([(10..10, "\nuser added this line")], None, cx);
2882 });
2883 });
2884
2885 project
2886 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2887 .await
2888 .unwrap();
2889
2890 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2891
2892 // Reject all
2893 action_log
2894 .update(cx, |log, cx| {
2895 let (task, _) = log.reject_edits_in_ranges(
2896 buffer.clone(),
2897 vec![Point::new(0, 0)..Point::new(100, 0)],
2898 None,
2899 cx,
2900 );
2901 task
2902 })
2903 .await
2904 .unwrap();
2905 cx.run_until_parked();
2906
2907 // File should still contain all the content
2908 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2909
2910 let content = buffer.read_with(cx, |buffer, _| buffer.text());
2911 assert_eq!(content, "ai content\nuser added this line");
2912 }
2913
2914 #[gpui::test]
2915 async fn test_reject_after_accepting_hunk_on_created_file(cx: &mut TestAppContext) {
2916 init_test(cx);
2917
2918 let fs = FakeFs::new(cx.executor());
2919 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2920 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2921
2922 let file_path = project
2923 .read_with(cx, |project, cx| {
2924 project.find_project_path("dir/new_file", cx)
2925 })
2926 .unwrap();
2927 let buffer = project
2928 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
2929 .await
2930 .unwrap();
2931
2932 // AI creates file with initial content
2933 cx.update(|cx| {
2934 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2935 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
2936 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2937 });
2938 project
2939 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2940 .await
2941 .unwrap();
2942 cx.run_until_parked();
2943 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2944
2945 // User accepts the single hunk
2946 action_log.update(cx, |log, cx| {
2947 let buffer_range = Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id());
2948 log.keep_edits_in_range(buffer.clone(), buffer_range, None, cx)
2949 });
2950 cx.run_until_parked();
2951 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2952 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2953
2954 // AI modifies the file
2955 cx.update(|cx| {
2956 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
2957 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2958 });
2959 project
2960 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2961 .await
2962 .unwrap();
2963 cx.run_until_parked();
2964 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2965
2966 // User rejects the hunk
2967 action_log
2968 .update(cx, |log, cx| {
2969 let (task, _) = log.reject_edits_in_ranges(
2970 buffer.clone(),
2971 vec![Anchor::min_max_range_for_buffer(
2972 buffer.read(cx).remote_id(),
2973 )],
2974 None,
2975 cx,
2976 );
2977 task
2978 })
2979 .await
2980 .unwrap();
2981 cx.run_until_parked();
2982 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await,);
2983 assert_eq!(
2984 buffer.read_with(cx, |buffer, _| buffer.text()),
2985 "ai content v1"
2986 );
2987 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2988 }
2989
2990 #[gpui::test]
2991 async fn test_reject_edits_on_previously_accepted_created_file(cx: &mut TestAppContext) {
2992 init_test(cx);
2993
2994 let fs = FakeFs::new(cx.executor());
2995 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2996 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2997
2998 let file_path = project
2999 .read_with(cx, |project, cx| {
3000 project.find_project_path("dir/new_file", cx)
3001 })
3002 .unwrap();
3003 let buffer = project
3004 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
3005 .await
3006 .unwrap();
3007
3008 // AI creates file with initial content
3009 cx.update(|cx| {
3010 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
3011 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
3012 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3013 });
3014 project
3015 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
3016 .await
3017 .unwrap();
3018 cx.run_until_parked();
3019
3020 // User clicks "Accept All"
3021 action_log.update(cx, |log, cx| log.keep_all_edits(None, cx));
3022 cx.run_until_parked();
3023 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
3024 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); // Hunks are cleared
3025
3026 // AI modifies file again
3027 cx.update(|cx| {
3028 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
3029 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3030 });
3031 project
3032 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
3033 .await
3034 .unwrap();
3035 cx.run_until_parked();
3036 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
3037
3038 // User clicks "Reject All"
3039 action_log
3040 .update(cx, |log, cx| log.reject_all_edits(None, cx))
3041 .await;
3042 cx.run_until_parked();
3043 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
3044 assert_eq!(
3045 buffer.read_with(cx, |buffer, _| buffer.text()),
3046 "ai content v1"
3047 );
3048 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
3049 }
3050
3051 #[gpui::test(iterations = 100)]
3052 async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
3053 init_test(cx);
3054
3055 let operations = env::var("OPERATIONS")
3056 .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
3057 .unwrap_or(20);
3058
3059 let text = RandomCharIter::new(&mut rng).take(50).collect::<String>();
3060 let fs = FakeFs::new(cx.executor());
3061 fs.insert_tree(path!("/dir"), json!({"file": text})).await;
3062 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3063 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3064 let file_path = project
3065 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3066 .unwrap();
3067 let buffer = project
3068 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3069 .await
3070 .unwrap();
3071
3072 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3073
3074 for _ in 0..operations {
3075 match rng.random_range(0..100) {
3076 0..25 => {
3077 action_log.update(cx, |log, cx| {
3078 let range = buffer.read(cx).random_byte_range(0, &mut rng);
3079 log::info!("keeping edits in range {:?}", range);
3080 log.keep_edits_in_range(buffer.clone(), range, None, cx)
3081 });
3082 }
3083 25..50 => {
3084 action_log
3085 .update(cx, |log, cx| {
3086 let range = buffer.read(cx).random_byte_range(0, &mut rng);
3087 log::info!("rejecting edits in range {:?}", range);
3088 let (task, _) =
3089 log.reject_edits_in_ranges(buffer.clone(), vec![range], None, cx);
3090 task
3091 })
3092 .await
3093 .unwrap();
3094 }
3095 _ => {
3096 let is_agent_edit = rng.random_bool(0.5);
3097 if is_agent_edit {
3098 log::info!("agent edit");
3099 } else {
3100 log::info!("user edit");
3101 }
3102 cx.update(|cx| {
3103 buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
3104 if is_agent_edit {
3105 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3106 }
3107 });
3108 }
3109 }
3110
3111 if rng.random_bool(0.2) {
3112 quiesce(&action_log, &buffer, cx);
3113 }
3114 }
3115
3116 quiesce(&action_log, &buffer, cx);
3117
3118 fn quiesce(
3119 action_log: &Entity<ActionLog>,
3120 buffer: &Entity<Buffer>,
3121 cx: &mut TestAppContext,
3122 ) {
3123 log::info!("quiescing...");
3124 cx.run_until_parked();
3125 action_log.update(cx, |log, cx| {
3126 let tracked_buffer = log.tracked_buffers.get(buffer).unwrap();
3127 let mut old_text = tracked_buffer.diff_base.clone();
3128 let new_text = buffer.read(cx).as_rope();
3129 for edit in tracked_buffer.unreviewed_edits.edits() {
3130 let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
3131 let old_end = old_text.point_to_offset(cmp::min(
3132 Point::new(edit.new.start + edit.old_len(), 0),
3133 old_text.max_point(),
3134 ));
3135 old_text.replace(
3136 old_start..old_end,
3137 &new_text.slice_rows(edit.new.clone()).to_string(),
3138 );
3139 }
3140 pretty_assertions::assert_eq!(old_text.to_string(), new_text.to_string());
3141 })
3142 }
3143 }
3144
3145 #[gpui::test]
3146 async fn test_keep_edits_on_commit(cx: &mut gpui::TestAppContext) {
3147 init_test(cx);
3148
3149 let fs = FakeFs::new(cx.background_executor.clone());
3150 fs.insert_tree(
3151 path!("/project"),
3152 json!({
3153 ".git": {},
3154 "file.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj",
3155 }),
3156 )
3157 .await;
3158 fs.set_head_for_repo(
3159 path!("/project/.git").as_ref(),
3160 &[("file.txt", "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())],
3161 "0000000",
3162 );
3163 cx.run_until_parked();
3164
3165 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
3166 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3167
3168 let file_path = project
3169 .read_with(cx, |project, cx| {
3170 project.find_project_path(path!("/project/file.txt"), cx)
3171 })
3172 .unwrap();
3173 let buffer = project
3174 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3175 .await
3176 .unwrap();
3177
3178 cx.update(|cx| {
3179 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3180 buffer.update(cx, |buffer, cx| {
3181 buffer.edit(
3182 [
3183 // Edit at the very start: a -> A
3184 (Point::new(0, 0)..Point::new(0, 1), "A"),
3185 // Deletion in the middle: remove lines d and e
3186 (Point::new(3, 0)..Point::new(5, 0), ""),
3187 // Modification: g -> GGG
3188 (Point::new(6, 0)..Point::new(6, 1), "GGG"),
3189 // Addition: insert new line after h
3190 (Point::new(7, 1)..Point::new(7, 1), "\nNEW"),
3191 // Edit the very last character: j -> J
3192 (Point::new(9, 0)..Point::new(9, 1), "J"),
3193 ],
3194 None,
3195 cx,
3196 );
3197 });
3198 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3199 });
3200 cx.run_until_parked();
3201 assert_eq!(
3202 unreviewed_hunks(&action_log, cx),
3203 vec![(
3204 buffer.clone(),
3205 vec![
3206 HunkStatus {
3207 range: Point::new(0, 0)..Point::new(1, 0),
3208 diff_status: DiffHunkStatusKind::Modified,
3209 old_text: "a\n".into()
3210 },
3211 HunkStatus {
3212 range: Point::new(3, 0)..Point::new(3, 0),
3213 diff_status: DiffHunkStatusKind::Deleted,
3214 old_text: "d\ne\n".into()
3215 },
3216 HunkStatus {
3217 range: Point::new(4, 0)..Point::new(5, 0),
3218 diff_status: DiffHunkStatusKind::Modified,
3219 old_text: "g\n".into()
3220 },
3221 HunkStatus {
3222 range: Point::new(6, 0)..Point::new(7, 0),
3223 diff_status: DiffHunkStatusKind::Added,
3224 old_text: "".into()
3225 },
3226 HunkStatus {
3227 range: Point::new(8, 0)..Point::new(8, 1),
3228 diff_status: DiffHunkStatusKind::Modified,
3229 old_text: "j".into()
3230 }
3231 ]
3232 )]
3233 );
3234
3235 // Simulate a git commit that matches some edits but not others:
3236 // - Accepts the first edit (a -> A)
3237 // - Accepts the deletion (remove d and e)
3238 // - Makes a different change to g (g -> G instead of GGG)
3239 // - Ignores the NEW line addition
3240 // - Ignores the last line edit (j stays as j)
3241 fs.set_head_for_repo(
3242 path!("/project/.git").as_ref(),
3243 &[("file.txt", "A\nb\nc\nf\nG\nh\ni\nj".into())],
3244 "0000001",
3245 );
3246 cx.run_until_parked();
3247 assert_eq!(
3248 unreviewed_hunks(&action_log, cx),
3249 vec![(
3250 buffer.clone(),
3251 vec![
3252 HunkStatus {
3253 range: Point::new(4, 0)..Point::new(5, 0),
3254 diff_status: DiffHunkStatusKind::Modified,
3255 old_text: "g\n".into()
3256 },
3257 HunkStatus {
3258 range: Point::new(6, 0)..Point::new(7, 0),
3259 diff_status: DiffHunkStatusKind::Added,
3260 old_text: "".into()
3261 },
3262 HunkStatus {
3263 range: Point::new(8, 0)..Point::new(8, 1),
3264 diff_status: DiffHunkStatusKind::Modified,
3265 old_text: "j".into()
3266 }
3267 ]
3268 )]
3269 );
3270
3271 // Make another commit that accepts the NEW line but with different content
3272 fs.set_head_for_repo(
3273 path!("/project/.git").as_ref(),
3274 &[("file.txt", "A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into())],
3275 "0000002",
3276 );
3277 cx.run_until_parked();
3278 assert_eq!(
3279 unreviewed_hunks(&action_log, cx),
3280 vec![(
3281 buffer,
3282 vec![
3283 HunkStatus {
3284 range: Point::new(6, 0)..Point::new(7, 0),
3285 diff_status: DiffHunkStatusKind::Added,
3286 old_text: "".into()
3287 },
3288 HunkStatus {
3289 range: Point::new(8, 0)..Point::new(8, 1),
3290 diff_status: DiffHunkStatusKind::Modified,
3291 old_text: "j".into()
3292 }
3293 ]
3294 )]
3295 );
3296
3297 // Final commit that accepts all remaining edits
3298 fs.set_head_for_repo(
3299 path!("/project/.git").as_ref(),
3300 &[("file.txt", "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())],
3301 "0000003",
3302 );
3303 cx.run_until_parked();
3304 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
3305 }
3306
3307 #[gpui::test]
3308 async fn test_undo_last_reject(cx: &mut TestAppContext) {
3309 init_test(cx);
3310
3311 let fs = FakeFs::new(cx.executor());
3312 fs.insert_tree(
3313 path!("/dir"),
3314 json!({
3315 "file1": "abc\ndef\nghi"
3316 }),
3317 )
3318 .await;
3319 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3320 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3321 let file_path = project
3322 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
3323 .unwrap();
3324
3325 let buffer = project
3326 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3327 .await
3328 .unwrap();
3329
3330 // Track the buffer and make an agent edit
3331 cx.update(|cx| {
3332 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3333 buffer.update(cx, |buffer, cx| {
3334 buffer
3335 .edit(
3336 [(Point::new(1, 0)..Point::new(1, 3), "AGENT_EDIT")],
3337 None,
3338 cx,
3339 )
3340 .unwrap()
3341 });
3342 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3343 });
3344 cx.run_until_parked();
3345
3346 // Verify the agent edit is there
3347 assert_eq!(
3348 buffer.read_with(cx, |buffer, _| buffer.text()),
3349 "abc\nAGENT_EDIT\nghi"
3350 );
3351 assert!(!unreviewed_hunks(&action_log, cx).is_empty());
3352
3353 // Reject all edits
3354 action_log
3355 .update(cx, |log, cx| log.reject_all_edits(None, cx))
3356 .await;
3357 cx.run_until_parked();
3358
3359 // Verify the buffer is back to original
3360 assert_eq!(
3361 buffer.read_with(cx, |buffer, _| buffer.text()),
3362 "abc\ndef\nghi"
3363 );
3364 assert!(unreviewed_hunks(&action_log, cx).is_empty());
3365
3366 // Verify undo state is available
3367 assert!(action_log.read_with(cx, |log, _| log.has_pending_undo()));
3368
3369 // Undo the reject
3370 action_log
3371 .update(cx, |log, cx| log.undo_last_reject(cx))
3372 .await;
3373
3374 cx.run_until_parked();
3375
3376 // Verify the agent edit is restored
3377 assert_eq!(
3378 buffer.read_with(cx, |buffer, _| buffer.text()),
3379 "abc\nAGENT_EDIT\nghi"
3380 );
3381
3382 // Verify undo state is cleared
3383 assert!(!action_log.read_with(cx, |log, _| log.has_pending_undo()));
3384 }
3385
3386 #[gpui::test]
3387 async fn test_linked_action_log_buffer_read(cx: &mut TestAppContext) {
3388 init_test(cx);
3389
3390 let fs = FakeFs::new(cx.executor());
3391 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3392 .await;
3393 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3394 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3395 let child_log =
3396 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3397
3398 let file_path = project
3399 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3400 .unwrap();
3401 let buffer = project
3402 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3403 .await
3404 .unwrap();
3405
3406 cx.update(|cx| {
3407 child_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3408 });
3409
3410 // Neither log considers the buffer stale immediately after reading it.
3411 let child_stale = cx.read(|cx| {
3412 child_log
3413 .read(cx)
3414 .stale_buffers(cx)
3415 .cloned()
3416 .collect::<Vec<_>>()
3417 });
3418 let parent_stale = cx.read(|cx| {
3419 parent_log
3420 .read(cx)
3421 .stale_buffers(cx)
3422 .cloned()
3423 .collect::<Vec<_>>()
3424 });
3425 assert!(child_stale.is_empty());
3426 assert!(parent_stale.is_empty());
3427
3428 // Simulate a user edit after the agent read the file.
3429 cx.update(|cx| {
3430 buffer.update(cx, |buffer, cx| {
3431 buffer.edit([(0..5, "goodbye")], None, cx).unwrap();
3432 });
3433 });
3434 cx.run_until_parked();
3435
3436 // Both child and parent should see the buffer as stale because both tracked
3437 // it at the pre-edit version via buffer_read forwarding.
3438 let child_stale = cx.read(|cx| {
3439 child_log
3440 .read(cx)
3441 .stale_buffers(cx)
3442 .cloned()
3443 .collect::<Vec<_>>()
3444 });
3445 let parent_stale = cx.read(|cx| {
3446 parent_log
3447 .read(cx)
3448 .stale_buffers(cx)
3449 .cloned()
3450 .collect::<Vec<_>>()
3451 });
3452 assert_eq!(child_stale, vec![buffer.clone()]);
3453 assert_eq!(parent_stale, vec![buffer]);
3454 }
3455
3456 #[gpui::test]
3457 async fn test_linked_action_log_buffer_edited(cx: &mut TestAppContext) {
3458 init_test(cx);
3459
3460 let fs = FakeFs::new(cx.executor());
3461 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi"}))
3462 .await;
3463 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3464 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3465 let child_log =
3466 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3467
3468 let file_path = project
3469 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3470 .unwrap();
3471 let buffer = project
3472 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3473 .await
3474 .unwrap();
3475
3476 cx.update(|cx| {
3477 child_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3478 buffer.update(cx, |buffer, cx| {
3479 buffer
3480 .edit([(Point::new(1, 0)..Point::new(1, 3), "DEF")], None, cx)
3481 .unwrap();
3482 });
3483 child_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3484 });
3485 cx.run_until_parked();
3486
3487 let expected_hunks = vec![(
3488 buffer,
3489 vec![HunkStatus {
3490 range: Point::new(1, 0)..Point::new(2, 0),
3491 diff_status: DiffHunkStatusKind::Modified,
3492 old_text: "def\n".into(),
3493 }],
3494 )];
3495 assert_eq!(
3496 unreviewed_hunks(&child_log, cx),
3497 expected_hunks,
3498 "child should track the agent edit"
3499 );
3500 assert_eq!(
3501 unreviewed_hunks(&parent_log, cx),
3502 expected_hunks,
3503 "parent should also track the agent edit via linked log forwarding"
3504 );
3505 }
3506
3507 #[gpui::test]
3508 async fn test_linked_action_log_buffer_created(cx: &mut TestAppContext) {
3509 init_test(cx);
3510
3511 let fs = FakeFs::new(cx.executor());
3512 fs.insert_tree(path!("/dir"), json!({})).await;
3513 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3514 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3515 let child_log =
3516 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3517
3518 let file_path = project
3519 .read_with(cx, |project, cx| {
3520 project.find_project_path("dir/new_file", cx)
3521 })
3522 .unwrap();
3523 let buffer = project
3524 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3525 .await
3526 .unwrap();
3527
3528 cx.update(|cx| {
3529 child_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
3530 buffer.update(cx, |buffer, cx| buffer.set_text("hello", cx));
3531 child_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3532 });
3533 project
3534 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
3535 .await
3536 .unwrap();
3537 cx.run_until_parked();
3538
3539 let expected_hunks = vec![(
3540 buffer.clone(),
3541 vec![HunkStatus {
3542 range: Point::new(0, 0)..Point::new(0, 5),
3543 diff_status: DiffHunkStatusKind::Added,
3544 old_text: "".into(),
3545 }],
3546 )];
3547 assert_eq!(
3548 unreviewed_hunks(&child_log, cx),
3549 expected_hunks,
3550 "child should track the created file"
3551 );
3552 assert_eq!(
3553 unreviewed_hunks(&parent_log, cx),
3554 expected_hunks,
3555 "parent should also track the created file via linked log forwarding"
3556 );
3557 }
3558
3559 #[gpui::test]
3560 async fn test_linked_action_log_will_delete_buffer(cx: &mut TestAppContext) {
3561 init_test(cx);
3562
3563 let fs = FakeFs::new(cx.executor());
3564 fs.insert_tree(path!("/dir"), json!({"file": "hello\n"}))
3565 .await;
3566 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3567 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3568 let child_log =
3569 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3570
3571 let file_path = project
3572 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3573 .unwrap();
3574 let buffer = project
3575 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
3576 .await
3577 .unwrap();
3578
3579 cx.update(|cx| {
3580 child_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
3581 });
3582 project
3583 .update(cx, |project, cx| project.delete_file(file_path, false, cx))
3584 .unwrap()
3585 .await
3586 .unwrap();
3587 cx.run_until_parked();
3588
3589 let expected_hunks = vec![(
3590 buffer.clone(),
3591 vec![HunkStatus {
3592 range: Point::new(0, 0)..Point::new(0, 0),
3593 diff_status: DiffHunkStatusKind::Deleted,
3594 old_text: "hello\n".into(),
3595 }],
3596 )];
3597 assert_eq!(
3598 unreviewed_hunks(&child_log, cx),
3599 expected_hunks,
3600 "child should track the deleted file"
3601 );
3602 assert_eq!(
3603 unreviewed_hunks(&parent_log, cx),
3604 expected_hunks,
3605 "parent should also track the deleted file via linked log forwarding"
3606 );
3607 }
3608
3609 /// Simulates the subagent scenario: two child logs linked to the same parent, each
3610 /// editing a different file. The parent accumulates all edits while each child
3611 /// only sees its own.
3612 #[gpui::test]
3613 async fn test_linked_action_log_independent_tracking(cx: &mut TestAppContext) {
3614 init_test(cx);
3615
3616 let fs = FakeFs::new(cx.executor());
3617 fs.insert_tree(
3618 path!("/dir"),
3619 json!({
3620 "file_a": "content of a",
3621 "file_b": "content of b",
3622 }),
3623 )
3624 .await;
3625 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3626 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3627 let child_log_1 =
3628 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3629 let child_log_2 =
3630 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3631
3632 let file_a_path = project
3633 .read_with(cx, |project, cx| {
3634 project.find_project_path("dir/file_a", cx)
3635 })
3636 .unwrap();
3637 let file_b_path = project
3638 .read_with(cx, |project, cx| {
3639 project.find_project_path("dir/file_b", cx)
3640 })
3641 .unwrap();
3642 let buffer_a = project
3643 .update(cx, |project, cx| project.open_buffer(file_a_path, cx))
3644 .await
3645 .unwrap();
3646 let buffer_b = project
3647 .update(cx, |project, cx| project.open_buffer(file_b_path, cx))
3648 .await
3649 .unwrap();
3650
3651 cx.update(|cx| {
3652 child_log_1.update(cx, |log, cx| log.buffer_read(buffer_a.clone(), cx));
3653 buffer_a.update(cx, |buffer, cx| {
3654 buffer.edit([(0..0, "MODIFIED: ")], None, cx).unwrap();
3655 });
3656 child_log_1.update(cx, |log, cx| log.buffer_edited(buffer_a.clone(), cx));
3657
3658 child_log_2.update(cx, |log, cx| log.buffer_read(buffer_b.clone(), cx));
3659 buffer_b.update(cx, |buffer, cx| {
3660 buffer.edit([(0..0, "MODIFIED: ")], None, cx).unwrap();
3661 });
3662 child_log_2.update(cx, |log, cx| log.buffer_edited(buffer_b.clone(), cx));
3663 });
3664 cx.run_until_parked();
3665
3666 let child_1_changed: Vec<_> = cx.read(|cx| {
3667 child_log_1
3668 .read(cx)
3669 .changed_buffers(cx)
3670 .into_keys()
3671 .collect()
3672 });
3673 let child_2_changed: Vec<_> = cx.read(|cx| {
3674 child_log_2
3675 .read(cx)
3676 .changed_buffers(cx)
3677 .into_keys()
3678 .collect()
3679 });
3680 let parent_changed: Vec<_> = cx.read(|cx| {
3681 parent_log
3682 .read(cx)
3683 .changed_buffers(cx)
3684 .into_keys()
3685 .collect()
3686 });
3687
3688 assert_eq!(
3689 child_1_changed,
3690 vec![buffer_a.clone()],
3691 "child 1 should only track file_a"
3692 );
3693 assert_eq!(
3694 child_2_changed,
3695 vec![buffer_b.clone()],
3696 "child 2 should only track file_b"
3697 );
3698 assert_eq!(parent_changed.len(), 2, "parent should track both files");
3699 assert!(
3700 parent_changed.contains(&buffer_a) && parent_changed.contains(&buffer_b),
3701 "parent should contain both buffer_a and buffer_b"
3702 );
3703 }
3704
3705 #[gpui::test]
3706 async fn test_file_read_time_recorded_on_buffer_read(cx: &mut TestAppContext) {
3707 init_test(cx);
3708
3709 let fs = FakeFs::new(cx.executor());
3710 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3711 .await;
3712 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3713 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3714
3715 let file_path = project
3716 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3717 .unwrap();
3718 let buffer = project
3719 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3720 .await
3721 .unwrap();
3722
3723 let abs_path = PathBuf::from(path!("/dir/file"));
3724 assert!(
3725 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3726 "file_read_time should be None before buffer_read"
3727 );
3728
3729 cx.update(|cx| {
3730 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3731 });
3732
3733 assert!(
3734 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3735 "file_read_time should be recorded after buffer_read"
3736 );
3737 }
3738
3739 #[gpui::test]
3740 async fn test_file_read_time_recorded_on_buffer_edited(cx: &mut TestAppContext) {
3741 init_test(cx);
3742
3743 let fs = FakeFs::new(cx.executor());
3744 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3745 .await;
3746 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3747 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3748
3749 let file_path = project
3750 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3751 .unwrap();
3752 let buffer = project
3753 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3754 .await
3755 .unwrap();
3756
3757 let abs_path = PathBuf::from(path!("/dir/file"));
3758 assert!(
3759 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3760 "file_read_time should be None before buffer_edited"
3761 );
3762
3763 cx.update(|cx| {
3764 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3765 });
3766
3767 assert!(
3768 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3769 "file_read_time should be recorded after buffer_edited"
3770 );
3771 }
3772
3773 #[gpui::test]
3774 async fn test_file_read_time_recorded_on_buffer_created(cx: &mut TestAppContext) {
3775 init_test(cx);
3776
3777 let fs = FakeFs::new(cx.executor());
3778 fs.insert_tree(path!("/dir"), json!({"file": "existing content"}))
3779 .await;
3780 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3781 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3782
3783 let file_path = project
3784 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3785 .unwrap();
3786 let buffer = project
3787 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3788 .await
3789 .unwrap();
3790
3791 let abs_path = PathBuf::from(path!("/dir/file"));
3792 assert!(
3793 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3794 "file_read_time should be None before buffer_created"
3795 );
3796
3797 cx.update(|cx| {
3798 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
3799 });
3800
3801 assert!(
3802 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3803 "file_read_time should be recorded after buffer_created"
3804 );
3805 }
3806
3807 #[gpui::test]
3808 async fn test_file_read_time_removed_on_delete(cx: &mut TestAppContext) {
3809 init_test(cx);
3810
3811 let fs = FakeFs::new(cx.executor());
3812 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3813 .await;
3814 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3815 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3816
3817 let file_path = project
3818 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3819 .unwrap();
3820 let buffer = project
3821 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3822 .await
3823 .unwrap();
3824
3825 let abs_path = PathBuf::from(path!("/dir/file"));
3826
3827 cx.update(|cx| {
3828 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3829 });
3830 assert!(
3831 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3832 "file_read_time should exist after buffer_read"
3833 );
3834
3835 cx.update(|cx| {
3836 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
3837 });
3838 assert!(
3839 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3840 "file_read_time should be removed after will_delete_buffer"
3841 );
3842 }
3843
3844 #[gpui::test]
3845 async fn test_file_read_time_not_forwarded_to_linked_action_log(cx: &mut TestAppContext) {
3846 init_test(cx);
3847
3848 let fs = FakeFs::new(cx.executor());
3849 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3850 .await;
3851 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3852 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3853 let child_log =
3854 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3855
3856 let file_path = project
3857 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3858 .unwrap();
3859 let buffer = project
3860 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3861 .await
3862 .unwrap();
3863
3864 let abs_path = PathBuf::from(path!("/dir/file"));
3865
3866 cx.update(|cx| {
3867 child_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3868 });
3869 assert!(
3870 child_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3871 "child should record file_read_time on buffer_read"
3872 );
3873 assert!(
3874 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3875 "parent should NOT get file_read_time from child's buffer_read"
3876 );
3877
3878 cx.update(|cx| {
3879 child_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3880 });
3881 assert!(
3882 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3883 "parent should NOT get file_read_time from child's buffer_edited"
3884 );
3885
3886 cx.update(|cx| {
3887 child_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
3888 });
3889 assert!(
3890 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3891 "parent should NOT get file_read_time from child's buffer_created"
3892 );
3893 }
3894
3895 #[gpui::test]
3896 async fn test_file_read_time_not_forwarded_to_linked_action_log_for_inferred_edits(
3897 cx: &mut TestAppContext,
3898 ) {
3899 init_test(cx);
3900
3901 let fs = FakeFs::new(cx.executor());
3902 fs.insert_tree(
3903 path!("/dir"),
3904 json!({
3905 "edit": "hello world\n",
3906 "delete": "goodbye world\n",
3907 }),
3908 )
3909 .await;
3910 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3911 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3912 let child_log =
3913 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3914
3915 let edit_file_path = project
3916 .read_with(cx, |project, cx| project.find_project_path("dir/edit", cx))
3917 .unwrap();
3918 let edit_buffer = project
3919 .update(cx, |project, cx| project.open_buffer(edit_file_path, cx))
3920 .await
3921 .unwrap();
3922 let edit_abs_path = PathBuf::from(path!("/dir/edit"));
3923 let edit_baseline_snapshot = edit_buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3924
3925 edit_buffer.update(cx, |buffer, cx| buffer.set_text("hello world!\n", cx));
3926 project
3927 .update(cx, |project, cx| {
3928 project.save_buffer(edit_buffer.clone(), cx)
3929 })
3930 .await
3931 .unwrap();
3932
3933 cx.update(|cx| {
3934 child_log.update(cx, |log, cx| {
3935 log.infer_buffer_edited_from_snapshot(
3936 edit_buffer.clone(),
3937 edit_baseline_snapshot.clone(),
3938 cx,
3939 );
3940 });
3941 });
3942
3943 assert!(
3944 child_log.read_with(cx, |log, _| log.file_read_time(&edit_abs_path).is_some()),
3945 "child should record file_read_time on inferred edit"
3946 );
3947 assert!(
3948 parent_log.read_with(cx, |log, _| log.file_read_time(&edit_abs_path).is_none()),
3949 "parent should NOT get file_read_time from child's inferred edit"
3950 );
3951
3952 let create_file_path = project
3953 .read_with(cx, |project, cx| {
3954 project.find_project_path("dir/new_file", cx)
3955 })
3956 .unwrap();
3957 let create_buffer = project
3958 .update(cx, |project, cx| project.open_buffer(create_file_path, cx))
3959 .await
3960 .unwrap();
3961 let create_abs_path = PathBuf::from(path!("/dir/new_file"));
3962 let create_baseline_snapshot =
3963 create_buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
3964
3965 create_buffer.update(cx, |buffer, cx| buffer.set_text("new file\n", cx));
3966 project
3967 .update(cx, |project, cx| {
3968 project.save_buffer(create_buffer.clone(), cx)
3969 })
3970 .await
3971 .unwrap();
3972
3973 cx.update(|cx| {
3974 child_log.update(cx, |log, cx| {
3975 log.infer_buffer_created(
3976 create_buffer.clone(),
3977 create_baseline_snapshot.clone(),
3978 cx,
3979 );
3980 });
3981 });
3982
3983 assert!(
3984 child_log.read_with(cx, |log, _| log.file_read_time(&create_abs_path).is_some()),
3985 "child should record file_read_time on inferred create"
3986 );
3987 assert!(
3988 parent_log.read_with(cx, |log, _| log.file_read_time(&create_abs_path).is_none()),
3989 "parent should NOT get file_read_time from child's inferred create"
3990 );
3991
3992 let delete_file_path = project
3993 .read_with(cx, |project, cx| {
3994 project.find_project_path("dir/delete", cx)
3995 })
3996 .unwrap();
3997 let delete_buffer = project
3998 .update(cx, |project, cx| project.open_buffer(delete_file_path, cx))
3999 .await
4000 .unwrap();
4001 let delete_abs_path = PathBuf::from(path!("/dir/delete"));
4002 let delete_baseline_snapshot =
4003 delete_buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4004
4005 cx.update(|cx| {
4006 parent_log.update(cx, |log, cx| log.buffer_read(delete_buffer.clone(), cx));
4007 child_log.update(cx, |log, cx| log.buffer_read(delete_buffer.clone(), cx));
4008 });
4009
4010 assert!(
4011 parent_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_some()),
4012 "parent should record its own file_read_time before inferred delete"
4013 );
4014 assert!(
4015 child_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_some()),
4016 "child should record its own file_read_time before inferred delete"
4017 );
4018
4019 fs.remove_file(path!("/dir/delete").as_ref(), RemoveOptions::default())
4020 .await
4021 .unwrap();
4022 cx.run_until_parked();
4023
4024 cx.update(|cx| {
4025 child_log.update(cx, |log, cx| {
4026 log.infer_buffer_deleted_from_snapshot(
4027 delete_buffer.clone(),
4028 delete_baseline_snapshot.clone(),
4029 cx,
4030 );
4031 });
4032 });
4033
4034 assert!(
4035 child_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_none()),
4036 "child should remove file_read_time on inferred delete"
4037 );
4038 assert!(
4039 parent_log.read_with(cx, |log, _| log.file_read_time(&delete_abs_path).is_some()),
4040 "parent should keep its own file_read_time on linked inferred delete"
4041 );
4042 }
4043
4044 #[gpui::test]
4045 async fn test_linked_action_log_infer_buffer_edited_from_snapshot(cx: &mut TestAppContext) {
4046 init_test(cx);
4047
4048 let fs = FakeFs::new(cx.executor());
4049 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4050 .await;
4051 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4052 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
4053 let child_log =
4054 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
4055
4056 let file_path = project
4057 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4058 .unwrap();
4059 let buffer = project
4060 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4061 .await
4062 .unwrap();
4063
4064 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4065
4066 buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx));
4067 project
4068 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4069 .await
4070 .unwrap();
4071
4072 cx.update(|cx| {
4073 child_log.update(cx, |log, cx| {
4074 log.infer_buffer_edited_from_snapshot(
4075 buffer.clone(),
4076 baseline_snapshot.clone(),
4077 cx,
4078 );
4079 });
4080 });
4081 cx.run_until_parked();
4082
4083 let child_hunks = unreviewed_hunks(&child_log, cx);
4084 assert!(
4085 !child_hunks.is_empty(),
4086 "child should track the inferred edit"
4087 );
4088 assert_eq!(
4089 unreviewed_hunks(&parent_log, cx),
4090 child_hunks,
4091 "parent should also track the inferred edit via linked log forwarding"
4092 );
4093 }
4094
4095 #[gpui::test]
4096 async fn test_linked_action_log_forwards_sequential_inferred_snapshots(
4097 cx: &mut TestAppContext,
4098 ) {
4099 init_test(cx);
4100
4101 let fs = FakeFs::new(cx.executor());
4102 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4103 .await;
4104 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4105 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
4106 let child_log =
4107 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
4108
4109 let file_path = project
4110 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4111 .expect("test file should exist");
4112 let buffer = project
4113 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4114 .await
4115 .expect("test buffer should open");
4116
4117 let first_baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4118 buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx));
4119 let second_baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4120 buffer.update(cx, |buffer, cx| {
4121 buffer.set_text("one\ntwo\nthree\nfour\n", cx)
4122 });
4123 project
4124 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4125 .await
4126 .expect("final inferred buffer contents should save");
4127
4128 cx.update(|cx| {
4129 child_log.update(cx, |log, cx| {
4130 log.infer_buffer_edited_from_snapshot(
4131 buffer.clone(),
4132 first_baseline_snapshot.clone(),
4133 cx,
4134 );
4135 });
4136 });
4137 cx.run_until_parked();
4138
4139 let first_child_hunks = unreviewed_hunks(&child_log, cx);
4140 assert!(
4141 !first_child_hunks.is_empty(),
4142 "the first inferred snapshot should produce review hunks"
4143 );
4144 assert_eq!(
4145 unreviewed_hunks(&parent_log, cx),
4146 first_child_hunks,
4147 "parent should match the first forwarded inferred snapshot"
4148 );
4149
4150 cx.update(|cx| {
4151 child_log.update(cx, |log, cx| {
4152 log.infer_buffer_edited_from_snapshot(
4153 buffer.clone(),
4154 second_baseline_snapshot.clone(),
4155 cx,
4156 );
4157 });
4158 });
4159 cx.run_until_parked();
4160
4161 let second_child_hunks = unreviewed_hunks(&child_log, cx);
4162 assert!(
4163 !second_child_hunks.is_empty(),
4164 "the second inferred snapshot should still produce review hunks"
4165 );
4166 assert_ne!(
4167 second_child_hunks, first_child_hunks,
4168 "the second inferred snapshot should refresh the tracked diff"
4169 );
4170 assert_eq!(
4171 unreviewed_hunks(&parent_log, cx),
4172 second_child_hunks,
4173 "parent should stay in sync after sequential inferred snapshots on one buffer"
4174 );
4175 }
4176
4177 #[gpui::test]
4178 async fn test_linked_action_log_infer_buffer_created(cx: &mut TestAppContext) {
4179 init_test(cx);
4180
4181 let fs = FakeFs::new(cx.executor());
4182 fs.insert_tree(path!("/dir"), json!({})).await;
4183 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4184 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
4185 let child_log =
4186 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
4187
4188 let file_path = project
4189 .read_with(cx, |project, cx| {
4190 project.find_project_path("dir/new_file", cx)
4191 })
4192 .unwrap();
4193 let buffer = project
4194 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4195 .await
4196 .unwrap();
4197
4198 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4199
4200 buffer.update(cx, |buffer, cx| buffer.set_text("hello\n", cx));
4201 project
4202 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4203 .await
4204 .unwrap();
4205
4206 cx.update(|cx| {
4207 child_log.update(cx, |log, cx| {
4208 log.infer_buffer_created(buffer.clone(), baseline_snapshot.clone(), cx);
4209 });
4210 });
4211 cx.run_until_parked();
4212
4213 let child_hunks = unreviewed_hunks(&child_log, cx);
4214 assert!(
4215 !child_hunks.is_empty(),
4216 "child should track the inferred creation"
4217 );
4218 assert_eq!(
4219 unreviewed_hunks(&parent_log, cx),
4220 child_hunks,
4221 "parent should also track the inferred creation via linked log forwarding"
4222 );
4223 }
4224
4225 #[gpui::test]
4226 async fn test_linked_action_log_infer_buffer_deleted_from_snapshot(cx: &mut TestAppContext) {
4227 init_test(cx);
4228
4229 let fs = FakeFs::new(cx.executor());
4230 fs.insert_tree(path!("/dir"), json!({"file": "hello\n"}))
4231 .await;
4232 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4233 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
4234 let child_log =
4235 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
4236
4237 let file_path = project
4238 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4239 .unwrap();
4240 let buffer = project
4241 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4242 .await
4243 .unwrap();
4244
4245 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4246
4247 fs.remove_file(path!("/dir/file").as_ref(), RemoveOptions::default())
4248 .await
4249 .unwrap();
4250 cx.run_until_parked();
4251
4252 cx.update(|cx| {
4253 child_log.update(cx, |log, cx| {
4254 log.infer_buffer_deleted_from_snapshot(
4255 buffer.clone(),
4256 baseline_snapshot.clone(),
4257 cx,
4258 );
4259 });
4260 });
4261 cx.run_until_parked();
4262
4263 let child_hunks = unreviewed_hunks(&child_log, cx);
4264 assert!(
4265 !child_hunks.is_empty(),
4266 "child should track the inferred deletion"
4267 );
4268 assert_eq!(
4269 unreviewed_hunks(&parent_log, cx),
4270 child_hunks,
4271 "parent should also track the inferred deletion via linked log forwarding"
4272 );
4273 }
4274
4275 #[gpui::test]
4276 async fn test_expected_external_edit_does_not_mark_read_time_or_stale_before_first_agent_change(
4277 cx: &mut TestAppContext,
4278 ) {
4279 init_test(cx);
4280
4281 let fs = FakeFs::new(cx.executor());
4282 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4283 .await;
4284 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4285 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4286
4287 let file_path = project
4288 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4289 .unwrap();
4290 let buffer = project
4291 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4292 .await
4293 .unwrap();
4294 let abs_path = PathBuf::from(path!("/dir/file"));
4295
4296 cx.update(|cx| {
4297 action_log.update(cx, |log, cx| {
4298 log.begin_expected_external_edit(buffer.clone(), cx);
4299 });
4300 });
4301
4302 assert!(
4303 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
4304 "expected external edit should not record file_read_time before an agent change"
4305 );
4306 assert!(
4307 action_log.read_with(cx, |log, cx| log.stale_buffers(cx).next().is_none()),
4308 "expected external edit should not mark a synthetic tracker as stale"
4309 );
4310
4311 buffer.update(cx, |buffer, cx| {
4312 buffer.edit([(0..0, "zero\n")], None, cx).unwrap();
4313 });
4314 cx.run_until_parked();
4315
4316 assert!(
4317 action_log.read_with(cx, |log, cx| log.changed_buffers(cx).is_empty()),
4318 "local edits before the first external change should not become review hunks"
4319 );
4320 assert!(
4321 action_log.read_with(cx, |log, cx| log.stale_buffers(cx).next().is_none()),
4322 "expectation-only tracking should stay out of stale_buffers"
4323 );
4324 assert!(
4325 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
4326 "local edits before the first external change should not record file_read_time"
4327 );
4328
4329 cx.update(|cx| {
4330 action_log.update(cx, |log, cx| {
4331 log.end_expected_external_edit(buffer.clone(), cx);
4332 });
4333 });
4334 cx.run_until_parked();
4335
4336 assert!(
4337 action_log.read_with(cx, |log, cx| log.changed_buffers(cx).is_empty()),
4338 "ending an expectation without an agent change should remove synthetic tracking"
4339 );
4340 }
4341
4342 #[gpui::test]
4343 async fn test_expected_external_edit_preserves_local_edits_before_first_agent_change(
4344 cx: &mut TestAppContext,
4345 ) {
4346 init_test(cx);
4347
4348 let fs = FakeFs::new(cx.executor());
4349 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4350 .await;
4351 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4352 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4353
4354 let file_path = project
4355 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4356 .unwrap();
4357 let buffer = project
4358 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4359 .await
4360 .unwrap();
4361
4362 cx.update(|cx| {
4363 action_log.update(cx, |log, cx| {
4364 log.begin_expected_external_edit(buffer.clone(), cx);
4365 });
4366 });
4367
4368 buffer.update(cx, |buffer, cx| {
4369 buffer.edit([(0..0, "zero\n")], None, cx).unwrap();
4370 });
4371 project
4372 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4373 .await
4374 .unwrap();
4375 cx.run_until_parked();
4376
4377 fs.save(
4378 path!("/dir/file").as_ref(),
4379 &"zero\none\ntwo\nthree\n".into(),
4380 Default::default(),
4381 )
4382 .await
4383 .unwrap();
4384 cx.run_until_parked();
4385
4386 assert_eq!(
4387 action_log.read_with(cx, |log, cx| log.changed_buffers(cx).len()),
4388 1,
4389 "the first external change should be attributed relative to the local user baseline"
4390 );
4391
4392 cx.update(|cx| {
4393 action_log.update(cx, |log, cx| {
4394 log.end_expected_external_edit(buffer.clone(), cx);
4395 });
4396 });
4397 cx.run_until_parked();
4398
4399 action_log
4400 .update(cx, |log, cx| log.reject_all_edits(None, cx))
4401 .await;
4402 cx.run_until_parked();
4403
4404 assert_eq!(
4405 buffer.read_with(cx, |buffer, _| buffer.text()),
4406 "zero\none\ntwo\n"
4407 );
4408 assert_eq!(
4409 String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(),
4410 "zero\none\ntwo\n"
4411 );
4412 }
4413
4414 #[gpui::test]
4415 async fn test_expected_external_edit_explicit_reload_arm_attributes_forced_reload(
4416 cx: &mut TestAppContext,
4417 ) {
4418 init_test(cx);
4419
4420 let fs = FakeFs::new(cx.executor());
4421 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4422 .await;
4423 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4424 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4425
4426 let file_path = project
4427 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4428 .unwrap();
4429 let buffer = project
4430 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4431 .await
4432 .unwrap();
4433
4434 cx.update(|cx| {
4435 action_log.update(cx, |log, cx| {
4436 log.begin_expected_external_edit(buffer.clone(), cx);
4437 });
4438 });
4439
4440 fs.save(
4441 path!("/dir/file").as_ref(),
4442 &"one\ntwo\nthree\n".into(),
4443 Default::default(),
4444 )
4445 .await
4446 .unwrap();
4447
4448 cx.update(|cx| {
4449 action_log.update(cx, |log, cx| {
4450 log.arm_expected_external_reload(buffer.clone(), cx);
4451 });
4452 });
4453
4454 let reload = project.update(cx, |project, cx| {
4455 let mut buffers = collections::HashSet::default();
4456 buffers.insert(buffer.clone());
4457 project.reload_buffers(buffers, false, cx)
4458 });
4459 reload.await.unwrap();
4460 cx.run_until_parked();
4461
4462 assert_eq!(
4463 action_log.read_with(cx, |log, cx| log.changed_buffers(cx).len()),
4464 1,
4465 "arming an expected reload should attribute an explicit reload before file-handle updates arrive"
4466 );
4467 }
4468
4469 #[gpui::test]
4470 async fn test_expected_external_edit_does_not_attribute_dirty_non_delete_external_changes(
4471 cx: &mut TestAppContext,
4472 ) {
4473 init_test(cx);
4474
4475 let fs = FakeFs::new(cx.executor());
4476 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4477 .await;
4478 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4479 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4480 let abs_path = PathBuf::from(path!("/dir/file"));
4481
4482 let file_path = project
4483 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4484 .unwrap();
4485 let buffer = project
4486 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4487 .await
4488 .unwrap();
4489
4490 cx.update(|cx| {
4491 action_log.update(cx, |log, cx| {
4492 log.begin_expected_external_edit(buffer.clone(), cx);
4493 });
4494 });
4495
4496 buffer.update(cx, |buffer, cx| {
4497 buffer.edit([(0..0, "zero\n")], None, cx).unwrap();
4498 });
4499 cx.run_until_parked();
4500
4501 fs.save(
4502 path!("/dir/file").as_ref(),
4503 &"one\ntwo\nthree\n".into(),
4504 Default::default(),
4505 )
4506 .await
4507 .unwrap();
4508 cx.run_until_parked();
4509
4510 assert!(
4511 action_log.read_with(cx, |log, cx| log.changed_buffers(cx).is_empty()),
4512 "dirty non-delete external changes should stay out of review until the behavior is explicitly supported"
4513 );
4514 assert!(
4515 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
4516 "unsupported dirty external changes should not record file_read_time"
4517 );
4518 assert_eq!(
4519 buffer.read_with(cx, |buffer, _| buffer.text()),
4520 "zero\none\ntwo\n",
4521 "unsupported dirty external changes should preserve local buffer contents"
4522 );
4523 }
4524
4525 #[gpui::test]
4526 async fn test_linked_expected_external_edit_tracks_review_without_parent_file_read_time(
4527 cx: &mut TestAppContext,
4528 ) {
4529 init_test(cx);
4530
4531 let fs = FakeFs::new(cx.executor());
4532 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4533 .await;
4534 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4535 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
4536 let child_log =
4537 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
4538
4539 let file_path = project
4540 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4541 .unwrap();
4542 let buffer = project
4543 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4544 .await
4545 .unwrap();
4546 let abs_path = PathBuf::from(path!("/dir/file"));
4547
4548 cx.update(|cx| {
4549 child_log.update(cx, |log, cx| {
4550 log.begin_expected_external_edit(buffer.clone(), cx);
4551 });
4552 });
4553
4554 assert!(
4555 child_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
4556 "child should not record file_read_time until the first external agent change"
4557 );
4558 assert!(
4559 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
4560 "parent should not inherit file_read_time from the child's pending expectation"
4561 );
4562
4563 fs.save(
4564 path!("/dir/file").as_ref(),
4565 &"one\ntwo\nthree\n".into(),
4566 Default::default(),
4567 )
4568 .await
4569 .unwrap();
4570 cx.run_until_parked();
4571
4572 cx.update(|cx| {
4573 child_log.update(cx, |log, cx| {
4574 log.end_expected_external_edit(buffer.clone(), cx);
4575 });
4576 });
4577 cx.run_until_parked();
4578
4579 let child_hunks = unreviewed_hunks(&child_log, cx);
4580 assert!(
4581 !child_hunks.is_empty(),
4582 "child should track the expected external edit"
4583 );
4584 assert_eq!(
4585 unreviewed_hunks(&parent_log, cx),
4586 child_hunks,
4587 "parent should also track the expected external edit"
4588 );
4589 assert!(
4590 child_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
4591 "child should record file_read_time once the expected external edit is attributed"
4592 );
4593 assert!(
4594 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
4595 "parent should still not inherit file_read_time from the child's expected external edit"
4596 );
4597 }
4598
4599 #[gpui::test]
4600 async fn test_expected_external_edit_starts_unattributed_even_with_existing_hunks(
4601 cx: &mut TestAppContext,
4602 ) {
4603 init_test(cx);
4604
4605 let fs = FakeFs::new(cx.executor());
4606 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4607 .await;
4608 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
4609 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4610 let file_path = project
4611 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4612 .unwrap();
4613 let buffer = project
4614 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4615 .await
4616 .unwrap();
4617
4618 cx.update(|cx| {
4619 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
4620 buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx));
4621 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
4622 });
4623 cx.run_until_parked();
4624
4625 assert!(
4626 !unreviewed_hunks(&action_log, cx).is_empty(),
4627 "buffer should already have tracked hunks before the expectation starts"
4628 );
4629
4630 cx.update(|cx| {
4631 action_log.update(cx, |log, cx| {
4632 log.begin_expected_external_edit(buffer.clone(), cx);
4633 });
4634 });
4635
4636 assert!(
4637 action_log.read_with(cx, |log, _| {
4638 log.tracked_buffers
4639 .get(&buffer)
4640 .and_then(|tracked_buffer| tracked_buffer.expected_external_edit.as_ref())
4641 .is_some_and(|expected_external_edit| {
4642 !expected_external_edit.has_attributed_change
4643 })
4644 }),
4645 "a new expected external edit should start as unattributed even when the buffer already has hunks"
4646 );
4647 }
4648
4649 #[gpui::test]
4650 async fn test_expected_external_edit_preserves_stale_tracking_for_existing_tracked_buffer(
4651 cx: &mut TestAppContext,
4652 ) {
4653 init_test(cx);
4654
4655 let fs = FakeFs::new(cx.executor());
4656 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4657 .await;
4658 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
4659 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4660 let file_path = project
4661 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4662 .expect("test file should exist");
4663 let buffer = project
4664 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4665 .await
4666 .expect("test buffer should open");
4667
4668 cx.update(|cx| {
4669 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
4670 });
4671
4672 cx.update(|cx| {
4673 buffer.update(cx, |buffer, cx| {
4674 assert!(buffer.edit([(0..0, "zero\n")], None, cx).is_some());
4675 });
4676 });
4677 cx.run_until_parked();
4678
4679 assert_eq!(
4680 action_log.read_with(cx, |log, cx| {
4681 log.stale_buffers(cx).cloned().collect::<Vec<_>>()
4682 }),
4683 vec![buffer.clone()],
4684 "user edits after a read should mark the tracked buffer as stale"
4685 );
4686
4687 cx.update(|cx| {
4688 action_log.update(cx, |log, cx| {
4689 log.begin_expected_external_edit(buffer.clone(), cx);
4690 });
4691 });
4692
4693 assert_eq!(
4694 action_log.read_with(cx, |log, cx| {
4695 log.stale_buffers(cx).cloned().collect::<Vec<_>>()
4696 }),
4697 vec![buffer.clone()],
4698 "starting an expected external edit should not clear existing stale tracking"
4699 );
4700
4701 cx.update(|cx| {
4702 action_log.update(cx, |log, cx| {
4703 log.end_expected_external_edit(buffer.clone(), cx);
4704 });
4705 });
4706
4707 assert_eq!(
4708 action_log.read_with(cx, |log, cx| {
4709 log.stale_buffers(cx).cloned().collect::<Vec<_>>()
4710 }),
4711 vec![buffer],
4712 "ending an unattributed expected external edit should preserve existing stale tracking"
4713 );
4714 }
4715
4716 #[gpui::test]
4717 async fn test_infer_buffer_created_preserves_non_empty_baseline_on_reject(
4718 cx: &mut TestAppContext,
4719 ) {
4720 init_test(cx);
4721
4722 let fs = FakeFs::new(cx.executor());
4723 fs.insert_tree(path!("/dir"), json!({})).await;
4724 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4725 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4726 let file_path = project
4727 .read_with(cx, |project, cx| {
4728 project.find_project_path("dir/new_file", cx)
4729 })
4730 .unwrap();
4731 let buffer = project
4732 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4733 .await
4734 .unwrap();
4735
4736 buffer.update(cx, |buffer, cx| buffer.set_text("draft\n", cx));
4737 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4738
4739 buffer.update(cx, |buffer, cx| buffer.set_text("draft\nagent\n", cx));
4740 project
4741 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4742 .await
4743 .unwrap();
4744
4745 cx.update(|cx| {
4746 action_log.update(cx, |log, cx| {
4747 log.infer_buffer_created(buffer.clone(), baseline_snapshot.clone(), cx);
4748 });
4749 });
4750 cx.run_until_parked();
4751
4752 action_log
4753 .update(cx, |log, cx| log.reject_all_edits(None, cx))
4754 .await;
4755 cx.run_until_parked();
4756
4757 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "draft\n");
4758 assert_eq!(
4759 String::from_utf8(fs.read_file_sync(path!("/dir/new_file")).unwrap()).unwrap(),
4760 "draft\n"
4761 );
4762 }
4763
4764 #[gpui::test]
4765 async fn test_infer_buffer_edited_from_snapshot(cx: &mut TestAppContext) {
4766 init_test(cx);
4767
4768 let fs = FakeFs::new(cx.executor());
4769 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
4770 .await;
4771 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4772 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4773 let file_path = project
4774 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4775 .unwrap();
4776 let buffer = project
4777 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4778 .await
4779 .unwrap();
4780
4781 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4782
4783 buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx));
4784 project
4785 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4786 .await
4787 .unwrap();
4788
4789 cx.update(|cx| {
4790 action_log.update(cx, |log, cx| {
4791 log.infer_buffer_edited_from_snapshot(
4792 buffer.clone(),
4793 baseline_snapshot.clone(),
4794 cx,
4795 );
4796 });
4797 });
4798 cx.run_until_parked();
4799
4800 assert!(
4801 !unreviewed_hunks(&action_log, cx).is_empty(),
4802 "inferred edit should produce reviewable hunks"
4803 );
4804
4805 action_log
4806 .update(cx, |log, cx| log.reject_all_edits(None, cx))
4807 .await;
4808 cx.run_until_parked();
4809
4810 assert_eq!(
4811 buffer.read_with(cx, |buffer, _| buffer.text()),
4812 "one\ntwo\n"
4813 );
4814 }
4815
4816 #[gpui::test]
4817 async fn test_infer_buffer_created(cx: &mut TestAppContext) {
4818 init_test(cx);
4819
4820 let fs = FakeFs::new(cx.executor());
4821 fs.insert_tree(path!("/dir"), json!({})).await;
4822 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4823 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4824 let file_path = project
4825 .read_with(cx, |project, cx| {
4826 project.find_project_path("dir/new_file", cx)
4827 })
4828 .unwrap();
4829 let buffer = project
4830 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4831 .await
4832 .unwrap();
4833
4834 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4835
4836 buffer.update(cx, |buffer, cx| buffer.set_text("hello\n", cx));
4837 project
4838 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4839 .await
4840 .unwrap();
4841
4842 cx.update(|cx| {
4843 action_log.update(cx, |log, cx| {
4844 log.infer_buffer_created(buffer.clone(), baseline_snapshot.clone(), cx);
4845 });
4846 });
4847 cx.run_until_parked();
4848
4849 assert!(
4850 !unreviewed_hunks(&action_log, cx).is_empty(),
4851 "inferred creation should produce reviewable hunks"
4852 );
4853
4854 action_log
4855 .update(cx, |log, cx| log.reject_all_edits(None, cx))
4856 .await;
4857 cx.run_until_parked();
4858
4859 assert!(fs.read_file_sync(path!("/dir/new_file")).is_err());
4860 }
4861
4862 #[gpui::test]
4863 async fn test_infer_buffer_deleted_from_snapshot(cx: &mut TestAppContext) {
4864 init_test(cx);
4865
4866 let fs = FakeFs::new(cx.executor());
4867 fs.insert_tree(path!("/dir"), json!({"file": "hello\n"}))
4868 .await;
4869 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4870 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4871 let file_path = project
4872 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4873 .unwrap();
4874 let buffer = project
4875 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4876 .await
4877 .unwrap();
4878
4879 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4880
4881 fs.remove_file(path!("/dir/file").as_ref(), RemoveOptions::default())
4882 .await
4883 .unwrap();
4884 cx.run_until_parked();
4885
4886 cx.update(|cx| {
4887 action_log.update(cx, |log, cx| {
4888 log.infer_buffer_deleted_from_snapshot(
4889 buffer.clone(),
4890 baseline_snapshot.clone(),
4891 cx,
4892 );
4893 });
4894 });
4895 cx.run_until_parked();
4896
4897 assert!(
4898 !unreviewed_hunks(&action_log, cx).is_empty(),
4899 "inferred deletion should produce reviewable hunks"
4900 );
4901
4902 action_log
4903 .update(cx, |log, cx| log.reject_all_edits(None, cx))
4904 .await;
4905 cx.run_until_parked();
4906
4907 assert_eq!(
4908 String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(),
4909 "hello\n"
4910 );
4911 }
4912
4913 #[gpui::test]
4914 async fn test_infer_buffer_deleted_from_snapshot_preserves_later_user_edits_on_reject(
4915 cx: &mut TestAppContext,
4916 ) {
4917 init_test(cx);
4918
4919 let fs = FakeFs::new(cx.executor());
4920 fs.insert_tree(path!("/dir"), json!({"file": "hello\n"}))
4921 .await;
4922 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4923 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4924 let file_path = project
4925 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4926 .unwrap();
4927 let buffer = project
4928 .update(cx, |project, cx| project.open_buffer(file_path, cx))
4929 .await
4930 .unwrap();
4931
4932 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
4933
4934 fs.remove_file(path!("/dir/file").as_ref(), RemoveOptions::default())
4935 .await
4936 .unwrap();
4937 cx.run_until_parked();
4938
4939 cx.update(|cx| {
4940 action_log.update(cx, |log, cx| {
4941 log.infer_buffer_deleted_from_snapshot(
4942 buffer.clone(),
4943 baseline_snapshot.clone(),
4944 cx,
4945 );
4946 });
4947 });
4948 cx.run_until_parked();
4949
4950 buffer.update(cx, |buffer, cx| buffer.append("world\n", cx));
4951 project
4952 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4953 .await
4954 .unwrap();
4955 cx.run_until_parked();
4956
4957 action_log
4958 .update(cx, |log, cx| log.reject_all_edits(None, cx))
4959 .await;
4960 cx.run_until_parked();
4961
4962 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "world\n");
4963 assert_eq!(
4964 String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(),
4965 "world\n"
4966 );
4967 }
4968
4969 #[gpui::test]
4970 async fn test_will_delete_buffer_preserves_later_user_edits_on_reject(cx: &mut TestAppContext) {
4971 init_test(cx);
4972
4973 let fs = FakeFs::new(cx.executor());
4974 fs.insert_tree(path!("/dir"), json!({"file": "hello\n"}))
4975 .await;
4976 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
4977 let action_log = cx.new(|_| ActionLog::new(project.clone()));
4978 let file_path = project
4979 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
4980 .unwrap();
4981 let buffer = project
4982 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
4983 .await
4984 .unwrap();
4985
4986 cx.update(|cx| {
4987 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
4988 });
4989 project
4990 .update(cx, |project, cx| project.delete_file(file_path, false, cx))
4991 .unwrap()
4992 .await
4993 .unwrap();
4994 cx.run_until_parked();
4995
4996 buffer.update(cx, |buffer, cx| buffer.append("world\n", cx));
4997 project
4998 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
4999 .await
5000 .unwrap();
5001 cx.run_until_parked();
5002
5003 action_log
5004 .update(cx, |log, cx| log.reject_all_edits(None, cx))
5005 .await;
5006 cx.run_until_parked();
5007
5008 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "world\n");
5009 assert_eq!(
5010 String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(),
5011 "world\n"
5012 );
5013 }
5014
5015 #[gpui::test]
5016 async fn test_infer_buffer_edited_from_snapshot_preserves_later_user_edits(
5017 cx: &mut TestAppContext,
5018 ) {
5019 init_test(cx);
5020
5021 let fs = FakeFs::new(cx.executor());
5022 fs.insert_tree(path!("/dir"), json!({"file": "one\ntwo\n"}))
5023 .await;
5024 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5025 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5026 let file_path = project
5027 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
5028 .unwrap();
5029 let buffer = project
5030 .update(cx, |project, cx| project.open_buffer(file_path, cx))
5031 .await
5032 .unwrap();
5033
5034 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
5035
5036 buffer.update(cx, |buffer, cx| buffer.set_text("one\ntwo\nthree\n", cx));
5037 project
5038 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
5039 .await
5040 .unwrap();
5041
5042 cx.update(|cx| {
5043 action_log.update(cx, |log, cx| {
5044 log.infer_buffer_edited_from_snapshot(
5045 buffer.clone(),
5046 baseline_snapshot.clone(),
5047 cx,
5048 );
5049 });
5050 });
5051 cx.run_until_parked();
5052
5053 buffer.update(cx, |buffer, cx| {
5054 buffer.edit([(0..0, "zero\n")], None, cx);
5055 });
5056 cx.run_until_parked();
5057
5058 action_log
5059 .update(cx, |log, cx| log.reject_all_edits(None, cx))
5060 .await;
5061 cx.run_until_parked();
5062
5063 assert_eq!(
5064 buffer.read_with(cx, |buffer, _| buffer.text()),
5065 "zero\none\ntwo\n"
5066 );
5067 assert_eq!(
5068 String::from_utf8(fs.read_file_sync(path!("/dir/file")).unwrap()).unwrap(),
5069 "zero\none\ntwo\n"
5070 );
5071 }
5072
5073 #[gpui::test]
5074 async fn test_infer_buffer_created_preserves_later_user_edits_on_reject(
5075 cx: &mut TestAppContext,
5076 ) {
5077 init_test(cx);
5078
5079 let fs = FakeFs::new(cx.executor());
5080 fs.insert_tree(path!("/dir"), json!({})).await;
5081 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
5082 let action_log = cx.new(|_| ActionLog::new(project.clone()));
5083 let file_path = project
5084 .read_with(cx, |project, cx| {
5085 project.find_project_path("dir/new_file", cx)
5086 })
5087 .unwrap();
5088 let buffer = project
5089 .update(cx, |project, cx| project.open_buffer(file_path, cx))
5090 .await
5091 .unwrap();
5092
5093 let baseline_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
5094
5095 buffer.update(cx, |buffer, cx| buffer.set_text("hello\n", cx));
5096 project
5097 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
5098 .await
5099 .unwrap();
5100
5101 cx.update(|cx| {
5102 action_log.update(cx, |log, cx| {
5103 log.infer_buffer_created(buffer.clone(), baseline_snapshot.clone(), cx);
5104 });
5105 });
5106 cx.run_until_parked();
5107
5108 buffer.update(cx, |buffer, cx| buffer.append("world\n", cx));
5109 cx.run_until_parked();
5110
5111 action_log
5112 .update(cx, |log, cx| log.reject_all_edits(None, cx))
5113 .await;
5114 cx.run_until_parked();
5115
5116 assert_eq!(
5117 buffer.read_with(cx, |buffer, _| buffer.text()),
5118 "hello\nworld\n"
5119 );
5120 assert!(fs.read_file_sync(path!("/dir/new_file")).is_ok());
5121 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
5122 }
5123
5124 #[derive(Debug, PartialEq)]
5125 struct HunkStatus {
5126 range: Range<Point>,
5127 diff_status: DiffHunkStatusKind,
5128 old_text: String,
5129 }
5130
5131 fn unreviewed_hunks(
5132 action_log: &Entity<ActionLog>,
5133 cx: &TestAppContext,
5134 ) -> Vec<(Entity<Buffer>, Vec<HunkStatus>)> {
5135 cx.read(|cx| {
5136 action_log
5137 .read(cx)
5138 .changed_buffers(cx)
5139 .into_iter()
5140 .map(|(buffer, diff)| {
5141 let snapshot = buffer.read(cx).snapshot();
5142 (
5143 buffer,
5144 diff.read(cx)
5145 .snapshot(cx)
5146 .hunks(&snapshot)
5147 .map(|hunk| HunkStatus {
5148 diff_status: hunk.status().kind,
5149 range: hunk.range,
5150 old_text: diff
5151 .read(cx)
5152 .base_text(cx)
5153 .text_for_range(hunk.diff_base_byte_range)
5154 .collect(),
5155 })
5156 .collect(),
5157 )
5158 })
5159 .collect()
5160 })
5161 }
5162}