1use anyhow::{Context as _, Result};
2use buffer_diff::BufferDiff;
3use clock;
4use collections::BTreeMap;
5use futures::{FutureExt, StreamExt, channel::mpsc};
6use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
7use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint};
8use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
9use std::{cmp, ops::Range, sync::Arc};
10use text::{Edit, Patch, Rope};
11use util::{
12 RangeExt, ResultExt as _,
13 paths::{PathStyle, RemotePathBuf},
14};
15
16/// Tracks actions performed by tools in a thread
17pub struct ActionLog {
18 /// Buffers that we want to notify the model about when they change.
19 tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
20 /// The project this action log is associated with
21 project: Entity<Project>,
22}
23
24impl ActionLog {
25 /// Creates a new, empty action log associated with the given project.
26 pub fn new(project: Entity<Project>) -> Self {
27 Self {
28 tracked_buffers: BTreeMap::default(),
29 project,
30 }
31 }
32
33 pub fn project(&self) -> &Entity<Project> {
34 &self.project
35 }
36
37 pub fn latest_snapshot(&self, buffer: &Entity<Buffer>) -> Option<text::BufferSnapshot> {
38 Some(self.tracked_buffers.get(buffer)?.snapshot.clone())
39 }
40
41 /// Return a unified diff patch with user edits made since last read or notification
42 pub fn unnotified_user_edits(&self, cx: &Context<Self>) -> Option<String> {
43 let diffs = self
44 .tracked_buffers
45 .values()
46 .filter_map(|tracked| {
47 if !tracked.may_have_unnotified_user_edits {
48 return None;
49 }
50
51 let text_with_latest_user_edits = tracked.diff_base.to_string();
52 let text_with_last_seen_user_edits = tracked.last_seen_base.to_string();
53 if text_with_latest_user_edits == text_with_last_seen_user_edits {
54 return None;
55 }
56 let patch = language::unified_diff(
57 &text_with_last_seen_user_edits,
58 &text_with_latest_user_edits,
59 );
60
61 let buffer = tracked.buffer.clone();
62 let file_path = buffer
63 .read(cx)
64 .file()
65 .map(|file| RemotePathBuf::new(file.full_path(cx), PathStyle::Posix).to_proto())
66 .unwrap_or_else(|| format!("buffer_{}", buffer.entity_id()));
67
68 let mut result = String::new();
69 result.push_str(&format!("--- a/{}\n", file_path));
70 result.push_str(&format!("+++ b/{}\n", file_path));
71 result.push_str(&patch);
72
73 Some(result)
74 })
75 .collect::<Vec<_>>();
76
77 if diffs.is_empty() {
78 return None;
79 }
80
81 let unified_diff = diffs.join("\n\n");
82 Some(unified_diff)
83 }
84
85 /// Return a unified diff patch with user edits made since last read/notification
86 /// and mark them as notified
87 pub fn flush_unnotified_user_edits(&mut self, cx: &Context<Self>) -> Option<String> {
88 let patch = self.unnotified_user_edits(cx);
89 self.tracked_buffers.values_mut().for_each(|tracked| {
90 tracked.may_have_unnotified_user_edits = false;
91 tracked.last_seen_base = tracked.diff_base.clone();
92 });
93 patch
94 }
95
96 fn track_buffer_internal(
97 &mut self,
98 buffer: Entity<Buffer>,
99 is_created: bool,
100 cx: &mut Context<Self>,
101 ) -> &mut TrackedBuffer {
102 let status = if is_created {
103 if let Some(tracked) = self.tracked_buffers.remove(&buffer) {
104 match tracked.status {
105 TrackedBufferStatus::Created {
106 existing_file_content,
107 } => TrackedBufferStatus::Created {
108 existing_file_content,
109 },
110 TrackedBufferStatus::Modified | TrackedBufferStatus::Deleted => {
111 TrackedBufferStatus::Created {
112 existing_file_content: Some(tracked.diff_base),
113 }
114 }
115 }
116 } else if buffer
117 .read(cx)
118 .file()
119 .is_some_and(|file| file.disk_state().exists())
120 {
121 TrackedBufferStatus::Created {
122 existing_file_content: Some(buffer.read(cx).as_rope().clone()),
123 }
124 } else {
125 TrackedBufferStatus::Created {
126 existing_file_content: None,
127 }
128 }
129 } else {
130 TrackedBufferStatus::Modified
131 };
132
133 let tracked_buffer = self
134 .tracked_buffers
135 .entry(buffer.clone())
136 .or_insert_with(|| {
137 let open_lsp_handle = self.project.update(cx, |project, cx| {
138 project.register_buffer_with_language_servers(&buffer, cx)
139 });
140
141 let text_snapshot = buffer.read(cx).text_snapshot();
142 let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
143 let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
144 let diff_base;
145 let last_seen_base;
146 let unreviewed_edits;
147 if is_created {
148 diff_base = Rope::default();
149 last_seen_base = Rope::default();
150 unreviewed_edits = Patch::new(vec![Edit {
151 old: 0..1,
152 new: 0..text_snapshot.max_point().row + 1,
153 }])
154 } else {
155 diff_base = buffer.read(cx).as_rope().clone();
156 last_seen_base = diff_base.clone();
157 unreviewed_edits = Patch::default();
158 }
159 TrackedBuffer {
160 buffer: buffer.clone(),
161 diff_base,
162 last_seen_base,
163 unreviewed_edits,
164 snapshot: text_snapshot,
165 status,
166 version: buffer.read(cx).version(),
167 diff,
168 diff_update: diff_update_tx,
169 may_have_unnotified_user_edits: false,
170 _open_lsp_handle: open_lsp_handle,
171 _maintain_diff: cx.spawn({
172 let buffer = buffer.clone();
173 async move |this, cx| {
174 Self::maintain_diff(this, buffer, diff_update_rx, cx)
175 .await
176 .ok();
177 }
178 }),
179 _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
180 }
181 });
182 tracked_buffer.version = buffer.read(cx).version();
183 tracked_buffer
184 }
185
186 fn handle_buffer_event(
187 &mut self,
188 buffer: Entity<Buffer>,
189 event: &BufferEvent,
190 cx: &mut Context<Self>,
191 ) {
192 match event {
193 BufferEvent::Edited => self.handle_buffer_edited(buffer, cx),
194 BufferEvent::FileHandleChanged => {
195 self.handle_buffer_file_changed(buffer, cx);
196 }
197 _ => {}
198 };
199 }
200
201 fn handle_buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
202 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
203 return;
204 };
205 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
206 }
207
208 fn handle_buffer_file_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
209 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
210 return;
211 };
212
213 match tracked_buffer.status {
214 TrackedBufferStatus::Created { .. } | TrackedBufferStatus::Modified => {
215 if buffer
216 .read(cx)
217 .file()
218 .is_some_and(|file| file.disk_state() == DiskState::Deleted)
219 {
220 // If the buffer had been edited by a tool, but it got
221 // deleted externally, we want to stop tracking it.
222 self.tracked_buffers.remove(&buffer);
223 }
224 cx.notify();
225 }
226 TrackedBufferStatus::Deleted => {
227 if buffer
228 .read(cx)
229 .file()
230 .is_some_and(|file| file.disk_state() != DiskState::Deleted)
231 {
232 // If the buffer had been deleted by a tool, but it got
233 // resurrected externally, we want to clear the edits we
234 // were tracking and reset the buffer's state.
235 self.tracked_buffers.remove(&buffer);
236 self.track_buffer_internal(buffer, false, cx);
237 }
238 cx.notify();
239 }
240 }
241 }
242
243 async fn maintain_diff(
244 this: WeakEntity<Self>,
245 buffer: Entity<Buffer>,
246 mut buffer_updates: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
247 cx: &mut AsyncApp,
248 ) -> Result<()> {
249 let git_store = this.read_with(cx, |this, cx| this.project.read(cx).git_store().clone())?;
250 let git_diff = this
251 .update(cx, |this, cx| {
252 this.project.update(cx, |project, cx| {
253 project.open_uncommitted_diff(buffer.clone(), cx)
254 })
255 })?
256 .await
257 .ok();
258 let buffer_repo = git_store.read_with(cx, |git_store, cx| {
259 git_store.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
260 })?;
261
262 let (mut git_diff_updates_tx, mut git_diff_updates_rx) = watch::channel(());
263 let _repo_subscription =
264 if let Some((git_diff, (buffer_repo, _))) = git_diff.as_ref().zip(buffer_repo) {
265 cx.update(|cx| {
266 let mut old_head = buffer_repo.read(cx).head_commit.clone();
267 Some(cx.subscribe(git_diff, move |_, event, cx| {
268 if let buffer_diff::BufferDiffEvent::DiffChanged { .. } = event {
269 let new_head = buffer_repo.read(cx).head_commit.clone();
270 if new_head != old_head {
271 old_head = new_head;
272 git_diff_updates_tx.send(()).ok();
273 }
274 }
275 }))
276 })?
277 } else {
278 None
279 };
280
281 loop {
282 futures::select_biased! {
283 buffer_update = buffer_updates.next() => {
284 if let Some((author, buffer_snapshot)) = buffer_update {
285 Self::track_edits(&this, &buffer, author, buffer_snapshot, cx).await?;
286 } else {
287 break;
288 }
289 }
290 _ = git_diff_updates_rx.changed().fuse() => {
291 if let Some(git_diff) = git_diff.as_ref() {
292 Self::keep_committed_edits(&this, &buffer, git_diff, cx).await?;
293 }
294 }
295 }
296 }
297
298 Ok(())
299 }
300
301 async fn track_edits(
302 this: &WeakEntity<ActionLog>,
303 buffer: &Entity<Buffer>,
304 author: ChangeAuthor,
305 buffer_snapshot: text::BufferSnapshot,
306 cx: &mut AsyncApp,
307 ) -> Result<()> {
308 let rebase = this.update(cx, |this, cx| {
309 let tracked_buffer = this
310 .tracked_buffers
311 .get_mut(buffer)
312 .context("buffer not tracked")?;
313
314 let rebase = cx.background_spawn({
315 let mut base_text = tracked_buffer.diff_base.clone();
316 let old_snapshot = tracked_buffer.snapshot.clone();
317 let new_snapshot = buffer_snapshot.clone();
318 let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
319 let edits = diff_snapshots(&old_snapshot, &new_snapshot);
320 let mut has_user_changes = false;
321 async move {
322 if let ChangeAuthor::User = author {
323 has_user_changes = apply_non_conflicting_edits(
324 &unreviewed_edits,
325 edits,
326 &mut base_text,
327 new_snapshot.as_rope(),
328 );
329 }
330
331 (Arc::new(base_text.to_string()), base_text, has_user_changes)
332 }
333 });
334
335 anyhow::Ok(rebase)
336 })??;
337 let (new_base_text, new_diff_base, has_user_changes) = rebase.await;
338
339 this.update(cx, |this, _| {
340 let tracked_buffer = this
341 .tracked_buffers
342 .get_mut(buffer)
343 .context("buffer not tracked")
344 .unwrap();
345 tracked_buffer.may_have_unnotified_user_edits |= has_user_changes;
346 })?;
347
348 Self::update_diff(
349 this,
350 buffer,
351 buffer_snapshot,
352 new_base_text,
353 new_diff_base,
354 cx,
355 )
356 .await
357 }
358
359 async fn keep_committed_edits(
360 this: &WeakEntity<ActionLog>,
361 buffer: &Entity<Buffer>,
362 git_diff: &Entity<BufferDiff>,
363 cx: &mut AsyncApp,
364 ) -> Result<()> {
365 let buffer_snapshot = this.read_with(cx, |this, _cx| {
366 let tracked_buffer = this
367 .tracked_buffers
368 .get(buffer)
369 .context("buffer not tracked")?;
370 anyhow::Ok(tracked_buffer.snapshot.clone())
371 })??;
372 let (new_base_text, new_diff_base) = this
373 .read_with(cx, |this, cx| {
374 let tracked_buffer = this
375 .tracked_buffers
376 .get(buffer)
377 .context("buffer not tracked")?;
378 let old_unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
379 let agent_diff_base = tracked_buffer.diff_base.clone();
380 let git_diff_base = git_diff.read(cx).base_text().as_rope().clone();
381 let buffer_text = tracked_buffer.snapshot.as_rope().clone();
382 anyhow::Ok(cx.background_spawn(async move {
383 let mut old_unreviewed_edits = old_unreviewed_edits.into_iter().peekable();
384 let committed_edits = language::line_diff(
385 &agent_diff_base.to_string(),
386 &git_diff_base.to_string(),
387 )
388 .into_iter()
389 .map(|(old, new)| Edit { old, new });
390
391 let mut new_agent_diff_base = agent_diff_base.clone();
392 let mut row_delta = 0i32;
393 for committed in committed_edits {
394 while let Some(unreviewed) = old_unreviewed_edits.peek() {
395 // If the committed edit matches the unreviewed
396 // edit, assume the user wants to keep it.
397 if committed.old == unreviewed.old {
398 let unreviewed_new =
399 buffer_text.slice_rows(unreviewed.new.clone()).to_string();
400 let committed_new =
401 git_diff_base.slice_rows(committed.new.clone()).to_string();
402 if unreviewed_new == committed_new {
403 let old_byte_start =
404 new_agent_diff_base.point_to_offset(Point::new(
405 (unreviewed.old.start as i32 + row_delta) as u32,
406 0,
407 ));
408 let old_byte_end =
409 new_agent_diff_base.point_to_offset(cmp::min(
410 Point::new(
411 (unreviewed.old.end as i32 + row_delta) as u32,
412 0,
413 ),
414 new_agent_diff_base.max_point(),
415 ));
416 new_agent_diff_base
417 .replace(old_byte_start..old_byte_end, &unreviewed_new);
418 row_delta +=
419 unreviewed.new_len() as i32 - unreviewed.old_len() as i32;
420 }
421 } else if unreviewed.old.start >= committed.old.end {
422 break;
423 }
424
425 old_unreviewed_edits.next().unwrap();
426 }
427 }
428
429 (
430 Arc::new(new_agent_diff_base.to_string()),
431 new_agent_diff_base,
432 )
433 }))
434 })??
435 .await;
436
437 Self::update_diff(
438 this,
439 buffer,
440 buffer_snapshot,
441 new_base_text,
442 new_diff_base,
443 cx,
444 )
445 .await
446 }
447
448 async fn update_diff(
449 this: &WeakEntity<ActionLog>,
450 buffer: &Entity<Buffer>,
451 buffer_snapshot: text::BufferSnapshot,
452 new_base_text: Arc<String>,
453 new_diff_base: Rope,
454 cx: &mut AsyncApp,
455 ) -> Result<()> {
456 let (diff, language, language_registry) = this.read_with(cx, |this, cx| {
457 let tracked_buffer = this
458 .tracked_buffers
459 .get(buffer)
460 .context("buffer not tracked")?;
461 anyhow::Ok((
462 tracked_buffer.diff.clone(),
463 buffer.read(cx).language().cloned(),
464 buffer.read(cx).language_registry(),
465 ))
466 })??;
467 let diff_snapshot = BufferDiff::update_diff(
468 diff.clone(),
469 buffer_snapshot.clone(),
470 Some(new_base_text),
471 true,
472 false,
473 language,
474 language_registry,
475 cx,
476 )
477 .await;
478 let mut unreviewed_edits = Patch::default();
479 if let Ok(diff_snapshot) = diff_snapshot {
480 unreviewed_edits = cx
481 .background_spawn({
482 let diff_snapshot = diff_snapshot.clone();
483 let buffer_snapshot = buffer_snapshot.clone();
484 let new_diff_base = new_diff_base.clone();
485 async move {
486 let mut unreviewed_edits = Patch::default();
487 for hunk in diff_snapshot
488 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer_snapshot)
489 {
490 let old_range = new_diff_base
491 .offset_to_point(hunk.diff_base_byte_range.start)
492 ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end);
493 let new_range = hunk.range.start..hunk.range.end;
494 unreviewed_edits.push(point_to_row_edit(
495 Edit {
496 old: old_range,
497 new: new_range,
498 },
499 &new_diff_base,
500 buffer_snapshot.as_rope(),
501 ));
502 }
503 unreviewed_edits
504 }
505 })
506 .await;
507
508 diff.update(cx, |diff, cx| {
509 diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx);
510 })?;
511 }
512 this.update(cx, |this, cx| {
513 let tracked_buffer = this
514 .tracked_buffers
515 .get_mut(buffer)
516 .context("buffer not tracked")?;
517 tracked_buffer.diff_base = new_diff_base;
518 tracked_buffer.snapshot = buffer_snapshot;
519 tracked_buffer.unreviewed_edits = unreviewed_edits;
520 cx.notify();
521 anyhow::Ok(())
522 })?
523 }
524
525 /// Track a buffer as read by agent, so we can notify the model about user edits.
526 pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
527 self.track_buffer_internal(buffer, false, cx);
528 }
529
530 /// Mark a buffer as created by agent, so we can refresh it in the context
531 pub fn buffer_created(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
532 self.track_buffer_internal(buffer, true, cx);
533 }
534
535 /// Mark a buffer as edited by agent, so we can refresh it in the context
536 pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
537 let tracked_buffer = self.track_buffer_internal(buffer, false, cx);
538 if let TrackedBufferStatus::Deleted = tracked_buffer.status {
539 tracked_buffer.status = TrackedBufferStatus::Modified;
540 }
541 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
542 }
543
544 pub fn will_delete_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
545 let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
546 match tracked_buffer.status {
547 TrackedBufferStatus::Created { .. } => {
548 self.tracked_buffers.remove(&buffer);
549 cx.notify();
550 }
551 TrackedBufferStatus::Modified => {
552 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
553 tracked_buffer.status = TrackedBufferStatus::Deleted;
554 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
555 }
556 TrackedBufferStatus::Deleted => {}
557 }
558 cx.notify();
559 }
560
561 pub fn keep_edits_in_range(
562 &mut self,
563 buffer: Entity<Buffer>,
564 buffer_range: Range<impl language::ToPoint>,
565 cx: &mut Context<Self>,
566 ) {
567 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
568 return;
569 };
570
571 match tracked_buffer.status {
572 TrackedBufferStatus::Deleted => {
573 self.tracked_buffers.remove(&buffer);
574 cx.notify();
575 }
576 _ => {
577 let buffer = buffer.read(cx);
578 let buffer_range =
579 buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
580 let mut delta = 0i32;
581
582 tracked_buffer.unreviewed_edits.retain_mut(|edit| {
583 edit.old.start = (edit.old.start as i32 + delta) as u32;
584 edit.old.end = (edit.old.end as i32 + delta) as u32;
585
586 if buffer_range.end.row < edit.new.start
587 || buffer_range.start.row > edit.new.end
588 {
589 true
590 } else {
591 let old_range = tracked_buffer
592 .diff_base
593 .point_to_offset(Point::new(edit.old.start, 0))
594 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
595 Point::new(edit.old.end, 0),
596 tracked_buffer.diff_base.max_point(),
597 ));
598 let new_range = tracked_buffer
599 .snapshot
600 .point_to_offset(Point::new(edit.new.start, 0))
601 ..tracked_buffer.snapshot.point_to_offset(cmp::min(
602 Point::new(edit.new.end, 0),
603 tracked_buffer.snapshot.max_point(),
604 ));
605 tracked_buffer.diff_base.replace(
606 old_range,
607 &tracked_buffer
608 .snapshot
609 .text_for_range(new_range)
610 .collect::<String>(),
611 );
612 delta += edit.new_len() as i32 - edit.old_len() as i32;
613 false
614 }
615 });
616 if tracked_buffer.unreviewed_edits.is_empty()
617 && let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status
618 {
619 tracked_buffer.status = TrackedBufferStatus::Modified;
620 }
621 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
622 }
623 }
624 }
625
626 pub fn reject_edits_in_ranges(
627 &mut self,
628 buffer: Entity<Buffer>,
629 buffer_ranges: Vec<Range<impl language::ToPoint>>,
630 cx: &mut Context<Self>,
631 ) -> Task<Result<()>> {
632 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
633 return Task::ready(Ok(()));
634 };
635
636 match &tracked_buffer.status {
637 TrackedBufferStatus::Created {
638 existing_file_content,
639 } => {
640 let task = if let Some(existing_file_content) = existing_file_content {
641 buffer.update(cx, |buffer, cx| {
642 buffer.start_transaction();
643 buffer.set_text("", cx);
644 for chunk in existing_file_content.chunks() {
645 buffer.append(chunk, cx);
646 }
647 buffer.end_transaction(cx);
648 });
649 self.project
650 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
651 } else {
652 // For a file created by AI with no pre-existing content,
653 // only delete the file if we're certain it contains only AI content
654 // with no edits from the user.
655
656 let initial_version = tracked_buffer.version.clone();
657 let current_version = buffer.read(cx).version();
658
659 let current_content = buffer.read(cx).text();
660 let tracked_content = tracked_buffer.snapshot.text();
661
662 let is_ai_only_content =
663 initial_version == current_version && current_content == tracked_content;
664
665 if is_ai_only_content {
666 buffer
667 .read(cx)
668 .entry_id(cx)
669 .and_then(|entry_id| {
670 self.project.update(cx, |project, cx| {
671 project.delete_entry(entry_id, false, cx)
672 })
673 })
674 .unwrap_or(Task::ready(Ok(())))
675 } else {
676 // Not sure how to disentangle edits made by the user
677 // from edits made by the AI at this point.
678 // For now, preserve both to avoid data loss.
679 //
680 // TODO: Better solution (disable "Reject" after user makes some
681 // edit or find a way to differentiate between AI and user edits)
682 Task::ready(Ok(()))
683 }
684 };
685
686 self.tracked_buffers.remove(&buffer);
687 cx.notify();
688 task
689 }
690 TrackedBufferStatus::Deleted => {
691 buffer.update(cx, |buffer, cx| {
692 buffer.set_text(tracked_buffer.diff_base.to_string(), cx)
693 });
694 let save = self
695 .project
696 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
697
698 // Clear all tracked edits for this buffer and start over as if we just read it.
699 self.tracked_buffers.remove(&buffer);
700 self.buffer_read(buffer.clone(), cx);
701 cx.notify();
702 save
703 }
704 TrackedBufferStatus::Modified => {
705 buffer.update(cx, |buffer, cx| {
706 let mut buffer_row_ranges = buffer_ranges
707 .into_iter()
708 .map(|range| {
709 range.start.to_point(buffer).row..range.end.to_point(buffer).row
710 })
711 .peekable();
712
713 let mut edits_to_revert = Vec::new();
714 for edit in tracked_buffer.unreviewed_edits.edits() {
715 let new_range = tracked_buffer
716 .snapshot
717 .anchor_before(Point::new(edit.new.start, 0))
718 ..tracked_buffer.snapshot.anchor_after(cmp::min(
719 Point::new(edit.new.end, 0),
720 tracked_buffer.snapshot.max_point(),
721 ));
722 let new_row_range = new_range.start.to_point(buffer).row
723 ..new_range.end.to_point(buffer).row;
724
725 let mut revert = false;
726 while let Some(buffer_row_range) = buffer_row_ranges.peek() {
727 if buffer_row_range.end < new_row_range.start {
728 buffer_row_ranges.next();
729 } else if buffer_row_range.start > new_row_range.end {
730 break;
731 } else {
732 revert = true;
733 break;
734 }
735 }
736
737 if revert {
738 let old_range = tracked_buffer
739 .diff_base
740 .point_to_offset(Point::new(edit.old.start, 0))
741 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
742 Point::new(edit.old.end, 0),
743 tracked_buffer.diff_base.max_point(),
744 ));
745 let old_text = tracked_buffer
746 .diff_base
747 .chunks_in_range(old_range)
748 .collect::<String>();
749 edits_to_revert.push((new_range, old_text));
750 }
751 }
752
753 buffer.edit(edits_to_revert, None, cx);
754 });
755 self.project
756 .update(cx, |project, cx| project.save_buffer(buffer, cx))
757 }
758 }
759 }
760
761 pub fn keep_all_edits(&mut self, cx: &mut Context<Self>) {
762 self.tracked_buffers
763 .retain(|_buffer, tracked_buffer| match tracked_buffer.status {
764 TrackedBufferStatus::Deleted => false,
765 _ => {
766 if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status {
767 tracked_buffer.status = TrackedBufferStatus::Modified;
768 }
769 tracked_buffer.unreviewed_edits.clear();
770 tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
771 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
772 true
773 }
774 });
775 cx.notify();
776 }
777
778 pub fn reject_all_edits(&mut self, cx: &mut Context<Self>) -> Task<()> {
779 let futures = self.changed_buffers(cx).into_keys().map(|buffer| {
780 let reject = self.reject_edits_in_ranges(buffer, vec![Anchor::MIN..Anchor::MAX], cx);
781
782 async move {
783 reject.await.log_err();
784 }
785 });
786
787 let task = futures::future::join_all(futures);
788
789 cx.spawn(async move |_, _| {
790 task.await;
791 })
792 }
793
794 /// Returns the set of buffers that contain edits that haven't been reviewed by the user.
795 pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, Entity<BufferDiff>> {
796 self.tracked_buffers
797 .iter()
798 .filter(|(_, tracked)| tracked.has_edits(cx))
799 .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
800 .collect()
801 }
802
803 /// Iterate over buffers changed since last read or edited by the model
804 pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
805 self.tracked_buffers
806 .iter()
807 .filter(|(buffer, tracked)| {
808 let buffer = buffer.read(cx);
809
810 tracked.version != buffer.version
811 && buffer
812 .file()
813 .is_some_and(|file| file.disk_state() != DiskState::Deleted)
814 })
815 .map(|(buffer, _)| buffer)
816 }
817}
818
819fn apply_non_conflicting_edits(
820 patch: &Patch<u32>,
821 edits: Vec<Edit<u32>>,
822 old_text: &mut Rope,
823 new_text: &Rope,
824) -> bool {
825 let mut old_edits = patch.edits().iter().cloned().peekable();
826 let mut new_edits = edits.into_iter().peekable();
827 let mut applied_delta = 0i32;
828 let mut rebased_delta = 0i32;
829 let mut has_made_changes = false;
830
831 while let Some(mut new_edit) = new_edits.next() {
832 let mut conflict = false;
833
834 // Push all the old edits that are before this new edit or that intersect with it.
835 while let Some(old_edit) = old_edits.peek() {
836 if new_edit.old.end < old_edit.new.start
837 || (!old_edit.new.is_empty() && new_edit.old.end == old_edit.new.start)
838 {
839 break;
840 } else if new_edit.old.start > old_edit.new.end
841 || (!old_edit.new.is_empty() && new_edit.old.start == old_edit.new.end)
842 {
843 let old_edit = old_edits.next().unwrap();
844 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
845 } else {
846 conflict = true;
847 if new_edits
848 .peek()
849 .is_some_and(|next_edit| next_edit.old.overlaps(&old_edit.new))
850 {
851 new_edit = new_edits.next().unwrap();
852 } else {
853 let old_edit = old_edits.next().unwrap();
854 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
855 }
856 }
857 }
858
859 if !conflict {
860 // This edit doesn't intersect with any old edit, so we can apply it to the old text.
861 new_edit.old.start = (new_edit.old.start as i32 + applied_delta - rebased_delta) as u32;
862 new_edit.old.end = (new_edit.old.end as i32 + applied_delta - rebased_delta) as u32;
863 let old_bytes = old_text.point_to_offset(Point::new(new_edit.old.start, 0))
864 ..old_text.point_to_offset(cmp::min(
865 Point::new(new_edit.old.end, 0),
866 old_text.max_point(),
867 ));
868 let new_bytes = new_text.point_to_offset(Point::new(new_edit.new.start, 0))
869 ..new_text.point_to_offset(cmp::min(
870 Point::new(new_edit.new.end, 0),
871 new_text.max_point(),
872 ));
873
874 old_text.replace(
875 old_bytes,
876 &new_text.chunks_in_range(new_bytes).collect::<String>(),
877 );
878 applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
879 has_made_changes = true;
880 }
881 }
882 has_made_changes
883}
884
885fn diff_snapshots(
886 old_snapshot: &text::BufferSnapshot,
887 new_snapshot: &text::BufferSnapshot,
888) -> Vec<Edit<u32>> {
889 let mut edits = new_snapshot
890 .edits_since::<Point>(&old_snapshot.version)
891 .map(|edit| point_to_row_edit(edit, old_snapshot.as_rope(), new_snapshot.as_rope()))
892 .peekable();
893 let mut row_edits = Vec::new();
894 while let Some(mut edit) = edits.next() {
895 while let Some(next_edit) = edits.peek() {
896 if edit.old.end >= next_edit.old.start {
897 edit.old.end = next_edit.old.end;
898 edit.new.end = next_edit.new.end;
899 edits.next();
900 } else {
901 break;
902 }
903 }
904 row_edits.push(edit);
905 }
906 row_edits
907}
908
909fn point_to_row_edit(edit: Edit<Point>, old_text: &Rope, new_text: &Rope) -> Edit<u32> {
910 if edit.old.start.column == old_text.line_len(edit.old.start.row)
911 && new_text
912 .chars_at(new_text.point_to_offset(edit.new.start))
913 .next()
914 == Some('\n')
915 && edit.old.start != old_text.max_point()
916 {
917 Edit {
918 old: edit.old.start.row + 1..edit.old.end.row + 1,
919 new: edit.new.start.row + 1..edit.new.end.row + 1,
920 }
921 } else if edit.old.start.column == 0 && edit.old.end.column == 0 && edit.new.end.column == 0 {
922 Edit {
923 old: edit.old.start.row..edit.old.end.row,
924 new: edit.new.start.row..edit.new.end.row,
925 }
926 } else {
927 Edit {
928 old: edit.old.start.row..edit.old.end.row + 1,
929 new: edit.new.start.row..edit.new.end.row + 1,
930 }
931 }
932}
933
934#[derive(Copy, Clone, Debug)]
935enum ChangeAuthor {
936 User,
937 Agent,
938}
939
940enum TrackedBufferStatus {
941 Created { existing_file_content: Option<Rope> },
942 Modified,
943 Deleted,
944}
945
946struct TrackedBuffer {
947 buffer: Entity<Buffer>,
948 diff_base: Rope,
949 last_seen_base: Rope,
950 unreviewed_edits: Patch<u32>,
951 status: TrackedBufferStatus,
952 version: clock::Global,
953 diff: Entity<BufferDiff>,
954 snapshot: text::BufferSnapshot,
955 diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
956 may_have_unnotified_user_edits: bool,
957 _open_lsp_handle: OpenLspBufferHandle,
958 _maintain_diff: Task<()>,
959 _subscription: Subscription,
960}
961
962impl TrackedBuffer {
963 fn has_edits(&self, cx: &App) -> bool {
964 self.diff
965 .read(cx)
966 .hunks(self.buffer.read(cx), cx)
967 .next()
968 .is_some()
969 }
970
971 fn schedule_diff_update(&self, author: ChangeAuthor, cx: &App) {
972 self.diff_update
973 .unbounded_send((author, self.buffer.read(cx).text_snapshot()))
974 .ok();
975 }
976}
977
978pub struct ChangedBuffer {
979 pub diff: Entity<BufferDiff>,
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985 use buffer_diff::DiffHunkStatusKind;
986 use gpui::TestAppContext;
987 use indoc::indoc;
988 use language::Point;
989 use project::{FakeFs, Fs, Project, RemoveOptions};
990 use rand::prelude::*;
991 use serde_json::json;
992 use settings::SettingsStore;
993 use std::env;
994 use util::{RandomCharIter, path};
995
996 #[ctor::ctor]
997 fn init_logger() {
998 zlog::init_test();
999 }
1000
1001 fn init_test(cx: &mut TestAppContext) {
1002 cx.update(|cx| {
1003 let settings_store = SettingsStore::test(cx);
1004 cx.set_global(settings_store);
1005 language::init(cx);
1006 Project::init_settings(cx);
1007 });
1008 }
1009
1010 #[gpui::test(iterations = 10)]
1011 async fn test_keep_edits(cx: &mut TestAppContext) {
1012 init_test(cx);
1013
1014 let fs = FakeFs::new(cx.executor());
1015 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1016 .await;
1017 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1018 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1019 let file_path = project
1020 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1021 .unwrap();
1022 let buffer = project
1023 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1024 .await
1025 .unwrap();
1026
1027 cx.update(|cx| {
1028 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1029 buffer.update(cx, |buffer, cx| {
1030 buffer
1031 .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx)
1032 .unwrap()
1033 });
1034 buffer.update(cx, |buffer, cx| {
1035 buffer
1036 .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx)
1037 .unwrap()
1038 });
1039 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1040 });
1041 cx.run_until_parked();
1042 assert_eq!(
1043 buffer.read_with(cx, |buffer, _| buffer.text()),
1044 "abc\ndEf\nghi\njkl\nmnO"
1045 );
1046 assert_eq!(
1047 unreviewed_hunks(&action_log, cx),
1048 vec![(
1049 buffer.clone(),
1050 vec![
1051 HunkStatus {
1052 range: Point::new(1, 0)..Point::new(2, 0),
1053 diff_status: DiffHunkStatusKind::Modified,
1054 old_text: "def\n".into(),
1055 },
1056 HunkStatus {
1057 range: Point::new(4, 0)..Point::new(4, 3),
1058 diff_status: DiffHunkStatusKind::Modified,
1059 old_text: "mno".into(),
1060 }
1061 ],
1062 )]
1063 );
1064
1065 action_log.update(cx, |log, cx| {
1066 log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), cx)
1067 });
1068 cx.run_until_parked();
1069 assert_eq!(
1070 unreviewed_hunks(&action_log, cx),
1071 vec![(
1072 buffer.clone(),
1073 vec![HunkStatus {
1074 range: Point::new(1, 0)..Point::new(2, 0),
1075 diff_status: DiffHunkStatusKind::Modified,
1076 old_text: "def\n".into(),
1077 }],
1078 )]
1079 );
1080
1081 action_log.update(cx, |log, cx| {
1082 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), cx)
1083 });
1084 cx.run_until_parked();
1085 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1086 }
1087
1088 #[gpui::test(iterations = 10)]
1089 async fn test_deletions(cx: &mut TestAppContext) {
1090 init_test(cx);
1091
1092 let fs = FakeFs::new(cx.executor());
1093 fs.insert_tree(
1094 path!("/dir"),
1095 json!({"file": "abc\ndef\nghi\njkl\nmno\npqr"}),
1096 )
1097 .await;
1098 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1099 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1100 let file_path = project
1101 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1102 .unwrap();
1103 let buffer = project
1104 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1105 .await
1106 .unwrap();
1107
1108 cx.update(|cx| {
1109 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1110 buffer.update(cx, |buffer, cx| {
1111 buffer
1112 .edit([(Point::new(1, 0)..Point::new(2, 0), "")], None, cx)
1113 .unwrap();
1114 buffer.finalize_last_transaction();
1115 });
1116 buffer.update(cx, |buffer, cx| {
1117 buffer
1118 .edit([(Point::new(3, 0)..Point::new(4, 0), "")], None, cx)
1119 .unwrap();
1120 buffer.finalize_last_transaction();
1121 });
1122 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1123 });
1124 cx.run_until_parked();
1125 assert_eq!(
1126 buffer.read_with(cx, |buffer, _| buffer.text()),
1127 "abc\nghi\njkl\npqr"
1128 );
1129 assert_eq!(
1130 unreviewed_hunks(&action_log, cx),
1131 vec![(
1132 buffer.clone(),
1133 vec![
1134 HunkStatus {
1135 range: Point::new(1, 0)..Point::new(1, 0),
1136 diff_status: DiffHunkStatusKind::Deleted,
1137 old_text: "def\n".into(),
1138 },
1139 HunkStatus {
1140 range: Point::new(3, 0)..Point::new(3, 0),
1141 diff_status: DiffHunkStatusKind::Deleted,
1142 old_text: "mno\n".into(),
1143 }
1144 ],
1145 )]
1146 );
1147
1148 buffer.update(cx, |buffer, cx| buffer.undo(cx));
1149 cx.run_until_parked();
1150 assert_eq!(
1151 buffer.read_with(cx, |buffer, _| buffer.text()),
1152 "abc\nghi\njkl\nmno\npqr"
1153 );
1154 assert_eq!(
1155 unreviewed_hunks(&action_log, cx),
1156 vec![(
1157 buffer.clone(),
1158 vec![HunkStatus {
1159 range: Point::new(1, 0)..Point::new(1, 0),
1160 diff_status: DiffHunkStatusKind::Deleted,
1161 old_text: "def\n".into(),
1162 }],
1163 )]
1164 );
1165
1166 action_log.update(cx, |log, cx| {
1167 log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), cx)
1168 });
1169 cx.run_until_parked();
1170 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1171 }
1172
1173 #[gpui::test(iterations = 10)]
1174 async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
1175 init_test(cx);
1176
1177 let fs = FakeFs::new(cx.executor());
1178 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1179 .await;
1180 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1181 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1182 let file_path = project
1183 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1184 .unwrap();
1185 let buffer = project
1186 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1187 .await
1188 .unwrap();
1189
1190 cx.update(|cx| {
1191 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1192 buffer.update(cx, |buffer, cx| {
1193 buffer
1194 .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
1195 .unwrap()
1196 });
1197 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1198 });
1199 cx.run_until_parked();
1200 assert_eq!(
1201 buffer.read_with(cx, |buffer, _| buffer.text()),
1202 "abc\ndeF\nGHI\njkl\nmno"
1203 );
1204 assert_eq!(
1205 unreviewed_hunks(&action_log, cx),
1206 vec![(
1207 buffer.clone(),
1208 vec![HunkStatus {
1209 range: Point::new(1, 0)..Point::new(3, 0),
1210 diff_status: DiffHunkStatusKind::Modified,
1211 old_text: "def\nghi\n".into(),
1212 }],
1213 )]
1214 );
1215
1216 buffer.update(cx, |buffer, cx| {
1217 buffer.edit(
1218 [
1219 (Point::new(0, 2)..Point::new(0, 2), "X"),
1220 (Point::new(3, 0)..Point::new(3, 0), "Y"),
1221 ],
1222 None,
1223 cx,
1224 )
1225 });
1226 cx.run_until_parked();
1227 assert_eq!(
1228 buffer.read_with(cx, |buffer, _| buffer.text()),
1229 "abXc\ndeF\nGHI\nYjkl\nmno"
1230 );
1231 assert_eq!(
1232 unreviewed_hunks(&action_log, cx),
1233 vec![(
1234 buffer.clone(),
1235 vec![HunkStatus {
1236 range: Point::new(1, 0)..Point::new(3, 0),
1237 diff_status: DiffHunkStatusKind::Modified,
1238 old_text: "def\nghi\n".into(),
1239 }],
1240 )]
1241 );
1242
1243 buffer.update(cx, |buffer, cx| {
1244 buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "Z")], None, cx)
1245 });
1246 cx.run_until_parked();
1247 assert_eq!(
1248 buffer.read_with(cx, |buffer, _| buffer.text()),
1249 "abXc\ndZeF\nGHI\nYjkl\nmno"
1250 );
1251 assert_eq!(
1252 unreviewed_hunks(&action_log, cx),
1253 vec![(
1254 buffer.clone(),
1255 vec![HunkStatus {
1256 range: Point::new(1, 0)..Point::new(3, 0),
1257 diff_status: DiffHunkStatusKind::Modified,
1258 old_text: "def\nghi\n".into(),
1259 }],
1260 )]
1261 );
1262
1263 action_log.update(cx, |log, cx| {
1264 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
1265 });
1266 cx.run_until_parked();
1267 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1268 }
1269
1270 #[gpui::test(iterations = 10)]
1271 async fn test_user_edits_notifications(cx: &mut TestAppContext) {
1272 init_test(cx);
1273
1274 let fs = FakeFs::new(cx.executor());
1275 fs.insert_tree(
1276 path!("/dir"),
1277 json!({"file": indoc! {"
1278 abc
1279 def
1280 ghi
1281 jkl
1282 mno"}}),
1283 )
1284 .await;
1285 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1286 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1287 let file_path = project
1288 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1289 .unwrap();
1290 let buffer = project
1291 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1292 .await
1293 .unwrap();
1294
1295 // Agent edits
1296 cx.update(|cx| {
1297 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1298 buffer.update(cx, |buffer, cx| {
1299 buffer
1300 .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
1301 .unwrap()
1302 });
1303 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1304 });
1305 cx.run_until_parked();
1306 assert_eq!(
1307 buffer.read_with(cx, |buffer, _| buffer.text()),
1308 indoc! {"
1309 abc
1310 deF
1311 GHI
1312 jkl
1313 mno"}
1314 );
1315 assert_eq!(
1316 unreviewed_hunks(&action_log, cx),
1317 vec![(
1318 buffer.clone(),
1319 vec![HunkStatus {
1320 range: Point::new(1, 0)..Point::new(3, 0),
1321 diff_status: DiffHunkStatusKind::Modified,
1322 old_text: "def\nghi\n".into(),
1323 }],
1324 )]
1325 );
1326
1327 // User edits
1328 buffer.update(cx, |buffer, cx| {
1329 buffer.edit(
1330 [
1331 (Point::new(0, 2)..Point::new(0, 2), "X"),
1332 (Point::new(3, 0)..Point::new(3, 0), "Y"),
1333 ],
1334 None,
1335 cx,
1336 )
1337 });
1338 cx.run_until_parked();
1339 assert_eq!(
1340 buffer.read_with(cx, |buffer, _| buffer.text()),
1341 indoc! {"
1342 abXc
1343 deF
1344 GHI
1345 Yjkl
1346 mno"}
1347 );
1348
1349 // User edits should be stored separately from agent's
1350 let user_edits = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx));
1351 assert_eq!(
1352 user_edits.expect("should have some user edits"),
1353 indoc! {"
1354 --- a/dir/file
1355 +++ b/dir/file
1356 @@ -1,5 +1,5 @@
1357 -abc
1358 +abXc
1359 def
1360 ghi
1361 -jkl
1362 +Yjkl
1363 mno
1364 "}
1365 );
1366
1367 action_log.update(cx, |log, cx| {
1368 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
1369 });
1370 cx.run_until_parked();
1371 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1372 }
1373
1374 #[gpui::test(iterations = 10)]
1375 async fn test_creating_files(cx: &mut TestAppContext) {
1376 init_test(cx);
1377
1378 let fs = FakeFs::new(cx.executor());
1379 fs.insert_tree(path!("/dir"), json!({})).await;
1380 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1381 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1382 let file_path = project
1383 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1384 .unwrap();
1385
1386 let buffer = project
1387 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1388 .await
1389 .unwrap();
1390 cx.update(|cx| {
1391 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1392 buffer.update(cx, |buffer, cx| buffer.set_text("lorem", cx));
1393 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1394 });
1395 project
1396 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1397 .await
1398 .unwrap();
1399 cx.run_until_parked();
1400 assert_eq!(
1401 unreviewed_hunks(&action_log, cx),
1402 vec![(
1403 buffer.clone(),
1404 vec![HunkStatus {
1405 range: Point::new(0, 0)..Point::new(0, 5),
1406 diff_status: DiffHunkStatusKind::Added,
1407 old_text: "".into(),
1408 }],
1409 )]
1410 );
1411
1412 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "X")], None, cx));
1413 cx.run_until_parked();
1414 assert_eq!(
1415 unreviewed_hunks(&action_log, cx),
1416 vec![(
1417 buffer.clone(),
1418 vec![HunkStatus {
1419 range: Point::new(0, 0)..Point::new(0, 6),
1420 diff_status: DiffHunkStatusKind::Added,
1421 old_text: "".into(),
1422 }],
1423 )]
1424 );
1425
1426 action_log.update(cx, |log, cx| {
1427 log.keep_edits_in_range(buffer.clone(), 0..5, cx)
1428 });
1429 cx.run_until_parked();
1430 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1431 }
1432
1433 #[gpui::test(iterations = 10)]
1434 async fn test_overwriting_files(cx: &mut TestAppContext) {
1435 init_test(cx);
1436
1437 let fs = FakeFs::new(cx.executor());
1438 fs.insert_tree(
1439 path!("/dir"),
1440 json!({
1441 "file1": "Lorem ipsum dolor"
1442 }),
1443 )
1444 .await;
1445 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1446 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1447 let file_path = project
1448 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1449 .unwrap();
1450
1451 let buffer = project
1452 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1453 .await
1454 .unwrap();
1455 cx.update(|cx| {
1456 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1457 buffer.update(cx, |buffer, cx| buffer.set_text("sit amet consecteur", cx));
1458 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1459 });
1460 project
1461 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1462 .await
1463 .unwrap();
1464 cx.run_until_parked();
1465 assert_eq!(
1466 unreviewed_hunks(&action_log, cx),
1467 vec![(
1468 buffer.clone(),
1469 vec![HunkStatus {
1470 range: Point::new(0, 0)..Point::new(0, 19),
1471 diff_status: DiffHunkStatusKind::Added,
1472 old_text: "".into(),
1473 }],
1474 )]
1475 );
1476
1477 action_log
1478 .update(cx, |log, cx| {
1479 log.reject_edits_in_ranges(buffer.clone(), vec![2..5], cx)
1480 })
1481 .await
1482 .unwrap();
1483 cx.run_until_parked();
1484 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1485 assert_eq!(
1486 buffer.read_with(cx, |buffer, _cx| buffer.text()),
1487 "Lorem ipsum dolor"
1488 );
1489 }
1490
1491 #[gpui::test(iterations = 10)]
1492 async fn test_overwriting_previously_edited_files(cx: &mut TestAppContext) {
1493 init_test(cx);
1494
1495 let fs = FakeFs::new(cx.executor());
1496 fs.insert_tree(
1497 path!("/dir"),
1498 json!({
1499 "file1": "Lorem ipsum dolor"
1500 }),
1501 )
1502 .await;
1503 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1504 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1505 let file_path = project
1506 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1507 .unwrap();
1508
1509 let buffer = project
1510 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1511 .await
1512 .unwrap();
1513 cx.update(|cx| {
1514 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1515 buffer.update(cx, |buffer, cx| buffer.append(" sit amet consecteur", cx));
1516 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1517 });
1518 project
1519 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1520 .await
1521 .unwrap();
1522 cx.run_until_parked();
1523 assert_eq!(
1524 unreviewed_hunks(&action_log, cx),
1525 vec![(
1526 buffer.clone(),
1527 vec![HunkStatus {
1528 range: Point::new(0, 0)..Point::new(0, 37),
1529 diff_status: DiffHunkStatusKind::Modified,
1530 old_text: "Lorem ipsum dolor".into(),
1531 }],
1532 )]
1533 );
1534
1535 cx.update(|cx| {
1536 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1537 buffer.update(cx, |buffer, cx| buffer.set_text("rewritten", cx));
1538 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1539 });
1540 project
1541 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1542 .await
1543 .unwrap();
1544 cx.run_until_parked();
1545 assert_eq!(
1546 unreviewed_hunks(&action_log, cx),
1547 vec![(
1548 buffer.clone(),
1549 vec![HunkStatus {
1550 range: Point::new(0, 0)..Point::new(0, 9),
1551 diff_status: DiffHunkStatusKind::Added,
1552 old_text: "".into(),
1553 }],
1554 )]
1555 );
1556
1557 action_log
1558 .update(cx, |log, cx| {
1559 log.reject_edits_in_ranges(buffer.clone(), vec![2..5], cx)
1560 })
1561 .await
1562 .unwrap();
1563 cx.run_until_parked();
1564 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1565 assert_eq!(
1566 buffer.read_with(cx, |buffer, _cx| buffer.text()),
1567 "Lorem ipsum dolor"
1568 );
1569 }
1570
1571 #[gpui::test(iterations = 10)]
1572 async fn test_deleting_files(cx: &mut TestAppContext) {
1573 init_test(cx);
1574
1575 let fs = FakeFs::new(cx.executor());
1576 fs.insert_tree(
1577 path!("/dir"),
1578 json!({"file1": "lorem\n", "file2": "ipsum\n"}),
1579 )
1580 .await;
1581
1582 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1583 let file1_path = project
1584 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1585 .unwrap();
1586 let file2_path = project
1587 .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
1588 .unwrap();
1589
1590 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1591 let buffer1 = project
1592 .update(cx, |project, cx| {
1593 project.open_buffer(file1_path.clone(), cx)
1594 })
1595 .await
1596 .unwrap();
1597 let buffer2 = project
1598 .update(cx, |project, cx| {
1599 project.open_buffer(file2_path.clone(), cx)
1600 })
1601 .await
1602 .unwrap();
1603
1604 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx));
1605 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx));
1606 project
1607 .update(cx, |project, cx| {
1608 project.delete_file(file1_path.clone(), false, cx)
1609 })
1610 .unwrap()
1611 .await
1612 .unwrap();
1613 project
1614 .update(cx, |project, cx| {
1615 project.delete_file(file2_path.clone(), false, cx)
1616 })
1617 .unwrap()
1618 .await
1619 .unwrap();
1620 cx.run_until_parked();
1621 assert_eq!(
1622 unreviewed_hunks(&action_log, cx),
1623 vec![
1624 (
1625 buffer1.clone(),
1626 vec![HunkStatus {
1627 range: Point::new(0, 0)..Point::new(0, 0),
1628 diff_status: DiffHunkStatusKind::Deleted,
1629 old_text: "lorem\n".into(),
1630 }]
1631 ),
1632 (
1633 buffer2.clone(),
1634 vec![HunkStatus {
1635 range: Point::new(0, 0)..Point::new(0, 0),
1636 diff_status: DiffHunkStatusKind::Deleted,
1637 old_text: "ipsum\n".into(),
1638 }],
1639 )
1640 ]
1641 );
1642
1643 // Simulate file1 being recreated externally.
1644 fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec())
1645 .await;
1646
1647 // Simulate file2 being recreated by a tool.
1648 let buffer2 = project
1649 .update(cx, |project, cx| project.open_buffer(file2_path, cx))
1650 .await
1651 .unwrap();
1652 action_log.update(cx, |log, cx| log.buffer_created(buffer2.clone(), cx));
1653 buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx));
1654 action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx));
1655 project
1656 .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx))
1657 .await
1658 .unwrap();
1659
1660 cx.run_until_parked();
1661 assert_eq!(
1662 unreviewed_hunks(&action_log, cx),
1663 vec![(
1664 buffer2.clone(),
1665 vec![HunkStatus {
1666 range: Point::new(0, 0)..Point::new(0, 5),
1667 diff_status: DiffHunkStatusKind::Added,
1668 old_text: "".into(),
1669 }],
1670 )]
1671 );
1672
1673 // Simulate file2 being deleted externally.
1674 fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default())
1675 .await
1676 .unwrap();
1677 cx.run_until_parked();
1678 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1679 }
1680
1681 #[gpui::test(iterations = 10)]
1682 async fn test_reject_edits(cx: &mut TestAppContext) {
1683 init_test(cx);
1684
1685 let fs = FakeFs::new(cx.executor());
1686 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1687 .await;
1688 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1689 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1690 let file_path = project
1691 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1692 .unwrap();
1693 let buffer = project
1694 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1695 .await
1696 .unwrap();
1697
1698 cx.update(|cx| {
1699 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1700 buffer.update(cx, |buffer, cx| {
1701 buffer
1702 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
1703 .unwrap()
1704 });
1705 buffer.update(cx, |buffer, cx| {
1706 buffer
1707 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
1708 .unwrap()
1709 });
1710 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1711 });
1712 cx.run_until_parked();
1713 assert_eq!(
1714 buffer.read_with(cx, |buffer, _| buffer.text()),
1715 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1716 );
1717 assert_eq!(
1718 unreviewed_hunks(&action_log, cx),
1719 vec![(
1720 buffer.clone(),
1721 vec![
1722 HunkStatus {
1723 range: Point::new(1, 0)..Point::new(3, 0),
1724 diff_status: DiffHunkStatusKind::Modified,
1725 old_text: "def\n".into(),
1726 },
1727 HunkStatus {
1728 range: Point::new(5, 0)..Point::new(5, 3),
1729 diff_status: DiffHunkStatusKind::Modified,
1730 old_text: "mno".into(),
1731 }
1732 ],
1733 )]
1734 );
1735
1736 // If the rejected range doesn't overlap with any hunk, we ignore it.
1737 action_log
1738 .update(cx, |log, cx| {
1739 log.reject_edits_in_ranges(
1740 buffer.clone(),
1741 vec![Point::new(4, 0)..Point::new(4, 0)],
1742 cx,
1743 )
1744 })
1745 .await
1746 .unwrap();
1747 cx.run_until_parked();
1748 assert_eq!(
1749 buffer.read_with(cx, |buffer, _| buffer.text()),
1750 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1751 );
1752 assert_eq!(
1753 unreviewed_hunks(&action_log, cx),
1754 vec![(
1755 buffer.clone(),
1756 vec![
1757 HunkStatus {
1758 range: Point::new(1, 0)..Point::new(3, 0),
1759 diff_status: DiffHunkStatusKind::Modified,
1760 old_text: "def\n".into(),
1761 },
1762 HunkStatus {
1763 range: Point::new(5, 0)..Point::new(5, 3),
1764 diff_status: DiffHunkStatusKind::Modified,
1765 old_text: "mno".into(),
1766 }
1767 ],
1768 )]
1769 );
1770
1771 action_log
1772 .update(cx, |log, cx| {
1773 log.reject_edits_in_ranges(
1774 buffer.clone(),
1775 vec![Point::new(0, 0)..Point::new(1, 0)],
1776 cx,
1777 )
1778 })
1779 .await
1780 .unwrap();
1781 cx.run_until_parked();
1782 assert_eq!(
1783 buffer.read_with(cx, |buffer, _| buffer.text()),
1784 "abc\ndef\nghi\njkl\nmnO"
1785 );
1786 assert_eq!(
1787 unreviewed_hunks(&action_log, cx),
1788 vec![(
1789 buffer.clone(),
1790 vec![HunkStatus {
1791 range: Point::new(4, 0)..Point::new(4, 3),
1792 diff_status: DiffHunkStatusKind::Modified,
1793 old_text: "mno".into(),
1794 }],
1795 )]
1796 );
1797
1798 action_log
1799 .update(cx, |log, cx| {
1800 log.reject_edits_in_ranges(
1801 buffer.clone(),
1802 vec![Point::new(4, 0)..Point::new(4, 0)],
1803 cx,
1804 )
1805 })
1806 .await
1807 .unwrap();
1808 cx.run_until_parked();
1809 assert_eq!(
1810 buffer.read_with(cx, |buffer, _| buffer.text()),
1811 "abc\ndef\nghi\njkl\nmno"
1812 );
1813 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1814 }
1815
1816 #[gpui::test(iterations = 10)]
1817 async fn test_reject_multiple_edits(cx: &mut TestAppContext) {
1818 init_test(cx);
1819
1820 let fs = FakeFs::new(cx.executor());
1821 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1822 .await;
1823 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1824 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1825 let file_path = project
1826 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1827 .unwrap();
1828 let buffer = project
1829 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1830 .await
1831 .unwrap();
1832
1833 cx.update(|cx| {
1834 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1835 buffer.update(cx, |buffer, cx| {
1836 buffer
1837 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
1838 .unwrap()
1839 });
1840 buffer.update(cx, |buffer, cx| {
1841 buffer
1842 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
1843 .unwrap()
1844 });
1845 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1846 });
1847 cx.run_until_parked();
1848 assert_eq!(
1849 buffer.read_with(cx, |buffer, _| buffer.text()),
1850 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1851 );
1852 assert_eq!(
1853 unreviewed_hunks(&action_log, cx),
1854 vec![(
1855 buffer.clone(),
1856 vec![
1857 HunkStatus {
1858 range: Point::new(1, 0)..Point::new(3, 0),
1859 diff_status: DiffHunkStatusKind::Modified,
1860 old_text: "def\n".into(),
1861 },
1862 HunkStatus {
1863 range: Point::new(5, 0)..Point::new(5, 3),
1864 diff_status: DiffHunkStatusKind::Modified,
1865 old_text: "mno".into(),
1866 }
1867 ],
1868 )]
1869 );
1870
1871 action_log.update(cx, |log, cx| {
1872 let range_1 = buffer.read(cx).anchor_before(Point::new(0, 0))
1873 ..buffer.read(cx).anchor_before(Point::new(1, 0));
1874 let range_2 = buffer.read(cx).anchor_before(Point::new(5, 0))
1875 ..buffer.read(cx).anchor_before(Point::new(5, 3));
1876
1877 log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], cx)
1878 .detach();
1879 assert_eq!(
1880 buffer.read_with(cx, |buffer, _| buffer.text()),
1881 "abc\ndef\nghi\njkl\nmno"
1882 );
1883 });
1884 cx.run_until_parked();
1885 assert_eq!(
1886 buffer.read_with(cx, |buffer, _| buffer.text()),
1887 "abc\ndef\nghi\njkl\nmno"
1888 );
1889 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1890 }
1891
1892 #[gpui::test(iterations = 10)]
1893 async fn test_reject_deleted_file(cx: &mut TestAppContext) {
1894 init_test(cx);
1895
1896 let fs = FakeFs::new(cx.executor());
1897 fs.insert_tree(path!("/dir"), json!({"file": "content"}))
1898 .await;
1899 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1900 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1901 let file_path = project
1902 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1903 .unwrap();
1904 let buffer = project
1905 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
1906 .await
1907 .unwrap();
1908
1909 cx.update(|cx| {
1910 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
1911 });
1912 project
1913 .update(cx, |project, cx| {
1914 project.delete_file(file_path.clone(), false, cx)
1915 })
1916 .unwrap()
1917 .await
1918 .unwrap();
1919 cx.run_until_parked();
1920 assert!(!fs.is_file(path!("/dir/file").as_ref()).await);
1921 assert_eq!(
1922 unreviewed_hunks(&action_log, cx),
1923 vec![(
1924 buffer.clone(),
1925 vec![HunkStatus {
1926 range: Point::new(0, 0)..Point::new(0, 0),
1927 diff_status: DiffHunkStatusKind::Deleted,
1928 old_text: "content".into(),
1929 }]
1930 )]
1931 );
1932
1933 action_log
1934 .update(cx, |log, cx| {
1935 log.reject_edits_in_ranges(
1936 buffer.clone(),
1937 vec![Point::new(0, 0)..Point::new(0, 0)],
1938 cx,
1939 )
1940 })
1941 .await
1942 .unwrap();
1943 cx.run_until_parked();
1944 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content");
1945 assert!(fs.is_file(path!("/dir/file").as_ref()).await);
1946 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1947 }
1948
1949 #[gpui::test(iterations = 10)]
1950 async fn test_reject_created_file(cx: &mut TestAppContext) {
1951 init_test(cx);
1952
1953 let fs = FakeFs::new(cx.executor());
1954 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1955 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1956 let file_path = project
1957 .read_with(cx, |project, cx| {
1958 project.find_project_path("dir/new_file", cx)
1959 })
1960 .unwrap();
1961 let buffer = project
1962 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1963 .await
1964 .unwrap();
1965 cx.update(|cx| {
1966 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1967 buffer.update(cx, |buffer, cx| buffer.set_text("content", cx));
1968 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1969 });
1970 project
1971 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1972 .await
1973 .unwrap();
1974 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
1975 cx.run_until_parked();
1976 assert_eq!(
1977 unreviewed_hunks(&action_log, cx),
1978 vec![(
1979 buffer.clone(),
1980 vec![HunkStatus {
1981 range: Point::new(0, 0)..Point::new(0, 7),
1982 diff_status: DiffHunkStatusKind::Added,
1983 old_text: "".into(),
1984 }],
1985 )]
1986 );
1987
1988 action_log
1989 .update(cx, |log, cx| {
1990 log.reject_edits_in_ranges(
1991 buffer.clone(),
1992 vec![Point::new(0, 0)..Point::new(0, 11)],
1993 cx,
1994 )
1995 })
1996 .await
1997 .unwrap();
1998 cx.run_until_parked();
1999 assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await);
2000 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2001 }
2002
2003 #[gpui::test]
2004 async fn test_reject_created_file_with_user_edits(cx: &mut TestAppContext) {
2005 init_test(cx);
2006
2007 let fs = FakeFs::new(cx.executor());
2008 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2009 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2010
2011 let file_path = project
2012 .read_with(cx, |project, cx| {
2013 project.find_project_path("dir/new_file", cx)
2014 })
2015 .unwrap();
2016 let buffer = project
2017 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2018 .await
2019 .unwrap();
2020
2021 // AI creates file with initial content
2022 cx.update(|cx| {
2023 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2024 buffer.update(cx, |buffer, cx| buffer.set_text("ai content", cx));
2025 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2026 });
2027
2028 project
2029 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2030 .await
2031 .unwrap();
2032
2033 cx.run_until_parked();
2034
2035 // User makes additional edits
2036 cx.update(|cx| {
2037 buffer.update(cx, |buffer, cx| {
2038 buffer.edit([(10..10, "\nuser added this line")], None, cx);
2039 });
2040 });
2041
2042 project
2043 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2044 .await
2045 .unwrap();
2046
2047 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2048
2049 // Reject all
2050 action_log
2051 .update(cx, |log, cx| {
2052 log.reject_edits_in_ranges(
2053 buffer.clone(),
2054 vec![Point::new(0, 0)..Point::new(100, 0)],
2055 cx,
2056 )
2057 })
2058 .await
2059 .unwrap();
2060 cx.run_until_parked();
2061
2062 // File should still contain all the content
2063 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2064
2065 let content = buffer.read_with(cx, |buffer, _| buffer.text());
2066 assert_eq!(content, "ai content\nuser added this line");
2067 }
2068
2069 #[gpui::test]
2070 async fn test_reject_after_accepting_hunk_on_created_file(cx: &mut TestAppContext) {
2071 init_test(cx);
2072
2073 let fs = FakeFs::new(cx.executor());
2074 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2075 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2076
2077 let file_path = project
2078 .read_with(cx, |project, cx| {
2079 project.find_project_path("dir/new_file", cx)
2080 })
2081 .unwrap();
2082 let buffer = project
2083 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
2084 .await
2085 .unwrap();
2086
2087 // AI creates file with initial content
2088 cx.update(|cx| {
2089 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2090 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
2091 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2092 });
2093 project
2094 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2095 .await
2096 .unwrap();
2097 cx.run_until_parked();
2098 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2099
2100 // User accepts the single hunk
2101 action_log.update(cx, |log, cx| {
2102 log.keep_edits_in_range(buffer.clone(), Anchor::MIN..Anchor::MAX, cx)
2103 });
2104 cx.run_until_parked();
2105 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2106 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2107
2108 // AI modifies the file
2109 cx.update(|cx| {
2110 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
2111 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2112 });
2113 project
2114 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2115 .await
2116 .unwrap();
2117 cx.run_until_parked();
2118 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2119
2120 // User rejects the hunk
2121 action_log
2122 .update(cx, |log, cx| {
2123 log.reject_edits_in_ranges(buffer.clone(), vec![Anchor::MIN..Anchor::MAX], cx)
2124 })
2125 .await
2126 .unwrap();
2127 cx.run_until_parked();
2128 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await,);
2129 assert_eq!(
2130 buffer.read_with(cx, |buffer, _| buffer.text()),
2131 "ai content v1"
2132 );
2133 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2134 }
2135
2136 #[gpui::test]
2137 async fn test_reject_edits_on_previously_accepted_created_file(cx: &mut TestAppContext) {
2138 init_test(cx);
2139
2140 let fs = FakeFs::new(cx.executor());
2141 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2142 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2143
2144 let file_path = project
2145 .read_with(cx, |project, cx| {
2146 project.find_project_path("dir/new_file", cx)
2147 })
2148 .unwrap();
2149 let buffer = project
2150 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
2151 .await
2152 .unwrap();
2153
2154 // AI creates file with initial content
2155 cx.update(|cx| {
2156 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2157 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
2158 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2159 });
2160 project
2161 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2162 .await
2163 .unwrap();
2164 cx.run_until_parked();
2165
2166 // User clicks "Accept All"
2167 action_log.update(cx, |log, cx| log.keep_all_edits(cx));
2168 cx.run_until_parked();
2169 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2170 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); // Hunks are cleared
2171
2172 // AI modifies file again
2173 cx.update(|cx| {
2174 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
2175 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2176 });
2177 project
2178 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2179 .await
2180 .unwrap();
2181 cx.run_until_parked();
2182 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2183
2184 // User clicks "Reject All"
2185 action_log
2186 .update(cx, |log, cx| log.reject_all_edits(cx))
2187 .await;
2188 cx.run_until_parked();
2189 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2190 assert_eq!(
2191 buffer.read_with(cx, |buffer, _| buffer.text()),
2192 "ai content v1"
2193 );
2194 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2195 }
2196
2197 #[gpui::test(iterations = 100)]
2198 async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
2199 init_test(cx);
2200
2201 let operations = env::var("OPERATIONS")
2202 .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
2203 .unwrap_or(20);
2204
2205 let text = RandomCharIter::new(&mut rng).take(50).collect::<String>();
2206 let fs = FakeFs::new(cx.executor());
2207 fs.insert_tree(path!("/dir"), json!({"file": text})).await;
2208 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2209 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2210 let file_path = project
2211 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2212 .unwrap();
2213 let buffer = project
2214 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2215 .await
2216 .unwrap();
2217
2218 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2219
2220 for _ in 0..operations {
2221 match rng.random_range(0..100) {
2222 0..25 => {
2223 action_log.update(cx, |log, cx| {
2224 let range = buffer.read(cx).random_byte_range(0, &mut rng);
2225 log::info!("keeping edits in range {:?}", range);
2226 log.keep_edits_in_range(buffer.clone(), range, cx)
2227 });
2228 }
2229 25..50 => {
2230 action_log
2231 .update(cx, |log, cx| {
2232 let range = buffer.read(cx).random_byte_range(0, &mut rng);
2233 log::info!("rejecting edits in range {:?}", range);
2234 log.reject_edits_in_ranges(buffer.clone(), vec![range], cx)
2235 })
2236 .await
2237 .unwrap();
2238 }
2239 _ => {
2240 let is_agent_edit = rng.random_bool(0.5);
2241 if is_agent_edit {
2242 log::info!("agent edit");
2243 } else {
2244 log::info!("user edit");
2245 }
2246 cx.update(|cx| {
2247 buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
2248 if is_agent_edit {
2249 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2250 }
2251 });
2252 }
2253 }
2254
2255 if rng.random_bool(0.2) {
2256 quiesce(&action_log, &buffer, cx);
2257 }
2258 }
2259
2260 quiesce(&action_log, &buffer, cx);
2261
2262 fn quiesce(
2263 action_log: &Entity<ActionLog>,
2264 buffer: &Entity<Buffer>,
2265 cx: &mut TestAppContext,
2266 ) {
2267 log::info!("quiescing...");
2268 cx.run_until_parked();
2269 action_log.update(cx, |log, cx| {
2270 let tracked_buffer = log.tracked_buffers.get(buffer).unwrap();
2271 let mut old_text = tracked_buffer.diff_base.clone();
2272 let new_text = buffer.read(cx).as_rope();
2273 for edit in tracked_buffer.unreviewed_edits.edits() {
2274 let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
2275 let old_end = old_text.point_to_offset(cmp::min(
2276 Point::new(edit.new.start + edit.old_len(), 0),
2277 old_text.max_point(),
2278 ));
2279 old_text.replace(
2280 old_start..old_end,
2281 &new_text.slice_rows(edit.new.clone()).to_string(),
2282 );
2283 }
2284 pretty_assertions::assert_eq!(old_text.to_string(), new_text.to_string());
2285 })
2286 }
2287 }
2288
2289 #[gpui::test]
2290 async fn test_keep_edits_on_commit(cx: &mut gpui::TestAppContext) {
2291 init_test(cx);
2292
2293 let fs = FakeFs::new(cx.background_executor.clone());
2294 fs.insert_tree(
2295 path!("/project"),
2296 json!({
2297 ".git": {},
2298 "file.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj",
2299 }),
2300 )
2301 .await;
2302 fs.set_head_for_repo(
2303 path!("/project/.git").as_ref(),
2304 &[("file.txt".into(), "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())],
2305 "0000000",
2306 );
2307 cx.run_until_parked();
2308
2309 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2310 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2311
2312 let file_path = project
2313 .read_with(cx, |project, cx| {
2314 project.find_project_path(path!("/project/file.txt"), cx)
2315 })
2316 .unwrap();
2317 let buffer = project
2318 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2319 .await
2320 .unwrap();
2321
2322 cx.update(|cx| {
2323 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2324 buffer.update(cx, |buffer, cx| {
2325 buffer.edit(
2326 [
2327 // Edit at the very start: a -> A
2328 (Point::new(0, 0)..Point::new(0, 1), "A"),
2329 // Deletion in the middle: remove lines d and e
2330 (Point::new(3, 0)..Point::new(5, 0), ""),
2331 // Modification: g -> GGG
2332 (Point::new(6, 0)..Point::new(6, 1), "GGG"),
2333 // Addition: insert new line after h
2334 (Point::new(7, 1)..Point::new(7, 1), "\nNEW"),
2335 // Edit the very last character: j -> J
2336 (Point::new(9, 0)..Point::new(9, 1), "J"),
2337 ],
2338 None,
2339 cx,
2340 );
2341 });
2342 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2343 });
2344 cx.run_until_parked();
2345 assert_eq!(
2346 unreviewed_hunks(&action_log, cx),
2347 vec![(
2348 buffer.clone(),
2349 vec![
2350 HunkStatus {
2351 range: Point::new(0, 0)..Point::new(1, 0),
2352 diff_status: DiffHunkStatusKind::Modified,
2353 old_text: "a\n".into()
2354 },
2355 HunkStatus {
2356 range: Point::new(3, 0)..Point::new(3, 0),
2357 diff_status: DiffHunkStatusKind::Deleted,
2358 old_text: "d\ne\n".into()
2359 },
2360 HunkStatus {
2361 range: Point::new(4, 0)..Point::new(5, 0),
2362 diff_status: DiffHunkStatusKind::Modified,
2363 old_text: "g\n".into()
2364 },
2365 HunkStatus {
2366 range: Point::new(6, 0)..Point::new(7, 0),
2367 diff_status: DiffHunkStatusKind::Added,
2368 old_text: "".into()
2369 },
2370 HunkStatus {
2371 range: Point::new(8, 0)..Point::new(8, 1),
2372 diff_status: DiffHunkStatusKind::Modified,
2373 old_text: "j".into()
2374 }
2375 ]
2376 )]
2377 );
2378
2379 // Simulate a git commit that matches some edits but not others:
2380 // - Accepts the first edit (a -> A)
2381 // - Accepts the deletion (remove d and e)
2382 // - Makes a different change to g (g -> G instead of GGG)
2383 // - Ignores the NEW line addition
2384 // - Ignores the last line edit (j stays as j)
2385 fs.set_head_for_repo(
2386 path!("/project/.git").as_ref(),
2387 &[("file.txt".into(), "A\nb\nc\nf\nG\nh\ni\nj".into())],
2388 "0000001",
2389 );
2390 cx.run_until_parked();
2391 assert_eq!(
2392 unreviewed_hunks(&action_log, cx),
2393 vec![(
2394 buffer.clone(),
2395 vec![
2396 HunkStatus {
2397 range: Point::new(4, 0)..Point::new(5, 0),
2398 diff_status: DiffHunkStatusKind::Modified,
2399 old_text: "g\n".into()
2400 },
2401 HunkStatus {
2402 range: Point::new(6, 0)..Point::new(7, 0),
2403 diff_status: DiffHunkStatusKind::Added,
2404 old_text: "".into()
2405 },
2406 HunkStatus {
2407 range: Point::new(8, 0)..Point::new(8, 1),
2408 diff_status: DiffHunkStatusKind::Modified,
2409 old_text: "j".into()
2410 }
2411 ]
2412 )]
2413 );
2414
2415 // Make another commit that accepts the NEW line but with different content
2416 fs.set_head_for_repo(
2417 path!("/project/.git").as_ref(),
2418 &[(
2419 "file.txt".into(),
2420 "A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into(),
2421 )],
2422 "0000002",
2423 );
2424 cx.run_until_parked();
2425 assert_eq!(
2426 unreviewed_hunks(&action_log, cx),
2427 vec![(
2428 buffer,
2429 vec![
2430 HunkStatus {
2431 range: Point::new(6, 0)..Point::new(7, 0),
2432 diff_status: DiffHunkStatusKind::Added,
2433 old_text: "".into()
2434 },
2435 HunkStatus {
2436 range: Point::new(8, 0)..Point::new(8, 1),
2437 diff_status: DiffHunkStatusKind::Modified,
2438 old_text: "j".into()
2439 }
2440 ]
2441 )]
2442 );
2443
2444 // Final commit that accepts all remaining edits
2445 fs.set_head_for_repo(
2446 path!("/project/.git").as_ref(),
2447 &[("file.txt".into(), "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())],
2448 "0000003",
2449 );
2450 cx.run_until_parked();
2451 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2452 }
2453
2454 #[derive(Debug, Clone, PartialEq, Eq)]
2455 struct HunkStatus {
2456 range: Range<Point>,
2457 diff_status: DiffHunkStatusKind,
2458 old_text: String,
2459 }
2460
2461 fn unreviewed_hunks(
2462 action_log: &Entity<ActionLog>,
2463 cx: &TestAppContext,
2464 ) -> Vec<(Entity<Buffer>, Vec<HunkStatus>)> {
2465 cx.read(|cx| {
2466 action_log
2467 .read(cx)
2468 .changed_buffers(cx)
2469 .into_iter()
2470 .map(|(buffer, diff)| {
2471 let snapshot = buffer.read(cx).snapshot();
2472 (
2473 buffer,
2474 diff.read(cx)
2475 .hunks(&snapshot, cx)
2476 .map(|hunk| HunkStatus {
2477 diff_status: hunk.status().kind,
2478 range: hunk.range,
2479 old_text: diff
2480 .read(cx)
2481 .base_text()
2482 .text_for_range(hunk.diff_base_byte_range)
2483 .collect(),
2484 })
2485 .collect(),
2486 )
2487 })
2488 .collect()
2489 })
2490 }
2491
2492 #[gpui::test]
2493 async fn test_format_patch(cx: &mut TestAppContext) {
2494 init_test(cx);
2495
2496 let fs = FakeFs::new(cx.executor());
2497 fs.insert_tree(
2498 path!("/dir"),
2499 json!({"test.txt": "line 1\nline 2\nline 3\n"}),
2500 )
2501 .await;
2502 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2503 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2504
2505 let file_path = project
2506 .read_with(cx, |project, cx| {
2507 project.find_project_path("dir/test.txt", cx)
2508 })
2509 .unwrap();
2510 let buffer = project
2511 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2512 .await
2513 .unwrap();
2514
2515 cx.update(|cx| {
2516 // Track the buffer and mark it as read first
2517 action_log.update(cx, |log, cx| {
2518 log.buffer_read(buffer.clone(), cx);
2519 });
2520
2521 // Make some edits to create a patch
2522 buffer.update(cx, |buffer, cx| {
2523 buffer
2524 .edit([(Point::new(1, 0)..Point::new(1, 6), "CHANGED")], None, cx)
2525 .unwrap(); // Replace "line2" with "CHANGED"
2526 });
2527 });
2528
2529 cx.run_until_parked();
2530
2531 // Get the patch
2532 let patch = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx));
2533
2534 // Verify the patch format contains expected unified diff elements
2535 assert_eq!(
2536 patch.unwrap(),
2537 indoc! {"
2538 --- a/dir/test.txt
2539 +++ b/dir/test.txt
2540 @@ -1,3 +1,3 @@
2541 line 1
2542 -line 2
2543 +CHANGED
2544 line 3
2545 "}
2546 );
2547 }
2548}