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