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