1use anyhow::{Context as _, Result};
2use buffer_diff::BufferDiff;
3use collections::BTreeMap;
4use futures::{StreamExt, channel::mpsc};
5use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
6use language::{Anchor, Buffer, BufferEvent, DiskState, Point};
7use project::{Project, ProjectItem};
8use std::{cmp, ops::Range, sync::Arc};
9use text::{Edit, Patch, Rope};
10use util::RangeExt;
11
12/// Tracks actions performed by tools in a thread
13pub struct ActionLog {
14 /// Buffers that we want to notify the model about when they change.
15 tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
16 /// Has the model edited a file since it last checked diagnostics?
17 edited_since_project_diagnostics_check: bool,
18 /// The project this action log is associated with
19 project: Entity<Project>,
20}
21
22impl ActionLog {
23 /// Creates a new, empty action log associated with the given project.
24 pub fn new(project: Entity<Project>) -> Self {
25 Self {
26 tracked_buffers: BTreeMap::default(),
27 edited_since_project_diagnostics_check: false,
28 project,
29 }
30 }
31
32 /// Notifies a diagnostics check
33 pub fn checked_project_diagnostics(&mut self) {
34 self.edited_since_project_diagnostics_check = false;
35 }
36
37 /// Returns true if any files have been edited since the last project diagnostics check
38 pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool {
39 self.edited_since_project_diagnostics_check
40 }
41
42 fn track_buffer(
43 &mut self,
44 buffer: Entity<Buffer>,
45 created: bool,
46 cx: &mut Context<Self>,
47 ) -> &mut TrackedBuffer {
48 let tracked_buffer = self
49 .tracked_buffers
50 .entry(buffer.clone())
51 .or_insert_with(|| {
52 let text_snapshot = buffer.read(cx).text_snapshot();
53 let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
54 let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
55 let base_text;
56 let status;
57 let unreviewed_changes;
58 if created {
59 base_text = Rope::default();
60 status = TrackedBufferStatus::Created;
61 unreviewed_changes = Patch::new(vec![Edit {
62 old: 0..1,
63 new: 0..text_snapshot.max_point().row + 1,
64 }])
65 } else {
66 base_text = buffer.read(cx).as_rope().clone();
67 status = TrackedBufferStatus::Modified;
68 unreviewed_changes = Patch::default();
69 }
70 TrackedBuffer {
71 buffer: buffer.clone(),
72 base_text,
73 unreviewed_changes,
74 snapshot: text_snapshot.clone(),
75 status,
76 version: buffer.read(cx).version(),
77 diff,
78 diff_update: diff_update_tx,
79 _maintain_diff: cx.spawn({
80 let buffer = buffer.clone();
81 async move |this, cx| {
82 Self::maintain_diff(this, buffer, diff_update_rx, cx)
83 .await
84 .ok();
85 }
86 }),
87 _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
88 }
89 });
90 tracked_buffer.version = buffer.read(cx).version();
91 tracked_buffer
92 }
93
94 fn handle_buffer_event(
95 &mut self,
96 buffer: Entity<Buffer>,
97 event: &BufferEvent,
98 cx: &mut Context<Self>,
99 ) {
100 match event {
101 BufferEvent::Edited { .. } => self.handle_buffer_edited(buffer, cx),
102 BufferEvent::FileHandleChanged => {
103 self.handle_buffer_file_changed(buffer, cx);
104 }
105 _ => {}
106 };
107 }
108
109 fn handle_buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
110 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
111 return;
112 };
113 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
114 }
115
116 fn handle_buffer_file_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
117 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
118 return;
119 };
120
121 match tracked_buffer.status {
122 TrackedBufferStatus::Created | TrackedBufferStatus::Modified => {
123 if buffer
124 .read(cx)
125 .file()
126 .map_or(false, |file| file.disk_state() == DiskState::Deleted)
127 {
128 // If the buffer had been edited by a tool, but it got
129 // deleted externally, we want to stop tracking it.
130 self.tracked_buffers.remove(&buffer);
131 }
132 cx.notify();
133 }
134 TrackedBufferStatus::Deleted => {
135 if buffer
136 .read(cx)
137 .file()
138 .map_or(false, |file| file.disk_state() != DiskState::Deleted)
139 {
140 // If the buffer had been deleted by a tool, but it got
141 // resurrected externally, we want to clear the changes we
142 // were tracking and reset the buffer's state.
143 self.tracked_buffers.remove(&buffer);
144 self.track_buffer(buffer, false, cx);
145 }
146 cx.notify();
147 }
148 }
149 }
150
151 async fn maintain_diff(
152 this: WeakEntity<Self>,
153 buffer: Entity<Buffer>,
154 mut diff_update: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
155 cx: &mut AsyncApp,
156 ) -> Result<()> {
157 while let Some((author, buffer_snapshot)) = diff_update.next().await {
158 let (rebase, diff, language, language_registry) =
159 this.read_with(cx, |this, cx| {
160 let tracked_buffer = this
161 .tracked_buffers
162 .get(&buffer)
163 .context("buffer not tracked")?;
164
165 let rebase = cx.background_spawn({
166 let mut base_text = tracked_buffer.base_text.clone();
167 let old_snapshot = tracked_buffer.snapshot.clone();
168 let new_snapshot = buffer_snapshot.clone();
169 let unreviewed_changes = tracked_buffer.unreviewed_changes.clone();
170 async move {
171 let edits = diff_snapshots(&old_snapshot, &new_snapshot);
172 if let ChangeAuthor::User = author {
173 apply_non_conflicting_edits(
174 &unreviewed_changes,
175 edits,
176 &mut base_text,
177 new_snapshot.as_rope(),
178 );
179 }
180 (Arc::new(base_text.to_string()), base_text)
181 }
182 });
183
184 anyhow::Ok((
185 rebase,
186 tracked_buffer.diff.clone(),
187 tracked_buffer.buffer.read(cx).language().cloned(),
188 tracked_buffer.buffer.read(cx).language_registry(),
189 ))
190 })??;
191
192 let (new_base_text, new_base_text_rope) = rebase.await;
193 let diff_snapshot = BufferDiff::update_diff(
194 diff.clone(),
195 buffer_snapshot.clone(),
196 Some(new_base_text),
197 true,
198 false,
199 language,
200 language_registry,
201 cx,
202 )
203 .await;
204
205 let mut unreviewed_changes = Patch::default();
206 if let Ok(diff_snapshot) = diff_snapshot {
207 unreviewed_changes = cx
208 .background_spawn({
209 let diff_snapshot = diff_snapshot.clone();
210 let buffer_snapshot = buffer_snapshot.clone();
211 let new_base_text_rope = new_base_text_rope.clone();
212 async move {
213 let mut unreviewed_changes = Patch::default();
214 for hunk in diff_snapshot.hunks_intersecting_range(
215 Anchor::MIN..Anchor::MAX,
216 &buffer_snapshot,
217 ) {
218 let old_range = new_base_text_rope
219 .offset_to_point(hunk.diff_base_byte_range.start)
220 ..new_base_text_rope
221 .offset_to_point(hunk.diff_base_byte_range.end);
222 let new_range = hunk.range.start..hunk.range.end;
223 unreviewed_changes.push(point_to_row_edit(
224 Edit {
225 old: old_range,
226 new: new_range,
227 },
228 &new_base_text_rope,
229 &buffer_snapshot.as_rope(),
230 ));
231 }
232 unreviewed_changes
233 }
234 })
235 .await;
236
237 diff.update(cx, |diff, cx| {
238 diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx)
239 })?;
240 }
241 this.update(cx, |this, cx| {
242 let tracked_buffer = this
243 .tracked_buffers
244 .get_mut(&buffer)
245 .context("buffer not tracked")?;
246 tracked_buffer.base_text = new_base_text_rope;
247 tracked_buffer.snapshot = buffer_snapshot;
248 tracked_buffer.unreviewed_changes = unreviewed_changes;
249 cx.notify();
250 anyhow::Ok(())
251 })??;
252 }
253
254 Ok(())
255 }
256
257 /// Track a buffer as read, so we can notify the model about user edits.
258 pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
259 self.track_buffer(buffer, false, cx);
260 }
261
262 /// Track a buffer that was added as context, so we can notify the model about user edits.
263 pub fn buffer_added_as_context(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
264 self.track_buffer(buffer, false, cx);
265 }
266
267 /// Track a buffer as read, so we can notify the model about user edits.
268 pub fn will_create_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
269 self.track_buffer(buffer.clone(), true, cx);
270 self.buffer_edited(buffer, cx)
271 }
272
273 /// Mark a buffer as edited, so we can refresh it in the context
274 pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
275 self.edited_since_project_diagnostics_check = true;
276
277 let tracked_buffer = self.track_buffer(buffer.clone(), false, cx);
278 if let TrackedBufferStatus::Deleted = tracked_buffer.status {
279 tracked_buffer.status = TrackedBufferStatus::Modified;
280 }
281 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
282 }
283
284 pub fn will_delete_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
285 let tracked_buffer = self.track_buffer(buffer.clone(), false, cx);
286 match tracked_buffer.status {
287 TrackedBufferStatus::Created => {
288 self.tracked_buffers.remove(&buffer);
289 cx.notify();
290 }
291 TrackedBufferStatus::Modified => {
292 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
293 tracked_buffer.status = TrackedBufferStatus::Deleted;
294 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
295 }
296 TrackedBufferStatus::Deleted => {}
297 }
298 cx.notify();
299 }
300
301 pub fn keep_edits_in_range(
302 &mut self,
303 buffer: Entity<Buffer>,
304 buffer_range: Range<impl language::ToPoint>,
305 cx: &mut Context<Self>,
306 ) {
307 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
308 return;
309 };
310
311 match tracked_buffer.status {
312 TrackedBufferStatus::Deleted => {
313 self.tracked_buffers.remove(&buffer);
314 cx.notify();
315 }
316 _ => {
317 let buffer = buffer.read(cx);
318 let buffer_range =
319 buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
320 let mut delta = 0i32;
321
322 tracked_buffer.unreviewed_changes.retain_mut(|edit| {
323 edit.old.start = (edit.old.start as i32 + delta) as u32;
324 edit.old.end = (edit.old.end as i32 + delta) as u32;
325
326 if buffer_range.end.row < edit.new.start
327 || buffer_range.start.row > edit.new.end
328 {
329 true
330 } else {
331 let old_range = tracked_buffer
332 .base_text
333 .point_to_offset(Point::new(edit.old.start, 0))
334 ..tracked_buffer.base_text.point_to_offset(cmp::min(
335 Point::new(edit.old.end, 0),
336 tracked_buffer.base_text.max_point(),
337 ));
338 let new_range = tracked_buffer
339 .snapshot
340 .point_to_offset(Point::new(edit.new.start, 0))
341 ..tracked_buffer.snapshot.point_to_offset(cmp::min(
342 Point::new(edit.new.end, 0),
343 tracked_buffer.snapshot.max_point(),
344 ));
345 tracked_buffer.base_text.replace(
346 old_range,
347 &tracked_buffer
348 .snapshot
349 .text_for_range(new_range)
350 .collect::<String>(),
351 );
352 delta += edit.new_len() as i32 - edit.old_len() as i32;
353 false
354 }
355 });
356 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
357 }
358 }
359 }
360
361 pub fn reject_edits_in_range(
362 &mut self,
363 buffer: Entity<Buffer>,
364 buffer_range: Range<impl language::ToPoint>,
365 cx: &mut Context<Self>,
366 ) -> Task<Result<()>> {
367 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
368 return Task::ready(Ok(()));
369 };
370
371 match tracked_buffer.status {
372 TrackedBufferStatus::Created => {
373 let delete = buffer
374 .read(cx)
375 .entry_id(cx)
376 .and_then(|entry_id| {
377 self.project
378 .update(cx, |project, cx| project.delete_entry(entry_id, false, cx))
379 })
380 .unwrap_or(Task::ready(Ok(())));
381 self.tracked_buffers.remove(&buffer);
382 cx.notify();
383 delete
384 }
385 TrackedBufferStatus::Deleted => {
386 buffer.update(cx, |buffer, cx| {
387 buffer.set_text(tracked_buffer.base_text.to_string(), cx)
388 });
389 let save = self
390 .project
391 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
392
393 // Clear all tracked changes for this buffer and start over as if we just read it.
394 self.tracked_buffers.remove(&buffer);
395 self.track_buffer(buffer.clone(), false, cx);
396 cx.notify();
397 save
398 }
399 TrackedBufferStatus::Modified => {
400 buffer.update(cx, |buffer, cx| {
401 let buffer_range =
402 buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
403
404 let mut edits_to_revert = Vec::new();
405 for edit in tracked_buffer.unreviewed_changes.edits() {
406 if buffer_range.end.row < edit.new.start {
407 break;
408 } else if buffer_range.start.row > edit.new.end {
409 continue;
410 }
411
412 let old_range = tracked_buffer
413 .base_text
414 .point_to_offset(Point::new(edit.old.start, 0))
415 ..tracked_buffer.base_text.point_to_offset(cmp::min(
416 Point::new(edit.old.end, 0),
417 tracked_buffer.base_text.max_point(),
418 ));
419 let old_text = tracked_buffer
420 .base_text
421 .chunks_in_range(old_range)
422 .collect::<String>();
423
424 let new_range = tracked_buffer
425 .snapshot
426 .anchor_before(Point::new(edit.new.start, 0))
427 ..tracked_buffer.snapshot.anchor_after(cmp::min(
428 Point::new(edit.new.end, 0),
429 tracked_buffer.snapshot.max_point(),
430 ));
431 edits_to_revert.push((new_range, old_text));
432 }
433
434 buffer.edit(edits_to_revert, None, cx);
435 });
436 self.project
437 .update(cx, |project, cx| project.save_buffer(buffer, cx))
438 }
439 }
440 }
441
442 pub fn keep_all_edits(&mut self, cx: &mut Context<Self>) {
443 self.tracked_buffers
444 .retain(|_buffer, tracked_buffer| match tracked_buffer.status {
445 TrackedBufferStatus::Deleted => false,
446 _ => {
447 tracked_buffer.unreviewed_changes.clear();
448 tracked_buffer.base_text = tracked_buffer.snapshot.as_rope().clone();
449 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
450 true
451 }
452 });
453 cx.notify();
454 }
455
456 /// Returns the set of buffers that contain changes that haven't been reviewed by the user.
457 pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, Entity<BufferDiff>> {
458 self.tracked_buffers
459 .iter()
460 .filter(|(_, tracked)| tracked.has_changes(cx))
461 .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
462 .collect()
463 }
464
465 /// Iterate over buffers changed since last read or edited by the model
466 pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
467 self.tracked_buffers
468 .iter()
469 .filter(|(buffer, tracked)| {
470 let buffer = buffer.read(cx);
471
472 tracked.version != buffer.version
473 && buffer
474 .file()
475 .map_or(false, |file| file.disk_state() != DiskState::Deleted)
476 })
477 .map(|(buffer, _)| buffer)
478 }
479}
480
481fn apply_non_conflicting_edits(
482 patch: &Patch<u32>,
483 edits: Vec<Edit<u32>>,
484 old_text: &mut Rope,
485 new_text: &Rope,
486) {
487 let mut old_edits = patch.edits().iter().cloned().peekable();
488 let mut new_edits = edits.into_iter().peekable();
489 let mut applied_delta = 0i32;
490 let mut rebased_delta = 0i32;
491
492 while let Some(mut new_edit) = new_edits.next() {
493 let mut conflict = false;
494
495 // Push all the old edits that are before this new edit or that intersect with it.
496 while let Some(old_edit) = old_edits.peek() {
497 if new_edit.old.end < old_edit.new.start
498 || (!old_edit.new.is_empty() && new_edit.old.end == old_edit.new.start)
499 {
500 break;
501 } else if new_edit.old.start > old_edit.new.end
502 || (!old_edit.new.is_empty() && new_edit.old.start == old_edit.new.end)
503 {
504 let old_edit = old_edits.next().unwrap();
505 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
506 } else {
507 conflict = true;
508 if new_edits
509 .peek()
510 .map_or(false, |next_edit| next_edit.old.overlaps(&old_edit.new))
511 {
512 new_edit = new_edits.next().unwrap();
513 } else {
514 let old_edit = old_edits.next().unwrap();
515 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
516 }
517 }
518 }
519
520 if !conflict {
521 // This edit doesn't intersect with any old edit, so we can apply it to the old text.
522 new_edit.old.start = (new_edit.old.start as i32 + applied_delta - rebased_delta) as u32;
523 new_edit.old.end = (new_edit.old.end as i32 + applied_delta - rebased_delta) as u32;
524 let old_bytes = old_text.point_to_offset(Point::new(new_edit.old.start, 0))
525 ..old_text.point_to_offset(cmp::min(
526 Point::new(new_edit.old.end, 0),
527 old_text.max_point(),
528 ));
529 let new_bytes = new_text.point_to_offset(Point::new(new_edit.new.start, 0))
530 ..new_text.point_to_offset(cmp::min(
531 Point::new(new_edit.new.end, 0),
532 new_text.max_point(),
533 ));
534
535 old_text.replace(
536 old_bytes,
537 &new_text.chunks_in_range(new_bytes).collect::<String>(),
538 );
539 applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
540 }
541 }
542}
543
544fn diff_snapshots(
545 old_snapshot: &text::BufferSnapshot,
546 new_snapshot: &text::BufferSnapshot,
547) -> Vec<Edit<u32>> {
548 let mut edits = new_snapshot
549 .edits_since::<Point>(&old_snapshot.version)
550 .map(|edit| point_to_row_edit(edit, old_snapshot.as_rope(), new_snapshot.as_rope()))
551 .peekable();
552 let mut row_edits = Vec::new();
553 while let Some(mut edit) = edits.next() {
554 while let Some(next_edit) = edits.peek() {
555 if edit.old.end >= next_edit.old.start {
556 edit.old.end = next_edit.old.end;
557 edit.new.end = next_edit.new.end;
558 edits.next();
559 } else {
560 break;
561 }
562 }
563 row_edits.push(edit);
564 }
565 row_edits
566}
567
568fn point_to_row_edit(edit: Edit<Point>, old_text: &Rope, new_text: &Rope) -> Edit<u32> {
569 if edit.old.start.column == old_text.line_len(edit.old.start.row)
570 && new_text
571 .chars_at(new_text.point_to_offset(edit.new.start))
572 .next()
573 == Some('\n')
574 && edit.old.start != old_text.max_point()
575 {
576 Edit {
577 old: edit.old.start.row + 1..edit.old.end.row + 1,
578 new: edit.new.start.row + 1..edit.new.end.row + 1,
579 }
580 } else if edit.old.start.column == 0
581 && edit.old.end.column == 0
582 && edit.new.end.column == 0
583 && edit.old.end != old_text.max_point()
584 {
585 Edit {
586 old: edit.old.start.row..edit.old.end.row,
587 new: edit.new.start.row..edit.new.end.row,
588 }
589 } else {
590 Edit {
591 old: edit.old.start.row..edit.old.end.row + 1,
592 new: edit.new.start.row..edit.new.end.row + 1,
593 }
594 }
595}
596
597enum ChangeAuthor {
598 User,
599 Agent,
600}
601
602#[derive(Copy, Clone, Eq, PartialEq)]
603enum TrackedBufferStatus {
604 Created,
605 Modified,
606 Deleted,
607}
608
609struct TrackedBuffer {
610 buffer: Entity<Buffer>,
611 base_text: Rope,
612 unreviewed_changes: Patch<u32>,
613 status: TrackedBufferStatus,
614 version: clock::Global,
615 diff: Entity<BufferDiff>,
616 snapshot: text::BufferSnapshot,
617 diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
618 _maintain_diff: Task<()>,
619 _subscription: Subscription,
620}
621
622impl TrackedBuffer {
623 fn has_changes(&self, cx: &App) -> bool {
624 self.diff
625 .read(cx)
626 .hunks(&self.buffer.read(cx), cx)
627 .next()
628 .is_some()
629 }
630
631 fn schedule_diff_update(&self, author: ChangeAuthor, cx: &App) {
632 self.diff_update
633 .unbounded_send((author, self.buffer.read(cx).text_snapshot()))
634 .ok();
635 }
636}
637
638pub struct ChangedBuffer {
639 pub diff: Entity<BufferDiff>,
640}
641
642#[cfg(test)]
643mod tests {
644 use std::env;
645
646 use super::*;
647 use buffer_diff::DiffHunkStatusKind;
648 use gpui::TestAppContext;
649 use language::Point;
650 use project::{FakeFs, Fs, Project, RemoveOptions};
651 use rand::prelude::*;
652 use serde_json::json;
653 use settings::SettingsStore;
654 use util::{RandomCharIter, path};
655
656 #[ctor::ctor]
657 fn init_logger() {
658 if std::env::var("RUST_LOG").is_ok() {
659 env_logger::init();
660 }
661 }
662
663 fn init_test(cx: &mut TestAppContext) {
664 cx.update(|cx| {
665 let settings_store = SettingsStore::test(cx);
666 cx.set_global(settings_store);
667 language::init(cx);
668 Project::init_settings(cx);
669 });
670 }
671
672 #[gpui::test(iterations = 10)]
673 async fn test_keep_edits(cx: &mut TestAppContext) {
674 init_test(cx);
675
676 let fs = FakeFs::new(cx.executor());
677 let project = Project::test(fs.clone(), [], cx).await;
678 let action_log = cx.new(|_| ActionLog::new(project.clone()));
679 let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
680
681 cx.update(|cx| {
682 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
683 buffer.update(cx, |buffer, cx| {
684 buffer
685 .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx)
686 .unwrap()
687 });
688 buffer.update(cx, |buffer, cx| {
689 buffer
690 .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx)
691 .unwrap()
692 });
693 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
694 });
695 cx.run_until_parked();
696 assert_eq!(
697 buffer.read_with(cx, |buffer, _| buffer.text()),
698 "abc\ndEf\nghi\njkl\nmnO"
699 );
700 assert_eq!(
701 unreviewed_hunks(&action_log, cx),
702 vec![(
703 buffer.clone(),
704 vec![
705 HunkStatus {
706 range: Point::new(1, 0)..Point::new(2, 0),
707 diff_status: DiffHunkStatusKind::Modified,
708 old_text: "def\n".into(),
709 },
710 HunkStatus {
711 range: Point::new(4, 0)..Point::new(4, 3),
712 diff_status: DiffHunkStatusKind::Modified,
713 old_text: "mno".into(),
714 }
715 ],
716 )]
717 );
718
719 action_log.update(cx, |log, cx| {
720 log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), cx)
721 });
722 cx.run_until_parked();
723 assert_eq!(
724 unreviewed_hunks(&action_log, cx),
725 vec![(
726 buffer.clone(),
727 vec![HunkStatus {
728 range: Point::new(1, 0)..Point::new(2, 0),
729 diff_status: DiffHunkStatusKind::Modified,
730 old_text: "def\n".into(),
731 }],
732 )]
733 );
734
735 action_log.update(cx, |log, cx| {
736 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), cx)
737 });
738 cx.run_until_parked();
739 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
740 }
741
742 #[gpui::test(iterations = 10)]
743 async fn test_deletions(cx: &mut TestAppContext) {
744 init_test(cx);
745
746 let fs = FakeFs::new(cx.executor());
747 let project = Project::test(fs.clone(), [], cx).await;
748 let action_log = cx.new(|_| ActionLog::new(project.clone()));
749 let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno\npqr", cx));
750
751 cx.update(|cx| {
752 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
753 buffer.update(cx, |buffer, cx| {
754 buffer
755 .edit([(Point::new(1, 0)..Point::new(2, 0), "")], None, cx)
756 .unwrap();
757 buffer.finalize_last_transaction();
758 });
759 buffer.update(cx, |buffer, cx| {
760 buffer
761 .edit([(Point::new(3, 0)..Point::new(4, 0), "")], None, cx)
762 .unwrap();
763 buffer.finalize_last_transaction();
764 });
765 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
766 });
767 cx.run_until_parked();
768 assert_eq!(
769 buffer.read_with(cx, |buffer, _| buffer.text()),
770 "abc\nghi\njkl\npqr"
771 );
772 assert_eq!(
773 unreviewed_hunks(&action_log, cx),
774 vec![(
775 buffer.clone(),
776 vec![
777 HunkStatus {
778 range: Point::new(1, 0)..Point::new(1, 0),
779 diff_status: DiffHunkStatusKind::Deleted,
780 old_text: "def\n".into(),
781 },
782 HunkStatus {
783 range: Point::new(3, 0)..Point::new(3, 0),
784 diff_status: DiffHunkStatusKind::Deleted,
785 old_text: "mno\n".into(),
786 }
787 ],
788 )]
789 );
790
791 buffer.update(cx, |buffer, cx| buffer.undo(cx));
792 cx.run_until_parked();
793 assert_eq!(
794 buffer.read_with(cx, |buffer, _| buffer.text()),
795 "abc\nghi\njkl\nmno\npqr"
796 );
797 assert_eq!(
798 unreviewed_hunks(&action_log, cx),
799 vec![(
800 buffer.clone(),
801 vec![HunkStatus {
802 range: Point::new(1, 0)..Point::new(1, 0),
803 diff_status: DiffHunkStatusKind::Deleted,
804 old_text: "def\n".into(),
805 }],
806 )]
807 );
808
809 action_log.update(cx, |log, cx| {
810 log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), cx)
811 });
812 cx.run_until_parked();
813 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
814 }
815
816 #[gpui::test(iterations = 10)]
817 async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
818 init_test(cx);
819
820 let fs = FakeFs::new(cx.executor());
821 let project = Project::test(fs.clone(), [], cx).await;
822 let action_log = cx.new(|_| ActionLog::new(project.clone()));
823 let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
824
825 cx.update(|cx| {
826 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
827 buffer.update(cx, |buffer, cx| {
828 buffer
829 .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
830 .unwrap()
831 });
832 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
833 });
834 cx.run_until_parked();
835 assert_eq!(
836 buffer.read_with(cx, |buffer, _| buffer.text()),
837 "abc\ndeF\nGHI\njkl\nmno"
838 );
839 assert_eq!(
840 unreviewed_hunks(&action_log, cx),
841 vec![(
842 buffer.clone(),
843 vec![HunkStatus {
844 range: Point::new(1, 0)..Point::new(3, 0),
845 diff_status: DiffHunkStatusKind::Modified,
846 old_text: "def\nghi\n".into(),
847 }],
848 )]
849 );
850
851 buffer.update(cx, |buffer, cx| {
852 buffer.edit(
853 [
854 (Point::new(0, 2)..Point::new(0, 2), "X"),
855 (Point::new(3, 0)..Point::new(3, 0), "Y"),
856 ],
857 None,
858 cx,
859 )
860 });
861 cx.run_until_parked();
862 assert_eq!(
863 buffer.read_with(cx, |buffer, _| buffer.text()),
864 "abXc\ndeF\nGHI\nYjkl\nmno"
865 );
866 assert_eq!(
867 unreviewed_hunks(&action_log, cx),
868 vec![(
869 buffer.clone(),
870 vec![HunkStatus {
871 range: Point::new(1, 0)..Point::new(3, 0),
872 diff_status: DiffHunkStatusKind::Modified,
873 old_text: "def\nghi\n".into(),
874 }],
875 )]
876 );
877
878 buffer.update(cx, |buffer, cx| {
879 buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "Z")], None, cx)
880 });
881 cx.run_until_parked();
882 assert_eq!(
883 buffer.read_with(cx, |buffer, _| buffer.text()),
884 "abXc\ndZeF\nGHI\nYjkl\nmno"
885 );
886 assert_eq!(
887 unreviewed_hunks(&action_log, cx),
888 vec![(
889 buffer.clone(),
890 vec![HunkStatus {
891 range: Point::new(1, 0)..Point::new(3, 0),
892 diff_status: DiffHunkStatusKind::Modified,
893 old_text: "def\nghi\n".into(),
894 }],
895 )]
896 );
897
898 action_log.update(cx, |log, cx| {
899 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
900 });
901 cx.run_until_parked();
902 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
903 }
904
905 #[gpui::test(iterations = 10)]
906 async fn test_creating_files(cx: &mut TestAppContext) {
907 init_test(cx);
908
909 let fs = FakeFs::new(cx.executor());
910 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
911 let action_log = cx.new(|_| ActionLog::new(project.clone()));
912
913 let fs = FakeFs::new(cx.executor());
914 fs.insert_tree(path!("/dir"), json!({})).await;
915
916 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
917 let file_path = project
918 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
919 .unwrap();
920
921 // Simulate file2 being recreated by a tool.
922 let buffer = project
923 .update(cx, |project, cx| project.open_buffer(file_path, cx))
924 .await
925 .unwrap();
926 cx.update(|cx| {
927 buffer.update(cx, |buffer, cx| buffer.set_text("lorem", cx));
928 action_log.update(cx, |log, cx| log.will_create_buffer(buffer.clone(), cx));
929 });
930 project
931 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
932 .await
933 .unwrap();
934 cx.run_until_parked();
935 assert_eq!(
936 unreviewed_hunks(&action_log, cx),
937 vec![(
938 buffer.clone(),
939 vec![HunkStatus {
940 range: Point::new(0, 0)..Point::new(0, 5),
941 diff_status: DiffHunkStatusKind::Added,
942 old_text: "".into(),
943 }],
944 )]
945 );
946
947 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "X")], None, cx));
948 cx.run_until_parked();
949 assert_eq!(
950 unreviewed_hunks(&action_log, cx),
951 vec![(
952 buffer.clone(),
953 vec![HunkStatus {
954 range: Point::new(0, 0)..Point::new(0, 6),
955 diff_status: DiffHunkStatusKind::Added,
956 old_text: "".into(),
957 }],
958 )]
959 );
960
961 action_log.update(cx, |log, cx| {
962 log.keep_edits_in_range(buffer.clone(), 0..5, cx)
963 });
964 cx.run_until_parked();
965 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
966 }
967
968 #[gpui::test(iterations = 10)]
969 async fn test_deleting_files(cx: &mut TestAppContext) {
970 init_test(cx);
971
972 let fs = FakeFs::new(cx.executor());
973 fs.insert_tree(
974 path!("/dir"),
975 json!({"file1": "lorem\n", "file2": "ipsum\n"}),
976 )
977 .await;
978
979 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
980 let file1_path = project
981 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
982 .unwrap();
983 let file2_path = project
984 .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
985 .unwrap();
986
987 let action_log = cx.new(|_| ActionLog::new(project.clone()));
988 let buffer1 = project
989 .update(cx, |project, cx| {
990 project.open_buffer(file1_path.clone(), cx)
991 })
992 .await
993 .unwrap();
994 let buffer2 = project
995 .update(cx, |project, cx| {
996 project.open_buffer(file2_path.clone(), cx)
997 })
998 .await
999 .unwrap();
1000
1001 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx));
1002 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx));
1003 project
1004 .update(cx, |project, cx| {
1005 project.delete_file(file1_path.clone(), false, cx)
1006 })
1007 .unwrap()
1008 .await
1009 .unwrap();
1010 project
1011 .update(cx, |project, cx| {
1012 project.delete_file(file2_path.clone(), false, cx)
1013 })
1014 .unwrap()
1015 .await
1016 .unwrap();
1017 cx.run_until_parked();
1018 assert_eq!(
1019 unreviewed_hunks(&action_log, cx),
1020 vec![
1021 (
1022 buffer1.clone(),
1023 vec![HunkStatus {
1024 range: Point::new(0, 0)..Point::new(0, 0),
1025 diff_status: DiffHunkStatusKind::Deleted,
1026 old_text: "lorem\n".into(),
1027 }]
1028 ),
1029 (
1030 buffer2.clone(),
1031 vec![HunkStatus {
1032 range: Point::new(0, 0)..Point::new(0, 0),
1033 diff_status: DiffHunkStatusKind::Deleted,
1034 old_text: "ipsum\n".into(),
1035 }],
1036 )
1037 ]
1038 );
1039
1040 // Simulate file1 being recreated externally.
1041 fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec())
1042 .await;
1043
1044 // Simulate file2 being recreated by a tool.
1045 let buffer2 = project
1046 .update(cx, |project, cx| project.open_buffer(file2_path, cx))
1047 .await
1048 .unwrap();
1049 buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx));
1050 action_log.update(cx, |log, cx| log.will_create_buffer(buffer2.clone(), cx));
1051 project
1052 .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx))
1053 .await
1054 .unwrap();
1055
1056 cx.run_until_parked();
1057 assert_eq!(
1058 unreviewed_hunks(&action_log, cx),
1059 vec![(
1060 buffer2.clone(),
1061 vec![HunkStatus {
1062 range: Point::new(0, 0)..Point::new(0, 5),
1063 diff_status: DiffHunkStatusKind::Modified,
1064 old_text: "ipsum\n".into(),
1065 }],
1066 )]
1067 );
1068
1069 // Simulate file2 being deleted externally.
1070 fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default())
1071 .await
1072 .unwrap();
1073 cx.run_until_parked();
1074 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1075 }
1076
1077 #[gpui::test(iterations = 10)]
1078 async fn test_reject_edits(cx: &mut TestAppContext) {
1079 init_test(cx);
1080
1081 let fs = FakeFs::new(cx.executor());
1082 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1083 .await;
1084 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1085 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1086 let file_path = project
1087 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1088 .unwrap();
1089 let buffer = project
1090 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1091 .await
1092 .unwrap();
1093
1094 cx.update(|cx| {
1095 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1096 buffer.update(cx, |buffer, cx| {
1097 buffer
1098 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
1099 .unwrap()
1100 });
1101 buffer.update(cx, |buffer, cx| {
1102 buffer
1103 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
1104 .unwrap()
1105 });
1106 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1107 });
1108 cx.run_until_parked();
1109 assert_eq!(
1110 buffer.read_with(cx, |buffer, _| buffer.text()),
1111 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1112 );
1113 assert_eq!(
1114 unreviewed_hunks(&action_log, cx),
1115 vec![(
1116 buffer.clone(),
1117 vec![
1118 HunkStatus {
1119 range: Point::new(1, 0)..Point::new(3, 0),
1120 diff_status: DiffHunkStatusKind::Modified,
1121 old_text: "def\n".into(),
1122 },
1123 HunkStatus {
1124 range: Point::new(5, 0)..Point::new(5, 3),
1125 diff_status: DiffHunkStatusKind::Modified,
1126 old_text: "mno".into(),
1127 }
1128 ],
1129 )]
1130 );
1131
1132 action_log
1133 .update(cx, |log, cx| {
1134 log.reject_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
1135 })
1136 .await
1137 .unwrap();
1138 cx.run_until_parked();
1139 assert_eq!(
1140 buffer.read_with(cx, |buffer, _| buffer.text()),
1141 "abc\ndef\nghi\njkl\nmnO"
1142 );
1143 assert_eq!(
1144 unreviewed_hunks(&action_log, cx),
1145 vec![(
1146 buffer.clone(),
1147 vec![HunkStatus {
1148 range: Point::new(4, 0)..Point::new(4, 3),
1149 diff_status: DiffHunkStatusKind::Modified,
1150 old_text: "mno".into(),
1151 }],
1152 )]
1153 );
1154
1155 action_log
1156 .update(cx, |log, cx| {
1157 log.reject_edits_in_range(buffer.clone(), Point::new(4, 0)..Point::new(4, 0), cx)
1158 })
1159 .await
1160 .unwrap();
1161 cx.run_until_parked();
1162 assert_eq!(
1163 buffer.read_with(cx, |buffer, _| buffer.text()),
1164 "abc\ndef\nghi\njkl\nmno"
1165 );
1166 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1167 }
1168
1169 #[gpui::test(iterations = 10)]
1170 async fn test_reject_deleted_file(cx: &mut TestAppContext) {
1171 init_test(cx);
1172
1173 let fs = FakeFs::new(cx.executor());
1174 fs.insert_tree(path!("/dir"), json!({"file": "content"}))
1175 .await;
1176 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1177 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1178 let file_path = project
1179 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1180 .unwrap();
1181 let buffer = project
1182 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
1183 .await
1184 .unwrap();
1185
1186 cx.update(|cx| {
1187 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
1188 });
1189 project
1190 .update(cx, |project, cx| {
1191 project.delete_file(file_path.clone(), false, cx)
1192 })
1193 .unwrap()
1194 .await
1195 .unwrap();
1196 cx.run_until_parked();
1197 assert!(!fs.is_file(path!("/dir/file").as_ref()).await);
1198 assert_eq!(
1199 unreviewed_hunks(&action_log, cx),
1200 vec![(
1201 buffer.clone(),
1202 vec![HunkStatus {
1203 range: Point::new(0, 0)..Point::new(0, 0),
1204 diff_status: DiffHunkStatusKind::Deleted,
1205 old_text: "content".into(),
1206 }]
1207 )]
1208 );
1209
1210 action_log
1211 .update(cx, |log, cx| {
1212 log.reject_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(0, 0), cx)
1213 })
1214 .await
1215 .unwrap();
1216 cx.run_until_parked();
1217 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content");
1218 assert!(fs.is_file(path!("/dir/file").as_ref()).await);
1219 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1220 }
1221
1222 #[gpui::test(iterations = 10)]
1223 async fn test_reject_created_file(cx: &mut TestAppContext) {
1224 init_test(cx);
1225
1226 let fs = FakeFs::new(cx.executor());
1227 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1228 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1229 let file_path = project
1230 .read_with(cx, |project, cx| {
1231 project.find_project_path("dir/new_file", cx)
1232 })
1233 .unwrap();
1234
1235 let buffer = project
1236 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1237 .await
1238 .unwrap();
1239 cx.update(|cx| {
1240 buffer.update(cx, |buffer, cx| buffer.set_text("content", cx));
1241 action_log.update(cx, |log, cx| log.will_create_buffer(buffer.clone(), cx));
1242 });
1243 project
1244 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1245 .await
1246 .unwrap();
1247 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
1248 cx.run_until_parked();
1249 assert_eq!(
1250 unreviewed_hunks(&action_log, cx),
1251 vec![(
1252 buffer.clone(),
1253 vec![HunkStatus {
1254 range: Point::new(0, 0)..Point::new(0, 7),
1255 diff_status: DiffHunkStatusKind::Added,
1256 old_text: "".into(),
1257 }],
1258 )]
1259 );
1260
1261 action_log
1262 .update(cx, |log, cx| {
1263 log.reject_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(0, 11), cx)
1264 })
1265 .await
1266 .unwrap();
1267 cx.run_until_parked();
1268 assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await);
1269 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1270 }
1271
1272 #[gpui::test(iterations = 100)]
1273 async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
1274 init_test(cx);
1275
1276 let operations = env::var("OPERATIONS")
1277 .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
1278 .unwrap_or(20);
1279
1280 let text = RandomCharIter::new(&mut rng).take(50).collect::<String>();
1281 let fs = FakeFs::new(cx.executor());
1282 fs.insert_tree(path!("/dir"), json!({"file": text})).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 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1294
1295 for _ in 0..operations {
1296 match rng.gen_range(0..100) {
1297 0..25 => {
1298 action_log.update(cx, |log, cx| {
1299 let range = buffer.read(cx).random_byte_range(0, &mut rng);
1300 log::info!("keeping edits in range {:?}", range);
1301 log.keep_edits_in_range(buffer.clone(), range, cx)
1302 });
1303 }
1304 25..50 => {
1305 action_log
1306 .update(cx, |log, cx| {
1307 let range = buffer.read(cx).random_byte_range(0, &mut rng);
1308 log::info!("rejecting edits in range {:?}", range);
1309 log.reject_edits_in_range(buffer.clone(), range, cx)
1310 })
1311 .await
1312 .unwrap();
1313 }
1314 _ => {
1315 let is_agent_change = rng.gen_bool(0.5);
1316 if is_agent_change {
1317 log::info!("agent edit");
1318 } else {
1319 log::info!("user edit");
1320 }
1321 cx.update(|cx| {
1322 buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
1323 if is_agent_change {
1324 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1325 }
1326 });
1327 }
1328 }
1329
1330 if rng.gen_bool(0.2) {
1331 quiesce(&action_log, &buffer, cx);
1332 }
1333 }
1334
1335 quiesce(&action_log, &buffer, cx);
1336
1337 fn quiesce(
1338 action_log: &Entity<ActionLog>,
1339 buffer: &Entity<Buffer>,
1340 cx: &mut TestAppContext,
1341 ) {
1342 log::info!("quiescing...");
1343 cx.run_until_parked();
1344 action_log.update(cx, |log, cx| {
1345 let tracked_buffer = log.track_buffer(buffer.clone(), false, cx);
1346 let mut old_text = tracked_buffer.base_text.clone();
1347 let new_text = buffer.read(cx).as_rope();
1348 for edit in tracked_buffer.unreviewed_changes.edits() {
1349 let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
1350 let old_end = old_text.point_to_offset(cmp::min(
1351 Point::new(edit.new.start + edit.old_len(), 0),
1352 old_text.max_point(),
1353 ));
1354 old_text.replace(
1355 old_start..old_end,
1356 &new_text.slice_rows(edit.new.clone()).to_string(),
1357 );
1358 }
1359 pretty_assertions::assert_eq!(old_text.to_string(), new_text.to_string());
1360 })
1361 }
1362 }
1363
1364 #[derive(Debug, Clone, PartialEq, Eq)]
1365 struct HunkStatus {
1366 range: Range<Point>,
1367 diff_status: DiffHunkStatusKind,
1368 old_text: String,
1369 }
1370
1371 fn unreviewed_hunks(
1372 action_log: &Entity<ActionLog>,
1373 cx: &TestAppContext,
1374 ) -> Vec<(Entity<Buffer>, Vec<HunkStatus>)> {
1375 cx.read(|cx| {
1376 action_log
1377 .read(cx)
1378 .changed_buffers(cx)
1379 .into_iter()
1380 .map(|(buffer, diff)| {
1381 let snapshot = buffer.read(cx).snapshot();
1382 (
1383 buffer,
1384 diff.read(cx)
1385 .hunks(&snapshot, cx)
1386 .map(|hunk| HunkStatus {
1387 diff_status: hunk.status().kind,
1388 range: hunk.range,
1389 old_text: diff
1390 .read(cx)
1391 .base_text()
1392 .text_for_range(hunk.diff_base_byte_range)
1393 .collect(),
1394 })
1395 .collect(),
1396 )
1397 })
1398 .collect()
1399 })
1400 }
1401}