Skip to repository content3495 lines · 129.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:03:50.052Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
action_log.rs
1use anyhow::{Context as _, Result};
2use buffer_diff::BufferDiff;
3use clock;
4use collections::{BTreeMap, HashMap};
5use fs::MTime;
6use futures::{FutureExt, StreamExt, channel::mpsc};
7use gpui::{
8 App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
9};
10use language::{Anchor, Buffer, BufferEvent, Point, ToOffset, ToPoint};
11use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle};
12use std::{
13 cmp,
14 ops::Range,
15 path::{Path, PathBuf},
16 sync::Arc,
17};
18use text::{Edit, Patch, Rope};
19use util::{RangeExt, ResultExt as _};
20
21/// Stores undo information for a single buffer's rejected edits
22#[derive(Clone)]
23pub struct PerBufferUndo {
24 pub buffer: WeakEntity<Buffer>,
25 pub edits_to_restore: Vec<(Range<Anchor>, String)>,
26 pub status: UndoBufferStatus,
27}
28
29/// Tracks the buffer status for undo purposes
30#[derive(Clone, Debug)]
31pub enum UndoBufferStatus {
32 Modified,
33 /// Buffer was created by the agent.
34 /// - `had_existing_content: true` - Agent overwrote an existing file. On reject, the
35 /// original content was restored. Undo is supported: we restore the agent's content.
36 /// - `had_existing_content: false` - Agent created a new file that didn't exist before.
37 /// On reject, the file was deleted. Undo is NOT currently supported (would require
38 /// recreating the file). Future TODO.
39 Created {
40 had_existing_content: bool,
41 },
42}
43
44/// Stores undo information for the most recent reject operation
45#[derive(Clone)]
46pub struct LastRejectUndo {
47 /// Per-buffer undo information
48 pub buffers: Vec<PerBufferUndo>,
49}
50
51/// Tracks actions performed by tools in a thread
52pub struct ActionLog {
53 /// Buffers that we want to notify the model about when they change.
54 tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
55 /// The project this action log is associated with
56 project: Entity<Project>,
57 /// An action log to forward all public methods to
58 /// Useful in cases like subagents, where we want to track individual diffs for this subagent,
59 /// but also want to associate the reads/writes with a parent review experience
60 linked_action_log: Option<Entity<ActionLog>>,
61 /// Stores undo information for the most recent reject operation
62 last_reject_undo: Option<LastRejectUndo>,
63 /// Tracks the last time files were read by the agent, to detect external modifications
64 file_read_times: HashMap<PathBuf, MTime>,
65}
66
67impl ActionLog {
68 /// Creates a new, empty action log associated with the given project.
69 pub fn new(project: Entity<Project>) -> Self {
70 Self {
71 tracked_buffers: BTreeMap::default(),
72 project,
73 linked_action_log: None,
74 last_reject_undo: None,
75 file_read_times: HashMap::default(),
76 }
77 }
78
79 pub fn with_linked_action_log(mut self, linked_action_log: Entity<ActionLog>) -> Self {
80 self.linked_action_log = Some(linked_action_log);
81 self
82 }
83
84 pub fn project(&self) -> &Entity<Project> {
85 &self.project
86 }
87
88 pub fn file_read_time(&self, path: &Path) -> Option<MTime> {
89 self.file_read_times.get(path).copied()
90 }
91
92 fn update_file_read_time(&mut self, buffer: &Entity<Buffer>, cx: &App) {
93 let buffer = buffer.read(cx);
94 if let Some(file) = buffer.file() {
95 if let Some(local_file) = file.as_local() {
96 if let Some(mtime) = file.disk_state().mtime() {
97 let abs_path = local_file.abs_path(cx);
98 self.file_read_times.insert(abs_path, mtime);
99 }
100 }
101 }
102 }
103
104 fn remove_file_read_time(&mut self, buffer: &Entity<Buffer>, cx: &App) {
105 let buffer = buffer.read(cx);
106 if let Some(file) = buffer.file() {
107 if let Some(local_file) = file.as_local() {
108 let abs_path = local_file.abs_path(cx);
109 self.file_read_times.remove(&abs_path);
110 }
111 }
112 }
113
114 fn track_buffer_internal(
115 &mut self,
116 buffer: Entity<Buffer>,
117 is_created: bool,
118 cx: &mut Context<Self>,
119 ) -> &mut TrackedBuffer {
120 let status = if is_created {
121 if let Some(tracked) = self.tracked_buffers.remove(&buffer) {
122 match tracked.status {
123 TrackedBufferStatus::Created {
124 existing_file_content,
125 } => TrackedBufferStatus::Created {
126 existing_file_content,
127 },
128 TrackedBufferStatus::Modified | TrackedBufferStatus::Deleted => {
129 TrackedBufferStatus::Created {
130 existing_file_content: Some(tracked.diff_base),
131 }
132 }
133 }
134 } else if buffer
135 .read(cx)
136 .file()
137 .is_some_and(|file| file.disk_state().exists())
138 {
139 TrackedBufferStatus::Created {
140 existing_file_content: Some(buffer.read(cx).as_rope().clone()),
141 }
142 } else {
143 TrackedBufferStatus::Created {
144 existing_file_content: None,
145 }
146 }
147 } else {
148 TrackedBufferStatus::Modified
149 };
150
151 let tracked_buffer = self
152 .tracked_buffers
153 .entry(buffer.clone())
154 .or_insert_with(|| {
155 let open_lsp_handle = self.project.update(cx, |project, cx| {
156 project.register_buffer_with_language_servers(&buffer, cx)
157 });
158
159 let text_snapshot = buffer.read(cx).text_snapshot();
160 let language = buffer.read(cx).language().cloned();
161 let language_registry = buffer.read(cx).language_registry();
162 let diff =
163 cx.new(|cx| BufferDiff::new(&text_snapshot, language, language_registry, cx));
164 let (diff_update_tx, diff_update_rx) = mpsc::unbounded();
165 let diff_base;
166 let unreviewed_edits;
167 if is_created {
168 diff_base = Rope::default();
169 unreviewed_edits = Patch::new(vec![Edit {
170 old: 0..1,
171 new: 0..text_snapshot.max_point().row + 1,
172 }])
173 } else {
174 diff_base = buffer.read(cx).as_rope().clone();
175 unreviewed_edits = Patch::default();
176 }
177 TrackedBuffer {
178 buffer: buffer.clone(),
179 diff_base,
180 unreviewed_edits,
181 snapshot: text_snapshot,
182 status,
183 version: buffer.read(cx).version(),
184 diff,
185 diff_update: diff_update_tx,
186 _open_lsp_handle: open_lsp_handle,
187 _maintain_diff: cx.spawn({
188 let buffer = buffer.clone();
189 async move |this, cx| {
190 Self::maintain_diff(this, buffer, diff_update_rx, cx)
191 .await
192 .ok();
193 }
194 }),
195 _subscription: cx.subscribe(&buffer, Self::handle_buffer_event),
196 }
197 });
198 tracked_buffer.version = buffer.read(cx).version();
199 tracked_buffer
200 }
201
202 fn handle_buffer_event(
203 &mut self,
204 buffer: Entity<Buffer>,
205 event: &BufferEvent,
206 cx: &mut Context<Self>,
207 ) {
208 match event {
209 BufferEvent::Edited { .. } => {
210 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
211 return;
212 };
213 let buffer_version = buffer.read(cx).version();
214 if !buffer_version.changed_since(&tracked_buffer.version) {
215 return;
216 }
217 self.handle_buffer_edited(buffer, cx);
218 }
219 BufferEvent::FileHandleChanged => {
220 self.handle_buffer_file_changed(buffer, cx);
221 }
222 _ => {}
223 };
224 }
225
226 fn handle_buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
227 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
228 return;
229 };
230 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
231 }
232
233 fn handle_buffer_file_changed(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
234 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
235 return;
236 };
237
238 match tracked_buffer.status {
239 TrackedBufferStatus::Created { .. } | TrackedBufferStatus::Modified => {
240 if buffer
241 .read(cx)
242 .file()
243 .is_some_and(|file| file.disk_state().is_deleted())
244 {
245 // If the buffer had been edited by a tool, but it got
246 // deleted externally, we want to stop tracking it.
247 self.tracked_buffers.remove(&buffer);
248 }
249 cx.notify();
250 }
251 TrackedBufferStatus::Deleted => {
252 if buffer
253 .read(cx)
254 .file()
255 .is_some_and(|file| !file.disk_state().is_deleted())
256 {
257 // If the buffer had been deleted by a tool, but it got
258 // resurrected externally, we want to clear the edits we
259 // were tracking and reset the buffer's state.
260 self.tracked_buffers.remove(&buffer);
261 self.track_buffer_internal(buffer, false, cx);
262 }
263 cx.notify();
264 }
265 }
266 }
267
268 async fn maintain_diff(
269 this: WeakEntity<Self>,
270 buffer: Entity<Buffer>,
271 mut buffer_updates: mpsc::UnboundedReceiver<(ChangeAuthor, text::BufferSnapshot)>,
272 cx: &mut AsyncApp,
273 ) -> Result<()> {
274 let git_diff = this
275 .update(cx, |this, cx| {
276 this.project.update(cx, |project, cx| {
277 project.open_uncommitted_diff(buffer.clone(), cx)
278 })
279 })?
280 .await
281 .ok();
282 let (mut git_diff_updates_tx, mut git_diff_updates_rx) = watch::channel(());
283 let _diff_subscription = if let Some(git_diff) = git_diff.as_ref() {
284 cx.update(|cx| {
285 Some(cx.subscribe(git_diff, move |_, event, _cx| {
286 if matches!(event, buffer_diff::BufferDiffEvent::BaseTextChanged) {
287 git_diff_updates_tx.send(()).ok();
288 }
289 }))
290 })
291 } else {
292 None
293 };
294
295 loop {
296 futures::select_biased! {
297 buffer_update = buffer_updates.next() => {
298 if let Some((author, buffer_snapshot)) = buffer_update {
299 Self::track_edits(&this, &buffer, author, buffer_snapshot, cx).await?;
300 } else {
301 break;
302 }
303 }
304 _ = git_diff_updates_rx.changed().fuse() => {
305 if let Some(git_diff) = git_diff.as_ref() {
306 Self::keep_committed_edits(&this, &buffer, git_diff, cx).await?;
307 }
308 }
309 }
310 }
311
312 Ok(())
313 }
314
315 async fn track_edits(
316 this: &WeakEntity<ActionLog>,
317 buffer: &Entity<Buffer>,
318 author: ChangeAuthor,
319 buffer_snapshot: text::BufferSnapshot,
320 cx: &mut AsyncApp,
321 ) -> Result<()> {
322 let rebase = this.update(cx, |this, cx| {
323 let tracked_buffer = this
324 .tracked_buffers
325 .get_mut(buffer)
326 .context("buffer not tracked")?;
327
328 let rebase = cx.background_spawn({
329 let mut base_text = tracked_buffer.diff_base.clone();
330 let old_snapshot = tracked_buffer.snapshot.clone();
331 let new_snapshot = buffer_snapshot.clone();
332 let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
333 let edits = diff_snapshots(&old_snapshot, &new_snapshot);
334 async move {
335 if let ChangeAuthor::User = author {
336 apply_non_conflicting_edits(
337 &unreviewed_edits,
338 edits,
339 &mut base_text,
340 new_snapshot.as_rope(),
341 );
342 }
343
344 (Arc::from(base_text.to_string().as_str()), base_text)
345 }
346 });
347
348 anyhow::Ok(rebase)
349 })??;
350 let (new_base_text, new_diff_base) = rebase.await;
351
352 Self::update_diff(
353 this,
354 buffer,
355 buffer_snapshot,
356 new_base_text,
357 new_diff_base,
358 cx,
359 )
360 .await
361 }
362
363 async fn keep_committed_edits(
364 this: &WeakEntity<ActionLog>,
365 buffer: &Entity<Buffer>,
366 git_diff: &Entity<BufferDiff>,
367 cx: &mut AsyncApp,
368 ) -> Result<()> {
369 let buffer_snapshot = this.read_with(cx, |this, _cx| {
370 let tracked_buffer = this
371 .tracked_buffers
372 .get(buffer)
373 .context("buffer not tracked")?;
374 anyhow::Ok(tracked_buffer.snapshot.clone())
375 })??;
376 let (new_base_text, new_diff_base) = this
377 .read_with(cx, |this, cx| {
378 let tracked_buffer = this
379 .tracked_buffers
380 .get(buffer)
381 .context("buffer not tracked")?;
382 let old_unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
383 let agent_diff_base = tracked_buffer.diff_base.clone();
384 let git_diff_base = git_diff.read(cx).base_text(cx).as_rope().clone();
385 let buffer_text = tracked_buffer.snapshot.as_rope().clone();
386 anyhow::Ok(cx.background_spawn(async move {
387 if buffer_text.len() == git_diff_base.len()
388 && buffer_text.chars_at(0).eq(git_diff_base.chars_at(0))
389 {
390 return (Arc::<str>::from(git_diff_base.to_string()), git_diff_base);
391 }
392 let mut old_unreviewed_edits = old_unreviewed_edits.into_iter().peekable();
393 let committed_edits = language::line_diff(
394 &agent_diff_base.to_string(),
395 &git_diff_base.to_string(),
396 )
397 .into_iter()
398 .map(|(old, new)| Edit { old, new });
399
400 let mut new_agent_diff_base = agent_diff_base.clone();
401 let mut row_delta = 0i32;
402 for committed in committed_edits {
403 while let Some(unreviewed) = old_unreviewed_edits.peek() {
404 // If the committed edit matches the unreviewed
405 // edit, assume the user wants to keep it.
406 if committed.old == unreviewed.old {
407 let unreviewed_new =
408 buffer_text.slice_rows(unreviewed.new.clone()).to_string();
409 let committed_new =
410 git_diff_base.slice_rows(committed.new.clone()).to_string();
411 if unreviewed_new == committed_new {
412 let old_byte_start =
413 new_agent_diff_base.point_to_offset(Point::new(
414 (unreviewed.old.start as i32 + row_delta) as u32,
415 0,
416 ));
417 let old_byte_end =
418 new_agent_diff_base.point_to_offset(cmp::min(
419 Point::new(
420 (unreviewed.old.end as i32 + row_delta) as u32,
421 0,
422 ),
423 new_agent_diff_base.max_point(),
424 ));
425 new_agent_diff_base
426 .replace(old_byte_start..old_byte_end, &unreviewed_new);
427 row_delta +=
428 unreviewed.new_len() as i32 - unreviewed.old_len() as i32;
429 }
430 } else if unreviewed.old.start >= committed.old.end {
431 break;
432 }
433
434 old_unreviewed_edits.next().unwrap();
435 }
436 }
437
438 (
439 Arc::from(new_agent_diff_base.to_string().as_str()),
440 new_agent_diff_base,
441 )
442 }))
443 })??
444 .await;
445
446 Self::update_diff(
447 this,
448 buffer,
449 buffer_snapshot,
450 new_base_text,
451 new_diff_base,
452 cx,
453 )
454 .await
455 }
456
457 async fn update_diff(
458 this: &WeakEntity<ActionLog>,
459 buffer: &Entity<Buffer>,
460 buffer_snapshot: text::BufferSnapshot,
461 new_base_text: Arc<str>,
462 new_diff_base: Rope,
463 cx: &mut AsyncApp,
464 ) -> Result<()> {
465 let diff = this.read_with(cx, |this, _cx| {
466 let tracked_buffer = this
467 .tracked_buffers
468 .get(buffer)
469 .context("buffer not tracked")?;
470 anyhow::Ok(tracked_buffer.diff.clone())
471 })??;
472 diff.update(cx, |diff, cx| {
473 diff.set_base_text(Some(new_base_text), buffer_snapshot.clone(), cx)
474 })
475 .await;
476 let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx));
477
478 let unreviewed_edits = cx
479 .background_spawn({
480 let buffer_snapshot = buffer_snapshot.clone();
481 let new_diff_base = new_diff_base.clone();
482 async move {
483 let mut unreviewed_edits = Patch::default();
484 for hunk in diff_snapshot.hunks_intersecting_range(
485 Anchor::min_for_buffer(buffer_snapshot.remote_id())
486 ..Anchor::max_for_buffer(buffer_snapshot.remote_id()),
487 &buffer_snapshot,
488 ) {
489 let old_range = new_diff_base
490 .offset_to_point(hunk.diff_base_byte_range.start)
491 ..new_diff_base.offset_to_point(hunk.diff_base_byte_range.end);
492 let new_range = hunk.range.start..hunk.range.end;
493 unreviewed_edits.push(point_to_row_edit(
494 Edit {
495 old: old_range,
496 new: new_range,
497 },
498 &new_diff_base,
499 buffer_snapshot.as_rope(),
500 ));
501 }
502 unreviewed_edits
503 }
504 })
505 .await;
506 this.update(cx, |this, cx| {
507 let tracked_buffer = this
508 .tracked_buffers
509 .get_mut(buffer)
510 .context("buffer not tracked")?;
511 tracked_buffer.diff_base = new_diff_base;
512 tracked_buffer.snapshot = buffer_snapshot;
513 tracked_buffer.unreviewed_edits = unreviewed_edits;
514 cx.notify();
515 anyhow::Ok(())
516 })?
517 }
518
519 /// Track a buffer as read by agent, so we can notify the model about user edits.
520 pub fn buffer_read(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
521 self.buffer_read_impl(buffer, true, cx);
522 }
523
524 fn buffer_read_impl(
525 &mut self,
526 buffer: Entity<Buffer>,
527 record_file_read_time: bool,
528 cx: &mut Context<Self>,
529 ) {
530 if let Some(linked_action_log) = &self.linked_action_log {
531 // We don't want to share read times since the other agent hasn't read it necessarily
532 linked_action_log.update(cx, |log, cx| {
533 log.buffer_read_impl(buffer.clone(), false, cx);
534 });
535 }
536 if record_file_read_time {
537 self.update_file_read_time(&buffer, cx);
538 }
539 self.track_buffer_internal(buffer, false, cx);
540 }
541
542 /// Mark a buffer as created by agent, so we can refresh it in the context
543 pub fn buffer_created(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
544 self.buffer_created_impl(buffer, true, cx);
545 }
546
547 fn buffer_created_impl(
548 &mut self,
549 buffer: Entity<Buffer>,
550 record_file_read_time: bool,
551 cx: &mut Context<Self>,
552 ) {
553 if let Some(linked_action_log) = &self.linked_action_log {
554 // We don't want to share read times since the other agent hasn't read it necessarily
555 linked_action_log.update(cx, |log, cx| {
556 log.buffer_created_impl(buffer.clone(), false, cx);
557 });
558 }
559 if record_file_read_time {
560 self.update_file_read_time(&buffer, cx);
561 }
562 self.track_buffer_internal(buffer, true, cx);
563 }
564
565 /// Mark a buffer as edited by agent, so we can refresh it in the context
566 pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
567 self.buffer_edited_impl(buffer, true, cx);
568 }
569
570 fn buffer_edited_impl(
571 &mut self,
572 buffer: Entity<Buffer>,
573 record_file_read_time: bool,
574 cx: &mut Context<Self>,
575 ) {
576 if let Some(linked_action_log) = &self.linked_action_log {
577 // We don't want to share read times since the other agent hasn't read it necessarily
578 linked_action_log.update(cx, |log, cx| {
579 log.buffer_edited_impl(buffer.clone(), false, cx);
580 });
581 }
582 if record_file_read_time {
583 self.update_file_read_time(&buffer, cx);
584 }
585 let new_version = buffer.read(cx).version();
586 let tracked_buffer = self.track_buffer_internal(buffer, false, cx);
587 if let TrackedBufferStatus::Deleted = tracked_buffer.status {
588 tracked_buffer.status = TrackedBufferStatus::Modified;
589 }
590
591 tracked_buffer.version = new_version;
592 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
593 }
594
595 pub fn will_delete_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
596 // Ok to propagate file read time removal to linked action log
597 self.remove_file_read_time(&buffer, cx);
598 let has_linked_action_log = self.linked_action_log.is_some();
599 let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx);
600 match tracked_buffer.status {
601 TrackedBufferStatus::Created { .. } => {
602 self.tracked_buffers.remove(&buffer);
603 cx.notify();
604 }
605 TrackedBufferStatus::Modified => {
606 tracked_buffer.status = TrackedBufferStatus::Deleted;
607 if !has_linked_action_log {
608 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
609 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
610 }
611 }
612
613 TrackedBufferStatus::Deleted => {}
614 }
615
616 if let Some(linked_action_log) = &mut self.linked_action_log {
617 linked_action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
618 }
619
620 if has_linked_action_log && let Some(tracked_buffer) = self.tracked_buffers.get(&buffer) {
621 tracked_buffer.schedule_diff_update(ChangeAuthor::Agent, cx);
622 }
623
624 cx.notify();
625 }
626
627 pub fn keep_edits_in_range(
628 &mut self,
629 buffer: Entity<Buffer>,
630 buffer_range: Range<impl language::ToPoint>,
631 telemetry: Option<ActionLogTelemetry>,
632 cx: &mut Context<Self>,
633 ) {
634 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
635 return;
636 };
637
638 let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx));
639 match tracked_buffer.status {
640 TrackedBufferStatus::Deleted => {
641 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
642 self.tracked_buffers.remove(&buffer);
643 cx.notify();
644 }
645 _ => {
646 let buffer = buffer.read(cx);
647 let buffer_range =
648 buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
649 let mut delta = 0i32;
650 tracked_buffer.unreviewed_edits.retain_mut(|edit| {
651 edit.old.start = (edit.old.start as i32 + delta) as u32;
652 edit.old.end = (edit.old.end as i32 + delta) as u32;
653
654 if buffer_range.end.row < edit.new.start
655 || buffer_range.start.row > edit.new.end
656 {
657 true
658 } else {
659 let old_range = tracked_buffer
660 .diff_base
661 .point_to_offset(Point::new(edit.old.start, 0))
662 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
663 Point::new(edit.old.end, 0),
664 tracked_buffer.diff_base.max_point(),
665 ));
666 let new_range = tracked_buffer
667 .snapshot
668 .point_to_offset(Point::new(edit.new.start, 0))
669 ..tracked_buffer.snapshot.point_to_offset(cmp::min(
670 Point::new(edit.new.end, 0),
671 tracked_buffer.snapshot.max_point(),
672 ));
673 tracked_buffer.diff_base.replace(
674 old_range,
675 &tracked_buffer
676 .snapshot
677 .text_for_range(new_range)
678 .collect::<String>(),
679 );
680 delta += edit.new_len() as i32 - edit.old_len() as i32;
681 metrics.add_edit(edit);
682 false
683 }
684 });
685 if tracked_buffer.unreviewed_edits.is_empty()
686 && let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status
687 {
688 tracked_buffer.status = TrackedBufferStatus::Modified;
689 }
690 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
691 }
692 }
693 if let Some(telemetry) = telemetry {
694 telemetry_report_accepted_edits(&telemetry, metrics);
695 }
696 }
697
698 pub fn reject_edits_in_ranges(
699 &mut self,
700 buffer: Entity<Buffer>,
701 buffer_ranges: Vec<Range<impl language::ToPoint>>,
702 telemetry: Option<ActionLogTelemetry>,
703 cx: &mut Context<Self>,
704 ) -> (Task<Result<()>>, Option<PerBufferUndo>) {
705 let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
706 return (Task::ready(Ok(())), None);
707 };
708
709 let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx));
710 let mut undo_info: Option<PerBufferUndo> = None;
711 let task = match &tracked_buffer.status {
712 TrackedBufferStatus::Created {
713 existing_file_content,
714 } => {
715 let task = if let Some(existing_file_content) = existing_file_content {
716 // Capture the agent's content before restoring existing file content
717 let agent_content = buffer.read(cx).text();
718 let buffer_id = buffer.read(cx).remote_id();
719
720 buffer.update(cx, |buffer, cx| {
721 buffer.start_transaction();
722 buffer.set_text("", cx);
723 for chunk in existing_file_content.chunks() {
724 buffer.append(chunk, cx);
725 }
726 buffer.end_transaction(cx);
727 });
728
729 undo_info = Some(PerBufferUndo {
730 buffer: buffer.downgrade(),
731 edits_to_restore: vec![(
732 Anchor::min_for_buffer(buffer_id)..Anchor::max_for_buffer(buffer_id),
733 agent_content,
734 )],
735 status: UndoBufferStatus::Created {
736 had_existing_content: true,
737 },
738 });
739
740 self.project
741 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
742 } else {
743 // For a file created by AI with no pre-existing content,
744 // only delete the file if we're certain it contains only AI content
745 // with no edits from the user.
746
747 let initial_version = tracked_buffer.version.clone();
748 let current_version = buffer.read(cx).version();
749
750 let current_content = buffer.read(cx).text();
751 let tracked_content = tracked_buffer.snapshot.text();
752
753 let is_ai_only_content =
754 initial_version == current_version && current_content == tracked_content;
755
756 if is_ai_only_content {
757 let task = buffer
758 .read(cx)
759 .entry_id(cx)
760 .and_then(|entry_id| {
761 self.project
762 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
763 })
764 .unwrap_or_else(|| Task::ready(Ok(())));
765
766 cx.background_spawn(async move {
767 task.await?;
768 Ok(())
769 })
770 } else {
771 // Not sure how to disentangle edits made by the user
772 // from edits made by the AI at this point.
773 // For now, preserve both to avoid data loss.
774 //
775 // TODO: Better solution (disable "Reject" after user makes some
776 // edit or find a way to differentiate between AI and user edits)
777 Task::ready(Ok(()))
778 }
779 };
780
781 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
782 self.tracked_buffers.remove(&buffer);
783 cx.notify();
784 task
785 }
786 TrackedBufferStatus::Deleted => {
787 buffer.update(cx, |buffer, cx| {
788 buffer.set_text(tracked_buffer.diff_base.to_string(), cx)
789 });
790 let save = self
791 .project
792 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
793
794 // Clear all tracked edits for this buffer and start over as if we just read it.
795 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
796 self.tracked_buffers.remove(&buffer);
797 self.buffer_read(buffer.clone(), cx);
798 cx.notify();
799 save
800 }
801 TrackedBufferStatus::Modified => {
802 let edits_to_restore = buffer.update(cx, |buffer, cx| {
803 let mut buffer_row_ranges = buffer_ranges
804 .into_iter()
805 .map(|range| {
806 range.start.to_point(buffer).row..range.end.to_point(buffer).row
807 })
808 .peekable();
809
810 let mut edits_to_revert = Vec::new();
811 let mut edits_for_undo = Vec::new();
812 for edit in tracked_buffer.unreviewed_edits.edits() {
813 let new_range = tracked_buffer
814 .snapshot
815 .anchor_before(Point::new(edit.new.start, 0))
816 ..tracked_buffer.snapshot.anchor_after(cmp::min(
817 Point::new(edit.new.end, 0),
818 tracked_buffer.snapshot.max_point(),
819 ));
820 let new_row_range = new_range.start.to_point(buffer).row
821 ..new_range.end.to_point(buffer).row;
822
823 let mut revert = false;
824 while let Some(buffer_row_range) = buffer_row_ranges.peek() {
825 if buffer_row_range.end < new_row_range.start {
826 buffer_row_ranges.next();
827 } else if buffer_row_range.start > new_row_range.end {
828 break;
829 } else {
830 revert = true;
831 break;
832 }
833 }
834
835 if revert {
836 metrics.add_edit(edit);
837 let old_range = tracked_buffer
838 .diff_base
839 .point_to_offset(Point::new(edit.old.start, 0))
840 ..tracked_buffer.diff_base.point_to_offset(cmp::min(
841 Point::new(edit.old.end, 0),
842 tracked_buffer.diff_base.max_point(),
843 ));
844 let old_text = tracked_buffer
845 .diff_base
846 .chunks_in_range(old_range)
847 .collect::<String>();
848
849 // Capture the agent's text before we revert it (for undo)
850 let new_range_offset =
851 new_range.start.to_offset(buffer)..new_range.end.to_offset(buffer);
852 let agent_text =
853 buffer.text_for_range(new_range_offset).collect::<String>();
854 edits_for_undo.push((new_range.clone(), agent_text));
855
856 edits_to_revert.push((new_range, old_text));
857 }
858 }
859
860 buffer.edit(edits_to_revert, None, cx);
861 edits_for_undo
862 });
863
864 if !edits_to_restore.is_empty() {
865 undo_info = Some(PerBufferUndo {
866 buffer: buffer.downgrade(),
867 edits_to_restore,
868 status: UndoBufferStatus::Modified,
869 });
870 }
871
872 self.project
873 .update(cx, |project, cx| project.save_buffer(buffer, cx))
874 }
875 };
876 if let Some(telemetry) = telemetry {
877 telemetry_report_rejected_edits(&telemetry, metrics);
878 }
879 (task, undo_info)
880 }
881
882 pub fn keep_all_edits(
883 &mut self,
884 telemetry: Option<ActionLogTelemetry>,
885 cx: &mut Context<Self>,
886 ) {
887 self.tracked_buffers.retain(|buffer, tracked_buffer| {
888 let mut metrics = ActionLogMetrics::for_buffer(buffer.read(cx));
889 metrics.add_edits(tracked_buffer.unreviewed_edits.edits());
890 if let Some(telemetry) = telemetry.as_ref() {
891 telemetry_report_accepted_edits(telemetry, metrics);
892 }
893 match tracked_buffer.status {
894 TrackedBufferStatus::Deleted => false,
895 _ => {
896 if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status {
897 tracked_buffer.status = TrackedBufferStatus::Modified;
898 }
899 tracked_buffer.unreviewed_edits.clear();
900 tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone();
901 tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx);
902 true
903 }
904 }
905 });
906
907 cx.notify();
908 }
909
910 pub fn reject_all_edits(
911 &mut self,
912 telemetry: Option<ActionLogTelemetry>,
913 cx: &mut Context<Self>,
914 ) -> Task<()> {
915 // Clear any previous undo state before starting a new reject operation
916 self.last_reject_undo = None;
917
918 let mut undo_buffers = Vec::new();
919 let mut futures = Vec::new();
920
921 for buffer in self
922 .changed_buffers(cx)
923 .map(|(buffer, _)| buffer)
924 .collect::<Vec<_>>()
925 {
926 let buffer_ranges = vec![Anchor::min_max_range_for_buffer(
927 buffer.read(cx).remote_id(),
928 )];
929 let (reject_task, undo_info) =
930 self.reject_edits_in_ranges(buffer, buffer_ranges, telemetry.clone(), cx);
931
932 if let Some(undo) = undo_info {
933 undo_buffers.push(undo);
934 }
935
936 futures.push(async move {
937 reject_task.await.log_err();
938 });
939 }
940
941 // Store the undo information if we have any
942 if !undo_buffers.is_empty() {
943 self.last_reject_undo = Some(LastRejectUndo {
944 buffers: undo_buffers,
945 });
946 }
947
948 let task = futures::future::join_all(futures);
949 cx.background_spawn(async move {
950 task.await;
951 })
952 }
953
954 pub fn has_pending_undo(&self) -> bool {
955 self.last_reject_undo.is_some()
956 }
957
958 pub fn set_last_reject_undo(&mut self, undo: LastRejectUndo) {
959 self.last_reject_undo = Some(undo);
960 }
961
962 /// Undoes the most recent reject operation, restoring the rejected agent changes.
963 /// This is a best-effort operation: if buffers have been closed or modified externally,
964 /// those buffers will be skipped.
965 pub fn undo_last_reject(&mut self, cx: &mut Context<Self>) -> Task<()> {
966 let Some(undo) = self.last_reject_undo.take() else {
967 return Task::ready(());
968 };
969
970 let mut save_tasks = Vec::with_capacity(undo.buffers.len());
971
972 for per_buffer_undo in undo.buffers {
973 // Skip if the buffer entity has been deallocated
974 let Some(buffer) = per_buffer_undo.buffer.upgrade() else {
975 continue;
976 };
977
978 buffer.update(cx, |buffer, cx| {
979 let mut valid_edits = Vec::new();
980
981 for (anchor_range, text_to_restore) in per_buffer_undo.edits_to_restore {
982 if anchor_range.start.buffer_id == buffer.remote_id()
983 && anchor_range.end.buffer_id == buffer.remote_id()
984 {
985 valid_edits.push((anchor_range, text_to_restore));
986 }
987 }
988
989 if !valid_edits.is_empty() {
990 buffer.edit(valid_edits, None, cx);
991 }
992 });
993
994 if !self.tracked_buffers.contains_key(&buffer) {
995 self.buffer_edited(buffer.clone(), cx);
996 }
997
998 let save = self
999 .project
1000 .update(cx, |project, cx| project.save_buffer(buffer, cx));
1001 save_tasks.push(save);
1002 }
1003
1004 cx.notify();
1005
1006 cx.background_spawn(async move {
1007 futures::future::join_all(save_tasks).await;
1008 })
1009 }
1010
1011 /// Returns the set of buffers that contain edits that haven't been reviewed by the user.
1012 pub fn changed_buffers(
1013 &self,
1014 cx: &App,
1015 ) -> impl Iterator<Item = (Entity<Buffer>, Entity<BufferDiff>)> {
1016 self.tracked_buffers
1017 .iter()
1018 .filter(|(_, tracked)| tracked.has_edits(cx))
1019 .map(|(buffer, tracked)| (buffer.clone(), tracked.diff.clone()))
1020 }
1021
1022 /// Returns the total number of lines added and removed across all unreviewed buffers.
1023 pub fn diff_stats(&self, cx: &App) -> DiffStats {
1024 DiffStats::all_files(self.changed_buffers(cx), cx)
1025 }
1026
1027 /// Iterate over buffers changed since last read or edited by the model
1028 pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
1029 self.tracked_buffers
1030 .iter()
1031 .filter(|(buffer, tracked)| {
1032 let buffer = buffer.read(cx);
1033
1034 tracked.version != buffer.version
1035 && buffer
1036 .file()
1037 .is_some_and(|file| !file.disk_state().is_deleted())
1038 })
1039 .map(|(buffer, _)| buffer)
1040 }
1041}
1042
1043#[derive(Default, Debug, Clone, Copy)]
1044pub struct DiffStats {
1045 pub lines_added: u32,
1046 pub lines_removed: u32,
1047}
1048
1049impl DiffStats {
1050 pub fn single_file(diff: &BufferDiff) -> Self {
1051 let (lines_added, lines_removed) = diff.changed_row_counts();
1052 DiffStats {
1053 lines_added,
1054 lines_removed,
1055 }
1056 }
1057
1058 pub fn all_files(
1059 changed_buffers: impl IntoIterator<Item = (Entity<Buffer>, Entity<BufferDiff>)>,
1060 cx: &App,
1061 ) -> Self {
1062 let mut total = DiffStats::default();
1063 for (_, diff) in changed_buffers {
1064 let stats = DiffStats::single_file(diff.read(cx));
1065 total.lines_added += stats.lines_added;
1066 total.lines_removed += stats.lines_removed;
1067 }
1068 total
1069 }
1070}
1071
1072#[derive(Clone)]
1073pub struct ActionLogTelemetry {
1074 pub agent_telemetry_id: SharedString,
1075 pub session_id: Arc<str>,
1076}
1077
1078struct ActionLogMetrics {
1079 lines_removed: u32,
1080 lines_added: u32,
1081 language: Option<SharedString>,
1082}
1083
1084impl ActionLogMetrics {
1085 fn for_buffer(buffer: &Buffer) -> Self {
1086 Self {
1087 language: buffer.language().map(|l| l.name().0),
1088 lines_removed: 0,
1089 lines_added: 0,
1090 }
1091 }
1092
1093 fn add_edits(&mut self, edits: &[Edit<u32>]) {
1094 for edit in edits {
1095 self.add_edit(edit);
1096 }
1097 }
1098
1099 fn add_edit(&mut self, edit: &Edit<u32>) {
1100 self.lines_added += edit.new_len();
1101 self.lines_removed += edit.old_len();
1102 }
1103}
1104
1105fn telemetry_report_accepted_edits(telemetry: &ActionLogTelemetry, metrics: ActionLogMetrics) {
1106 telemetry::event!(
1107 "Agent Edits Accepted",
1108 agent = telemetry.agent_telemetry_id,
1109 session = telemetry.session_id,
1110 language = metrics.language,
1111 lines_added = metrics.lines_added,
1112 lines_removed = metrics.lines_removed
1113 );
1114}
1115
1116fn telemetry_report_rejected_edits(telemetry: &ActionLogTelemetry, metrics: ActionLogMetrics) {
1117 telemetry::event!(
1118 "Agent Edits Rejected",
1119 agent = telemetry.agent_telemetry_id,
1120 session = telemetry.session_id,
1121 language = metrics.language,
1122 lines_added = metrics.lines_added,
1123 lines_removed = metrics.lines_removed
1124 );
1125}
1126
1127fn apply_non_conflicting_edits(
1128 patch: &Patch<u32>,
1129 edits: Vec<Edit<u32>>,
1130 old_text: &mut Rope,
1131 new_text: &Rope,
1132) -> bool {
1133 let mut old_edits = patch.edits().iter().cloned().peekable();
1134 let mut new_edits = edits.into_iter().peekable();
1135 let mut applied_delta = 0i32;
1136 let mut rebased_delta = 0i32;
1137 let mut has_made_changes = false;
1138
1139 while let Some(mut new_edit) = new_edits.next() {
1140 let mut conflict = false;
1141
1142 // Push all the old edits that are before this new edit or that intersect with it.
1143 while let Some(old_edit) = old_edits.peek() {
1144 if new_edit.old.end < old_edit.new.start
1145 || (!old_edit.new.is_empty() && new_edit.old.end == old_edit.new.start)
1146 {
1147 break;
1148 } else if new_edit.old.start > old_edit.new.end
1149 || (!old_edit.new.is_empty() && new_edit.old.start == old_edit.new.end)
1150 {
1151 let old_edit = old_edits.next().unwrap();
1152 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
1153 } else {
1154 conflict = true;
1155 if new_edits
1156 .peek()
1157 .is_some_and(|next_edit| next_edit.old.overlaps(&old_edit.new))
1158 {
1159 new_edit = new_edits.next().unwrap();
1160 } else {
1161 let old_edit = old_edits.next().unwrap();
1162 rebased_delta += old_edit.new_len() as i32 - old_edit.old_len() as i32;
1163 }
1164 }
1165 }
1166
1167 if !conflict {
1168 // This edit doesn't intersect with any old edit, so we can apply it to the old text.
1169 new_edit.old.start = (new_edit.old.start as i32 + applied_delta - rebased_delta) as u32;
1170 new_edit.old.end = (new_edit.old.end as i32 + applied_delta - rebased_delta) as u32;
1171 let old_bytes = old_text.point_to_offset(Point::new(new_edit.old.start, 0))
1172 ..old_text.point_to_offset(cmp::min(
1173 Point::new(new_edit.old.end, 0),
1174 old_text.max_point(),
1175 ));
1176 let new_bytes = new_text.point_to_offset(Point::new(new_edit.new.start, 0))
1177 ..new_text.point_to_offset(cmp::min(
1178 Point::new(new_edit.new.end, 0),
1179 new_text.max_point(),
1180 ));
1181
1182 old_text.replace(
1183 old_bytes,
1184 &new_text.chunks_in_range(new_bytes).collect::<String>(),
1185 );
1186 applied_delta += new_edit.new_len() as i32 - new_edit.old_len() as i32;
1187 has_made_changes = true;
1188 }
1189 }
1190 has_made_changes
1191}
1192
1193fn diff_snapshots(
1194 old_snapshot: &text::BufferSnapshot,
1195 new_snapshot: &text::BufferSnapshot,
1196) -> Vec<Edit<u32>> {
1197 let mut edits = new_snapshot
1198 .edits_since::<Point>(&old_snapshot.version)
1199 .map(|edit| point_to_row_edit(edit, old_snapshot.as_rope(), new_snapshot.as_rope()))
1200 .peekable();
1201 let mut row_edits = Vec::new();
1202 while let Some(mut edit) = edits.next() {
1203 while let Some(next_edit) = edits.peek() {
1204 if edit.old.end >= next_edit.old.start {
1205 edit.old.end = next_edit.old.end;
1206 edit.new.end = next_edit.new.end;
1207 edits.next();
1208 } else {
1209 break;
1210 }
1211 }
1212 row_edits.push(edit);
1213 }
1214 row_edits
1215}
1216
1217fn point_to_row_edit(edit: Edit<Point>, old_text: &Rope, new_text: &Rope) -> Edit<u32> {
1218 if edit.old.start.column == old_text.line_len(edit.old.start.row)
1219 && new_text
1220 .chars_at(new_text.point_to_offset(edit.new.start))
1221 .next()
1222 == Some('\n')
1223 && edit.old.start != old_text.max_point()
1224 {
1225 Edit {
1226 old: edit.old.start.row + 1..edit.old.end.row + 1,
1227 new: edit.new.start.row + 1..edit.new.end.row + 1,
1228 }
1229 } else if edit.old.start.column == 0 && edit.old.end.column == 0 && edit.new.end.column == 0 {
1230 Edit {
1231 old: edit.old.start.row..edit.old.end.row,
1232 new: edit.new.start.row..edit.new.end.row,
1233 }
1234 } else {
1235 Edit {
1236 old: edit.old.start.row..edit.old.end.row + 1,
1237 new: edit.new.start.row..edit.new.end.row + 1,
1238 }
1239 }
1240}
1241
1242#[derive(Copy, Clone, Debug)]
1243enum ChangeAuthor {
1244 User,
1245 Agent,
1246}
1247
1248#[derive(Debug)]
1249enum TrackedBufferStatus {
1250 Created { existing_file_content: Option<Rope> },
1251 Modified,
1252 Deleted,
1253}
1254
1255pub struct TrackedBuffer {
1256 buffer: Entity<Buffer>,
1257 diff_base: Rope,
1258 unreviewed_edits: Patch<u32>,
1259 status: TrackedBufferStatus,
1260 version: clock::Global,
1261 diff: Entity<BufferDiff>,
1262 snapshot: text::BufferSnapshot,
1263 diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>,
1264 _open_lsp_handle: OpenLspBufferHandle,
1265 _maintain_diff: Task<()>,
1266 _subscription: Subscription,
1267}
1268
1269impl TrackedBuffer {
1270 #[cfg(any(test, feature = "test-support"))]
1271 pub fn diff(&self) -> &Entity<BufferDiff> {
1272 &self.diff
1273 }
1274
1275 #[cfg(any(test, feature = "test-support"))]
1276 pub fn diff_base_len(&self) -> usize {
1277 self.diff_base.len()
1278 }
1279
1280 fn has_edits(&self, cx: &App) -> bool {
1281 self.diff
1282 .read(cx)
1283 .snapshot(cx)
1284 .hunks(self.buffer.read(cx))
1285 .next()
1286 .is_some()
1287 }
1288
1289 fn schedule_diff_update(&self, author: ChangeAuthor, cx: &App) {
1290 self.diff_update
1291 .unbounded_send((author, self.buffer.read(cx).text_snapshot()))
1292 .ok();
1293 }
1294}
1295
1296pub struct ChangedBuffer {
1297 pub diff: Entity<BufferDiff>,
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303 use buffer_diff::DiffHunkStatusKind;
1304 use gpui::TestAppContext;
1305 use indoc::indoc;
1306 use language::Point;
1307 use project::{FakeFs, Fs, Project, RemoveOptions};
1308 use rand::prelude::*;
1309 use serde_json::json;
1310 use settings::SettingsStore;
1311 use std::env;
1312 use util::{RandomCharIter, path};
1313
1314 #[ctor::ctor(unsafe)]
1315 fn init_logger() {
1316 zlog::init_test();
1317 }
1318
1319 fn init_test(cx: &mut TestAppContext) {
1320 cx.update(|cx| {
1321 let settings_store = SettingsStore::test(cx);
1322 cx.set_global(settings_store);
1323 });
1324 }
1325
1326 #[gpui::test(iterations = 10)]
1327 async fn test_keep_edits(cx: &mut TestAppContext) {
1328 init_test(cx);
1329
1330 let fs = FakeFs::new(cx.executor());
1331 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1332 .await;
1333 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1334 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1335 let file_path = project
1336 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1337 .unwrap();
1338 let buffer = project
1339 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1340 .await
1341 .unwrap();
1342
1343 cx.update(|cx| {
1344 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1345 buffer.update(cx, |buffer, cx| {
1346 buffer
1347 .edit([(Point::new(1, 1)..Point::new(1, 2), "E")], None, cx)
1348 .unwrap()
1349 });
1350 buffer.update(cx, |buffer, cx| {
1351 buffer
1352 .edit([(Point::new(4, 2)..Point::new(4, 3), "O")], None, cx)
1353 .unwrap()
1354 });
1355 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1356 });
1357 cx.run_until_parked();
1358 assert_eq!(
1359 buffer.read_with(cx, |buffer, _| buffer.text()),
1360 "abc\ndEf\nghi\njkl\nmnO"
1361 );
1362 assert_eq!(
1363 unreviewed_hunks(&action_log, cx),
1364 vec![(
1365 buffer.clone(),
1366 vec![
1367 HunkStatus {
1368 range: Point::new(1, 0)..Point::new(2, 0),
1369 diff_status: DiffHunkStatusKind::Modified,
1370 old_text: "def\n".into(),
1371 },
1372 HunkStatus {
1373 range: Point::new(4, 0)..Point::new(4, 3),
1374 diff_status: DiffHunkStatusKind::Modified,
1375 old_text: "mno".into(),
1376 }
1377 ],
1378 )]
1379 );
1380
1381 action_log.update(cx, |log, cx| {
1382 log.keep_edits_in_range(buffer.clone(), Point::new(3, 0)..Point::new(4, 3), None, cx)
1383 });
1384 cx.run_until_parked();
1385 assert_eq!(
1386 unreviewed_hunks(&action_log, cx),
1387 vec![(
1388 buffer.clone(),
1389 vec![HunkStatus {
1390 range: Point::new(1, 0)..Point::new(2, 0),
1391 diff_status: DiffHunkStatusKind::Modified,
1392 old_text: "def\n".into(),
1393 }],
1394 )]
1395 );
1396
1397 action_log.update(cx, |log, cx| {
1398 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(4, 3), None, cx)
1399 });
1400 cx.run_until_parked();
1401 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1402 }
1403
1404 #[gpui::test(iterations = 10)]
1405 async fn test_deletions(cx: &mut TestAppContext) {
1406 init_test(cx);
1407
1408 let fs = FakeFs::new(cx.executor());
1409 fs.insert_tree(
1410 path!("/dir"),
1411 json!({"file": "abc\ndef\nghi\njkl\nmno\npqr"}),
1412 )
1413 .await;
1414 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1415 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1416 let file_path = project
1417 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1418 .unwrap();
1419 let buffer = project
1420 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1421 .await
1422 .unwrap();
1423
1424 cx.update(|cx| {
1425 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1426 buffer.update(cx, |buffer, cx| {
1427 buffer
1428 .edit([(Point::new(1, 0)..Point::new(2, 0), "")], None, cx)
1429 .unwrap();
1430 buffer.finalize_last_transaction();
1431 });
1432 buffer.update(cx, |buffer, cx| {
1433 buffer
1434 .edit([(Point::new(3, 0)..Point::new(4, 0), "")], None, cx)
1435 .unwrap();
1436 buffer.finalize_last_transaction();
1437 });
1438 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1439 });
1440 cx.run_until_parked();
1441 assert_eq!(
1442 buffer.read_with(cx, |buffer, _| buffer.text()),
1443 "abc\nghi\njkl\npqr"
1444 );
1445 assert_eq!(
1446 unreviewed_hunks(&action_log, cx),
1447 vec![(
1448 buffer.clone(),
1449 vec![
1450 HunkStatus {
1451 range: Point::new(1, 0)..Point::new(1, 0),
1452 diff_status: DiffHunkStatusKind::Deleted,
1453 old_text: "def\n".into(),
1454 },
1455 HunkStatus {
1456 range: Point::new(3, 0)..Point::new(3, 0),
1457 diff_status: DiffHunkStatusKind::Deleted,
1458 old_text: "mno\n".into(),
1459 }
1460 ],
1461 )]
1462 );
1463
1464 buffer.update(cx, |buffer, cx| buffer.undo(cx));
1465 cx.run_until_parked();
1466 assert_eq!(
1467 buffer.read_with(cx, |buffer, _| buffer.text()),
1468 "abc\nghi\njkl\nmno\npqr"
1469 );
1470 assert_eq!(
1471 unreviewed_hunks(&action_log, cx),
1472 vec![(
1473 buffer.clone(),
1474 vec![HunkStatus {
1475 range: Point::new(1, 0)..Point::new(1, 0),
1476 diff_status: DiffHunkStatusKind::Deleted,
1477 old_text: "def\n".into(),
1478 }],
1479 )]
1480 );
1481
1482 action_log.update(cx, |log, cx| {
1483 log.keep_edits_in_range(buffer.clone(), Point::new(1, 0)..Point::new(1, 0), None, cx)
1484 });
1485 cx.run_until_parked();
1486 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1487 }
1488
1489 #[gpui::test(iterations = 10)]
1490 async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
1491 init_test(cx);
1492
1493 let fs = FakeFs::new(cx.executor());
1494 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1495 .await;
1496 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1497 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1498 let file_path = project
1499 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1500 .unwrap();
1501 let buffer = project
1502 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1503 .await
1504 .unwrap();
1505
1506 cx.update(|cx| {
1507 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1508 buffer.update(cx, |buffer, cx| {
1509 buffer
1510 .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx)
1511 .unwrap()
1512 });
1513 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1514 });
1515 cx.run_until_parked();
1516 assert_eq!(
1517 buffer.read_with(cx, |buffer, _| buffer.text()),
1518 "abc\ndeF\nGHI\njkl\nmno"
1519 );
1520 assert_eq!(
1521 unreviewed_hunks(&action_log, cx),
1522 vec![(
1523 buffer.clone(),
1524 vec![HunkStatus {
1525 range: Point::new(1, 0)..Point::new(3, 0),
1526 diff_status: DiffHunkStatusKind::Modified,
1527 old_text: "def\nghi\n".into(),
1528 }],
1529 )]
1530 );
1531
1532 buffer.update(cx, |buffer, cx| {
1533 buffer.edit(
1534 [
1535 (Point::new(0, 2)..Point::new(0, 2), "X"),
1536 (Point::new(3, 0)..Point::new(3, 0), "Y"),
1537 ],
1538 None,
1539 cx,
1540 )
1541 });
1542 cx.run_until_parked();
1543 assert_eq!(
1544 buffer.read_with(cx, |buffer, _| buffer.text()),
1545 "abXc\ndeF\nGHI\nYjkl\nmno"
1546 );
1547 assert_eq!(
1548 unreviewed_hunks(&action_log, cx),
1549 vec![(
1550 buffer.clone(),
1551 vec![HunkStatus {
1552 range: Point::new(1, 0)..Point::new(3, 0),
1553 diff_status: DiffHunkStatusKind::Modified,
1554 old_text: "def\nghi\n".into(),
1555 }],
1556 )]
1557 );
1558
1559 buffer.update(cx, |buffer, cx| {
1560 buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "Z")], None, cx)
1561 });
1562 cx.run_until_parked();
1563 assert_eq!(
1564 buffer.read_with(cx, |buffer, _| buffer.text()),
1565 "abXc\ndZeF\nGHI\nYjkl\nmno"
1566 );
1567 assert_eq!(
1568 unreviewed_hunks(&action_log, cx),
1569 vec![(
1570 buffer.clone(),
1571 vec![HunkStatus {
1572 range: Point::new(1, 0)..Point::new(3, 0),
1573 diff_status: DiffHunkStatusKind::Modified,
1574 old_text: "def\nghi\n".into(),
1575 }],
1576 )]
1577 );
1578
1579 action_log.update(cx, |log, cx| {
1580 log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), None, cx)
1581 });
1582 cx.run_until_parked();
1583 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1584 }
1585
1586 #[gpui::test(iterations = 10)]
1587 async fn test_creating_files(cx: &mut TestAppContext) {
1588 init_test(cx);
1589
1590 let fs = FakeFs::new(cx.executor());
1591 fs.insert_tree(path!("/dir"), json!({})).await;
1592 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1593 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1594 let file_path = project
1595 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1596 .unwrap();
1597
1598 let buffer = project
1599 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1600 .await
1601 .unwrap();
1602 cx.update(|cx| {
1603 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1604 buffer.update(cx, |buffer, cx| buffer.set_text("lorem", cx));
1605 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1606 });
1607 project
1608 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1609 .await
1610 .unwrap();
1611 cx.run_until_parked();
1612 assert_eq!(
1613 unreviewed_hunks(&action_log, cx),
1614 vec![(
1615 buffer.clone(),
1616 vec![HunkStatus {
1617 range: Point::new(0, 0)..Point::new(0, 5),
1618 diff_status: DiffHunkStatusKind::Added,
1619 old_text: "".into(),
1620 }],
1621 )]
1622 );
1623
1624 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "X")], None, cx));
1625 cx.run_until_parked();
1626 assert_eq!(
1627 unreviewed_hunks(&action_log, cx),
1628 vec![(
1629 buffer.clone(),
1630 vec![HunkStatus {
1631 range: Point::new(0, 0)..Point::new(0, 6),
1632 diff_status: DiffHunkStatusKind::Added,
1633 old_text: "".into(),
1634 }],
1635 )]
1636 );
1637
1638 action_log.update(cx, |log, cx| {
1639 log.keep_edits_in_range(buffer.clone(), 0..5, None, cx)
1640 });
1641 cx.run_until_parked();
1642 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1643 }
1644
1645 #[gpui::test(iterations = 10)]
1646 async fn test_overwriting_files(cx: &mut TestAppContext) {
1647 init_test(cx);
1648
1649 let fs = FakeFs::new(cx.executor());
1650 fs.insert_tree(
1651 path!("/dir"),
1652 json!({
1653 "file1": "Lorem ipsum dolor"
1654 }),
1655 )
1656 .await;
1657 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1658 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1659 let file_path = project
1660 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1661 .unwrap();
1662
1663 let buffer = project
1664 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1665 .await
1666 .unwrap();
1667 cx.update(|cx| {
1668 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1669 buffer.update(cx, |buffer, cx| buffer.set_text("sit amet consecteur", cx));
1670 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1671 });
1672 project
1673 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1674 .await
1675 .unwrap();
1676 cx.run_until_parked();
1677 assert_eq!(
1678 unreviewed_hunks(&action_log, cx),
1679 vec![(
1680 buffer.clone(),
1681 vec![HunkStatus {
1682 range: Point::new(0, 0)..Point::new(0, 19),
1683 diff_status: DiffHunkStatusKind::Added,
1684 old_text: "".into(),
1685 }],
1686 )]
1687 );
1688
1689 action_log
1690 .update(cx, |log, cx| {
1691 let (task, _) = log.reject_edits_in_ranges(buffer.clone(), vec![2..5], None, cx);
1692 task
1693 })
1694 .await
1695 .unwrap();
1696 cx.run_until_parked();
1697 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1698 assert_eq!(
1699 buffer.read_with(cx, |buffer, _cx| buffer.text()),
1700 "Lorem ipsum dolor"
1701 );
1702 }
1703
1704 #[gpui::test(iterations = 10)]
1705 async fn test_overwriting_previously_edited_files(cx: &mut TestAppContext) {
1706 init_test(cx);
1707
1708 let fs = FakeFs::new(cx.executor());
1709 fs.insert_tree(
1710 path!("/dir"),
1711 json!({
1712 "file1": "Lorem ipsum dolor"
1713 }),
1714 )
1715 .await;
1716 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1717 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1718 let file_path = project
1719 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1720 .unwrap();
1721
1722 let buffer = project
1723 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1724 .await
1725 .unwrap();
1726 cx.update(|cx| {
1727 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1728 buffer.update(cx, |buffer, cx| buffer.append(" sit amet consecteur", cx));
1729 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1730 });
1731 project
1732 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1733 .await
1734 .unwrap();
1735 cx.run_until_parked();
1736 assert_eq!(
1737 unreviewed_hunks(&action_log, cx),
1738 vec![(
1739 buffer.clone(),
1740 vec![HunkStatus {
1741 range: Point::new(0, 0)..Point::new(0, 37),
1742 diff_status: DiffHunkStatusKind::Modified,
1743 old_text: "Lorem ipsum dolor".into(),
1744 }],
1745 )]
1746 );
1747
1748 cx.update(|cx| {
1749 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
1750 buffer.update(cx, |buffer, cx| buffer.set_text("rewritten", cx));
1751 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1752 });
1753 project
1754 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1755 .await
1756 .unwrap();
1757 cx.run_until_parked();
1758 assert_eq!(
1759 unreviewed_hunks(&action_log, cx),
1760 vec![(
1761 buffer.clone(),
1762 vec![HunkStatus {
1763 range: Point::new(0, 0)..Point::new(0, 9),
1764 diff_status: DiffHunkStatusKind::Added,
1765 old_text: "".into(),
1766 }],
1767 )]
1768 );
1769
1770 action_log
1771 .update(cx, |log, cx| {
1772 let (task, _) = log.reject_edits_in_ranges(buffer.clone(), vec![2..5], None, cx);
1773 task
1774 })
1775 .await
1776 .unwrap();
1777 cx.run_until_parked();
1778 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1779 assert_eq!(
1780 buffer.read_with(cx, |buffer, _cx| buffer.text()),
1781 "Lorem ipsum dolor"
1782 );
1783 }
1784
1785 #[gpui::test(iterations = 10)]
1786 async fn test_deleting_files(cx: &mut TestAppContext) {
1787 init_test(cx);
1788
1789 let fs = FakeFs::new(cx.executor());
1790 fs.insert_tree(
1791 path!("/dir"),
1792 json!({"file1": "lorem\n", "file2": "ipsum\n"}),
1793 )
1794 .await;
1795
1796 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1797 let file1_path = project
1798 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
1799 .unwrap();
1800 let file2_path = project
1801 .read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
1802 .unwrap();
1803
1804 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1805 let buffer1 = project
1806 .update(cx, |project, cx| {
1807 project.open_buffer(file1_path.clone(), cx)
1808 })
1809 .await
1810 .unwrap();
1811 let buffer2 = project
1812 .update(cx, |project, cx| {
1813 project.open_buffer(file2_path.clone(), cx)
1814 })
1815 .await
1816 .unwrap();
1817
1818 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer1.clone(), cx));
1819 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer2.clone(), cx));
1820 project
1821 .update(cx, |project, cx| {
1822 project.delete_file(file1_path.clone(), cx)
1823 })
1824 .unwrap()
1825 .await
1826 .unwrap();
1827 project
1828 .update(cx, |project, cx| {
1829 project.delete_file(file2_path.clone(), cx)
1830 })
1831 .unwrap()
1832 .await
1833 .unwrap();
1834 cx.run_until_parked();
1835 assert_eq!(
1836 unreviewed_hunks(&action_log, cx),
1837 vec![
1838 (
1839 buffer1.clone(),
1840 vec![HunkStatus {
1841 range: Point::new(0, 0)..Point::new(0, 0),
1842 diff_status: DiffHunkStatusKind::Deleted,
1843 old_text: "lorem\n".into(),
1844 }]
1845 ),
1846 (
1847 buffer2.clone(),
1848 vec![HunkStatus {
1849 range: Point::new(0, 0)..Point::new(0, 0),
1850 diff_status: DiffHunkStatusKind::Deleted,
1851 old_text: "ipsum\n".into(),
1852 }],
1853 )
1854 ]
1855 );
1856
1857 // Simulate file1 being recreated externally.
1858 fs.insert_file(path!("/dir/file1"), "LOREM".as_bytes().to_vec())
1859 .await;
1860
1861 // Simulate file2 being recreated by a tool.
1862 let buffer2 = project
1863 .update(cx, |project, cx| project.open_buffer(file2_path, cx))
1864 .await
1865 .unwrap();
1866 action_log.update(cx, |log, cx| log.buffer_created(buffer2.clone(), cx));
1867 buffer2.update(cx, |buffer, cx| buffer.set_text("IPSUM", cx));
1868 action_log.update(cx, |log, cx| log.buffer_edited(buffer2.clone(), cx));
1869 project
1870 .update(cx, |project, cx| project.save_buffer(buffer2.clone(), cx))
1871 .await
1872 .unwrap();
1873
1874 cx.run_until_parked();
1875 assert_eq!(
1876 unreviewed_hunks(&action_log, cx),
1877 vec![(
1878 buffer2.clone(),
1879 vec![HunkStatus {
1880 range: Point::new(0, 0)..Point::new(0, 5),
1881 diff_status: DiffHunkStatusKind::Added,
1882 old_text: "".into(),
1883 }],
1884 )]
1885 );
1886
1887 // Simulate file2 being deleted externally.
1888 fs.remove_file(path!("/dir/file2").as_ref(), RemoveOptions::default())
1889 .await
1890 .unwrap();
1891 cx.run_until_parked();
1892 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
1893 }
1894
1895 #[gpui::test(iterations = 10)]
1896 async fn test_reject_edits(cx: &mut TestAppContext) {
1897 init_test(cx);
1898
1899 let fs = FakeFs::new(cx.executor());
1900 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
1901 .await;
1902 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
1903 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1904 let file_path = project
1905 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
1906 .unwrap();
1907 let buffer = project
1908 .update(cx, |project, cx| project.open_buffer(file_path, cx))
1909 .await
1910 .unwrap();
1911
1912 cx.update(|cx| {
1913 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
1914 buffer.update(cx, |buffer, cx| {
1915 buffer
1916 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
1917 .unwrap()
1918 });
1919 buffer.update(cx, |buffer, cx| {
1920 buffer
1921 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
1922 .unwrap()
1923 });
1924 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
1925 });
1926 cx.run_until_parked();
1927 assert_eq!(
1928 buffer.read_with(cx, |buffer, _| buffer.text()),
1929 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1930 );
1931 assert_eq!(
1932 unreviewed_hunks(&action_log, cx),
1933 vec![(
1934 buffer.clone(),
1935 vec![
1936 HunkStatus {
1937 range: Point::new(1, 0)..Point::new(3, 0),
1938 diff_status: DiffHunkStatusKind::Modified,
1939 old_text: "def\n".into(),
1940 },
1941 HunkStatus {
1942 range: Point::new(5, 0)..Point::new(5, 3),
1943 diff_status: DiffHunkStatusKind::Modified,
1944 old_text: "mno".into(),
1945 }
1946 ],
1947 )]
1948 );
1949
1950 // If the rejected range doesn't overlap with any hunk, we ignore it.
1951 action_log
1952 .update(cx, |log, cx| {
1953 let (task, _) = log.reject_edits_in_ranges(
1954 buffer.clone(),
1955 vec![Point::new(4, 0)..Point::new(4, 0)],
1956 None,
1957 cx,
1958 );
1959 task
1960 })
1961 .await
1962 .unwrap();
1963 cx.run_until_parked();
1964 assert_eq!(
1965 buffer.read_with(cx, |buffer, _| buffer.text()),
1966 "abc\ndE\nXYZf\nghi\njkl\nmnO"
1967 );
1968 assert_eq!(
1969 unreviewed_hunks(&action_log, cx),
1970 vec![(
1971 buffer.clone(),
1972 vec![
1973 HunkStatus {
1974 range: Point::new(1, 0)..Point::new(3, 0),
1975 diff_status: DiffHunkStatusKind::Modified,
1976 old_text: "def\n".into(),
1977 },
1978 HunkStatus {
1979 range: Point::new(5, 0)..Point::new(5, 3),
1980 diff_status: DiffHunkStatusKind::Modified,
1981 old_text: "mno".into(),
1982 }
1983 ],
1984 )]
1985 );
1986
1987 action_log
1988 .update(cx, |log, cx| {
1989 let (task, _) = log.reject_edits_in_ranges(
1990 buffer.clone(),
1991 vec![Point::new(0, 0)..Point::new(1, 0)],
1992 None,
1993 cx,
1994 );
1995 task
1996 })
1997 .await
1998 .unwrap();
1999 cx.run_until_parked();
2000 assert_eq!(
2001 buffer.read_with(cx, |buffer, _| buffer.text()),
2002 "abc\ndef\nghi\njkl\nmnO"
2003 );
2004 assert_eq!(
2005 unreviewed_hunks(&action_log, cx),
2006 vec![(
2007 buffer.clone(),
2008 vec![HunkStatus {
2009 range: Point::new(4, 0)..Point::new(4, 3),
2010 diff_status: DiffHunkStatusKind::Modified,
2011 old_text: "mno".into(),
2012 }],
2013 )]
2014 );
2015
2016 action_log
2017 .update(cx, |log, cx| {
2018 let (task, _) = log.reject_edits_in_ranges(
2019 buffer.clone(),
2020 vec![Point::new(4, 0)..Point::new(4, 0)],
2021 None,
2022 cx,
2023 );
2024 task
2025 })
2026 .await
2027 .unwrap();
2028 cx.run_until_parked();
2029 assert_eq!(
2030 buffer.read_with(cx, |buffer, _| buffer.text()),
2031 "abc\ndef\nghi\njkl\nmno"
2032 );
2033 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2034 }
2035
2036 #[gpui::test(iterations = 10)]
2037 async fn test_reject_multiple_edits(cx: &mut TestAppContext) {
2038 init_test(cx);
2039
2040 let fs = FakeFs::new(cx.executor());
2041 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
2042 .await;
2043 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2044 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2045 let file_path = project
2046 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2047 .unwrap();
2048 let buffer = project
2049 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2050 .await
2051 .unwrap();
2052
2053 cx.update(|cx| {
2054 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2055 buffer.update(cx, |buffer, cx| {
2056 buffer
2057 .edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
2058 .unwrap()
2059 });
2060 buffer.update(cx, |buffer, cx| {
2061 buffer
2062 .edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
2063 .unwrap()
2064 });
2065 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2066 });
2067 cx.run_until_parked();
2068 assert_eq!(
2069 buffer.read_with(cx, |buffer, _| buffer.text()),
2070 "abc\ndE\nXYZf\nghi\njkl\nmnO"
2071 );
2072 assert_eq!(
2073 unreviewed_hunks(&action_log, cx),
2074 vec![(
2075 buffer.clone(),
2076 vec![
2077 HunkStatus {
2078 range: Point::new(1, 0)..Point::new(3, 0),
2079 diff_status: DiffHunkStatusKind::Modified,
2080 old_text: "def\n".into(),
2081 },
2082 HunkStatus {
2083 range: Point::new(5, 0)..Point::new(5, 3),
2084 diff_status: DiffHunkStatusKind::Modified,
2085 old_text: "mno".into(),
2086 }
2087 ],
2088 )]
2089 );
2090
2091 action_log.update(cx, |log, cx| {
2092 let range_1 = buffer.read(cx).anchor_before(Point::new(0, 0))
2093 ..buffer.read(cx).anchor_before(Point::new(1, 0));
2094 let range_2 = buffer.read(cx).anchor_before(Point::new(5, 0))
2095 ..buffer.read(cx).anchor_before(Point::new(5, 3));
2096
2097 let (task, _) =
2098 log.reject_edits_in_ranges(buffer.clone(), vec![range_1, range_2], None, cx);
2099 task.detach();
2100 assert_eq!(
2101 buffer.read_with(cx, |buffer, _| buffer.text()),
2102 "abc\ndef\nghi\njkl\nmno"
2103 );
2104 });
2105 cx.run_until_parked();
2106 assert_eq!(
2107 buffer.read_with(cx, |buffer, _| buffer.text()),
2108 "abc\ndef\nghi\njkl\nmno"
2109 );
2110 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2111 }
2112
2113 #[gpui::test(iterations = 10)]
2114 async fn test_reject_deleted_file(cx: &mut TestAppContext) {
2115 init_test(cx);
2116
2117 let fs = FakeFs::new(cx.executor());
2118 fs.insert_tree(path!("/dir"), json!({"file": "content"}))
2119 .await;
2120 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2121 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2122 let file_path = project
2123 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2124 .unwrap();
2125 let buffer = project
2126 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
2127 .await
2128 .unwrap();
2129
2130 cx.update(|cx| {
2131 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
2132 });
2133 project
2134 .update(cx, |project, cx| project.delete_file(file_path.clone(), cx))
2135 .unwrap()
2136 .await
2137 .unwrap();
2138 cx.run_until_parked();
2139 assert!(!fs.is_file(path!("/dir/file").as_ref()).await);
2140 assert_eq!(
2141 unreviewed_hunks(&action_log, cx),
2142 vec![(
2143 buffer.clone(),
2144 vec![HunkStatus {
2145 range: Point::new(0, 0)..Point::new(0, 0),
2146 diff_status: DiffHunkStatusKind::Deleted,
2147 old_text: "content".into(),
2148 }]
2149 )]
2150 );
2151
2152 action_log
2153 .update(cx, |log, cx| {
2154 let (task, _) = log.reject_edits_in_ranges(
2155 buffer.clone(),
2156 vec![Point::new(0, 0)..Point::new(0, 0)],
2157 None,
2158 cx,
2159 );
2160 task
2161 })
2162 .await
2163 .unwrap();
2164 cx.run_until_parked();
2165 assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content");
2166 assert!(fs.is_file(path!("/dir/file").as_ref()).await);
2167 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2168 }
2169
2170 #[gpui::test(iterations = 10)]
2171 async fn test_reject_created_file(cx: &mut TestAppContext) {
2172 init_test(cx);
2173
2174 let fs = FakeFs::new(cx.executor());
2175 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2176 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2177 let file_path = project
2178 .read_with(cx, |project, cx| {
2179 project.find_project_path("dir/new_file", cx)
2180 })
2181 .unwrap();
2182 let buffer = project
2183 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2184 .await
2185 .unwrap();
2186 cx.update(|cx| {
2187 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2188 buffer.update(cx, |buffer, cx| buffer.set_text("content", cx));
2189 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2190 });
2191 project
2192 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2193 .await
2194 .unwrap();
2195 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2196 cx.run_until_parked();
2197 assert_eq!(
2198 unreviewed_hunks(&action_log, cx),
2199 vec![(
2200 buffer.clone(),
2201 vec![HunkStatus {
2202 range: Point::new(0, 0)..Point::new(0, 7),
2203 diff_status: DiffHunkStatusKind::Added,
2204 old_text: "".into(),
2205 }],
2206 )]
2207 );
2208
2209 action_log
2210 .update(cx, |log, cx| {
2211 let (task, _) = log.reject_edits_in_ranges(
2212 buffer.clone(),
2213 vec![Point::new(0, 0)..Point::new(0, 11)],
2214 None,
2215 cx,
2216 );
2217 task
2218 })
2219 .await
2220 .unwrap();
2221 cx.run_until_parked();
2222 assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await);
2223 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2224 }
2225
2226 #[gpui::test]
2227 async fn test_reject_created_file_with_user_edits(cx: &mut TestAppContext) {
2228 init_test(cx);
2229
2230 let fs = FakeFs::new(cx.executor());
2231 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2232 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2233
2234 let file_path = project
2235 .read_with(cx, |project, cx| {
2236 project.find_project_path("dir/new_file", cx)
2237 })
2238 .unwrap();
2239 let buffer = project
2240 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2241 .await
2242 .unwrap();
2243
2244 // AI creates file with initial content
2245 cx.update(|cx| {
2246 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2247 buffer.update(cx, |buffer, cx| buffer.set_text("ai content", cx));
2248 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2249 });
2250
2251 project
2252 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2253 .await
2254 .unwrap();
2255
2256 cx.run_until_parked();
2257
2258 // User makes additional edits
2259 cx.update(|cx| {
2260 buffer.update(cx, |buffer, cx| {
2261 buffer.edit([(10..10, "\nuser added this line")], None, cx);
2262 });
2263 });
2264
2265 project
2266 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2267 .await
2268 .unwrap();
2269
2270 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2271
2272 // Reject all
2273 action_log
2274 .update(cx, |log, cx| {
2275 let (task, _) = log.reject_edits_in_ranges(
2276 buffer.clone(),
2277 vec![Point::new(0, 0)..Point::new(100, 0)],
2278 None,
2279 cx,
2280 );
2281 task
2282 })
2283 .await
2284 .unwrap();
2285 cx.run_until_parked();
2286
2287 // File should still contain all the content
2288 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2289
2290 let content = buffer.read_with(cx, |buffer, _| buffer.text());
2291 assert_eq!(content, "ai content\nuser added this line");
2292 }
2293
2294 #[gpui::test]
2295 async fn test_reject_after_accepting_hunk_on_created_file(cx: &mut TestAppContext) {
2296 init_test(cx);
2297
2298 let fs = FakeFs::new(cx.executor());
2299 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2300 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2301
2302 let file_path = project
2303 .read_with(cx, |project, cx| {
2304 project.find_project_path("dir/new_file", cx)
2305 })
2306 .unwrap();
2307 let buffer = project
2308 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
2309 .await
2310 .unwrap();
2311
2312 // AI creates file with initial content
2313 cx.update(|cx| {
2314 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2315 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
2316 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2317 });
2318 project
2319 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2320 .await
2321 .unwrap();
2322 cx.run_until_parked();
2323 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2324
2325 // User accepts the single hunk
2326 action_log.update(cx, |log, cx| {
2327 let buffer_range = Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id());
2328 log.keep_edits_in_range(buffer.clone(), buffer_range, None, cx)
2329 });
2330 cx.run_until_parked();
2331 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2332 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2333
2334 // AI modifies the file
2335 cx.update(|cx| {
2336 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
2337 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2338 });
2339 project
2340 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2341 .await
2342 .unwrap();
2343 cx.run_until_parked();
2344 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2345
2346 // User rejects the hunk
2347 action_log
2348 .update(cx, |log, cx| {
2349 let (task, _) = log.reject_edits_in_ranges(
2350 buffer.clone(),
2351 vec![Anchor::min_max_range_for_buffer(
2352 buffer.read(cx).remote_id(),
2353 )],
2354 None,
2355 cx,
2356 );
2357 task
2358 })
2359 .await
2360 .unwrap();
2361 cx.run_until_parked();
2362 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await,);
2363 assert_eq!(
2364 buffer.read_with(cx, |buffer, _| buffer.text()),
2365 "ai content v1"
2366 );
2367 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2368 }
2369
2370 #[gpui::test]
2371 async fn test_reject_edits_on_previously_accepted_created_file(cx: &mut TestAppContext) {
2372 init_test(cx);
2373
2374 let fs = FakeFs::new(cx.executor());
2375 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2376 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2377
2378 let file_path = project
2379 .read_with(cx, |project, cx| {
2380 project.find_project_path("dir/new_file", cx)
2381 })
2382 .unwrap();
2383 let buffer = project
2384 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
2385 .await
2386 .unwrap();
2387
2388 // AI creates file with initial content
2389 cx.update(|cx| {
2390 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
2391 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx));
2392 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2393 });
2394 project
2395 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2396 .await
2397 .unwrap();
2398 cx.run_until_parked();
2399
2400 // User clicks "Accept All"
2401 action_log.update(cx, |log, cx| log.keep_all_edits(None, cx));
2402 cx.run_until_parked();
2403 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2404 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); // Hunks are cleared
2405
2406 // AI modifies file again
2407 cx.update(|cx| {
2408 buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx));
2409 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2410 });
2411 project
2412 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
2413 .await
2414 .unwrap();
2415 cx.run_until_parked();
2416 assert_ne!(unreviewed_hunks(&action_log, cx), vec![]);
2417
2418 // User clicks "Reject All"
2419 action_log
2420 .update(cx, |log, cx| log.reject_all_edits(None, cx))
2421 .await;
2422 cx.run_until_parked();
2423 assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
2424 assert_eq!(
2425 buffer.read_with(cx, |buffer, _| buffer.text()),
2426 "ai content v1"
2427 );
2428 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2429 }
2430
2431 #[gpui::test(iterations = 100)]
2432 async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
2433 init_test(cx);
2434
2435 let operations = env::var("OPERATIONS")
2436 .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
2437 .unwrap_or(20);
2438
2439 let text = RandomCharIter::new(&mut rng).take(50).collect::<String>();
2440 let fs = FakeFs::new(cx.executor());
2441 fs.insert_tree(path!("/dir"), json!({"file": text})).await;
2442 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2443 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2444 let file_path = project
2445 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2446 .unwrap();
2447 let buffer = project
2448 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2449 .await
2450 .unwrap();
2451
2452 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2453
2454 for _ in 0..operations {
2455 match rng.random_range(0..100) {
2456 0..25 => {
2457 action_log.update(cx, |log, cx| {
2458 let range = buffer.read(cx).random_byte_range(0, &mut rng);
2459 log::info!("keeping edits in range {:?}", range);
2460 log.keep_edits_in_range(buffer.clone(), range, None, cx)
2461 });
2462 }
2463 25..50 => {
2464 action_log
2465 .update(cx, |log, cx| {
2466 let range = buffer.read(cx).random_byte_range(0, &mut rng);
2467 log::info!("rejecting edits in range {:?}", range);
2468 let (task, _) =
2469 log.reject_edits_in_ranges(buffer.clone(), vec![range], None, cx);
2470 task
2471 })
2472 .await
2473 .unwrap();
2474 }
2475 _ => {
2476 let is_agent_edit = rng.random_bool(0.5);
2477 if is_agent_edit {
2478 log::info!("agent edit");
2479 } else {
2480 log::info!("user edit");
2481 }
2482 cx.update(|cx| {
2483 buffer.update(cx, |buffer, cx| buffer.randomly_edit(&mut rng, 1, cx));
2484 if is_agent_edit {
2485 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2486 }
2487 });
2488 }
2489 }
2490
2491 if rng.random_bool(0.2) {
2492 quiesce(&action_log, &buffer, cx);
2493 }
2494 }
2495
2496 quiesce(&action_log, &buffer, cx);
2497
2498 fn quiesce(
2499 action_log: &Entity<ActionLog>,
2500 buffer: &Entity<Buffer>,
2501 cx: &mut TestAppContext,
2502 ) {
2503 log::info!("quiescing...");
2504 cx.run_until_parked();
2505 action_log.update(cx, |log, cx| {
2506 let tracked_buffer = log.tracked_buffers.get(buffer).unwrap();
2507 let mut old_text = tracked_buffer.diff_base.clone();
2508 let new_text = buffer.read(cx).as_rope();
2509 for edit in tracked_buffer.unreviewed_edits.edits() {
2510 let old_start = old_text.point_to_offset(Point::new(edit.new.start, 0));
2511 let old_end = old_text.point_to_offset(cmp::min(
2512 Point::new(edit.new.start + edit.old_len(), 0),
2513 old_text.max_point(),
2514 ));
2515 old_text.replace(
2516 old_start..old_end,
2517 &new_text.slice_rows(edit.new.clone()).to_string(),
2518 );
2519 }
2520 pretty_assertions::assert_eq!(old_text.to_string(), new_text.to_string());
2521 })
2522 }
2523 }
2524
2525 #[gpui::test]
2526 async fn test_keep_edits_on_commit(cx: &mut gpui::TestAppContext) {
2527 init_test(cx);
2528
2529 let fs = FakeFs::new(cx.background_executor.clone());
2530 fs.insert_tree(
2531 path!("/project"),
2532 json!({
2533 ".git": {},
2534 "file.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj",
2535 }),
2536 )
2537 .await;
2538 fs.set_head_for_repo(
2539 path!("/project/.git").as_ref(),
2540 &[("file.txt", "a\nb\nc\nd\ne\nf\ng\nh\ni\nj".into())],
2541 "0000000",
2542 );
2543 cx.run_until_parked();
2544
2545 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2546 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2547
2548 let file_path = project
2549 .read_with(cx, |project, cx| {
2550 project.find_project_path(path!("/project/file.txt"), cx)
2551 })
2552 .unwrap();
2553 let buffer = project
2554 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2555 .await
2556 .unwrap();
2557
2558 cx.update(|cx| {
2559 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2560 buffer.update(cx, |buffer, cx| {
2561 buffer.edit(
2562 [
2563 // Edit at the very start: a -> A
2564 (Point::new(0, 0)..Point::new(0, 1), "A"),
2565 // Deletion in the middle: remove lines d and e
2566 (Point::new(3, 0)..Point::new(5, 0), ""),
2567 // Modification: g -> GGG
2568 (Point::new(6, 0)..Point::new(6, 1), "GGG"),
2569 // Addition: insert new line after h
2570 (Point::new(7, 1)..Point::new(7, 1), "\nNEW"),
2571 // Edit the very last character: j -> J
2572 (Point::new(9, 0)..Point::new(9, 1), "J"),
2573 ],
2574 None,
2575 cx,
2576 );
2577 });
2578 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2579 });
2580 cx.run_until_parked();
2581 assert_eq!(
2582 unreviewed_hunks(&action_log, cx),
2583 vec![(
2584 buffer.clone(),
2585 vec![
2586 HunkStatus {
2587 range: Point::new(0, 0)..Point::new(1, 0),
2588 diff_status: DiffHunkStatusKind::Modified,
2589 old_text: "a\n".into()
2590 },
2591 HunkStatus {
2592 range: Point::new(3, 0)..Point::new(3, 0),
2593 diff_status: DiffHunkStatusKind::Deleted,
2594 old_text: "d\ne\n".into()
2595 },
2596 HunkStatus {
2597 range: Point::new(4, 0)..Point::new(5, 0),
2598 diff_status: DiffHunkStatusKind::Modified,
2599 old_text: "g\n".into()
2600 },
2601 HunkStatus {
2602 range: Point::new(6, 0)..Point::new(7, 0),
2603 diff_status: DiffHunkStatusKind::Added,
2604 old_text: "".into()
2605 },
2606 HunkStatus {
2607 range: Point::new(8, 0)..Point::new(8, 1),
2608 diff_status: DiffHunkStatusKind::Modified,
2609 old_text: "j".into()
2610 }
2611 ]
2612 )]
2613 );
2614
2615 // Simulate a git commit that matches some edits but not others:
2616 // - Accepts the first edit (a -> A)
2617 // - Accepts the deletion (remove d and e)
2618 // - Makes a different change to g (g -> G instead of GGG)
2619 // - Ignores the NEW line addition
2620 // - Ignores the last line edit (j stays as j)
2621 fs.set_head_for_repo(
2622 path!("/project/.git").as_ref(),
2623 &[("file.txt", "A\nb\nc\nf\nG\nh\ni\nj".into())],
2624 "0000001",
2625 );
2626 cx.run_until_parked();
2627 assert_eq!(
2628 unreviewed_hunks(&action_log, cx),
2629 vec![(
2630 buffer.clone(),
2631 vec![
2632 HunkStatus {
2633 range: Point::new(4, 0)..Point::new(5, 0),
2634 diff_status: DiffHunkStatusKind::Modified,
2635 old_text: "g\n".into()
2636 },
2637 HunkStatus {
2638 range: Point::new(6, 0)..Point::new(7, 0),
2639 diff_status: DiffHunkStatusKind::Added,
2640 old_text: "".into()
2641 },
2642 HunkStatus {
2643 range: Point::new(8, 0)..Point::new(8, 1),
2644 diff_status: DiffHunkStatusKind::Modified,
2645 old_text: "j".into()
2646 }
2647 ]
2648 )]
2649 );
2650
2651 // Make another commit that accepts the NEW line but with different content
2652 fs.set_head_for_repo(
2653 path!("/project/.git").as_ref(),
2654 &[("file.txt", "A\nb\nc\nf\nGGG\nh\nDIFFERENT\ni\nj".into())],
2655 "0000002",
2656 );
2657 cx.run_until_parked();
2658 assert_eq!(
2659 unreviewed_hunks(&action_log, cx),
2660 vec![(
2661 buffer,
2662 vec![
2663 HunkStatus {
2664 range: Point::new(6, 0)..Point::new(7, 0),
2665 diff_status: DiffHunkStatusKind::Added,
2666 old_text: "".into()
2667 },
2668 HunkStatus {
2669 range: Point::new(8, 0)..Point::new(8, 1),
2670 diff_status: DiffHunkStatusKind::Modified,
2671 old_text: "j".into()
2672 }
2673 ]
2674 )]
2675 );
2676
2677 // Final commit that accepts all remaining edits
2678 fs.set_head_for_repo(
2679 path!("/project/.git").as_ref(),
2680 &[("file.txt", "A\nb\nc\nf\nGGG\nh\nNEW\ni\nJ".into())],
2681 "0000003",
2682 );
2683 cx.run_until_parked();
2684 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2685 }
2686
2687 #[gpui::test]
2688 async fn test_keep_edits_on_commit_with_shifted_diff_boundaries(cx: &mut TestAppContext) {
2689 init_test(cx);
2690
2691 let initial_text = indoc! {"
2692 use crate::{Alpha, Beta};
2693
2694 fn keep() {
2695 work();
2696 }
2697
2698 fn remove() {
2699 work();
2700 }
2701
2702 fn after() {
2703 work();
2704 }
2705 "};
2706 let fs = FakeFs::new(cx.executor());
2707 fs.insert_tree(
2708 path!("/project"),
2709 json!({
2710 ".git": {},
2711 "file.rs": initial_text,
2712 }),
2713 )
2714 .await;
2715 fs.set_head_for_repo(
2716 path!("/project/.git").as_ref(),
2717 &[("file.rs", initial_text.into())],
2718 "0000000",
2719 );
2720 cx.run_until_parked();
2721
2722 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2723 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2724
2725 let file_path = project
2726 .read_with(cx, |project, cx| {
2727 project.find_project_path(path!("/project/file.rs"), cx)
2728 })
2729 .unwrap();
2730 let buffer = project
2731 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2732 .await
2733 .unwrap();
2734
2735 let final_text = indoc! {"
2736 use crate::{Alpha};
2737
2738 fn keep() {
2739 work();
2740 }
2741
2742 fn after() {
2743 work();
2744 }
2745 "};
2746
2747 cx.update(|cx| {
2748 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2749 buffer.update(cx, |buffer, cx| {
2750 buffer.set_text(final_text, cx);
2751 });
2752 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2753 });
2754 cx.run_until_parked();
2755 assert!(!unreviewed_hunks(&action_log, cx).is_empty());
2756
2757 fs.set_head_for_repo(
2758 path!("/project/.git").as_ref(),
2759 &[("file.rs", final_text.into())],
2760 "0000001",
2761 );
2762 cx.run_until_parked();
2763
2764 assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
2765 }
2766
2767 /// Regression test: when head_commit updates before the BufferDiff's base
2768 /// text does, an intermediate DiffChanged (e.g. from a buffer-edit diff
2769 /// recalculation) must NOT consume the commit signal. The subscription
2770 /// should only fire once the base text itself has changed.
2771 #[gpui::test]
2772 async fn test_keep_edits_on_commit_with_stale_diff_changed(cx: &mut TestAppContext) {
2773 init_test(cx);
2774
2775 let fs = FakeFs::new(cx.executor());
2776 fs.insert_tree(
2777 path!("/project"),
2778 json!({
2779 ".git": {},
2780 "file.txt": "aaa\nbbb\nccc\nddd\neee",
2781 }),
2782 )
2783 .await;
2784 fs.set_head_for_repo(
2785 path!("/project/.git").as_ref(),
2786 &[("file.txt", "aaa\nbbb\nccc\nddd\neee".into())],
2787 "0000000",
2788 );
2789 cx.run_until_parked();
2790
2791 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2792 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2793
2794 let file_path = project
2795 .read_with(cx, |project, cx| {
2796 project.find_project_path(path!("/project/file.txt"), cx)
2797 })
2798 .unwrap();
2799 let buffer = project
2800 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2801 .await
2802 .unwrap();
2803
2804 // Agent makes an edit: bbb -> BBB
2805 cx.update(|cx| {
2806 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2807 buffer.update(cx, |buffer, cx| {
2808 buffer.edit([(Point::new(1, 0)..Point::new(1, 3), "BBB")], None, cx);
2809 });
2810 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2811 });
2812 cx.run_until_parked();
2813
2814 // Verify the edit is tracked
2815 let hunks = unreviewed_hunks(&action_log, cx);
2816 assert_eq!(hunks.len(), 1);
2817 let hunk = &hunks[0].1;
2818 assert_eq!(hunk.len(), 1);
2819 assert_eq!(hunk[0].old_text, "bbb\n");
2820
2821 // Simulate the race condition: update only the HEAD SHA first,
2822 // without changing the committed file contents. This is analogous
2823 // to compute_snapshot updating head_commit before
2824 // reload_buffer_diff_bases has loaded the new base text.
2825 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2826 state.refs.insert("HEAD".into(), "0000001".into());
2827 })
2828 .unwrap();
2829 cx.run_until_parked();
2830
2831 // Make a user edit (on a different line) to trigger a buffer diff
2832 // recalculation. This fires DiffChanged while the BufferDiff base
2833 // text is still the OLD text. With the old head_commit-based
2834 // subscription this would "consume" the commit detection.
2835 cx.update(|cx| {
2836 buffer.update(cx, |buffer, cx| {
2837 buffer.edit([(Point::new(3, 0)..Point::new(3, 3), "DDD")], None, cx);
2838 });
2839 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2840 });
2841 cx.run_until_parked();
2842
2843 // Now update the committed file contents to match the buffer
2844 // (the agent edit was committed). Keep the same SHA so head_commit
2845 // does NOT change again — this is the second half of the race.
2846 {
2847 use git::repository::repo_path;
2848 fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
2849 state
2850 .head_contents
2851 .insert(repo_path("file.txt"), "aaa\nBBB\nccc\nDDD\neee".into());
2852 })
2853 .unwrap();
2854 }
2855 cx.run_until_parked();
2856
2857 // The agent's edit (bbb -> BBB) should be accepted because the
2858 // committed content now matches. Only the user edit (ddd -> DDD)
2859 // should remain, but since the user edit is tracked as coming from
2860 // the user (ChangeAuthor::User) it would have been rebased into
2861 // the diff base already. So no unreviewed hunks should remain.
2862 assert_eq!(
2863 unreviewed_hunks(&action_log, cx),
2864 vec![],
2865 "agent edits should have been accepted after the base text update"
2866 );
2867 }
2868
2869 #[gpui::test]
2870 async fn test_undo_last_reject(cx: &mut TestAppContext) {
2871 init_test(cx);
2872
2873 let fs = FakeFs::new(cx.executor());
2874 fs.insert_tree(
2875 path!("/dir"),
2876 json!({
2877 "file1": "abc\ndef\nghi"
2878 }),
2879 )
2880 .await;
2881 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2882 let action_log = cx.new(|_| ActionLog::new(project.clone()));
2883 let file_path = project
2884 .read_with(cx, |project, cx| project.find_project_path("dir/file1", cx))
2885 .unwrap();
2886
2887 let buffer = project
2888 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2889 .await
2890 .unwrap();
2891
2892 // Track the buffer and make an agent edit
2893 cx.update(|cx| {
2894 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2895 buffer.update(cx, |buffer, cx| {
2896 buffer
2897 .edit(
2898 [(Point::new(1, 0)..Point::new(1, 3), "AGENT_EDIT")],
2899 None,
2900 cx,
2901 )
2902 .unwrap()
2903 });
2904 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
2905 });
2906 cx.run_until_parked();
2907
2908 // Verify the agent edit is there
2909 assert_eq!(
2910 buffer.read_with(cx, |buffer, _| buffer.text()),
2911 "abc\nAGENT_EDIT\nghi"
2912 );
2913 assert!(!unreviewed_hunks(&action_log, cx).is_empty());
2914
2915 // Reject all edits
2916 action_log
2917 .update(cx, |log, cx| log.reject_all_edits(None, cx))
2918 .await;
2919 cx.run_until_parked();
2920
2921 // Verify the buffer is back to original
2922 assert_eq!(
2923 buffer.read_with(cx, |buffer, _| buffer.text()),
2924 "abc\ndef\nghi"
2925 );
2926 assert!(unreviewed_hunks(&action_log, cx).is_empty());
2927
2928 // Verify undo state is available
2929 assert!(action_log.read_with(cx, |log, _| log.has_pending_undo()));
2930
2931 // Undo the reject
2932 action_log
2933 .update(cx, |log, cx| log.undo_last_reject(cx))
2934 .await;
2935
2936 cx.run_until_parked();
2937
2938 // Verify the agent edit is restored
2939 assert_eq!(
2940 buffer.read_with(cx, |buffer, _| buffer.text()),
2941 "abc\nAGENT_EDIT\nghi"
2942 );
2943
2944 // Verify undo state is cleared
2945 assert!(!action_log.read_with(cx, |log, _| log.has_pending_undo()));
2946 }
2947
2948 #[gpui::test]
2949 async fn test_linked_action_log_buffer_read(cx: &mut TestAppContext) {
2950 init_test(cx);
2951
2952 let fs = FakeFs::new(cx.executor());
2953 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
2954 .await;
2955 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
2956 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
2957 let child_log =
2958 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
2959
2960 let file_path = project
2961 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
2962 .unwrap();
2963 let buffer = project
2964 .update(cx, |project, cx| project.open_buffer(file_path, cx))
2965 .await
2966 .unwrap();
2967
2968 cx.update(|cx| {
2969 child_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
2970 });
2971
2972 // Neither log considers the buffer stale immediately after reading it.
2973 let child_stale = cx.read(|cx| {
2974 child_log
2975 .read(cx)
2976 .stale_buffers(cx)
2977 .cloned()
2978 .collect::<Vec<_>>()
2979 });
2980 let parent_stale = cx.read(|cx| {
2981 parent_log
2982 .read(cx)
2983 .stale_buffers(cx)
2984 .cloned()
2985 .collect::<Vec<_>>()
2986 });
2987 assert!(child_stale.is_empty());
2988 assert!(parent_stale.is_empty());
2989
2990 // Simulate a user edit after the agent read the file.
2991 cx.update(|cx| {
2992 buffer.update(cx, |buffer, cx| {
2993 buffer.edit([(0..5, "goodbye")], None, cx).unwrap();
2994 });
2995 });
2996 cx.run_until_parked();
2997
2998 // Both child and parent should see the buffer as stale because both tracked
2999 // it at the pre-edit version via buffer_read forwarding.
3000 let child_stale = cx.read(|cx| {
3001 child_log
3002 .read(cx)
3003 .stale_buffers(cx)
3004 .cloned()
3005 .collect::<Vec<_>>()
3006 });
3007 let parent_stale = cx.read(|cx| {
3008 parent_log
3009 .read(cx)
3010 .stale_buffers(cx)
3011 .cloned()
3012 .collect::<Vec<_>>()
3013 });
3014 assert_eq!(child_stale, vec![buffer.clone()]);
3015 assert_eq!(parent_stale, vec![buffer]);
3016 }
3017
3018 #[gpui::test]
3019 async fn test_linked_action_log_buffer_edited(cx: &mut TestAppContext) {
3020 init_test(cx);
3021
3022 let fs = FakeFs::new(cx.executor());
3023 fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi"}))
3024 .await;
3025 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3026 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3027 let child_log =
3028 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3029
3030 let file_path = project
3031 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3032 .unwrap();
3033 let buffer = project
3034 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3035 .await
3036 .unwrap();
3037
3038 cx.update(|cx| {
3039 child_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3040 buffer.update(cx, |buffer, cx| {
3041 buffer
3042 .edit([(Point::new(1, 0)..Point::new(1, 3), "DEF")], None, cx)
3043 .unwrap();
3044 });
3045 child_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3046 });
3047 cx.run_until_parked();
3048
3049 let expected_hunks = vec![(
3050 buffer,
3051 vec![HunkStatus {
3052 range: Point::new(1, 0)..Point::new(2, 0),
3053 diff_status: DiffHunkStatusKind::Modified,
3054 old_text: "def\n".into(),
3055 }],
3056 )];
3057 assert_eq!(
3058 unreviewed_hunks(&child_log, cx),
3059 expected_hunks,
3060 "child should track the agent edit"
3061 );
3062 assert_eq!(
3063 unreviewed_hunks(&parent_log, cx),
3064 expected_hunks,
3065 "parent should also track the agent edit via linked log forwarding"
3066 );
3067 }
3068
3069 #[gpui::test]
3070 async fn test_linked_action_log_buffer_created(cx: &mut TestAppContext) {
3071 init_test(cx);
3072
3073 let fs = FakeFs::new(cx.executor());
3074 fs.insert_tree(path!("/dir"), json!({})).await;
3075 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3076 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3077 let child_log =
3078 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3079
3080 let file_path = project
3081 .read_with(cx, |project, cx| {
3082 project.find_project_path("dir/new_file", cx)
3083 })
3084 .unwrap();
3085 let buffer = project
3086 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3087 .await
3088 .unwrap();
3089
3090 cx.update(|cx| {
3091 child_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
3092 buffer.update(cx, |buffer, cx| buffer.set_text("hello", cx));
3093 child_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3094 });
3095 project
3096 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
3097 .await
3098 .unwrap();
3099 cx.run_until_parked();
3100
3101 let expected_hunks = vec![(
3102 buffer.clone(),
3103 vec![HunkStatus {
3104 range: Point::new(0, 0)..Point::new(0, 5),
3105 diff_status: DiffHunkStatusKind::Added,
3106 old_text: "".into(),
3107 }],
3108 )];
3109 assert_eq!(
3110 unreviewed_hunks(&child_log, cx),
3111 expected_hunks,
3112 "child should track the created file"
3113 );
3114 assert_eq!(
3115 unreviewed_hunks(&parent_log, cx),
3116 expected_hunks,
3117 "parent should also track the created file via linked log forwarding"
3118 );
3119 }
3120
3121 #[gpui::test]
3122 async fn test_linked_action_log_will_delete_buffer(cx: &mut TestAppContext) {
3123 init_test(cx);
3124
3125 let fs = FakeFs::new(cx.executor());
3126 fs.insert_tree(path!("/dir"), json!({"file": "hello\n"}))
3127 .await;
3128 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3129 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3130 let child_log =
3131 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3132
3133 let file_path = project
3134 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3135 .unwrap();
3136 let buffer = project
3137 .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
3138 .await
3139 .unwrap();
3140
3141 cx.update(|cx| {
3142 child_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
3143 });
3144 project
3145 .update(cx, |project, cx| project.delete_file(file_path, cx))
3146 .unwrap()
3147 .await
3148 .unwrap();
3149 cx.run_until_parked();
3150
3151 let expected_hunks = vec![(
3152 buffer.clone(),
3153 vec![HunkStatus {
3154 range: Point::new(0, 0)..Point::new(0, 0),
3155 diff_status: DiffHunkStatusKind::Deleted,
3156 old_text: "hello\n".into(),
3157 }],
3158 )];
3159 assert_eq!(
3160 unreviewed_hunks(&child_log, cx),
3161 expected_hunks,
3162 "child should track the deleted file"
3163 );
3164 assert_eq!(
3165 unreviewed_hunks(&parent_log, cx),
3166 expected_hunks,
3167 "parent should also track the deleted file via linked log forwarding"
3168 );
3169 }
3170
3171 /// Simulates the subagent scenario: two child logs linked to the same parent, each
3172 /// editing a different file. The parent accumulates all edits while each child
3173 /// only sees its own.
3174 #[gpui::test]
3175 async fn test_linked_action_log_independent_tracking(cx: &mut TestAppContext) {
3176 init_test(cx);
3177
3178 let fs = FakeFs::new(cx.executor());
3179 fs.insert_tree(
3180 path!("/dir"),
3181 json!({
3182 "file_a": "content of a",
3183 "file_b": "content of b",
3184 }),
3185 )
3186 .await;
3187 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3188 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3189 let child_log_1 =
3190 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3191 let child_log_2 =
3192 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3193
3194 let file_a_path = project
3195 .read_with(cx, |project, cx| {
3196 project.find_project_path("dir/file_a", cx)
3197 })
3198 .unwrap();
3199 let file_b_path = project
3200 .read_with(cx, |project, cx| {
3201 project.find_project_path("dir/file_b", cx)
3202 })
3203 .unwrap();
3204 let buffer_a = project
3205 .update(cx, |project, cx| project.open_buffer(file_a_path, cx))
3206 .await
3207 .unwrap();
3208 let buffer_b = project
3209 .update(cx, |project, cx| project.open_buffer(file_b_path, cx))
3210 .await
3211 .unwrap();
3212
3213 cx.update(|cx| {
3214 child_log_1.update(cx, |log, cx| log.buffer_read(buffer_a.clone(), cx));
3215 buffer_a.update(cx, |buffer, cx| {
3216 buffer.edit([(0..0, "MODIFIED: ")], None, cx).unwrap();
3217 });
3218 child_log_1.update(cx, |log, cx| log.buffer_edited(buffer_a.clone(), cx));
3219
3220 child_log_2.update(cx, |log, cx| log.buffer_read(buffer_b.clone(), cx));
3221 buffer_b.update(cx, |buffer, cx| {
3222 buffer.edit([(0..0, "MODIFIED: ")], None, cx).unwrap();
3223 });
3224 child_log_2.update(cx, |log, cx| log.buffer_edited(buffer_b.clone(), cx));
3225 });
3226 cx.run_until_parked();
3227
3228 let child_1_changed: Vec<_> = cx.read(|cx| {
3229 child_log_1
3230 .read(cx)
3231 .changed_buffers(cx)
3232 .map(|(buffer, _)| buffer)
3233 .collect()
3234 });
3235 let child_2_changed: Vec<_> = cx.read(|cx| {
3236 child_log_2
3237 .read(cx)
3238 .changed_buffers(cx)
3239 .map(|(buffer, _)| buffer)
3240 .collect()
3241 });
3242 let parent_changed: Vec<_> = cx.read(|cx| {
3243 parent_log
3244 .read(cx)
3245 .changed_buffers(cx)
3246 .map(|(buffer, _)| buffer)
3247 .collect()
3248 });
3249
3250 assert_eq!(
3251 child_1_changed,
3252 vec![buffer_a.clone()],
3253 "child 1 should only track file_a"
3254 );
3255 assert_eq!(
3256 child_2_changed,
3257 vec![buffer_b.clone()],
3258 "child 2 should only track file_b"
3259 );
3260 assert_eq!(parent_changed.len(), 2, "parent should track both files");
3261 assert!(
3262 parent_changed.contains(&buffer_a) && parent_changed.contains(&buffer_b),
3263 "parent should contain both buffer_a and buffer_b"
3264 );
3265 }
3266
3267 #[gpui::test]
3268 async fn test_file_read_time_recorded_on_buffer_read(cx: &mut TestAppContext) {
3269 init_test(cx);
3270
3271 let fs = FakeFs::new(cx.executor());
3272 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3273 .await;
3274 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3275 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3276
3277 let file_path = project
3278 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3279 .unwrap();
3280 let buffer = project
3281 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3282 .await
3283 .unwrap();
3284
3285 let abs_path = PathBuf::from(path!("/dir/file"));
3286 assert!(
3287 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3288 "file_read_time should be None before buffer_read"
3289 );
3290
3291 cx.update(|cx| {
3292 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3293 });
3294
3295 assert!(
3296 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3297 "file_read_time should be recorded after buffer_read"
3298 );
3299 }
3300
3301 #[gpui::test]
3302 async fn test_file_read_time_recorded_on_buffer_edited(cx: &mut TestAppContext) {
3303 init_test(cx);
3304
3305 let fs = FakeFs::new(cx.executor());
3306 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3307 .await;
3308 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3309 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3310
3311 let file_path = project
3312 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3313 .unwrap();
3314 let buffer = project
3315 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3316 .await
3317 .unwrap();
3318
3319 let abs_path = PathBuf::from(path!("/dir/file"));
3320 assert!(
3321 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3322 "file_read_time should be None before buffer_edited"
3323 );
3324
3325 cx.update(|cx| {
3326 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3327 });
3328
3329 assert!(
3330 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3331 "file_read_time should be recorded after buffer_edited"
3332 );
3333 }
3334
3335 #[gpui::test]
3336 async fn test_file_read_time_recorded_on_buffer_created(cx: &mut TestAppContext) {
3337 init_test(cx);
3338
3339 let fs = FakeFs::new(cx.executor());
3340 fs.insert_tree(path!("/dir"), json!({"file": "existing content"}))
3341 .await;
3342 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3343 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3344
3345 let file_path = project
3346 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3347 .unwrap();
3348 let buffer = project
3349 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3350 .await
3351 .unwrap();
3352
3353 let abs_path = PathBuf::from(path!("/dir/file"));
3354 assert!(
3355 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3356 "file_read_time should be None before buffer_created"
3357 );
3358
3359 cx.update(|cx| {
3360 action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
3361 });
3362
3363 assert!(
3364 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3365 "file_read_time should be recorded after buffer_created"
3366 );
3367 }
3368
3369 #[gpui::test]
3370 async fn test_file_read_time_removed_on_delete(cx: &mut TestAppContext) {
3371 init_test(cx);
3372
3373 let fs = FakeFs::new(cx.executor());
3374 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3375 .await;
3376 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3377 let action_log = cx.new(|_| ActionLog::new(project.clone()));
3378
3379 let file_path = project
3380 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3381 .unwrap();
3382 let buffer = project
3383 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3384 .await
3385 .unwrap();
3386
3387 let abs_path = PathBuf::from(path!("/dir/file"));
3388
3389 cx.update(|cx| {
3390 action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3391 });
3392 assert!(
3393 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3394 "file_read_time should exist after buffer_read"
3395 );
3396
3397 cx.update(|cx| {
3398 action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
3399 });
3400 assert!(
3401 action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3402 "file_read_time should be removed after will_delete_buffer"
3403 );
3404 }
3405
3406 #[gpui::test]
3407 async fn test_file_read_time_not_forwarded_to_linked_action_log(cx: &mut TestAppContext) {
3408 init_test(cx);
3409
3410 let fs = FakeFs::new(cx.executor());
3411 fs.insert_tree(path!("/dir"), json!({"file": "hello world"}))
3412 .await;
3413 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
3414 let parent_log = cx.new(|_| ActionLog::new(project.clone()));
3415 let child_log =
3416 cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone()));
3417
3418 let file_path = project
3419 .read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
3420 .unwrap();
3421 let buffer = project
3422 .update(cx, |project, cx| project.open_buffer(file_path, cx))
3423 .await
3424 .unwrap();
3425
3426 let abs_path = PathBuf::from(path!("/dir/file"));
3427
3428 cx.update(|cx| {
3429 child_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
3430 });
3431 assert!(
3432 child_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()),
3433 "child should record file_read_time on buffer_read"
3434 );
3435 assert!(
3436 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3437 "parent should NOT get file_read_time from child's buffer_read"
3438 );
3439
3440 cx.update(|cx| {
3441 child_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
3442 });
3443 assert!(
3444 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3445 "parent should NOT get file_read_time from child's buffer_edited"
3446 );
3447
3448 cx.update(|cx| {
3449 child_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx));
3450 });
3451 assert!(
3452 parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()),
3453 "parent should NOT get file_read_time from child's buffer_created"
3454 );
3455 }
3456
3457 #[derive(Debug, PartialEq)]
3458 struct HunkStatus {
3459 range: Range<Point>,
3460 diff_status: DiffHunkStatusKind,
3461 old_text: String,
3462 }
3463
3464 fn unreviewed_hunks(
3465 action_log: &Entity<ActionLog>,
3466 cx: &TestAppContext,
3467 ) -> Vec<(Entity<Buffer>, Vec<HunkStatus>)> {
3468 cx.read(|cx| {
3469 action_log
3470 .read(cx)
3471 .changed_buffers(cx)
3472 .map(|(buffer, diff)| {
3473 let snapshot = buffer.read(cx).snapshot();
3474 (
3475 buffer,
3476 diff.read(cx)
3477 .snapshot(cx)
3478 .hunks(&snapshot)
3479 .map(|hunk| HunkStatus {
3480 diff_status: hunk.status().kind,
3481 range: hunk.range,
3482 old_text: diff
3483 .read(cx)
3484 .base_text(cx)
3485 .text_for_range(hunk.diff_base_byte_range)
3486 .collect(),
3487 })
3488 .collect(),
3489 )
3490 })
3491 .collect()
3492 })
3493 }
3494}
3495