Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:57:36.981Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

action_log.rs

3495 lines · 129.4 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.