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, ToPoint};
7use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
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 pub fn project(&self) -> &Entity<Project> {
33 &self.project
34 }
35
36 /// Notifies a diagnostics check
37 pub fn checked_project_diagnostics(&mut self) {
38 self.edited_since_project_diagnostics_check = false;
39 }
40
41 /// Returns true if any files have been edited since the last project diagnostics check
42 pub fn has_edited_files_since_project_diagnostics_check(&self) -> bool {
43 self.edited_since_project_diagnostics_check
44 }
45
46 fn track_buffer_internal(
47 &mut self,
48 buffer: Entity<Buffer>,
49 is_created: bool,
50 cx: &mut Context<Self>,
51 ) -> &mut TrackedBuffer {
52 let status = if is_created {
53 if let Some(tracked) = self.tracked_buffers.remove(&buffer) {
54 match tracked.status {
55 TrackedBufferStatus::Created {
56 existing_file_content,
57 } => TrackedBufferStatus::Created {
58 existing_file_content,
59 },
60 TrackedBufferStatus::Modified | TrackedBufferStatus::Deleted => {
61 TrackedBufferStatus::Created {
62 existing_file_content: Some(tracked.diff_base),
63 }
64 }
65 }
66 } else if buffer
67 .read(cx)
68 .file()
69 .map_or(false, |file| file.disk_state().exists())
70 {
71 TrackedBufferStatus::Created {
72 existing_file_content: Some(buffer.read(cx).as_rope().clone()),
73 }
74 } else {
75 TrackedBufferStatus::Created {
76 existing_file_content: None,
77 }
78 }
79 } else {
80 TrackedBufferStatus::Modified
81 };
82
83 let tracked_buffer = self
84 .tracked_buffers
85 .entry(buffer.clone())
86 .or_insert_with(|| {
87 let open_lsp_handle = self.project.update(cx, |project, cx| {
88 project.register_buffer_with_language_servers(&buffer, cx)
89 });
90
91 let text_snapshot = buffer.read(cx).text_snapshot();
92 let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
93 let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
94 let diff_base;
95 let unreviewed_changes;
96 if is_created {
97 diff_base = Rope::default();
98 unreviewed_changes = Patch::new(vec![Edit {
99 old: 0..1,
100 new: 0..text_snapshot.max_point().row + 1,
101 }])
102 } else {
103 diff_base = buffer.read(cx).as_rope().clone();
104 unreviewed_changes = Patch::default();
105 }
106 TrackedBuffer {
107 buffer: buffer.clone(),
108 diff_base,
109 unreviewed_changes,
110 snapshot: text_snapshot.clone(),
111 status,
112 version: buffer.read(cx).version(),
113 diff,
114 diff_update: diff_update_tx,
115 _open_lsp_handle: open_lsp_handle,
116 _maintain_diff: cx.spawn({
117 let buffer = buffer.clone();
118 async move |this, cx| {
119 Self::maintain_diff(this, buffer, diff_update_rx, cx)
120 .await
121 .ok();
122 }
123 }),
124 _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
125 }
126 });
127 tracked_buffer.version = buffer.read(cx).version();
128 tracked_buffer
129 }
130
131 fn handle_buffer_event(
132 &mut self,
133 buffer: Entity<Buffer>,
134 event: &BufferEvent,
135 cx: &mut Context<Self>,
136 ) {
137 match event {
138 BufferEvent::Edited { .. } => self.handle_buffer_edited(buffer, cx),
139 BufferEvent::FileHandleChanged => {
140 self.handle_buffer_file_changed(buffer, cx);
141 }
142 _ => {}
143 };
144 }
145
146 fn handle_buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
147 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
148 return;
149 };
150 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
151 }
152
153 fn handle_buffer_file_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
154 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
155 return;
156 };
157
158 match tracked_buffer.status {
159 TrackedBufferStatus::Created { .. } | TrackedBufferStatus::Modified => {
160 if buffer
161 .read(cx)
162 .file()
163 .map_or(false, |file| file.disk_state() == DiskState::Deleted)
164 {
165 // If the buffer had been edited by a tool, but it got
166 // deleted externally, we want to stop tracking it.
167 self.tracked_buffers.remove(&buffer);
168 }
169 cx.notify();
170 }
171 TrackedBufferStatus::Deleted => {
172 if buffer
173 .read(cx)
174 .file()
175 .map_or(false, |file| file.disk_state() != DiskState::Deleted)
176 {
177 // If the buffer had been deleted by a tool, but it got
178 // resurrected externally, we want to clear the changes we
179 // were tracking and reset the buffer's state.
180 self.tracked_buffers.remove(&buffer);
181 self.track_buffer_internal(buffer, false, cx);
182 }
183 cx.notify();
184 }
185 }
186 }
187
188 async fn maintain_diff(
189 this: WeakEntity<Self>,
190 buffer: Entity<Buffer>,
191 mut diff_update: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
192 cx: &mut AsyncApp,
193 ) -> Result<()> {
194 while let Some((author, buffer_snapshot)) = diff_update.next().await {
195 let (rebase, diff, language, language_registry) =
196 this.read_with(cx, |this, cx| {
197 let tracked_buffer = this
198 .tracked_buffers
199 .get(&buffer)
200 .context("buffer not tracked")?;
201
202 let rebase = cx.background_spawn({
203 let mut base_text = tracked_buffer.diff_base.clone();
204 let old_snapshot = tracked_buffer.snapshot.clone();
205 let new_snapshot = buffer_snapshot.clone();
206 let unreviewed_changes = tracked_buffer.unreviewed_changes.clone();
207 async move {
208 let edits = diff_snapshots(&old_snapshot, &new_snapshot);
209 if let ChangeAuthor::User = author {
210 apply_non_conflicting_edits(
211 &unreviewed_changes,
212 edits,
213 &mut base_text,
214 new_snapshot.as_rope(),
215 );
216 }
217 (Arc::new(base_text.to_string()), base_text)
218 }
219 });
220
221 anyhow::Ok((
222 rebase,
223 tracked_buffer.diff.clone(),
224 tracked_buffer.buffer.read(cx).language().cloned(),
225 tracked_buffer.buffer.read(cx).language_registry(),
226 ))
227 })??;
228
229 let (new_base_text, new_diff_base) = rebase.await;
230 let diff_snapshot = BufferDiff::update_diff(
231 diff.clone(),
232 buffer_snapshot.clone(),
233 Some(new_base_text),
234 true,
235 false,
236 language,
237 language_registry,
238 cx,
239 )
240 .await;
241
242 let mut unreviewed_changes = Patch::default();
243 if let Ok(diff_snapshot) = diff_snapshot {
244 unreviewed_changes = cx
245 .background_spawn({
246 let diff_snapshot = diff_snapshot.clone();
247 let buffer_snapshot = buffer_snapshot.clone();
248 let new_diff_base = new_diff_base.clone();
249 async move {
250 let mut unreviewed_changes = Patch::default();
251 for hunk in diff_snapshot.hunks_intersecting_range(
252 Anchor::MIN..Anchor::MAX,
253 &buffer_snapshot,
254 ) {
255 let old_range = new_diff_base
256 .offset_to_point(hunk.diff_base_byte_range.start)
257 ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end);
258 let new_range = hunk.range.start..hunk.range.end;
259 unreviewed_changes.push(point_to_row_edit(
260 Edit {
261 old: old_range,
262 new: new_range,
263 },
264 &new_diff_base,
265 &buffer_snapshot.as_rope(),
266 ));
267 }
268 unreviewed_changes
269 }
270 })
271 .await;
272
273 diff.update(cx, |diff, cx| {
274 diff.set_snapshot(diff_snapshot, &buffer_snapshot, cx)
275 })?;
276 }
277 this.update(cx, |this, cx| {
278 let tracked_buffer = this
279 .tracked_buffers
280 .get_mut(&buffer)
281 .context("buffer not tracked")?;
282 tracked_buffer.diff_base = new_diff_base;
283 tracked_buffer.snapshot = buffer_snapshot;
284 tracked_buffer.unreviewed_changes = unreviewed_changes;
285 cx.notify();
286 anyhow::Ok(())
287 })??;
288 }
289
290 Ok(())
291 }
292
293 /// Track a buffer as read, so we can notify the model about user edits.
294 pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
295 self.track_buffer_internal(buffer, false, cx);
296 }
297
298 /// Mark a buffer as edited, so we can refresh it in the context
299 pub fn buffer_created(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
300 self.edited_since_project_diagnostics_check = true;
301 self.track_buffer_internal(buffer.clone(), true, cx);
302 }
303
304 /// Mark a buffer as edited, so we can refresh it in the context
305 pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
306 self.edited_since_project_diagnostics_check = true;
307
308 let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
309 if let TrackedBufferStatus::Deleted = tracked_buffer.status {
310 tracked_buffer.status = TrackedBufferStatus::Modified;
311 }
312 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
313 }
314
315 pub fn will_delete_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
316 let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
317 match tracked_buffer.status {
318 TrackedBufferStatus::Created { .. } => {
319 self.tracked_buffers.remove(&buffer);
320 cx.notify();
321 }
322 TrackedBufferStatus::Modified => {
323 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
324 tracked_buffer.status = TrackedBufferStatus::Deleted;
325 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
326 }
327 TrackedBufferStatus::Deleted => {}
328 }
329 cx.notify();
330 }
331
332 pub fn keep_edits_in_range(
333 &mut self,
334 buffer: Entity<Buffer>,
335 buffer_range: Range<impl language::ToPoint>,
336 cx: &mut Context<Self>,
337 ) {
338 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
339 return;
340 };
341
342 match tracked_buffer.status {
343 TrackedBufferStatus::Deleted => {
344 self.tracked_buffers.remove(&buffer);
345 cx.notify();
346 }
347 _ => {
348 let buffer = buffer.read(cx);
349 let buffer_range =
350 buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
351 let mut delta = 0i32;
352
353 tracked_buffer.unreviewed_changes.retain_mut(|edit| {
354 edit.old.start = (edit.old.start as i32 + delta) as u32;
355 edit.old.end = (edit.old.end as i32 + delta) as u32;
356
357 if buffer_range.end.row < edit.new.start
358 || buffer_range.start.row > edit.new.end
359 {
360 true
361 } else {
362 let old_range = tracked_buffer
363 .diff_base
364 .point_to_offset(Point::new(edit.old.start, 0))
365 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
366 Point::new(edit.old.end, 0),
367 tracked_buffer.diff_base.max_point(),
368 ));
369 let new_range = tracked_buffer
370 .snapshot
371 .point_to_offset(Point::new(edit.new.start, 0))
372 ..tracked_buffer.snapshot.point_to_offset(cmp::min(
373 Point::new(edit.new.end, 0),
374 tracked_buffer.snapshot.max_point(),
375 ));
376 tracked_buffer.diff_base.replace(
377 old_range,
378 &tracked_buffer
379 .snapshot
380 .text_for_range(new_range)
381 .collect::<String>(),
382 );
383 delta += edit.new_len() as i32 - edit.old_len() as i32;
384 false
385 }
386 });
387 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
388 }
389 }
390 }
391
392 pub fn reject_edits_in_ranges(
393 &mut self,
394 buffer: Entity<Buffer>,
395 buffer_ranges: Vec<Range<impl language::ToPoint>>,
396 cx: &mut Context<Self>,
397 ) -> Task<Result<()>> {
398 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
399 return Task::ready(Ok(()));
400 };
401
402 match &tracked_buffer.status {
403 TrackedBufferStatus::Created {
404 existing_file_content,
405 } => {
406 let task = if let Some(existing_file_content) = existing_file_content {
407 buffer.update(cx, |buffer, cx| {
408 buffer.start_transaction();
409 buffer.set_text("", cx);
410 for chunk in existing_file_content.chunks() {
411 buffer.append(chunk, cx);
412 }
413 buffer.end_transaction(cx);
414 });
415 self.project
416 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
417 } else {
418 // For a file created by AI with no pre-existing content,
419 // only delete the file if we're certain it contains only AI content
420 // with no edits from the user.
421
422 let initial_version = tracked_buffer.version.clone();
423 let current_version = buffer.read(cx).version();
424
425 let current_content = buffer.read(cx).text();
426 let tracked_content = tracked_buffer.snapshot.text();
427
428 let is_ai_only_content =
429 initial_version == current_version && current_content == tracked_content;
430
431 if is_ai_only_content {
432 buffer
433 .read(cx)
434 .entry_id(cx)
435 .and_then(|entry_id| {
436 self.project.update(cx, |project, cx| {
437 project.delete_entry(entry_id, false, cx)
438 })
439 })
440 .unwrap_or(Task::ready(Ok(())))
441 } else {
442 // Not sure how to disentangle edits made by the user
443 // from edits made by the AI at this point.
444 // For now, preserve both to avoid data loss.
445 //
446 // TODO: Better solution (disable "Reject" after user makes some
447 // edit or find a way to differentiate between AI and user edits)
448 Task::ready(Ok(()))
449 }
450 };
451
452 self.tracked_buffers.remove(&buffer);
453 cx.notify();
454 task
455 }
456 TrackedBufferStatus::Deleted => {
457 buffer.update(cx, |buffer, cx| {
458 buffer.set_text(tracked_buffer.diff_base.to_string(), cx)
459 });
460 let save = self
461 .project
462 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
463
464 // Clear all tracked changes for this buffer and start over as if we just read it.
465 self.tracked_buffers.remove(&buffer);
466 self.buffer_read(buffer.clone(), cx);
467 cx.notify();
468 save
469 }
470 TrackedBufferStatus::Modified => {
471 buffer.update(cx, |buffer, cx| {
472 let mut buffer_row_ranges = buffer_ranges
473 .into_iter()
474 .map(|range| {
475 range.start.to_point(buffer).row..range.end.to_point(buffer).row
476 })
477 .peekable();
478
479 let mut edits_to_revert = Vec::new();
480 for edit in tracked_buffer.unreviewed_changes.edits() {
481 let new_range = tracked_buffer
482 .snapshot
483 .anchor_before(Point::new(edit.new.start, 0))
484 ..tracked_buffer.snapshot.anchor_after(cmp::min(
485 Point::new(edit.new.end, 0),
486 tracked_buffer.snapshot.max_point(),
487 ));
488 let new_row_range = new_range.start.to_point(buffer).row
489 ..new_range.end.to_point(buffer).row;
490
491 let mut revert = false;
492 while let Some(buffer_row_range) = buffer_row_ranges.peek() {
493 if buffer_row_range.end < new_row_range.start {
494 buffer_row_ranges.next();
495 } else if buffer_row_range.start > new_row_range.end {
496 break;
497 } else {
498 revert = true;
499 break;
500 }
501 }
502
503 if revert {
504 let old_range = tracked_buffer
505 .diff_base
506 .point_to_offset(Point::new(edit.old.start, 0))
507 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
508 Point::new(edit.old.end, 0),
509 tracked_buffer.diff_base.max_point(),
510 ));
511 let old_text = tracked_buffer
512 .diff_base
513 .chunks_in_range(old_range)
514 .collect::<String>();
515 edits_to_revert.push((new_range, old_text));
516 }
517 }
518
519 buffer.edit(edits_to_revert, None, cx);
520 });
521 self.project
522 .update(cx, |project, cx| project.save_buffer(buffer, cx))
523 }
524 }
525 }
526
527 pub fn keep_all_edits(&mut self, cx: &mut Context<Self>) {
528 self.tracked_buffers
529 .retain(|_buffer, tracked_buffer| match tracked_buffer.status {
530 TrackedBufferStatus::Deleted => false,
531 _ => {
532 tracked_buffer.unreviewed_changes.clear();
533 tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
534 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
535 true
536 }
537 });
538 cx.notify();
539 }
540
541 /// Returns the set of buffers that contain changes that haven't been reviewed by the user.
542 pub fn changed_buffers(&self, cx: &App) -> BTreeMap<Entity<Buffer>, Entity<BufferDiff>> {
543 self.tracked_buffers
544 .iter()
545 .filter(|(_, tracked)| tracked.has_changes(cx))
546 .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
547 .collect()
548 }
549
550 /// Iterate over buffers changed since last read or edited by the model
551 pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
552 self.tracked_buffers
553 .iter()
554 .filter(|(buffer, tracked)| {
555 let buffer = buffer.read(cx);
556
557 tracked.version != buffer.version
558 && buffer
559 .file()
560 .map_or(false, |file| file.disk_state() != DiskState::Deleted)
561 })
562 .map(|(buffer, _)| buffer)
563 }
564}
565
566fn apply_non_conflicting_edits(
567 patch: &Patch<u32>,
568 edits: Vec<Edit<u32>>,
569 old_text: &mut Rope,
570 new_text: &Rope,
571) {
572 let mut old_edits = patch.edits().iter().cloned().peekable();
573 let mut new_edits = edits.into_iter().peekable();
574 let mut applied_delta = 0i32;
575 let mut rebased_delta = 0i32;
576
577 while let Some(mut new_edit) = new_edits.next() {
578 let mut conflict = false;
579
580 // Push all the old edits that are before this new edit or that intersect with it.
581 while let Some(old_edit) = old_edits.peek() {
582 if new_edit.old.end < old_edit.new.start
583 || (!old_edit.new.is_empty() && new_edit.old.end == old_edit.new.start)
584 {
585 break;
586 } else if new_edit.old.start > old_edit.new.end
587 || (!old_edit.new.is_empty() && new_edit.old.start == old_edit.new.end)
588 {
589 let old_edit = old_edits.next().unwrap();
590 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
591 } else {
592 conflict = true;
593 if new_edits
594 .peek()
595 .map_or(false, |next_edit| next_edit.old.overlaps(&old_edit.new))
596 {
597 new_edit = new_edits.next().unwrap();
598 } else {
599 let old_edit = old_edits.next().unwrap();
600 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
601 }
602 }
603 }
604
605 if !conflict {
606 // This edit doesn't intersect with any old edit, so we can apply it to the old text.
607 new_edit.old.start = (new_edit.old.start as i32 + applied_delta - rebased_delta) as u32;
608 new_edit.old.end = (new_edit.old.end as i32 + applied_delta - rebased_delta) as u32;
609 let old_bytes = old_text.point_to_offset(Point::new(new_edit.old.start, 0))
610 ..old_text.point_to_offset(cmp::min(
611 Point::new(new_edit.old.end, 0),
612 old_text.max_point(),
613 ));
614 let new_bytes = new_text.point_to_offset(Point::new(new_edit.new.start, 0))
615 ..new_text.point_to_offset(cmp::min(
616 Point::new(new_edit.new.end, 0),
617 new_text.max_point(),
618 ));
619
620 old_text.replace(
621 old_bytes,
622 &new_text.chunks_in_range(new_bytes).collect::<String>(),
623 );
624 applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
625 }
626 }
627}
628
629fn diff_snapshots(
630 old_snapshot: &text::BufferSnapshot,
631 new_snapshot: &text::BufferSnapshot,
632) -> Vec<Edit<u32>> {
633 let mut edits = new_snapshot
634 .edits_since::<Point>(&old_snapshot.version)
635 .map(|edit| point_to_row_edit(edit, old_snapshot.as_rope(), new_snapshot.as_rope()))
636 .peekable();
637 let mut row_edits = Vec::new();
638 while let Some(mut edit) = edits.next() {
639 while let Some(next_edit) = edits.peek() {
640 if edit.old.end >= next_edit.old.start {
641 edit.old.end = next_edit.old.end;
642 edit.new.end = next_edit.new.end;
643 edits.next();
644 } else {
645 break;
646 }
647 }
648 row_edits.push(edit);
649 }
650 row_edits
651}
652
653fn point_to_row_edit(edit: Edit<Point>, old_text: &Rope, new_text: &Rope) -> Edit<u32> {
654 if edit.old.start.column == old_text.line_len(edit.old.start.row)
655 && new_text
656 .chars_at(new_text.point_to_offset(edit.new.start))
657 .next()
658 == Some('\n')
659 && edit.old.start != old_text.max_point()
660 {
661 Edit {
662 old: edit.old.start.row + 1..edit.old.end.row + 1,
663 new: edit.new.start.row + 1..edit.new.end.row + 1,
664 }
665 } else if edit.old.start.column == 0
666 && edit.old.end.column == 0
667 && edit.new.end.column == 0
668 && edit.old.end != old_text.max_point()
669 {
670 Edit {
671 old: edit.old.start.row..edit.old.end.row,
672 new: edit.new.start.row..edit.new.end.row,
673 }
674 } else {
675 Edit {
676 old: edit.old.start.row..edit.old.end.row + 1,
677 new: edit.new.start.row..edit.new.end.row + 1,
678 }
679 }
680}
681
682#[derive(Copy, Clone, Debug)]
683enum ChangeAuthor {
684 User,
685 Agent,
686}
687
688enum TrackedBufferStatus {
689 Created { existing_file_content: Option<Rope> },
690 Modified,
691 Deleted,
692}
693
694struct TrackedBuffer {
695 buffer: Entity<Buffer>,
696 diff_base: Rope,
697 unreviewed_changes: Patch<u32>,
698 status: TrackedBufferStatus,
699 version: clock::Global,
700 diff: Entity<BufferDiff>,
701 snapshot: text::BufferSnapshot,
702 diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
703 _open_lsp_handle: OpenLspBufferHandle,
704 _maintain_diff: Task<()>,
705 _subscription: Subscription,
706}
707
708impl TrackedBuffer {
709 fn has_changes(&self, cx: &App) -> bool {
710 self.diff
711 .read(cx)
712 .hunks(&self.buffer.read(cx), cx)
713 .next()
714 .is_some()
715 }
716
717 fn schedule_diff_update(&self, author: ChangeAuthor, cx: &App) {
718 self.diff_update
719 .unbounded_send((author, self.buffer.read(cx).text_snapshot()))
720 .ok();
721 }
722}
723
724pub struct ChangedBuffer {
725 pub diff: Entity<BufferDiff>,
726}
727
728#[cfg(test)]
729mod tests {
730 use std::env;
731
732 use super::*;
733 use buffer_diff::DiffHunkStatusKind;
734 use gpui::TestAppContext;
735 use language::Point;
736 use project::{FakeFs, Fs, Project, RemoveOptions};
737 use rand::prelude::*;
738 use serde_json::json;
739 use settings::SettingsStore;
740 use util::{RandomCharIter, path};
741
742 #[ctor::ctor]
743 fn init_logger() {
744 zlog::init_test();
745 }
746
747 fn init_test(cx: &mut TestAppContext) {
748 cx.update(|cx| {
749 let settings_store = SettingsStore::test(cx);
750 cx.set_global(settings_store);
751 language::init(cx);
752 Project::init_settings(cx);
753 });
754 }
755
756 #[gpui::test(iterations = 10)]
757 async fn test_keep_edits(cx: &mut TestAppContext) {
758 init_test(cx);
759
760 let fs = FakeFs::new(cx.executor());
761 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
762 .await;
763 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
764 let action_log = cx.new(|_| ActionLog::new(project.clone()));
765 let file_path = project
766 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
767 .unwrap();
768 let buffer = project
769 .update(cx, |project, cx| project.open_buffer(file_path, cx))
770 .await
771 .unwrap();
772
773 cx.update(|cx| {
774 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
775 buffer.update(cx, |buffer, cx| {
776 buffer
777 .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx)
778 .unwrap()
779 });
780 buffer.update(cx, |buffer, cx| {
781 buffer
782 .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx)
783 .unwrap()
784 });
785 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
786 });
787 cx.run_until_parked();
788 assert_eq!(
789 buffer.read_with(cx, |buffer, _| buffer.text()),
790 "abc\ndEf\nghi\njkl\nmnO"
791 );
792 assert_eq!(
793 unreviewed_hunks(&action_log, cx),
794 vec![(
795 buffer.clone(),
796 vec![
797 HunkStatus {
798 range: Point::new(1, 0)..Point::new(2, 0),
799 diff_status: DiffHunkStatusKind::Modified,
800 old_text: "def\n".into(),
801 },
802 HunkStatus {
803 range: Point::new(4, 0)..Point::new(4, 3),
804 diff_status: DiffHunkStatusKind::Modified,
805 old_text: "mno".into(),
806 }
807 ],
808 )]
809 );
810
811 action_log.update(cx, |log, cx| {
812 log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), cx)
813 });
814 cx.run_until_parked();
815 assert_eq!(
816 unreviewed_hunks(&action_log, cx),
817 vec![(
818 buffer.clone(),
819 vec![HunkStatus {
820 range: Point::new(1, 0)..Point::new(2, 0),
821 diff_status: DiffHunkStatusKind::Modified,
822 old_text: "def\n".into(),
823 }],
824 )]
825 );
826
827 action_log.update(cx, |log, cx| {
828 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), cx)
829 });
830 cx.run_until_parked();
831 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
832 }
833
834 #[gpui::test(iterations = 10)]
835 async fn test_deletions(cx: &mut TestAppContext) {
836 init_test(cx);
837
838 let fs = FakeFs::new(cx.executor());
839 fs.insert_tree(
840 path!("/dir"),
841 json!({"file": "abc\ndef\nghi\njkl\nmno\npqr"}),
842 )
843 .await;
844 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
845 let action_log = cx.new(|_| ActionLog::new(project.clone()));
846 let file_path = project
847 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
848 .unwrap();
849 let buffer = project
850 .update(cx, |project, cx| project.open_buffer(file_path, cx))
851 .await
852 .unwrap();
853
854 cx.update(|cx| {
855 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
856 buffer.update(cx, |buffer, cx| {
857 buffer
858 .edit([(Point::new(1, 0)..Point::new(2, 0), "")], None, cx)
859 .unwrap();
860 buffer.finalize_last_transaction();
861 });
862 buffer.update(cx, |buffer, cx| {
863 buffer
864 .edit([(Point::new(3, 0)..Point::new(4, 0), "")], None, cx)
865 .unwrap();
866 buffer.finalize_last_transaction();
867 });
868 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
869 });
870 cx.run_until_parked();
871 assert_eq!(
872 buffer.read_with(cx, |buffer, _| buffer.text()),
873 "abc\nghi\njkl\npqr"
874 );
875 assert_eq!(
876 unreviewed_hunks(&action_log, cx),
877 vec![(
878 buffer.clone(),
879 vec![
880 HunkStatus {
881 range: Point::new(1, 0)..Point::new(1, 0),
882 diff_status: DiffHunkStatusKind::Deleted,
883 old_text: "def\n".into(),
884 },
885 HunkStatus {
886 range: Point::new(3, 0)..Point::new(3, 0),
887 diff_status: DiffHunkStatusKind::Deleted,
888 old_text: "mno\n".into(),
889 }
890 ],
891 )]
892 );
893
894 buffer.update(cx, |buffer, cx| buffer.undo(cx));
895 cx.run_until_parked();
896 assert_eq!(
897 buffer.read_with(cx, |buffer, _| buffer.text()),
898 "abc\nghi\njkl\nmno\npqr"
899 );
900 assert_eq!(
901 unreviewed_hunks(&action_log, cx),
902 vec![(
903 buffer.clone(),
904 vec![HunkStatus {
905 range: Point::new(1, 0)..Point::new(1, 0),
906 diff_status: DiffHunkStatusKind::Deleted,
907 old_text: "def\n".into(),
908 }],
909 )]
910 );
911
912 action_log.update(cx, |log, cx| {
913 log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), cx)
914 });
915 cx.run_until_parked();
916 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
917 }
918
919 #[gpui::test(iterations = 10)]
920 async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
921 init_test(cx);
922
923 let fs = FakeFs::new(cx.executor());
924 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
925 .await;
926 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
927 let action_log = cx.new(|_| ActionLog::new(project.clone()));
928 let file_path = project
929 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
930 .unwrap();
931 let buffer = project
932 .update(cx, |project, cx| project.open_buffer(file_path, cx))
933 .await
934 .unwrap();
935
936 cx.update(|cx| {
937 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
938 buffer.update(cx, |buffer, cx| {
939 buffer
940 .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
941 .unwrap()
942 });
943 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
944 });
945 cx.run_until_parked();
946 assert_eq!(
947 buffer.read_with(cx, |buffer, _| buffer.text()),
948 "abc\ndeF\nGHI\njkl\nmno"
949 );
950 assert_eq!(
951 unreviewed_hunks(&action_log, cx),
952 vec![(
953 buffer.clone(),
954 vec![HunkStatus {
955 range: Point::new(1, 0)..Point::new(3, 0),
956 diff_status: DiffHunkStatusKind::Modified,
957 old_text: "def\nghi\n".into(),
958 }],
959 )]
960 );
961
962 buffer.update(cx, |buffer, cx| {
963 buffer.edit(
964 [
965 (Point::new(0, 2)..Point::new(0, 2), "X"),
966 (Point::new(3, 0)..Point::new(3, 0), "Y"),
967 ],
968 None,
969 cx,
970 )
971 });
972 cx.run_until_parked();
973 assert_eq!(
974 buffer.read_with(cx, |buffer, _| buffer.text()),
975 "abXc\ndeF\nGHI\nYjkl\nmno"
976 );
977 assert_eq!(
978 unreviewed_hunks(&action_log, cx),
979 vec![(
980 buffer.clone(),
981 vec![HunkStatus {
982 range: Point::new(1, 0)..Point::new(3, 0),
983 diff_status: DiffHunkStatusKind::Modified,
984 old_text: "def\nghi\n".into(),
985 }],
986 )]
987 );
988
989 buffer.update(cx, |buffer, cx| {
990 buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "Z")], None, cx)
991 });
992 cx.run_until_parked();
993 assert_eq!(
994 buffer.read_with(cx, |buffer, _| buffer.text()),
995 "abXc\ndZeF\nGHI\nYjkl\nmno"
996 );
997 assert_eq!(
998 unreviewed_hunks(&action_log, cx),
999 vec![(
1000 buffer.clone(),
1001 vec![HunkStatus {
1002 range: Point::new(1, 0)..Point::new(3, 0),
1003 diff_status: DiffHunkStatusKind::Modified,
1004 old_text: "def\nghi\n".into(),
1005 }],
1006 )]
1007 );
1008
1009 action_log.update(cx, |log, cx| {
1010 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
1011 });
1012 cx.run_until_parked();
1013 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1014 }
1015
1016 #[gpui::test(iterations = 10)]
1017 async fn test_creating_files(cx: &mut TestAppContext) {
1018 init_test(cx);
1019
1020 let fs = FakeFs::new(cx.executor());
1021 fs.insert_tree(path!("/dir"), json!({})).await;
1022 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1023 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1024 let file_path = project
1025 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1026 .unwrap();
1027
1028 let buffer = project
1029 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1030 .await
1031 .unwrap();
1032 cx.update(|cx| {
1033 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1034 buffer.update(cx, |buffer, cx| buffer.set_text("lorem", cx));
1035 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1036 });
1037 project
1038 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1039 .await
1040 .unwrap();
1041 cx.run_until_parked();
1042 assert_eq!(
1043 unreviewed_hunks(&action_log, cx),
1044 vec![(
1045 buffer.clone(),
1046 vec![HunkStatus {
1047 range: Point::new(0, 0)..Point::new(0, 5),
1048 diff_status: DiffHunkStatusKind::Added,
1049 old_text: "".into(),
1050 }],
1051 )]
1052 );
1053
1054 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "X")], None, cx));
1055 cx.run_until_parked();
1056 assert_eq!(
1057 unreviewed_hunks(&action_log, cx),
1058 vec![(
1059 buffer.clone(),
1060 vec![HunkStatus {
1061 range: Point::new(0, 0)..Point::new(0, 6),
1062 diff_status: DiffHunkStatusKind::Added,
1063 old_text: "".into(),
1064 }],
1065 )]
1066 );
1067
1068 action_log.update(cx, |log, cx| {
1069 log.keep_edits_in_range(buffer.clone(), 0..5, cx)
1070 });
1071 cx.run_until_parked();
1072 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1073 }
1074
1075 #[gpui::test(iterations = 10)]
1076 async fn test_overwriting_files(cx: &mut TestAppContext) {
1077 init_test(cx);
1078
1079 let fs = FakeFs::new(cx.executor());
1080 fs.insert_tree(
1081 path!("/dir"),
1082 json!({
1083 "file1": "Lorem ipsum dolor"
1084 }),
1085 )
1086 .await;
1087 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1088 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1089 let file_path = project
1090 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1091 .unwrap();
1092
1093 let buffer = project
1094 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1095 .await
1096 .unwrap();
1097 cx.update(|cx| {
1098 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1099 buffer.update(cx, |buffer, cx| buffer.set_text("sit amet consecteur", cx));
1100 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1101 });
1102 project
1103 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1104 .await
1105 .unwrap();
1106 cx.run_until_parked();
1107 assert_eq!(
1108 unreviewed_hunks(&action_log, cx),
1109 vec![(
1110 buffer.clone(),
1111 vec![HunkStatus {
1112 range: Point::new(0, 0)..Point::new(0, 19),
1113 diff_status: DiffHunkStatusKind::Added,
1114 old_text: "".into(),
1115 }],
1116 )]
1117 );
1118
1119 action_log
1120 .update(cx, |log, cx| {
1121 log.reject_edits_in_ranges(buffer.clone(), vec![2..5], cx)
1122 })
1123 .await
1124 .unwrap();
1125 cx.run_until_parked();
1126 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1127 assert_eq!(
1128 buffer.read_with(cx, |buffer, _cx| buffer.text()),
1129 "Lorem ipsum dolor"
1130 );
1131 }
1132
1133 #[gpui::test(iterations = 10)]
1134 async fn test_overwriting_previously_edited_files(cx: &mut TestAppContext) {
1135 init_test(cx);
1136
1137 let fs = FakeFs::new(cx.executor());
1138 fs.insert_tree(
1139 path!("/dir"),
1140 json!({
1141 "file1": "Lorem ipsum dolor"
1142 }),
1143 )
1144 .await;
1145 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1146 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1147 let file_path = project
1148 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1149 .unwrap();
1150
1151 let buffer = project
1152 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1153 .await
1154 .unwrap();
1155 cx.update(|cx| {
1156 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1157 buffer.update(cx, |buffer, cx| buffer.append(" sit amet consecteur", cx));
1158 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1159 });
1160 project
1161 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1162 .await
1163 .unwrap();
1164 cx.run_until_parked();
1165 assert_eq!(
1166 unreviewed_hunks(&action_log, cx),
1167 vec![(
1168 buffer.clone(),
1169 vec![HunkStatus {
1170 range: Point::new(0, 0)..Point::new(0, 37),
1171 diff_status: DiffHunkStatusKind::Modified,
1172 old_text: "Lorem ipsum dolor".into(),
1173 }],
1174 )]
1175 );
1176
1177 cx.update(|cx| {
1178 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1179 buffer.update(cx, |buffer, cx| buffer.set_text("rewritten", cx));
1180 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1181 });
1182 project
1183 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1184 .await
1185 .unwrap();
1186 cx.run_until_parked();
1187 assert_eq!(
1188 unreviewed_hunks(&action_log, cx),
1189 vec![(
1190 buffer.clone(),
1191 vec![HunkStatus {
1192 range: Point::new(0, 0)..Point::new(0, 9),
1193 diff_status: DiffHunkStatusKind::Added,
1194 old_text: "".into(),
1195 }],
1196 )]
1197 );
1198
1199 action_log
1200 .update(cx, |log, cx| {
1201 log.reject_edits_in_ranges(buffer.clone(), vec![2..5], cx)
1202 })
1203 .await
1204 .unwrap();
1205 cx.run_until_parked();
1206 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1207 assert_eq!(
1208 buffer.read_with(cx, |buffer, _cx| buffer.text()),
1209 "Lorem ipsum dolor"
1210 );
1211 }
1212
1213 #[gpui::test(iterations = 10)]
1214 async fn test_deleting_files(cx: &mut TestAppContext) {
1215 init_test(cx);
1216
1217 let fs = FakeFs::new(cx.executor());
1218 fs.insert_tree(
1219 path!("/dir"),
1220 json!({"file1": "lorem\n", "file2": "ipsum\n"}),
1221 )
1222 .await;
1223
1224 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1225 let file1_path = project
1226 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1227 .unwrap();
1228 let file2_path = project
1229 .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
1230 .unwrap();
1231
1232 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1233 let buffer1 = project
1234 .update(cx, |project, cx| {
1235 project.open_buffer(file1_path.clone(), cx)
1236 })
1237 .await
1238 .unwrap();
1239 let buffer2 = project
1240 .update(cx, |project, cx| {
1241 project.open_buffer(file2_path.clone(), cx)
1242 })
1243 .await
1244 .unwrap();
1245
1246 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx));
1247 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx));
1248 project
1249 .update(cx, |project, cx| {
1250 project.delete_file(file1_path.clone(), false, cx)
1251 })
1252 .unwrap()
1253 .await
1254 .unwrap();
1255 project
1256 .update(cx, |project, cx| {
1257 project.delete_file(file2_path.clone(), false, cx)
1258 })
1259 .unwrap()
1260 .await
1261 .unwrap();
1262 cx.run_until_parked();
1263 assert_eq!(
1264 unreviewed_hunks(&action_log, cx),
1265 vec![
1266 (
1267 buffer1.clone(),
1268 vec![HunkStatus {
1269 range: Point::new(0, 0)..Point::new(0, 0),
1270 diff_status: DiffHunkStatusKind::Deleted,
1271 old_text: "lorem\n".into(),
1272 }]
1273 ),
1274 (
1275 buffer2.clone(),
1276 vec![HunkStatus {
1277 range: Point::new(0, 0)..Point::new(0, 0),
1278 diff_status: DiffHunkStatusKind::Deleted,
1279 old_text: "ipsum\n".into(),
1280 }],
1281 )
1282 ]
1283 );
1284
1285 // Simulate file1 being recreated externally.
1286 fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec())
1287 .await;
1288
1289 // Simulate file2 being recreated by a tool.
1290 let buffer2 = project
1291 .update(cx, |project, cx| project.open_buffer(file2_path, cx))
1292 .await
1293 .unwrap();
1294 action_log.update(cx, |log, cx| log.buffer_created(buffer2.clone(), cx));
1295 buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx));
1296 action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx));
1297 project
1298 .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx))
1299 .await
1300 .unwrap();
1301
1302 cx.run_until_parked();
1303 assert_eq!(
1304 unreviewed_hunks(&action_log, cx),
1305 vec![(
1306 buffer2.clone(),
1307 vec![HunkStatus {
1308 range: Point::new(0, 0)..Point::new(0, 5),
1309 diff_status: DiffHunkStatusKind::Added,
1310 old_text: "".into(),
1311 }],
1312 )]
1313 );
1314
1315 // Simulate file2 being deleted externally.
1316 fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default())
1317 .await
1318 .unwrap();
1319 cx.run_until_parked();
1320 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1321 }
1322
1323 #[gpui::test(iterations = 10)]
1324 async fn test_reject_edits(cx: &mut TestAppContext) {
1325 init_test(cx);
1326
1327 let fs = FakeFs::new(cx.executor());
1328 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1329 .await;
1330 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1331 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1332 let file_path = project
1333 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1334 .unwrap();
1335 let buffer = project
1336 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1337 .await
1338 .unwrap();
1339
1340 cx.update(|cx| {
1341 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1342 buffer.update(cx, |buffer, cx| {
1343 buffer
1344 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
1345 .unwrap()
1346 });
1347 buffer.update(cx, |buffer, cx| {
1348 buffer
1349 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
1350 .unwrap()
1351 });
1352 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1353 });
1354 cx.run_until_parked();
1355 assert_eq!(
1356 buffer.read_with(cx, |buffer, _| buffer.text()),
1357 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1358 );
1359 assert_eq!(
1360 unreviewed_hunks(&action_log, cx),
1361 vec![(
1362 buffer.clone(),
1363 vec![
1364 HunkStatus {
1365 range: Point::new(1, 0)..Point::new(3, 0),
1366 diff_status: DiffHunkStatusKind::Modified,
1367 old_text: "def\n".into(),
1368 },
1369 HunkStatus {
1370 range: Point::new(5, 0)..Point::new(5, 3),
1371 diff_status: DiffHunkStatusKind::Modified,
1372 old_text: "mno".into(),
1373 }
1374 ],
1375 )]
1376 );
1377
1378 // If the rejected range doesn't overlap with any hunk, we ignore it.
1379 action_log
1380 .update(cx, |log, cx| {
1381 log.reject_edits_in_ranges(
1382 buffer.clone(),
1383 vec![Point::new(4, 0)..Point::new(4, 0)],
1384 cx,
1385 )
1386 })
1387 .await
1388 .unwrap();
1389 cx.run_until_parked();
1390 assert_eq!(
1391 buffer.read_with(cx, |buffer, _| buffer.text()),
1392 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1393 );
1394 assert_eq!(
1395 unreviewed_hunks(&action_log, cx),
1396 vec![(
1397 buffer.clone(),
1398 vec![
1399 HunkStatus {
1400 range: Point::new(1, 0)..Point::new(3, 0),
1401 diff_status: DiffHunkStatusKind::Modified,
1402 old_text: "def\n".into(),
1403 },
1404 HunkStatus {
1405 range: Point::new(5, 0)..Point::new(5, 3),
1406 diff_status: DiffHunkStatusKind::Modified,
1407 old_text: "mno".into(),
1408 }
1409 ],
1410 )]
1411 );
1412
1413 action_log
1414 .update(cx, |log, cx| {
1415 log.reject_edits_in_ranges(
1416 buffer.clone(),
1417 vec![Point::new(0, 0)..Point::new(1, 0)],
1418 cx,
1419 )
1420 })
1421 .await
1422 .unwrap();
1423 cx.run_until_parked();
1424 assert_eq!(
1425 buffer.read_with(cx, |buffer, _| buffer.text()),
1426 "abc\ndef\nghi\njkl\nmnO"
1427 );
1428 assert_eq!(
1429 unreviewed_hunks(&action_log, cx),
1430 vec![(
1431 buffer.clone(),
1432 vec![HunkStatus {
1433 range: Point::new(4, 0)..Point::new(4, 3),
1434 diff_status: DiffHunkStatusKind::Modified,
1435 old_text: "mno".into(),
1436 }],
1437 )]
1438 );
1439
1440 action_log
1441 .update(cx, |log, cx| {
1442 log.reject_edits_in_ranges(
1443 buffer.clone(),
1444 vec![Point::new(4, 0)..Point::new(4, 0)],
1445 cx,
1446 )
1447 })
1448 .await
1449 .unwrap();
1450 cx.run_until_parked();
1451 assert_eq!(
1452 buffer.read_with(cx, |buffer, _| buffer.text()),
1453 "abc\ndef\nghi\njkl\nmno"
1454 );
1455 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1456 }
1457
1458 #[gpui::test(iterations = 10)]
1459 async fn test_reject_multiple_edits(cx: &mut TestAppContext) {
1460 init_test(cx);
1461
1462 let fs = FakeFs::new(cx.executor());
1463 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1464 .await;
1465 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1466 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1467 let file_path = project
1468 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1469 .unwrap();
1470 let buffer = project
1471 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1472 .await
1473 .unwrap();
1474
1475 cx.update(|cx| {
1476 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1477 buffer.update(cx, |buffer, cx| {
1478 buffer
1479 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
1480 .unwrap()
1481 });
1482 buffer.update(cx, |buffer, cx| {
1483 buffer
1484 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
1485 .unwrap()
1486 });
1487 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1488 });
1489 cx.run_until_parked();
1490 assert_eq!(
1491 buffer.read_with(cx, |buffer, _| buffer.text()),
1492 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1493 );
1494 assert_eq!(
1495 unreviewed_hunks(&action_log, cx),
1496 vec![(
1497 buffer.clone(),
1498 vec![
1499 HunkStatus {
1500 range: Point::new(1, 0)..Point::new(3, 0),
1501 diff_status: DiffHunkStatusKind::Modified,
1502 old_text: "def\n".into(),
1503 },
1504 HunkStatus {
1505 range: Point::new(5, 0)..Point::new(5, 3),
1506 diff_status: DiffHunkStatusKind::Modified,
1507 old_text: "mno".into(),
1508 }
1509 ],
1510 )]
1511 );
1512
1513 action_log.update(cx, |log, cx| {
1514 let range_1 = buffer.read(cx).anchor_before(Point::new(0, 0))
1515 ..buffer.read(cx).anchor_before(Point::new(1, 0));
1516 let range_2 = buffer.read(cx).anchor_before(Point::new(5, 0))
1517 ..buffer.read(cx).anchor_before(Point::new(5, 3));
1518
1519 log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], cx)
1520 .detach();
1521 assert_eq!(
1522 buffer.read_with(cx, |buffer, _| buffer.text()),
1523 "abc\ndef\nghi\njkl\nmno"
1524 );
1525 });
1526 cx.run_until_parked();
1527 assert_eq!(
1528 buffer.read_with(cx, |buffer, _| buffer.text()),
1529 "abc\ndef\nghi\njkl\nmno"
1530 );
1531 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1532 }
1533
1534 #[gpui::test(iterations = 10)]
1535 async fn test_reject_deleted_file(cx: &mut TestAppContext) {
1536 init_test(cx);
1537
1538 let fs = FakeFs::new(cx.executor());
1539 fs.insert_tree(path!("/dir"), json!({"file": "content"}))
1540 .await;
1541 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1542 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1543 let file_path = project
1544 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1545 .unwrap();
1546 let buffer = project
1547 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
1548 .await
1549 .unwrap();
1550
1551 cx.update(|cx| {
1552 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
1553 });
1554 project
1555 .update(cx, |project, cx| {
1556 project.delete_file(file_path.clone(), false, cx)
1557 })
1558 .unwrap()
1559 .await
1560 .unwrap();
1561 cx.run_until_parked();
1562 assert!(!fs.is_file(path!("/dir/file").as_ref()).await);
1563 assert_eq!(
1564 unreviewed_hunks(&action_log, cx),
1565 vec![(
1566 buffer.clone(),
1567 vec![HunkStatus {
1568 range: Point::new(0, 0)..Point::new(0, 0),
1569 diff_status: DiffHunkStatusKind::Deleted,
1570 old_text: "content".into(),
1571 }]
1572 )]
1573 );
1574
1575 action_log
1576 .update(cx, |log, cx| {
1577 log.reject_edits_in_ranges(
1578 buffer.clone(),
1579 vec![Point::new(0, 0)..Point::new(0, 0)],
1580 cx,
1581 )
1582 })
1583 .await
1584 .unwrap();
1585 cx.run_until_parked();
1586 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content");
1587 assert!(fs.is_file(path!("/dir/file").as_ref()).await);
1588 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1589 }
1590
1591 #[gpui::test(iterations = 10)]
1592 async fn test_reject_created_file(cx: &mut TestAppContext) {
1593 init_test(cx);
1594
1595 let fs = FakeFs::new(cx.executor());
1596 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1597 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1598 let file_path = project
1599 .read_with(cx, |project, cx| {
1600 project.find_project_path("dir/new_file", cx)
1601 })
1602 .unwrap();
1603 let buffer = project
1604 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1605 .await
1606 .unwrap();
1607 cx.update(|cx| {
1608 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1609 buffer.update(cx, |buffer, cx| buffer.set_text("content", cx));
1610 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1611 });
1612 project
1613 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1614 .await
1615 .unwrap();
1616 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
1617 cx.run_until_parked();
1618 assert_eq!(
1619 unreviewed_hunks(&action_log, cx),
1620 vec![(
1621 buffer.clone(),
1622 vec![HunkStatus {
1623 range: Point::new(0, 0)..Point::new(0, 7),
1624 diff_status: DiffHunkStatusKind::Added,
1625 old_text: "".into(),
1626 }],
1627 )]
1628 );
1629
1630 action_log
1631 .update(cx, |log, cx| {
1632 log.reject_edits_in_ranges(
1633 buffer.clone(),
1634 vec![Point::new(0, 0)..Point::new(0, 11)],
1635 cx,
1636 )
1637 })
1638 .await
1639 .unwrap();
1640 cx.run_until_parked();
1641 assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await);
1642 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1643 }
1644
1645 #[gpui::test]
1646 async fn test_reject_created_file_with_user_edits(cx: &mut TestAppContext) {
1647 init_test(cx);
1648
1649 let fs = FakeFs::new(cx.executor());
1650 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1651 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1652
1653 let file_path = project
1654 .read_with(cx, |project, cx| {
1655 project.find_project_path("dir/new_file", cx)
1656 })
1657 .unwrap();
1658 let buffer = project
1659 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1660 .await
1661 .unwrap();
1662
1663 // AI creates file with initial content
1664 cx.update(|cx| {
1665 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1666 buffer.update(cx, |buffer, cx| buffer.set_text("ai content", cx));
1667 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1668 });
1669
1670 project
1671 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1672 .await
1673 .unwrap();
1674
1675 cx.run_until_parked();
1676
1677 // User makes additional edits
1678 cx.update(|cx| {
1679 buffer.update(cx, |buffer, cx| {
1680 buffer.edit([(10..10, "\nuser added this line")], None, cx);
1681 });
1682 });
1683
1684 project
1685 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1686 .await
1687 .unwrap();
1688
1689 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
1690
1691 // Reject all
1692 action_log
1693 .update(cx, |log, cx| {
1694 log.reject_edits_in_ranges(
1695 buffer.clone(),
1696 vec![Point::new(0, 0)..Point::new(100, 0)],
1697 cx,
1698 )
1699 })
1700 .await
1701 .unwrap();
1702 cx.run_until_parked();
1703
1704 // File should still contain all the content
1705 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
1706
1707 let content = buffer.read_with(cx, |buffer, _| buffer.text());
1708 assert_eq!(content, "ai content\nuser added this line");
1709 }
1710
1711 #[gpui::test(iterations = 100)]
1712 async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
1713 init_test(cx);
1714
1715 let operations = env::var("OPERATIONS")
1716 .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
1717 .unwrap_or(20);
1718
1719 let text = RandomCharIter::new(&mut rng).take(50).collect::<String>();
1720 let fs = FakeFs::new(cx.executor());
1721 fs.insert_tree(path!("/dir"), json!({"file": text})).await;
1722 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1723 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1724 let file_path = project
1725 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1726 .unwrap();
1727 let buffer = project
1728 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1729 .await
1730 .unwrap();
1731
1732 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1733
1734 for _ in 0..operations {
1735 match rng.gen_range(0..100) {
1736 0..25 => {
1737 action_log.update(cx, |log, cx| {
1738 let range = buffer.read(cx).random_byte_range(0, &mut rng);
1739 log::info!("keeping edits in range {:?}", range);
1740 log.keep_edits_in_range(buffer.clone(), range, cx)
1741 });
1742 }
1743 25..50 => {
1744 action_log
1745 .update(cx, |log, cx| {
1746 let range = buffer.read(cx).random_byte_range(0, &mut rng);
1747 log::info!("rejecting edits in range {:?}", range);
1748 log.reject_edits_in_ranges(buffer.clone(), vec![range], cx)
1749 })
1750 .await
1751 .unwrap();
1752 }
1753 _ => {
1754 let is_agent_change = rng.gen_bool(0.5);
1755 if is_agent_change {
1756 log::info!("agent edit");
1757 } else {
1758 log::info!("user edit");
1759 }
1760 cx.update(|cx| {
1761 buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
1762 if is_agent_change {
1763 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1764 }
1765 });
1766 }
1767 }
1768
1769 if rng.gen_bool(0.2) {
1770 quiesce(&action_log, &buffer, cx);
1771 }
1772 }
1773
1774 quiesce(&action_log, &buffer, cx);
1775
1776 fn quiesce(
1777 action_log: &Entity<ActionLog>,
1778 buffer: &Entity<Buffer>,
1779 cx: &mut TestAppContext,
1780 ) {
1781 log::info!("quiescing...");
1782 cx.run_until_parked();
1783 action_log.update(cx, |log, cx| {
1784 let tracked_buffer = log.tracked_buffers.get(&buffer).unwrap();
1785 let mut old_text = tracked_buffer.diff_base.clone();
1786 let new_text = buffer.read(cx).as_rope();
1787 for edit in tracked_buffer.unreviewed_changes.edits() {
1788 let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
1789 let old_end = old_text.point_to_offset(cmp::min(
1790 Point::new(edit.new.start + edit.old_len(), 0),
1791 old_text.max_point(),
1792 ));
1793 old_text.replace(
1794 old_start..old_end,
1795 &new_text.slice_rows(edit.new.clone()).to_string(),
1796 );
1797 }
1798 pretty_assertions::assert_eq!(old_text.to_string(), new_text.to_string());
1799 })
1800 }
1801 }
1802
1803 #[derive(Debug, Clone, PartialEq, Eq)]
1804 struct HunkStatus {
1805 range: Range<Point>,
1806 diff_status: DiffHunkStatusKind,
1807 old_text: String,
1808 }
1809
1810 fn unreviewed_hunks(
1811 action_log: &Entity<ActionLog>,
1812 cx: &TestAppContext,
1813 ) -> Vec<(Entity<Buffer>, Vec<HunkStatus>)> {
1814 cx.read(|cx| {
1815 action_log
1816 .read(cx)
1817 .changed_buffers(cx)
1818 .into_iter()
1819 .map(|(buffer, diff)| {
1820 let snapshot = buffer.read(cx).snapshot();
1821 (
1822 buffer,
1823 diff.read(cx)
1824 .hunks(&snapshot, cx)
1825 .map(|hunk| HunkStatus {
1826 diff_status: hunk.status().kind,
1827 range: hunk.range,
1828 old_text: diff
1829 .read(cx)
1830 .base_text()
1831 .text_for_range(hunk.diff_base_byte_range)
1832 .collect(),
1833 })
1834 .collect(),
1835 )
1836 })
1837 .collect()
1838 })
1839 }
1840}