edit_file_tool.rs

   1use crate::{
   2    Templates,
   3    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
   4    schema::json_schema_for,
   5};
   6use anyhow::{Result, anyhow};
   7use assistant_tool::{
   8    ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput,
   9    ToolUseStatus,
  10};
  11use buffer_diff::{BufferDiff, BufferDiffSnapshot};
  12use editor::{Editor, EditorMode, MultiBuffer, PathKey};
  13use futures::StreamExt;
  14use gpui::{
  15    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, EntityId, Task,
  16    TextStyleRefinement, WeakEntity, pulsating_between,
  17};
  18use indoc::formatdoc;
  19use language::{
  20    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Rope, TextBuffer,
  21    language_settings::SoftWrap,
  22};
  23use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
  24use markdown::{Markdown, MarkdownElement, MarkdownStyle};
  25use project::{Project, ProjectPath};
  26use schemars::JsonSchema;
  27use serde::{Deserialize, Serialize};
  28use settings::Settings;
  29use std::{
  30    path::{Path, PathBuf},
  31    sync::Arc,
  32    time::Duration,
  33};
  34use theme::ThemeSettings;
  35use ui::{Disclosure, Tooltip, prelude::*};
  36use util::ResultExt;
  37use workspace::Workspace;
  38
  39pub struct EditFileTool;
  40
  41#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  42pub struct EditFileToolInput {
  43    /// A one-line, user-friendly markdown description of the edit. This will be
  44    /// shown in the UI and also passed to another model to perform the edit.
  45    ///
  46    /// Be terse, but also descriptive in what you want to achieve with this
  47    /// edit. Avoid generic instructions.
  48    ///
  49    /// NEVER mention the file path in this description.
  50    ///
  51    /// <example>Fix API endpoint URLs</example>
  52    /// <example>Update copyright year in `page_footer`</example>
  53    ///
  54    /// Make sure to include this field before all the others in the input object
  55    /// so that we can display it immediately.
  56    pub display_description: String,
  57
  58    /// The full path of the file to create or modify in the project.
  59    ///
  60    /// WARNING: When specifying which file path need changing, you MUST
  61    /// start each path with one of the project's root directories.
  62    ///
  63    /// The following examples assume we have two root directories in the project:
  64    /// - backend
  65    /// - frontend
  66    ///
  67    /// <example>
  68    /// `backend/src/main.rs`
  69    ///
  70    /// Notice how the file path starts with root-1. Without that, the path
  71    /// would be ambiguous and the call would fail!
  72    /// </example>
  73    ///
  74    /// <example>
  75    /// `frontend/db.js`
  76    /// </example>
  77    pub path: PathBuf,
  78
  79    /// The mode of operation on the file. Possible values:
  80    /// - 'edit': Make granular edits to an existing file.
  81    /// - 'create': Create a new file if it doesn't exist.
  82    /// - 'overwrite': Replace the entire contents of an existing file.
  83    ///
  84    /// When a file already exists or you just created it, prefer editing
  85    /// it as opposed to recreating it from scratch.
  86    pub mode: EditFileMode,
  87}
  88
  89#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  90#[serde(rename_all = "lowercase")]
  91pub enum EditFileMode {
  92    Edit,
  93    Create,
  94    Overwrite,
  95}
  96
  97#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  98pub struct EditFileToolOutput {
  99    pub original_path: PathBuf,
 100    pub new_text: String,
 101    pub old_text: String,
 102    pub raw_output: Option<EditAgentOutput>,
 103}
 104
 105#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 106struct PartialInput {
 107    #[serde(default)]
 108    path: String,
 109    #[serde(default)]
 110    display_description: String,
 111}
 112
 113const DEFAULT_UI_TEXT: &str = "Editing file";
 114
 115impl Tool for EditFileTool {
 116    fn name(&self) -> String {
 117        "edit_file".into()
 118    }
 119
 120    fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
 121        false
 122    }
 123
 124    fn description(&self) -> String {
 125        include_str!("edit_file_tool/description.md").to_string()
 126    }
 127
 128    fn icon(&self) -> IconName {
 129        IconName::Pencil
 130    }
 131
 132    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 133        json_schema_for::<EditFileToolInput>(format)
 134    }
 135
 136    fn ui_text(&self, input: &serde_json::Value) -> String {
 137        match serde_json::from_value::<EditFileToolInput>(input.clone()) {
 138            Ok(input) => input.display_description,
 139            Err(_) => "Editing file".to_string(),
 140        }
 141    }
 142
 143    fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
 144        if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
 145            let description = input.display_description.trim();
 146            if !description.is_empty() {
 147                return description.to_string();
 148            }
 149
 150            let path = input.path.trim();
 151            if !path.is_empty() {
 152                return path.to_string();
 153            }
 154        }
 155
 156        DEFAULT_UI_TEXT.to_string()
 157    }
 158
 159    fn run(
 160        self: Arc<Self>,
 161        input: serde_json::Value,
 162        request: Arc<LanguageModelRequest>,
 163        project: Entity<Project>,
 164        action_log: Entity<ActionLog>,
 165        model: Arc<dyn LanguageModel>,
 166        window: Option<AnyWindowHandle>,
 167        cx: &mut App,
 168    ) -> ToolResult {
 169        let input = match serde_json::from_value::<EditFileToolInput>(input) {
 170            Ok(input) => input,
 171            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 172        };
 173
 174        let project_path = match resolve_path(&input, project.clone(), cx) {
 175            Ok(path) => path,
 176            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 177        };
 178
 179        let card = window.and_then(|window| {
 180            window
 181                .update(cx, |_, window, cx| {
 182                    cx.new(|cx| {
 183                        EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
 184                    })
 185                })
 186                .ok()
 187        });
 188
 189        let card_clone = card.clone();
 190        let task = cx.spawn(async move |cx: &mut AsyncApp| {
 191            let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new());
 192
 193            let buffer = project
 194                .update(cx, |project, cx| {
 195                    project.open_buffer(project_path.clone(), cx)
 196                })?
 197                .await?;
 198
 199            let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 200            let old_text = cx
 201                .background_spawn({
 202                    let old_snapshot = old_snapshot.clone();
 203                    async move { old_snapshot.text() }
 204                })
 205                .await;
 206
 207            let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
 208                edit_agent.edit(
 209                    buffer.clone(),
 210                    input.display_description.clone(),
 211                    &request,
 212                    cx,
 213                )
 214            } else {
 215                edit_agent.overwrite(
 216                    buffer.clone(),
 217                    input.display_description.clone(),
 218                    &request,
 219                    cx,
 220                )
 221            };
 222
 223            let mut hallucinated_old_text = false;
 224            while let Some(event) = events.next().await {
 225                match event {
 226                    EditAgentOutputEvent::Edited => {
 227                        if let Some(card) = card_clone.as_ref() {
 228                            let new_snapshot =
 229                                buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 230                            let new_text = cx
 231                                .background_spawn({
 232                                    let new_snapshot = new_snapshot.clone();
 233                                    async move { new_snapshot.text() }
 234                                })
 235                                .await;
 236                            card.update(cx, |card, cx| {
 237                                card.set_diff(
 238                                    project_path.path.clone(),
 239                                    old_text.clone(),
 240                                    new_text,
 241                                    cx,
 242                                );
 243                            })
 244                            .log_err();
 245                        }
 246                    }
 247                    EditAgentOutputEvent::OldTextNotFound(_) => hallucinated_old_text = true,
 248                }
 249            }
 250            let agent_output = output.await?;
 251
 252            project
 253                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
 254                .await?;
 255
 256            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 257            let new_text = cx.background_spawn({
 258                let new_snapshot = new_snapshot.clone();
 259                async move { new_snapshot.text() }
 260            });
 261            let diff = cx.background_spawn(async move {
 262                language::unified_diff(&old_snapshot.text(), &new_snapshot.text())
 263            });
 264            let (new_text, diff) = futures::join!(new_text, diff);
 265
 266            let output = EditFileToolOutput {
 267                original_path: project_path.path.to_path_buf(),
 268                new_text: new_text.clone(),
 269                old_text: old_text.clone(),
 270                raw_output: Some(agent_output),
 271            };
 272
 273            if let Some(card) = card_clone {
 274                card.update(cx, |card, cx| {
 275                    card.set_diff(project_path.path.clone(), old_text, new_text, cx);
 276                })
 277                .log_err();
 278            }
 279
 280            let input_path = input.path.display();
 281            if diff.is_empty() {
 282                if hallucinated_old_text {
 283                    Err(anyhow!(formatdoc! {"
 284                        Some edits were produced but none of them could be applied.
 285                        Read the relevant sections of {input_path} again so that
 286                        I can perform the requested edits.
 287                    "}))
 288                } else {
 289                    Ok("No edits were made.".to_string().into())
 290                }
 291            } else {
 292                Ok(ToolResultOutput {
 293                    content: ToolResultContent::Text(format!(
 294                        "Edited {}:\n\n```diff\n{}\n```",
 295                        input_path, diff
 296                    )),
 297                    output: serde_json::to_value(output).ok(),
 298                })
 299            }
 300        });
 301
 302        ToolResult {
 303            output: task,
 304            card: card.map(AnyToolCard::from),
 305        }
 306    }
 307
 308    fn deserialize_card(
 309        self: Arc<Self>,
 310        output: serde_json::Value,
 311        project: Entity<Project>,
 312        window: &mut Window,
 313        cx: &mut App,
 314    ) -> Option<AnyToolCard> {
 315        let output = match serde_json::from_value::<EditFileToolOutput>(output) {
 316            Ok(output) => output,
 317            Err(_) => return None,
 318        };
 319
 320        let card = cx.new(|cx| {
 321            let mut card = EditFileToolCard::new(output.original_path.clone(), project, window, cx);
 322            card.set_diff(
 323                output.original_path.into(),
 324                output.old_text,
 325                output.new_text,
 326                cx,
 327            );
 328            card
 329        });
 330
 331        Some(card.into())
 332    }
 333}
 334
 335/// Validate that the file path is valid, meaning:
 336///
 337/// - For `edit` and `overwrite`, the path must point to an existing file.
 338/// - For `create`, the file must not already exist, but it's parent dir must exist.
 339fn resolve_path(
 340    input: &EditFileToolInput,
 341    project: Entity<Project>,
 342    cx: &mut App,
 343) -> Result<ProjectPath> {
 344    let project = project.read(cx);
 345
 346    match input.mode {
 347        EditFileMode::Edit | EditFileMode::Overwrite => {
 348            let path = project
 349                .find_project_path(&input.path, cx)
 350                .ok_or_else(|| anyhow!("Can't edit file: path not found"))?;
 351
 352            let entry = project
 353                .entry_for_path(&path, cx)
 354                .ok_or_else(|| anyhow!("Can't edit file: path not found"))?;
 355
 356            if !entry.is_file() {
 357                return Err(anyhow!("Can't edit file: path is a directory"));
 358            }
 359
 360            Ok(path)
 361        }
 362
 363        EditFileMode::Create => {
 364            if let Some(path) = project.find_project_path(&input.path, cx) {
 365                if project.entry_for_path(&path, cx).is_some() {
 366                    return Err(anyhow!("Can't create file: file already exists"));
 367                }
 368            }
 369
 370            let parent_path = input
 371                .path
 372                .parent()
 373                .ok_or_else(|| anyhow!("Can't create file: incorrect path"))?;
 374
 375            let parent_project_path = project.find_project_path(&parent_path, cx);
 376
 377            let parent_entry = parent_project_path
 378                .as_ref()
 379                .and_then(|path| project.entry_for_path(&path, cx))
 380                .ok_or_else(|| anyhow!("Can't create file: parent directory doesn't exist"))?;
 381
 382            if !parent_entry.is_dir() {
 383                return Err(anyhow!("Can't create file: parent is not a directory"));
 384            }
 385
 386            let file_name = input
 387                .path
 388                .file_name()
 389                .ok_or_else(|| anyhow!("Can't create file: invalid filename"))?;
 390
 391            let new_file_path = parent_project_path.map(|parent| ProjectPath {
 392                path: Arc::from(parent.path.join(file_name)),
 393                ..parent
 394            });
 395
 396            new_file_path.ok_or_else(|| anyhow!("Can't create file"))
 397        }
 398    }
 399}
 400
 401pub struct EditFileToolCard {
 402    path: PathBuf,
 403    editor: Entity<Editor>,
 404    multibuffer: Entity<MultiBuffer>,
 405    project: Entity<Project>,
 406    diff_task: Option<Task<Result<()>>>,
 407    preview_expanded: bool,
 408    error_expanded: Option<Entity<Markdown>>,
 409    full_height_expanded: bool,
 410    total_lines: Option<u32>,
 411    editor_unique_id: EntityId,
 412}
 413
 414impl EditFileToolCard {
 415    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
 416        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
 417        let editor = cx.new(|cx| {
 418            let mut editor = Editor::new(
 419                EditorMode::Full {
 420                    scale_ui_elements_with_buffer_font_size: false,
 421                    show_active_line_background: false,
 422                    sized_by_content: true,
 423                },
 424                multibuffer.clone(),
 425                Some(project.clone()),
 426                window,
 427                cx,
 428            );
 429            editor.set_show_gutter(false, cx);
 430            editor.disable_inline_diagnostics();
 431            editor.disable_expand_excerpt_buttons(cx);
 432            editor.disable_scrollbars_and_minimap(window, cx);
 433            editor.set_soft_wrap_mode(SoftWrap::None, cx);
 434            editor.scroll_manager.set_forbid_vertical_scroll(true);
 435            editor.set_show_indent_guides(false, cx);
 436            editor.set_read_only(true);
 437            editor.set_show_breakpoints(false, cx);
 438            editor.set_show_code_actions(false, cx);
 439            editor.set_show_git_diff_gutter(false, cx);
 440            editor.set_expand_all_diff_hunks(cx);
 441            editor
 442        });
 443        Self {
 444            editor_unique_id: editor.entity_id(),
 445            path,
 446            project,
 447            editor,
 448            multibuffer,
 449            diff_task: None,
 450            preview_expanded: true,
 451            error_expanded: None,
 452            full_height_expanded: true,
 453            total_lines: None,
 454        }
 455    }
 456
 457    pub fn has_diff(&self) -> bool {
 458        self.total_lines.is_some()
 459    }
 460
 461    pub fn set_diff(
 462        &mut self,
 463        path: Arc<Path>,
 464        old_text: String,
 465        new_text: String,
 466        cx: &mut Context<Self>,
 467    ) {
 468        let language_registry = self.project.read(cx).languages().clone();
 469        self.diff_task = Some(cx.spawn(async move |this, cx| {
 470            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
 471            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
 472
 473            this.update(cx, |this, cx| {
 474                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
 475                    let snapshot = buffer.read(cx).snapshot();
 476                    let diff = buffer_diff.read(cx);
 477                    let diff_hunk_ranges = diff
 478                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
 479                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
 480                        .collect::<Vec<_>>();
 481                    multibuffer.clear(cx);
 482                    multibuffer.set_excerpts_for_path(
 483                        PathKey::for_buffer(&buffer, cx),
 484                        buffer,
 485                        diff_hunk_ranges,
 486                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
 487                        cx,
 488                    );
 489                    multibuffer.add_diff(buffer_diff, cx);
 490                    let end = multibuffer.len(cx);
 491                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
 492                });
 493
 494                cx.notify();
 495            })
 496        }));
 497    }
 498}
 499
 500impl ToolCard for EditFileToolCard {
 501    fn render(
 502        &mut self,
 503        status: &ToolUseStatus,
 504        window: &mut Window,
 505        workspace: WeakEntity<Workspace>,
 506        cx: &mut Context<Self>,
 507    ) -> impl IntoElement {
 508        let error_message = match status {
 509            ToolUseStatus::Error(err) => Some(err),
 510            _ => None,
 511        };
 512
 513        let path_label_button = h_flex()
 514            .id(("edit-tool-path-label-button", self.editor_unique_id))
 515            .w_full()
 516            .max_w_full()
 517            .px_1()
 518            .gap_0p5()
 519            .cursor_pointer()
 520            .rounded_sm()
 521            .opacity(0.8)
 522            .hover(|label| {
 523                label
 524                    .opacity(1.)
 525                    .bg(cx.theme().colors().element_hover.opacity(0.5))
 526            })
 527            .tooltip(Tooltip::text("Jump to File"))
 528            .child(
 529                h_flex()
 530                    .child(
 531                        Icon::new(IconName::Pencil)
 532                            .size(IconSize::XSmall)
 533                            .color(Color::Muted),
 534                    )
 535                    .child(
 536                        div()
 537                            .text_size(rems(0.8125))
 538                            .child(self.path.display().to_string())
 539                            .ml_1p5()
 540                            .mr_0p5(),
 541                    )
 542                    .child(
 543                        Icon::new(IconName::ArrowUpRight)
 544                            .size(IconSize::XSmall)
 545                            .color(Color::Ignored),
 546                    ),
 547            )
 548            .on_click({
 549                let path = self.path.clone();
 550                let workspace = workspace.clone();
 551                move |_, window, cx| {
 552                    workspace
 553                        .update(cx, {
 554                            |workspace, cx| {
 555                                let Some(project_path) =
 556                                    workspace.project().read(cx).find_project_path(&path, cx)
 557                                else {
 558                                    return;
 559                                };
 560                                let open_task =
 561                                    workspace.open_path(project_path, None, true, window, cx);
 562                                window
 563                                    .spawn(cx, async move |cx| {
 564                                        let item = open_task.await?;
 565                                        if let Some(active_editor) = item.downcast::<Editor>() {
 566                                            active_editor
 567                                                .update_in(cx, |editor, window, cx| {
 568                                                    editor.go_to_singleton_buffer_point(
 569                                                        language::Point::new(0, 0),
 570                                                        window,
 571                                                        cx,
 572                                                    );
 573                                                })
 574                                                .log_err();
 575                                        }
 576                                        anyhow::Ok(())
 577                                    })
 578                                    .detach_and_log_err(cx);
 579                            }
 580                        })
 581                        .ok();
 582                }
 583            })
 584            .into_any_element();
 585
 586        let codeblock_header_bg = cx
 587            .theme()
 588            .colors()
 589            .element_background
 590            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
 591
 592        let codeblock_header = h_flex()
 593            .flex_none()
 594            .p_1()
 595            .gap_1()
 596            .justify_between()
 597            .rounded_t_md()
 598            .when(error_message.is_none(), |header| {
 599                header.bg(codeblock_header_bg)
 600            })
 601            .child(path_label_button)
 602            .when_some(error_message, |header, error_message| {
 603                header.child(
 604                    h_flex()
 605                        .gap_1()
 606                        .child(
 607                            Icon::new(IconName::Close)
 608                                .size(IconSize::Small)
 609                                .color(Color::Error),
 610                        )
 611                        .child(
 612                            Disclosure::new(
 613                                ("edit-file-error-disclosure", self.editor_unique_id),
 614                                self.error_expanded.is_some(),
 615                            )
 616                            .opened_icon(IconName::ChevronUp)
 617                            .closed_icon(IconName::ChevronDown)
 618                            .on_click(cx.listener({
 619                                let error_message = error_message.clone();
 620
 621                                move |this, _event, _window, cx| {
 622                                    if this.error_expanded.is_some() {
 623                                        this.error_expanded.take();
 624                                    } else {
 625                                        this.error_expanded = Some(cx.new(|cx| {
 626                                            Markdown::new(error_message.clone(), None, None, cx)
 627                                        }))
 628                                    }
 629                                    cx.notify();
 630                                }
 631                            })),
 632                        ),
 633                )
 634            })
 635            .when(error_message.is_none() && self.has_diff(), |header| {
 636                header.child(
 637                    Disclosure::new(
 638                        ("edit-file-disclosure", self.editor_unique_id),
 639                        self.preview_expanded,
 640                    )
 641                    .opened_icon(IconName::ChevronUp)
 642                    .closed_icon(IconName::ChevronDown)
 643                    .on_click(cx.listener(
 644                        move |this, _event, _window, _cx| {
 645                            this.preview_expanded = !this.preview_expanded;
 646                        },
 647                    )),
 648                )
 649            });
 650
 651        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
 652            let line_height = editor
 653                .style()
 654                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
 655                .unwrap_or_default();
 656
 657            editor.set_text_style_refinement(TextStyleRefinement {
 658                font_size: Some(
 659                    TextSize::Small
 660                        .rems(cx)
 661                        .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
 662                        .into(),
 663                ),
 664                ..TextStyleRefinement::default()
 665            });
 666            let element = editor.render(window, cx);
 667            (element.into_any_element(), line_height)
 668        });
 669
 670        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
 671            (IconName::ChevronUp, "Collapse Code Block")
 672        } else {
 673            (IconName::ChevronDown, "Expand Code Block")
 674        };
 675
 676        let gradient_overlay =
 677            div()
 678                .absolute()
 679                .bottom_0()
 680                .left_0()
 681                .w_full()
 682                .h_2_5()
 683                .bg(gpui::linear_gradient(
 684                    0.,
 685                    gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
 686                    gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
 687                ));
 688
 689        let border_color = cx.theme().colors().border.opacity(0.6);
 690
 691        const DEFAULT_COLLAPSED_LINES: u32 = 10;
 692        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
 693
 694        let waiting_for_diff = {
 695            let styles = [
 696                ("w_4_5", (0.1, 0.85), 2000),
 697                ("w_1_4", (0.2, 0.75), 2200),
 698                ("w_2_4", (0.15, 0.64), 1900),
 699                ("w_3_5", (0.25, 0.72), 2300),
 700                ("w_2_5", (0.3, 0.56), 1800),
 701            ];
 702
 703            let mut container = v_flex()
 704                .p_3()
 705                .gap_1()
 706                .border_t_1()
 707                .rounded_b_md()
 708                .border_color(border_color)
 709                .bg(cx.theme().colors().editor_background);
 710
 711            for (width_method, pulse_range, duration_ms) in styles.iter() {
 712                let (min_opacity, max_opacity) = *pulse_range;
 713                let placeholder = match *width_method {
 714                    "w_4_5" => div().w_3_4(),
 715                    "w_1_4" => div().w_1_4(),
 716                    "w_2_4" => div().w_2_4(),
 717                    "w_3_5" => div().w_3_5(),
 718                    "w_2_5" => div().w_2_5(),
 719                    _ => div().w_1_2(),
 720                }
 721                .id("loading_div")
 722                .h_1()
 723                .rounded_full()
 724                .bg(cx.theme().colors().element_active)
 725                .with_animation(
 726                    "loading_pulsate",
 727                    Animation::new(Duration::from_millis(*duration_ms))
 728                        .repeat()
 729                        .with_easing(pulsating_between(min_opacity, max_opacity)),
 730                    |label, delta| label.opacity(delta),
 731                );
 732
 733                container = container.child(placeholder);
 734            }
 735
 736            container
 737        };
 738
 739        v_flex()
 740            .mb_2()
 741            .border_1()
 742            .when(error_message.is_some(), |card| card.border_dashed())
 743            .border_color(border_color)
 744            .rounded_md()
 745            .overflow_hidden()
 746            .child(codeblock_header)
 747            .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
 748                card.child(
 749                    v_flex()
 750                        .p_2()
 751                        .gap_1()
 752                        .border_t_1()
 753                        .border_dashed()
 754                        .border_color(border_color)
 755                        .bg(cx.theme().colors().editor_background)
 756                        .rounded_b_md()
 757                        .child(
 758                            Label::new("Error")
 759                                .size(LabelSize::XSmall)
 760                                .color(Color::Error),
 761                        )
 762                        .child(
 763                            div()
 764                                .rounded_md()
 765                                .text_ui_sm(cx)
 766                                .bg(cx.theme().colors().editor_background)
 767                                .child(MarkdownElement::new(
 768                                    error_markdown.clone(),
 769                                    markdown_style(window, cx),
 770                                )),
 771                        ),
 772                )
 773            })
 774            .when(!self.has_diff() && error_message.is_none(), |card| {
 775                card.child(waiting_for_diff)
 776            })
 777            .when(self.preview_expanded && self.has_diff(), |card| {
 778                card.child(
 779                    v_flex()
 780                        .relative()
 781                        .h_full()
 782                        .when(!self.full_height_expanded, |editor_container| {
 783                            editor_container
 784                                .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
 785                        })
 786                        .overflow_hidden()
 787                        .border_t_1()
 788                        .border_color(border_color)
 789                        .bg(cx.theme().colors().editor_background)
 790                        .child(editor)
 791                        .when(
 792                            !self.full_height_expanded && is_collapsible,
 793                            |editor_container| editor_container.child(gradient_overlay),
 794                        ),
 795                )
 796                .when(is_collapsible, |card| {
 797                    card.child(
 798                        h_flex()
 799                            .id(("expand-button", self.editor_unique_id))
 800                            .flex_none()
 801                            .cursor_pointer()
 802                            .h_5()
 803                            .justify_center()
 804                            .border_t_1()
 805                            .rounded_b_md()
 806                            .border_color(border_color)
 807                            .bg(cx.theme().colors().editor_background)
 808                            .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
 809                            .child(
 810                                Icon::new(full_height_icon)
 811                                    .size(IconSize::Small)
 812                                    .color(Color::Muted),
 813                            )
 814                            .tooltip(Tooltip::text(full_height_tooltip_label))
 815                            .on_click(cx.listener(move |this, _event, _window, _cx| {
 816                                this.full_height_expanded = !this.full_height_expanded;
 817                            })),
 818                    )
 819                })
 820            })
 821    }
 822}
 823
 824fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
 825    let theme_settings = ThemeSettings::get_global(cx);
 826    let ui_font_size = TextSize::Default.rems(cx);
 827    let mut text_style = window.text_style();
 828
 829    text_style.refine(&TextStyleRefinement {
 830        font_family: Some(theme_settings.ui_font.family.clone()),
 831        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
 832        font_features: Some(theme_settings.ui_font.features.clone()),
 833        font_size: Some(ui_font_size.into()),
 834        color: Some(cx.theme().colors().text),
 835        ..Default::default()
 836    });
 837
 838    MarkdownStyle {
 839        base_text_style: text_style.clone(),
 840        selection_background_color: cx.theme().players().local().selection,
 841        ..Default::default()
 842    }
 843}
 844
 845async fn build_buffer(
 846    mut text: String,
 847    path: Arc<Path>,
 848    language_registry: &Arc<language::LanguageRegistry>,
 849    cx: &mut AsyncApp,
 850) -> Result<Entity<Buffer>> {
 851    let line_ending = LineEnding::detect(&text);
 852    LineEnding::normalize(&mut text);
 853    let text = Rope::from(text);
 854    let language = cx
 855        .update(|_cx| language_registry.language_for_file_path(&path))?
 856        .await
 857        .ok();
 858    let buffer = cx.new(|cx| {
 859        let buffer = TextBuffer::new_normalized(
 860            0,
 861            cx.entity_id().as_non_zero_u64().into(),
 862            line_ending,
 863            text,
 864        );
 865        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
 866        buffer.set_language(language, cx);
 867        buffer
 868    })?;
 869    Ok(buffer)
 870}
 871
 872async fn build_buffer_diff(
 873    mut old_text: String,
 874    buffer: &Entity<Buffer>,
 875    language_registry: &Arc<LanguageRegistry>,
 876    cx: &mut AsyncApp,
 877) -> Result<Entity<BufferDiff>> {
 878    LineEnding::normalize(&mut old_text);
 879
 880    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
 881
 882    let base_buffer = cx
 883        .update(|cx| {
 884            Buffer::build_snapshot(
 885                old_text.clone().into(),
 886                buffer.language().cloned(),
 887                Some(language_registry.clone()),
 888                cx,
 889            )
 890        })?
 891        .await;
 892
 893    let diff_snapshot = cx
 894        .update(|cx| {
 895            BufferDiffSnapshot::new_with_base_buffer(
 896                buffer.text.clone(),
 897                Some(old_text.into()),
 898                base_buffer,
 899                cx,
 900            )
 901        })?
 902        .await;
 903
 904    let secondary_diff = cx.new(|cx| {
 905        let mut diff = BufferDiff::new(&buffer, cx);
 906        diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
 907        diff
 908    })?;
 909
 910    cx.new(|cx| {
 911        let mut diff = BufferDiff::new(&buffer.text, cx);
 912        diff.set_snapshot(diff_snapshot, &buffer, cx);
 913        diff.set_secondary_diff(secondary_diff);
 914        diff
 915    })
 916}
 917
 918#[cfg(test)]
 919mod tests {
 920    use std::result::Result;
 921
 922    use super::*;
 923    use client::TelemetrySettings;
 924    use fs::FakeFs;
 925    use gpui::TestAppContext;
 926    use language_model::fake_provider::FakeLanguageModel;
 927    use serde_json::json;
 928    use settings::SettingsStore;
 929    use util::path;
 930
 931    #[gpui::test]
 932    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
 933        init_test(cx);
 934
 935        let fs = FakeFs::new(cx.executor());
 936        fs.insert_tree("/root", json!({})).await;
 937        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 938        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 939        let model = Arc::new(FakeLanguageModel::default());
 940        let result = cx
 941            .update(|cx| {
 942                let input = serde_json::to_value(EditFileToolInput {
 943                    display_description: "Some edit".into(),
 944                    path: "root/nonexistent_file.txt".into(),
 945                    mode: EditFileMode::Edit,
 946                })
 947                .unwrap();
 948                Arc::new(EditFileTool)
 949                    .run(
 950                        input,
 951                        Arc::default(),
 952                        project.clone(),
 953                        action_log,
 954                        model,
 955                        None,
 956                        cx,
 957                    )
 958                    .output
 959            })
 960            .await;
 961        assert_eq!(
 962            result.unwrap_err().to_string(),
 963            "Can't edit file: path not found"
 964        );
 965    }
 966
 967    #[gpui::test]
 968    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
 969        let mode = &EditFileMode::Create;
 970
 971        let result = test_resolve_path(mode, "root/new.txt", cx);
 972        assert_resolved_path_eq(result.await, "new.txt");
 973
 974        let result = test_resolve_path(mode, "new.txt", cx);
 975        assert_resolved_path_eq(result.await, "new.txt");
 976
 977        let result = test_resolve_path(mode, "dir/new.txt", cx);
 978        assert_resolved_path_eq(result.await, "dir/new.txt");
 979
 980        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
 981        assert_eq!(
 982            result.await.unwrap_err().to_string(),
 983            "Can't create file: file already exists"
 984        );
 985
 986        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
 987        assert_eq!(
 988            result.await.unwrap_err().to_string(),
 989            "Can't create file: parent directory doesn't exist"
 990        );
 991    }
 992
 993    #[gpui::test]
 994    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
 995        let mode = &EditFileMode::Edit;
 996
 997        let path_with_root = "root/dir/subdir/existing.txt";
 998        let path_without_root = "dir/subdir/existing.txt";
 999        let result = test_resolve_path(mode, path_with_root, cx);
1000        assert_resolved_path_eq(result.await, path_without_root);
1001
1002        let result = test_resolve_path(mode, path_without_root, cx);
1003        assert_resolved_path_eq(result.await, path_without_root);
1004
1005        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1006        assert_eq!(
1007            result.await.unwrap_err().to_string(),
1008            "Can't edit file: path not found"
1009        );
1010
1011        let result = test_resolve_path(mode, "root/dir", cx);
1012        assert_eq!(
1013            result.await.unwrap_err().to_string(),
1014            "Can't edit file: path is a directory"
1015        );
1016    }
1017
1018    async fn test_resolve_path(
1019        mode: &EditFileMode,
1020        path: &str,
1021        cx: &mut TestAppContext,
1022    ) -> Result<ProjectPath, anyhow::Error> {
1023        init_test(cx);
1024
1025        let fs = FakeFs::new(cx.executor());
1026        fs.insert_tree(
1027            "/root",
1028            json!({
1029                "dir": {
1030                    "subdir": {
1031                        "existing.txt": "hello"
1032                    }
1033                }
1034            }),
1035        )
1036        .await;
1037        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1038
1039        let input = EditFileToolInput {
1040            display_description: "Some edit".into(),
1041            path: path.into(),
1042            mode: mode.clone(),
1043        };
1044
1045        let result = cx.update(|cx| resolve_path(&input, project, cx));
1046        result
1047    }
1048
1049    fn assert_resolved_path_eq(path: Result<ProjectPath, anyhow::Error>, expected: &str) {
1050        let actual = path
1051            .expect("Should return valid path")
1052            .path
1053            .to_str()
1054            .unwrap()
1055            .replace("\\", "/"); // Naive Windows paths normalization
1056        assert_eq!(actual, expected);
1057    }
1058
1059    #[test]
1060    fn still_streaming_ui_text_with_path() {
1061        let input = json!({
1062            "path": "src/main.rs",
1063            "display_description": "",
1064            "old_string": "old code",
1065            "new_string": "new code"
1066        });
1067
1068        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1069    }
1070
1071    #[test]
1072    fn still_streaming_ui_text_with_description() {
1073        let input = json!({
1074            "path": "",
1075            "display_description": "Fix error handling",
1076            "old_string": "old code",
1077            "new_string": "new code"
1078        });
1079
1080        assert_eq!(
1081            EditFileTool.still_streaming_ui_text(&input),
1082            "Fix error handling",
1083        );
1084    }
1085
1086    #[test]
1087    fn still_streaming_ui_text_with_path_and_description() {
1088        let input = json!({
1089            "path": "src/main.rs",
1090            "display_description": "Fix error handling",
1091            "old_string": "old code",
1092            "new_string": "new code"
1093        });
1094
1095        assert_eq!(
1096            EditFileTool.still_streaming_ui_text(&input),
1097            "Fix error handling",
1098        );
1099    }
1100
1101    #[test]
1102    fn still_streaming_ui_text_no_path_or_description() {
1103        let input = json!({
1104            "path": "",
1105            "display_description": "",
1106            "old_string": "old code",
1107            "new_string": "new code"
1108        });
1109
1110        assert_eq!(
1111            EditFileTool.still_streaming_ui_text(&input),
1112            DEFAULT_UI_TEXT,
1113        );
1114    }
1115
1116    #[test]
1117    fn still_streaming_ui_text_with_null() {
1118        let input = serde_json::Value::Null;
1119
1120        assert_eq!(
1121            EditFileTool.still_streaming_ui_text(&input),
1122            DEFAULT_UI_TEXT,
1123        );
1124    }
1125
1126    fn init_test(cx: &mut TestAppContext) {
1127        cx.update(|cx| {
1128            let settings_store = SettingsStore::test(cx);
1129            cx.set_global(settings_store);
1130            language::init(cx);
1131            TelemetrySettings::register(cx);
1132            Project::init_settings(cx);
1133        });
1134    }
1135}