action_log.rs

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