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