edit_file_tool.rs

   1use crate::{
   2    Templates,
   3    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
   4    schema::json_schema_for,
   5};
   6use anyhow::{Context as _, Result, anyhow};
   7use assistant_tool::{
   8    ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput,
   9    ToolUseStatus,
  10};
  11use language::language_settings::{self, FormatOnSave};
  12use project::lsp_store::{FormatTrigger, LspFormatTarget};
  13use std::collections::HashSet;
  14
  15use buffer_diff::{BufferDiff, BufferDiffSnapshot};
  16use editor::{Editor, EditorMode, MultiBuffer, PathKey};
  17use futures::StreamExt;
  18use gpui::{
  19    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, EntityId, Task,
  20    TextStyleRefinement, WeakEntity, pulsating_between,
  21};
  22use indoc::formatdoc;
  23use language::{
  24    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Rope, TextBuffer,
  25    language_settings::SoftWrap,
  26};
  27use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
  28use markdown::{Markdown, MarkdownElement, MarkdownStyle};
  29use project::{Project, ProjectPath};
  30use schemars::JsonSchema;
  31use serde::{Deserialize, Serialize};
  32use settings::Settings;
  33use std::{
  34    path::{Path, PathBuf},
  35    sync::Arc,
  36    time::Duration,
  37};
  38use theme::ThemeSettings;
  39use ui::{Disclosure, Tooltip, prelude::*};
  40use util::ResultExt;
  41use workspace::Workspace;
  42
  43pub struct EditFileTool;
  44
  45#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  46pub struct EditFileToolInput {
  47    /// A one-line, user-friendly markdown description of the edit. This will be
  48    /// shown in the UI and also passed to another model to perform the edit.
  49    ///
  50    /// Be terse, but also descriptive in what you want to achieve with this
  51    /// edit. Avoid generic instructions.
  52    ///
  53    /// NEVER mention the file path in this description.
  54    ///
  55    /// <example>Fix API endpoint URLs</example>
  56    /// <example>Update copyright year in `page_footer`</example>
  57    ///
  58    /// Make sure to include this field before all the others in the input object
  59    /// so that we can display it immediately.
  60    pub display_description: String,
  61
  62    /// The full path of the file to create or modify in the project.
  63    ///
  64    /// WARNING: When specifying which file path need changing, you MUST
  65    /// start each path with one of the project's root directories.
  66    ///
  67    /// The following examples assume we have two root directories in the project:
  68    /// - backend
  69    /// - frontend
  70    ///
  71    /// <example>
  72    /// `backend/src/main.rs`
  73    ///
  74    /// Notice how the file path starts with root-1. Without that, the path
  75    /// would be ambiguous and the call would fail!
  76    /// </example>
  77    ///
  78    /// <example>
  79    /// `frontend/db.js`
  80    /// </example>
  81    pub path: PathBuf,
  82
  83    /// The mode of operation on the file. Possible values:
  84    /// - 'edit': Make granular edits to an existing file.
  85    /// - 'create': Create a new file if it doesn't exist.
  86    /// - 'overwrite': Replace the entire contents of an existing file.
  87    ///
  88    /// When a file already exists or you just created it, prefer editing
  89    /// it as opposed to recreating it from scratch.
  90    pub mode: EditFileMode,
  91}
  92
  93#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  94#[serde(rename_all = "lowercase")]
  95pub enum EditFileMode {
  96    Edit,
  97    Create,
  98    Overwrite,
  99}
 100
 101#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 102pub struct EditFileToolOutput {
 103    pub original_path: PathBuf,
 104    pub new_text: String,
 105    pub old_text: String,
 106    pub raw_output: Option<EditAgentOutput>,
 107}
 108
 109#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 110struct PartialInput {
 111    #[serde(default)]
 112    path: String,
 113    #[serde(default)]
 114    display_description: String,
 115}
 116
 117const DEFAULT_UI_TEXT: &str = "Editing file";
 118
 119impl Tool for EditFileTool {
 120    fn name(&self) -> String {
 121        "edit_file".into()
 122    }
 123
 124    fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
 125        false
 126    }
 127
 128    fn description(&self) -> String {
 129        include_str!("edit_file_tool/description.md").to_string()
 130    }
 131
 132    fn icon(&self) -> IconName {
 133        IconName::Pencil
 134    }
 135
 136    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 137        json_schema_for::<EditFileToolInput>(format)
 138    }
 139
 140    fn ui_text(&self, input: &serde_json::Value) -> String {
 141        match serde_json::from_value::<EditFileToolInput>(input.clone()) {
 142            Ok(input) => input.display_description,
 143            Err(_) => "Editing file".to_string(),
 144        }
 145    }
 146
 147    fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
 148        if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
 149            let description = input.display_description.trim();
 150            if !description.is_empty() {
 151                return description.to_string();
 152            }
 153
 154            let path = input.path.trim();
 155            if !path.is_empty() {
 156                return path.to_string();
 157            }
 158        }
 159
 160        DEFAULT_UI_TEXT.to_string()
 161    }
 162
 163    fn run(
 164        self: Arc<Self>,
 165        input: serde_json::Value,
 166        request: Arc<LanguageModelRequest>,
 167        project: Entity<Project>,
 168        action_log: Entity<ActionLog>,
 169        model: Arc<dyn LanguageModel>,
 170        window: Option<AnyWindowHandle>,
 171        cx: &mut App,
 172    ) -> ToolResult {
 173        let input = match serde_json::from_value::<EditFileToolInput>(input) {
 174            Ok(input) => input,
 175            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 176        };
 177
 178        let project_path = match resolve_path(&input, project.clone(), cx) {
 179            Ok(path) => path,
 180            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 181        };
 182
 183        let card = window.and_then(|window| {
 184            window
 185                .update(cx, |_, window, cx| {
 186                    cx.new(|cx| {
 187                        EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
 188                    })
 189                })
 190                .ok()
 191        });
 192
 193        let card_clone = card.clone();
 194        let task = cx.spawn(async move |cx: &mut AsyncApp| {
 195            let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new());
 196
 197            let buffer = project
 198                .update(cx, |project, cx| {
 199                    project.open_buffer(project_path.clone(), cx)
 200                })?
 201                .await?;
 202
 203            let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 204            let old_text = cx
 205                .background_spawn({
 206                    let old_snapshot = old_snapshot.clone();
 207                    async move { old_snapshot.text() }
 208                })
 209                .await;
 210
 211            let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
 212                edit_agent.edit(
 213                    buffer.clone(),
 214                    input.display_description.clone(),
 215                    &request,
 216                    cx,
 217                )
 218            } else {
 219                edit_agent.overwrite(
 220                    buffer.clone(),
 221                    input.display_description.clone(),
 222                    &request,
 223                    cx,
 224                )
 225            };
 226
 227            let mut hallucinated_old_text = false;
 228            while let Some(event) = events.next().await {
 229                match event {
 230                    EditAgentOutputEvent::Edited => {
 231                        if let Some(card) = card_clone.as_ref() {
 232                            let new_snapshot =
 233                                buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 234                            let new_text = cx
 235                                .background_spawn({
 236                                    let new_snapshot = new_snapshot.clone();
 237                                    async move { new_snapshot.text() }
 238                                })
 239                                .await;
 240                            card.update(cx, |card, cx| {
 241                                card.set_diff(
 242                                    project_path.path.clone(),
 243                                    old_text.clone(),
 244                                    new_text,
 245                                    cx,
 246                                );
 247                            })
 248                            .log_err();
 249                        }
 250                    }
 251                    EditAgentOutputEvent::OldTextNotFound(_) => hallucinated_old_text = true,
 252                }
 253            }
 254            let agent_output = output.await?;
 255
 256            // Format buffer if format_on_save is enabled, before saving.
 257            // If any part of the formatting operation fails, log an error but
 258            // don't block the completion of the edit tool's work.
 259            let should_format = buffer
 260                .read_with(cx, |buffer, cx| {
 261                    let settings = language_settings::language_settings(
 262                        buffer.language().map(|l| l.name()),
 263                        buffer.file(),
 264                        cx,
 265                    );
 266                    !matches!(settings.format_on_save, FormatOnSave::Off)
 267                })
 268                .log_err()
 269                .unwrap_or(false);
 270
 271            if should_format {
 272                let buffers = HashSet::from_iter([buffer.clone()]);
 273
 274                if let Some(format_task) = project
 275                    .update(cx, move |project, cx| {
 276                        project.format(
 277                            buffers,
 278                            LspFormatTarget::Buffers,
 279                            false, // Don't push to history since the tool did it.
 280                            FormatTrigger::Save,
 281                            cx,
 282                        )
 283                    })
 284                    .log_err()
 285                {
 286                    format_task.await.log_err();
 287                }
 288            }
 289
 290            project
 291                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
 292                .await?;
 293
 294            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 295            let new_text = cx.background_spawn({
 296                let new_snapshot = new_snapshot.clone();
 297                async move { new_snapshot.text() }
 298            });
 299            let diff = cx.background_spawn(async move {
 300                language::unified_diff(&old_snapshot.text(), &new_snapshot.text())
 301            });
 302            let (new_text, diff) = futures::join!(new_text, diff);
 303
 304            let output = EditFileToolOutput {
 305                original_path: project_path.path.to_path_buf(),
 306                new_text: new_text.clone(),
 307                old_text: old_text.clone(),
 308                raw_output: Some(agent_output),
 309            };
 310
 311            if let Some(card) = card_clone {
 312                card.update(cx, |card, cx| {
 313                    card.set_diff(project_path.path.clone(), old_text, new_text, cx);
 314                })
 315                .log_err();
 316            }
 317
 318            let input_path = input.path.display();
 319            if diff.is_empty() {
 320                anyhow::ensure!(
 321                    !hallucinated_old_text,
 322                    formatdoc! {"
 323                    Some edits were produced but none of them could be applied.
 324                    Read the relevant sections of {input_path} again so that
 325                    I can perform the requested edits.
 326                "}
 327                );
 328                Ok("No edits were made.".to_string().into())
 329            } else {
 330                Ok(ToolResultOutput {
 331                    content: ToolResultContent::Text(format!(
 332                        "Edited {}:\n\n```diff\n{}\n```",
 333                        input_path, diff
 334                    )),
 335                    output: serde_json::to_value(output).ok(),
 336                })
 337            }
 338        });
 339
 340        ToolResult {
 341            output: task,
 342            card: card.map(AnyToolCard::from),
 343        }
 344    }
 345
 346    fn deserialize_card(
 347        self: Arc<Self>,
 348        output: serde_json::Value,
 349        project: Entity<Project>,
 350        window: &mut Window,
 351        cx: &mut App,
 352    ) -> Option<AnyToolCard> {
 353        let output = match serde_json::from_value::<EditFileToolOutput>(output) {
 354            Ok(output) => output,
 355            Err(_) => return None,
 356        };
 357
 358        let card = cx.new(|cx| {
 359            let mut card = EditFileToolCard::new(output.original_path.clone(), project, window, cx);
 360            card.set_diff(
 361                output.original_path.into(),
 362                output.old_text,
 363                output.new_text,
 364                cx,
 365            );
 366            card
 367        });
 368
 369        Some(card.into())
 370    }
 371}
 372
 373/// Validate that the file path is valid, meaning:
 374///
 375/// - For `edit` and `overwrite`, the path must point to an existing file.
 376/// - For `create`, the file must not already exist, but it's parent dir must exist.
 377fn resolve_path(
 378    input: &EditFileToolInput,
 379    project: Entity<Project>,
 380    cx: &mut App,
 381) -> Result<ProjectPath> {
 382    let project = project.read(cx);
 383
 384    match input.mode {
 385        EditFileMode::Edit | EditFileMode::Overwrite => {
 386            let path = project
 387                .find_project_path(&input.path, cx)
 388                .context("Can't edit file: path not found")?;
 389
 390            let entry = project
 391                .entry_for_path(&path, cx)
 392                .context("Can't edit file: path not found")?;
 393
 394            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
 395            Ok(path)
 396        }
 397
 398        EditFileMode::Create => {
 399            if let Some(path) = project.find_project_path(&input.path, cx) {
 400                anyhow::ensure!(
 401                    project.entry_for_path(&path, cx).is_none(),
 402                    "Can't create file: file already exists"
 403                );
 404            }
 405
 406            let parent_path = input
 407                .path
 408                .parent()
 409                .context("Can't create file: incorrect path")?;
 410
 411            let parent_project_path = project.find_project_path(&parent_path, cx);
 412
 413            let parent_entry = parent_project_path
 414                .as_ref()
 415                .and_then(|path| project.entry_for_path(&path, cx))
 416                .context("Can't create file: parent directory doesn't exist")?;
 417
 418            anyhow::ensure!(
 419                parent_entry.is_dir(),
 420                "Can't create file: parent is not a directory"
 421            );
 422
 423            let file_name = input
 424                .path
 425                .file_name()
 426                .context("Can't create file: invalid filename")?;
 427
 428            let new_file_path = parent_project_path.map(|parent| ProjectPath {
 429                path: Arc::from(parent.path.join(file_name)),
 430                ..parent
 431            });
 432
 433            new_file_path.context("Can't create file")
 434        }
 435    }
 436}
 437
 438pub struct EditFileToolCard {
 439    path: PathBuf,
 440    editor: Entity<Editor>,
 441    multibuffer: Entity<MultiBuffer>,
 442    project: Entity<Project>,
 443    diff_task: Option<Task<Result<()>>>,
 444    preview_expanded: bool,
 445    error_expanded: Option<Entity<Markdown>>,
 446    full_height_expanded: bool,
 447    total_lines: Option<u32>,
 448    editor_unique_id: EntityId,
 449}
 450
 451impl EditFileToolCard {
 452    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
 453        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
 454        let editor = cx.new(|cx| {
 455            let mut editor = Editor::new(
 456                EditorMode::Full {
 457                    scale_ui_elements_with_buffer_font_size: false,
 458                    show_active_line_background: false,
 459                    sized_by_content: true,
 460                },
 461                multibuffer.clone(),
 462                Some(project.clone()),
 463                window,
 464                cx,
 465            );
 466            editor.set_show_gutter(false, cx);
 467            editor.disable_inline_diagnostics();
 468            editor.disable_expand_excerpt_buttons(cx);
 469            editor.disable_scrollbars_and_minimap(window, cx);
 470            editor.set_soft_wrap_mode(SoftWrap::None, cx);
 471            editor.scroll_manager.set_forbid_vertical_scroll(true);
 472            editor.set_show_indent_guides(false, cx);
 473            editor.set_read_only(true);
 474            editor.set_show_breakpoints(false, cx);
 475            editor.set_show_code_actions(false, cx);
 476            editor.set_show_git_diff_gutter(false, cx);
 477            editor.set_expand_all_diff_hunks(cx);
 478            editor
 479        });
 480        Self {
 481            editor_unique_id: editor.entity_id(),
 482            path,
 483            project,
 484            editor,
 485            multibuffer,
 486            diff_task: None,
 487            preview_expanded: true,
 488            error_expanded: None,
 489            full_height_expanded: true,
 490            total_lines: None,
 491        }
 492    }
 493
 494    pub fn has_diff(&self) -> bool {
 495        self.total_lines.is_some()
 496    }
 497
 498    pub fn set_diff(
 499        &mut self,
 500        path: Arc<Path>,
 501        old_text: String,
 502        new_text: String,
 503        cx: &mut Context<Self>,
 504    ) {
 505        let language_registry = self.project.read(cx).languages().clone();
 506        self.diff_task = Some(cx.spawn(async move |this, cx| {
 507            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
 508            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
 509
 510            this.update(cx, |this, cx| {
 511                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
 512                    let snapshot = buffer.read(cx).snapshot();
 513                    let diff = buffer_diff.read(cx);
 514                    let diff_hunk_ranges = diff
 515                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
 516                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
 517                        .collect::<Vec<_>>();
 518                    multibuffer.clear(cx);
 519                    multibuffer.set_excerpts_for_path(
 520                        PathKey::for_buffer(&buffer, cx),
 521                        buffer,
 522                        diff_hunk_ranges,
 523                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
 524                        cx,
 525                    );
 526                    multibuffer.add_diff(buffer_diff, cx);
 527                    let end = multibuffer.len(cx);
 528                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
 529                });
 530
 531                cx.notify();
 532            })
 533        }));
 534    }
 535}
 536
 537impl ToolCard for EditFileToolCard {
 538    fn render(
 539        &mut self,
 540        status: &ToolUseStatus,
 541        window: &mut Window,
 542        workspace: WeakEntity<Workspace>,
 543        cx: &mut Context<Self>,
 544    ) -> impl IntoElement {
 545        let error_message = match status {
 546            ToolUseStatus::Error(err) => Some(err),
 547            _ => None,
 548        };
 549
 550        let path_label_button = h_flex()
 551            .id(("edit-tool-path-label-button", self.editor_unique_id))
 552            .w_full()
 553            .max_w_full()
 554            .px_1()
 555            .gap_0p5()
 556            .cursor_pointer()
 557            .rounded_sm()
 558            .opacity(0.8)
 559            .hover(|label| {
 560                label
 561                    .opacity(1.)
 562                    .bg(cx.theme().colors().element_hover.opacity(0.5))
 563            })
 564            .tooltip(Tooltip::text("Jump to File"))
 565            .child(
 566                h_flex()
 567                    .child(
 568                        Icon::new(IconName::Pencil)
 569                            .size(IconSize::XSmall)
 570                            .color(Color::Muted),
 571                    )
 572                    .child(
 573                        div()
 574                            .text_size(rems(0.8125))
 575                            .child(self.path.display().to_string())
 576                            .ml_1p5()
 577                            .mr_0p5(),
 578                    )
 579                    .child(
 580                        Icon::new(IconName::ArrowUpRight)
 581                            .size(IconSize::XSmall)
 582                            .color(Color::Ignored),
 583                    ),
 584            )
 585            .on_click({
 586                let path = self.path.clone();
 587                let workspace = workspace.clone();
 588                move |_, window, cx| {
 589                    workspace
 590                        .update(cx, {
 591                            |workspace, cx| {
 592                                let Some(project_path) =
 593                                    workspace.project().read(cx).find_project_path(&path, cx)
 594                                else {
 595                                    return;
 596                                };
 597                                let open_task =
 598                                    workspace.open_path(project_path, None, true, window, cx);
 599                                window
 600                                    .spawn(cx, async move |cx| {
 601                                        let item = open_task.await?;
 602                                        if let Some(active_editor) = item.downcast::<Editor>() {
 603                                            active_editor
 604                                                .update_in(cx, |editor, window, cx| {
 605                                                    editor.go_to_singleton_buffer_point(
 606                                                        language::Point::new(0, 0),
 607                                                        window,
 608                                                        cx,
 609                                                    );
 610                                                })
 611                                                .log_err();
 612                                        }
 613                                        anyhow::Ok(())
 614                                    })
 615                                    .detach_and_log_err(cx);
 616                            }
 617                        })
 618                        .ok();
 619                }
 620            })
 621            .into_any_element();
 622
 623        let codeblock_header_bg = cx
 624            .theme()
 625            .colors()
 626            .element_background
 627            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
 628
 629        let codeblock_header = h_flex()
 630            .flex_none()
 631            .p_1()
 632            .gap_1()
 633            .justify_between()
 634            .rounded_t_md()
 635            .when(error_message.is_none(), |header| {
 636                header.bg(codeblock_header_bg)
 637            })
 638            .child(path_label_button)
 639            .when_some(error_message, |header, error_message| {
 640                header.child(
 641                    h_flex()
 642                        .gap_1()
 643                        .child(
 644                            Icon::new(IconName::Close)
 645                                .size(IconSize::Small)
 646                                .color(Color::Error),
 647                        )
 648                        .child(
 649                            Disclosure::new(
 650                                ("edit-file-error-disclosure", self.editor_unique_id),
 651                                self.error_expanded.is_some(),
 652                            )
 653                            .opened_icon(IconName::ChevronUp)
 654                            .closed_icon(IconName::ChevronDown)
 655                            .on_click(cx.listener({
 656                                let error_message = error_message.clone();
 657
 658                                move |this, _event, _window, cx| {
 659                                    if this.error_expanded.is_some() {
 660                                        this.error_expanded.take();
 661                                    } else {
 662                                        this.error_expanded = Some(cx.new(|cx| {
 663                                            Markdown::new(error_message.clone(), None, None, cx)
 664                                        }))
 665                                    }
 666                                    cx.notify();
 667                                }
 668                            })),
 669                        ),
 670                )
 671            })
 672            .when(error_message.is_none() && self.has_diff(), |header| {
 673                header.child(
 674                    Disclosure::new(
 675                        ("edit-file-disclosure", self.editor_unique_id),
 676                        self.preview_expanded,
 677                    )
 678                    .opened_icon(IconName::ChevronUp)
 679                    .closed_icon(IconName::ChevronDown)
 680                    .on_click(cx.listener(
 681                        move |this, _event, _window, _cx| {
 682                            this.preview_expanded = !this.preview_expanded;
 683                        },
 684                    )),
 685                )
 686            });
 687
 688        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
 689            let line_height = editor
 690                .style()
 691                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
 692                .unwrap_or_default();
 693
 694            editor.set_text_style_refinement(TextStyleRefinement {
 695                font_size: Some(
 696                    TextSize::Small
 697                        .rems(cx)
 698                        .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
 699                        .into(),
 700                ),
 701                ..TextStyleRefinement::default()
 702            });
 703            let element = editor.render(window, cx);
 704            (element.into_any_element(), line_height)
 705        });
 706
 707        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
 708            (IconName::ChevronUp, "Collapse Code Block")
 709        } else {
 710            (IconName::ChevronDown, "Expand Code Block")
 711        };
 712
 713        let gradient_overlay =
 714            div()
 715                .absolute()
 716                .bottom_0()
 717                .left_0()
 718                .w_full()
 719                .h_2_5()
 720                .bg(gpui::linear_gradient(
 721                    0.,
 722                    gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
 723                    gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
 724                ));
 725
 726        let border_color = cx.theme().colors().border.opacity(0.6);
 727
 728        const DEFAULT_COLLAPSED_LINES: u32 = 10;
 729        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
 730
 731        let waiting_for_diff = {
 732            let styles = [
 733                ("w_4_5", (0.1, 0.85), 2000),
 734                ("w_1_4", (0.2, 0.75), 2200),
 735                ("w_2_4", (0.15, 0.64), 1900),
 736                ("w_3_5", (0.25, 0.72), 2300),
 737                ("w_2_5", (0.3, 0.56), 1800),
 738            ];
 739
 740            let mut container = v_flex()
 741                .p_3()
 742                .gap_1()
 743                .border_t_1()
 744                .rounded_b_md()
 745                .border_color(border_color)
 746                .bg(cx.theme().colors().editor_background);
 747
 748            for (width_method, pulse_range, duration_ms) in styles.iter() {
 749                let (min_opacity, max_opacity) = *pulse_range;
 750                let placeholder = match *width_method {
 751                    "w_4_5" => div().w_3_4(),
 752                    "w_1_4" => div().w_1_4(),
 753                    "w_2_4" => div().w_2_4(),
 754                    "w_3_5" => div().w_3_5(),
 755                    "w_2_5" => div().w_2_5(),
 756                    _ => div().w_1_2(),
 757                }
 758                .id("loading_div")
 759                .h_1()
 760                .rounded_full()
 761                .bg(cx.theme().colors().element_active)
 762                .with_animation(
 763                    "loading_pulsate",
 764                    Animation::new(Duration::from_millis(*duration_ms))
 765                        .repeat()
 766                        .with_easing(pulsating_between(min_opacity, max_opacity)),
 767                    |label, delta| label.opacity(delta),
 768                );
 769
 770                container = container.child(placeholder);
 771            }
 772
 773            container
 774        };
 775
 776        v_flex()
 777            .mb_2()
 778            .border_1()
 779            .when(error_message.is_some(), |card| card.border_dashed())
 780            .border_color(border_color)
 781            .rounded_md()
 782            .overflow_hidden()
 783            .child(codeblock_header)
 784            .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
 785                card.child(
 786                    v_flex()
 787                        .p_2()
 788                        .gap_1()
 789                        .border_t_1()
 790                        .border_dashed()
 791                        .border_color(border_color)
 792                        .bg(cx.theme().colors().editor_background)
 793                        .rounded_b_md()
 794                        .child(
 795                            Label::new("Error")
 796                                .size(LabelSize::XSmall)
 797                                .color(Color::Error),
 798                        )
 799                        .child(
 800                            div()
 801                                .rounded_md()
 802                                .text_ui_sm(cx)
 803                                .bg(cx.theme().colors().editor_background)
 804                                .child(MarkdownElement::new(
 805                                    error_markdown.clone(),
 806                                    markdown_style(window, cx),
 807                                )),
 808                        ),
 809                )
 810            })
 811            .when(!self.has_diff() && error_message.is_none(), |card| {
 812                card.child(waiting_for_diff)
 813            })
 814            .when(self.preview_expanded && self.has_diff(), |card| {
 815                card.child(
 816                    v_flex()
 817                        .relative()
 818                        .h_full()
 819                        .when(!self.full_height_expanded, |editor_container| {
 820                            editor_container
 821                                .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
 822                        })
 823                        .overflow_hidden()
 824                        .border_t_1()
 825                        .border_color(border_color)
 826                        .bg(cx.theme().colors().editor_background)
 827                        .child(editor)
 828                        .when(
 829                            !self.full_height_expanded && is_collapsible,
 830                            |editor_container| editor_container.child(gradient_overlay),
 831                        ),
 832                )
 833                .when(is_collapsible, |card| {
 834                    card.child(
 835                        h_flex()
 836                            .id(("expand-button", self.editor_unique_id))
 837                            .flex_none()
 838                            .cursor_pointer()
 839                            .h_5()
 840                            .justify_center()
 841                            .border_t_1()
 842                            .rounded_b_md()
 843                            .border_color(border_color)
 844                            .bg(cx.theme().colors().editor_background)
 845                            .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
 846                            .child(
 847                                Icon::new(full_height_icon)
 848                                    .size(IconSize::Small)
 849                                    .color(Color::Muted),
 850                            )
 851                            .tooltip(Tooltip::text(full_height_tooltip_label))
 852                            .on_click(cx.listener(move |this, _event, _window, _cx| {
 853                                this.full_height_expanded = !this.full_height_expanded;
 854                            })),
 855                    )
 856                })
 857            })
 858    }
 859}
 860
 861fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
 862    let theme_settings = ThemeSettings::get_global(cx);
 863    let ui_font_size = TextSize::Default.rems(cx);
 864    let mut text_style = window.text_style();
 865
 866    text_style.refine(&TextStyleRefinement {
 867        font_family: Some(theme_settings.ui_font.family.clone()),
 868        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
 869        font_features: Some(theme_settings.ui_font.features.clone()),
 870        font_size: Some(ui_font_size.into()),
 871        color: Some(cx.theme().colors().text),
 872        ..Default::default()
 873    });
 874
 875    MarkdownStyle {
 876        base_text_style: text_style.clone(),
 877        selection_background_color: cx.theme().players().local().selection,
 878        ..Default::default()
 879    }
 880}
 881
 882async fn build_buffer(
 883    mut text: String,
 884    path: Arc<Path>,
 885    language_registry: &Arc<language::LanguageRegistry>,
 886    cx: &mut AsyncApp,
 887) -> Result<Entity<Buffer>> {
 888    let line_ending = LineEnding::detect(&text);
 889    LineEnding::normalize(&mut text);
 890    let text = Rope::from(text);
 891    let language = cx
 892        .update(|_cx| language_registry.language_for_file_path(&path))?
 893        .await
 894        .ok();
 895    let buffer = cx.new(|cx| {
 896        let buffer = TextBuffer::new_normalized(
 897            0,
 898            cx.entity_id().as_non_zero_u64().into(),
 899            line_ending,
 900            text,
 901        );
 902        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
 903        buffer.set_language(language, cx);
 904        buffer
 905    })?;
 906    Ok(buffer)
 907}
 908
 909async fn build_buffer_diff(
 910    mut old_text: String,
 911    buffer: &Entity<Buffer>,
 912    language_registry: &Arc<LanguageRegistry>,
 913    cx: &mut AsyncApp,
 914) -> Result<Entity<BufferDiff>> {
 915    LineEnding::normalize(&mut old_text);
 916
 917    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
 918
 919    let base_buffer = cx
 920        .update(|cx| {
 921            Buffer::build_snapshot(
 922                old_text.clone().into(),
 923                buffer.language().cloned(),
 924                Some(language_registry.clone()),
 925                cx,
 926            )
 927        })?
 928        .await;
 929
 930    let diff_snapshot = cx
 931        .update(|cx| {
 932            BufferDiffSnapshot::new_with_base_buffer(
 933                buffer.text.clone(),
 934                Some(old_text.into()),
 935                base_buffer,
 936                cx,
 937            )
 938        })?
 939        .await;
 940
 941    let secondary_diff = cx.new(|cx| {
 942        let mut diff = BufferDiff::new(&buffer, cx);
 943        diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
 944        diff
 945    })?;
 946
 947    cx.new(|cx| {
 948        let mut diff = BufferDiff::new(&buffer.text, cx);
 949        diff.set_snapshot(diff_snapshot, &buffer, cx);
 950        diff.set_secondary_diff(secondary_diff);
 951        diff
 952    })
 953}
 954
 955#[cfg(test)]
 956mod tests {
 957    use super::*;
 958    use client::TelemetrySettings;
 959    use fs::{FakeFs, Fs};
 960    use gpui::{TestAppContext, UpdateGlobal};
 961    use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher};
 962    use language_model::fake_provider::FakeLanguageModel;
 963    use language_settings::{AllLanguageSettings, Formatter, FormatterList, SelectedFormatter};
 964    use lsp;
 965    use serde_json::json;
 966    use settings::SettingsStore;
 967    use std::sync::Arc;
 968    use util::path;
 969
 970    #[gpui::test]
 971    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
 972        init_test(cx);
 973
 974        let fs = FakeFs::new(cx.executor());
 975        fs.insert_tree("/root", json!({})).await;
 976        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 977        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 978        let model = Arc::new(FakeLanguageModel::default());
 979        let result = cx
 980            .update(|cx| {
 981                let input = serde_json::to_value(EditFileToolInput {
 982                    display_description: "Some edit".into(),
 983                    path: "root/nonexistent_file.txt".into(),
 984                    mode: EditFileMode::Edit,
 985                })
 986                .unwrap();
 987                Arc::new(EditFileTool)
 988                    .run(
 989                        input,
 990                        Arc::default(),
 991                        project.clone(),
 992                        action_log,
 993                        model,
 994                        None,
 995                        cx,
 996                    )
 997                    .output
 998            })
 999            .await;
1000        assert_eq!(
1001            result.unwrap_err().to_string(),
1002            "Can't edit file: path not found"
1003        );
1004    }
1005
1006    #[gpui::test]
1007    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
1008        let mode = &EditFileMode::Create;
1009
1010        let result = test_resolve_path(mode, "root/new.txt", cx);
1011        assert_resolved_path_eq(result.await, "new.txt");
1012
1013        let result = test_resolve_path(mode, "new.txt", cx);
1014        assert_resolved_path_eq(result.await, "new.txt");
1015
1016        let result = test_resolve_path(mode, "dir/new.txt", cx);
1017        assert_resolved_path_eq(result.await, "dir/new.txt");
1018
1019        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
1020        assert_eq!(
1021            result.await.unwrap_err().to_string(),
1022            "Can't create file: file already exists"
1023        );
1024
1025        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
1026        assert_eq!(
1027            result.await.unwrap_err().to_string(),
1028            "Can't create file: parent directory doesn't exist"
1029        );
1030    }
1031
1032    #[gpui::test]
1033    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
1034        let mode = &EditFileMode::Edit;
1035
1036        let path_with_root = "root/dir/subdir/existing.txt";
1037        let path_without_root = "dir/subdir/existing.txt";
1038        let result = test_resolve_path(mode, path_with_root, cx);
1039        assert_resolved_path_eq(result.await, path_without_root);
1040
1041        let result = test_resolve_path(mode, path_without_root, cx);
1042        assert_resolved_path_eq(result.await, path_without_root);
1043
1044        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1045        assert_eq!(
1046            result.await.unwrap_err().to_string(),
1047            "Can't edit file: path not found"
1048        );
1049
1050        let result = test_resolve_path(mode, "root/dir", cx);
1051        assert_eq!(
1052            result.await.unwrap_err().to_string(),
1053            "Can't edit file: path is a directory"
1054        );
1055    }
1056
1057    async fn test_resolve_path(
1058        mode: &EditFileMode,
1059        path: &str,
1060        cx: &mut TestAppContext,
1061    ) -> anyhow::Result<ProjectPath> {
1062        init_test(cx);
1063
1064        let fs = FakeFs::new(cx.executor());
1065        fs.insert_tree(
1066            "/root",
1067            json!({
1068                "dir": {
1069                    "subdir": {
1070                        "existing.txt": "hello"
1071                    }
1072                }
1073            }),
1074        )
1075        .await;
1076        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1077
1078        let input = EditFileToolInput {
1079            display_description: "Some edit".into(),
1080            path: path.into(),
1081            mode: mode.clone(),
1082        };
1083
1084        let result = cx.update(|cx| resolve_path(&input, project, cx));
1085        result
1086    }
1087
1088    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1089        let actual = path
1090            .expect("Should return valid path")
1091            .path
1092            .to_str()
1093            .unwrap()
1094            .replace("\\", "/"); // Naive Windows paths normalization
1095        assert_eq!(actual, expected);
1096    }
1097
1098    #[test]
1099    fn still_streaming_ui_text_with_path() {
1100        let input = json!({
1101            "path": "src/main.rs",
1102            "display_description": "",
1103            "old_string": "old code",
1104            "new_string": "new code"
1105        });
1106
1107        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1108    }
1109
1110    #[test]
1111    fn still_streaming_ui_text_with_description() {
1112        let input = json!({
1113            "path": "",
1114            "display_description": "Fix error handling",
1115            "old_string": "old code",
1116            "new_string": "new code"
1117        });
1118
1119        assert_eq!(
1120            EditFileTool.still_streaming_ui_text(&input),
1121            "Fix error handling",
1122        );
1123    }
1124
1125    #[test]
1126    fn still_streaming_ui_text_with_path_and_description() {
1127        let input = json!({
1128            "path": "src/main.rs",
1129            "display_description": "Fix error handling",
1130            "old_string": "old code",
1131            "new_string": "new code"
1132        });
1133
1134        assert_eq!(
1135            EditFileTool.still_streaming_ui_text(&input),
1136            "Fix error handling",
1137        );
1138    }
1139
1140    #[test]
1141    fn still_streaming_ui_text_no_path_or_description() {
1142        let input = json!({
1143            "path": "",
1144            "display_description": "",
1145            "old_string": "old code",
1146            "new_string": "new code"
1147        });
1148
1149        assert_eq!(
1150            EditFileTool.still_streaming_ui_text(&input),
1151            DEFAULT_UI_TEXT,
1152        );
1153    }
1154
1155    #[test]
1156    fn still_streaming_ui_text_with_null() {
1157        let input = serde_json::Value::Null;
1158
1159        assert_eq!(
1160            EditFileTool.still_streaming_ui_text(&input),
1161            DEFAULT_UI_TEXT,
1162        );
1163    }
1164
1165    fn init_test(cx: &mut TestAppContext) {
1166        cx.update(|cx| {
1167            let settings_store = SettingsStore::test(cx);
1168            cx.set_global(settings_store);
1169            language::init(cx);
1170            TelemetrySettings::register(cx);
1171            Project::init_settings(cx);
1172        });
1173    }
1174
1175    #[gpui::test]
1176    async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1177        init_test(cx);
1178
1179        let fs = FakeFs::new(cx.executor());
1180        fs.insert_tree("/root", json!({"src": {}})).await;
1181
1182        // Create a simple file with trailing whitespace
1183        fs.save(
1184            path!("/root/src/main.rs").as_ref(),
1185            &"initial content".into(),
1186            LineEnding::Unix,
1187        )
1188        .await
1189        .unwrap();
1190
1191        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1192        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1193        let model = Arc::new(FakeLanguageModel::default());
1194
1195        // First, test with remove_trailing_whitespace_on_save enabled
1196        cx.update(|cx| {
1197            SettingsStore::update_global(cx, |store, cx| {
1198                store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
1199                    settings.defaults.remove_trailing_whitespace_on_save = Some(true);
1200                });
1201            });
1202        });
1203
1204        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1205            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
1206
1207        // Have the model stream content that contains trailing whitespace
1208        let edit_result = {
1209            let edit_task = cx.update(|cx| {
1210                let input = serde_json::to_value(EditFileToolInput {
1211                    display_description: "Create main function".into(),
1212                    path: "root/src/main.rs".into(),
1213                    mode: EditFileMode::Overwrite,
1214                })
1215                .unwrap();
1216                Arc::new(EditFileTool)
1217                    .run(
1218                        input,
1219                        Arc::default(),
1220                        project.clone(),
1221                        action_log.clone(),
1222                        model.clone(),
1223                        None,
1224                        cx,
1225                    )
1226                    .output
1227            });
1228
1229            // Stream the content with trailing whitespace
1230            cx.executor().run_until_parked();
1231            model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
1232            model.end_last_completion_stream();
1233
1234            edit_task.await
1235        };
1236        assert!(edit_result.is_ok());
1237
1238        // Wait for any async operations (e.g. formatting) to complete
1239        cx.executor().run_until_parked();
1240
1241        // Read the file to verify trailing whitespace was removed automatically
1242        assert_eq!(
1243            // Ignore carriage returns on Windows
1244            fs.load(path!("/root/src/main.rs").as_ref())
1245                .await
1246                .unwrap()
1247                .replace("\r\n", "\n"),
1248            "fn main() {\n    println!(\"Hello!\");\n}\n",
1249            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1250        );
1251
1252        // Next, test with remove_trailing_whitespace_on_save disabled
1253        cx.update(|cx| {
1254            SettingsStore::update_global(cx, |store, cx| {
1255                store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
1256                    settings.defaults.remove_trailing_whitespace_on_save = Some(false);
1257                });
1258            });
1259        });
1260
1261        // Stream edits again with trailing whitespace
1262        let edit_result = {
1263            let edit_task = cx.update(|cx| {
1264                let input = serde_json::to_value(EditFileToolInput {
1265                    display_description: "Update main function".into(),
1266                    path: "root/src/main.rs".into(),
1267                    mode: EditFileMode::Overwrite,
1268                })
1269                .unwrap();
1270                Arc::new(EditFileTool)
1271                    .run(
1272                        input,
1273                        Arc::default(),
1274                        project.clone(),
1275                        action_log.clone(),
1276                        model.clone(),
1277                        None,
1278                        cx,
1279                    )
1280                    .output
1281            });
1282
1283            // Stream the content with trailing whitespace
1284            cx.executor().run_until_parked();
1285            model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
1286            model.end_last_completion_stream();
1287
1288            edit_task.await
1289        };
1290        assert!(edit_result.is_ok());
1291
1292        // Wait for any async operations (e.g. formatting) to complete
1293        cx.executor().run_until_parked();
1294
1295        // Verify the file still has trailing whitespace
1296        // Read the file again - it should still have trailing whitespace
1297        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1298        assert_eq!(
1299            // Ignore carriage returns on Windows
1300            final_content.replace("\r\n", "\n"),
1301            CONTENT_WITH_TRAILING_WHITESPACE,
1302            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1303        );
1304    }
1305
1306    #[gpui::test]
1307    async fn test_format_on_save(cx: &mut TestAppContext) {
1308        init_test(cx);
1309
1310        let fs = FakeFs::new(cx.executor());
1311        fs.insert_tree("/root", json!({"src": {}})).await;
1312
1313        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1314
1315        // Set up a Rust language with LSP formatting support
1316        let rust_language = Arc::new(Language::new(
1317            LanguageConfig {
1318                name: "Rust".into(),
1319                matcher: LanguageMatcher {
1320                    path_suffixes: vec!["rs".to_string()],
1321                    ..Default::default()
1322                },
1323                ..Default::default()
1324            },
1325            None,
1326        ));
1327
1328        // Register the language and fake LSP
1329        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1330        language_registry.add(rust_language);
1331
1332        let mut fake_language_servers = language_registry.register_fake_lsp(
1333            "Rust",
1334            FakeLspAdapter {
1335                capabilities: lsp::ServerCapabilities {
1336                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
1337                    ..Default::default()
1338                },
1339                ..Default::default()
1340            },
1341        );
1342
1343        // Create the file
1344        fs.save(
1345            path!("/root/src/main.rs").as_ref(),
1346            &"initial content".into(),
1347            LineEnding::Unix,
1348        )
1349        .await
1350        .unwrap();
1351
1352        // Open the buffer to trigger LSP initialization
1353        let buffer = project
1354            .update(cx, |project, cx| {
1355                project.open_local_buffer(path!("/root/src/main.rs"), cx)
1356            })
1357            .await
1358            .unwrap();
1359
1360        // Register the buffer with language servers
1361        let _handle = project.update(cx, |project, cx| {
1362            project.register_buffer_with_language_servers(&buffer, cx)
1363        });
1364
1365        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
1366        const FORMATTED_CONTENT: &str =
1367            "This file was formatted by the fake formatter in the test.\n";
1368
1369        // Get the fake language server and set up formatting handler
1370        let fake_language_server = fake_language_servers.next().await.unwrap();
1371        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
1372            |_, _| async move {
1373                Ok(Some(vec![lsp::TextEdit {
1374                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
1375                    new_text: FORMATTED_CONTENT.to_string(),
1376                }]))
1377            }
1378        });
1379
1380        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1381        let model = Arc::new(FakeLanguageModel::default());
1382
1383        // First, test with format_on_save enabled
1384        cx.update(|cx| {
1385            SettingsStore::update_global(cx, |store, cx| {
1386                store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
1387                    settings.defaults.format_on_save = Some(FormatOnSave::On);
1388                    settings.defaults.formatter = Some(SelectedFormatter::Auto);
1389                });
1390            });
1391        });
1392
1393        // Have the model stream unformatted content
1394        let edit_result = {
1395            let edit_task = cx.update(|cx| {
1396                let input = serde_json::to_value(EditFileToolInput {
1397                    display_description: "Create main function".into(),
1398                    path: "root/src/main.rs".into(),
1399                    mode: EditFileMode::Overwrite,
1400                })
1401                .unwrap();
1402                Arc::new(EditFileTool)
1403                    .run(
1404                        input,
1405                        Arc::default(),
1406                        project.clone(),
1407                        action_log.clone(),
1408                        model.clone(),
1409                        None,
1410                        cx,
1411                    )
1412                    .output
1413            });
1414
1415            // Stream the unformatted content
1416            cx.executor().run_until_parked();
1417            model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
1418            model.end_last_completion_stream();
1419
1420            edit_task.await
1421        };
1422        assert!(edit_result.is_ok());
1423
1424        // Wait for any async operations (e.g. formatting) to complete
1425        cx.executor().run_until_parked();
1426
1427        // Read the file to verify it was formatted automatically
1428        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1429        assert_eq!(
1430            // Ignore carriage returns on Windows
1431            new_content.replace("\r\n", "\n"),
1432            FORMATTED_CONTENT,
1433            "Code should be formatted when format_on_save is enabled"
1434        );
1435
1436        // Next, test with format_on_save disabled
1437        cx.update(|cx| {
1438            SettingsStore::update_global(cx, |store, cx| {
1439                store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
1440                    settings.defaults.format_on_save = Some(FormatOnSave::Off);
1441                });
1442            });
1443        });
1444
1445        // Stream unformatted edits again
1446        let edit_result = {
1447            let edit_task = cx.update(|cx| {
1448                let input = serde_json::to_value(EditFileToolInput {
1449                    display_description: "Update main function".into(),
1450                    path: "root/src/main.rs".into(),
1451                    mode: EditFileMode::Overwrite,
1452                })
1453                .unwrap();
1454                Arc::new(EditFileTool)
1455                    .run(
1456                        input,
1457                        Arc::default(),
1458                        project.clone(),
1459                        action_log.clone(),
1460                        model.clone(),
1461                        None,
1462                        cx,
1463                    )
1464                    .output
1465            });
1466
1467            // Stream the unformatted content
1468            cx.executor().run_until_parked();
1469            model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
1470            model.end_last_completion_stream();
1471
1472            edit_task.await
1473        };
1474        assert!(edit_result.is_ok());
1475
1476        // Wait for any async operations (e.g. formatting) to complete
1477        cx.executor().run_until_parked();
1478
1479        // Verify the file is still unformatted
1480        assert_eq!(
1481            // Ignore carriage returns on Windows
1482            fs.load(path!("/root/src/main.rs").as_ref())
1483                .await
1484                .unwrap()
1485                .replace("\r\n", "\n"),
1486            UNFORMATTED_CONTENT,
1487            "Code should remain unformatted when format_on_save is disabled"
1488        );
1489
1490        // Finally, test with format_on_save set to a list
1491        cx.update(|cx| {
1492            SettingsStore::update_global(cx, |store, cx| {
1493                store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
1494                    settings.defaults.format_on_save = Some(FormatOnSave::List(FormatterList(
1495                        vec![Formatter::LanguageServer { name: None }].into(),
1496                    )));
1497                });
1498            });
1499        });
1500
1501        // Stream unformatted edits again
1502        let edit_result = {
1503            let edit_task = cx.update(|cx| {
1504                let input = serde_json::to_value(EditFileToolInput {
1505                    display_description: "Update main function with list formatter".into(),
1506                    path: "root/src/main.rs".into(),
1507                    mode: EditFileMode::Overwrite,
1508                })
1509                .unwrap();
1510                Arc::new(EditFileTool)
1511                    .run(
1512                        input,
1513                        Arc::default(),
1514                        project.clone(),
1515                        action_log.clone(),
1516                        model.clone(),
1517                        None,
1518                        cx,
1519                    )
1520                    .output
1521            });
1522
1523            // Stream the unformatted content
1524            cx.executor().run_until_parked();
1525            model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
1526            model.end_last_completion_stream();
1527
1528            edit_task.await
1529        };
1530        assert!(edit_result.is_ok());
1531
1532        // Wait for any async operations (e.g. formatting) to complete
1533        cx.executor().run_until_parked();
1534
1535        // Read the file to verify it was formatted with the specified formatter
1536        assert_eq!(
1537            // Ignore carriage returns on Windows
1538            fs.load(path!("/root/src/main.rs").as_ref())
1539                .await
1540                .unwrap()
1541                .replace("\r\n", "\n"),
1542            FORMATTED_CONTENT,
1543            "Code should be formatted when format_on_save is set to a list"
1544        );
1545    }
1546}