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