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