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