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