edit_file_tool.rs

   1use crate::{
   2    Templates,
   3    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
   4    schema::json_schema_for,
   5    ui::{COLLAPSED_LINES, ToolOutputPreview},
   6};
   7use action_log::ActionLog;
   8use agent_settings;
   9use anyhow::{Context as _, Result, anyhow};
  10use assistant_tool::{
  11    AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
  12};
  13use buffer_diff::{BufferDiff, BufferDiffSnapshot};
  14use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
  15use futures::StreamExt;
  16use gpui::{
  17    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
  18    TextStyleRefinement, Transformation, WeakEntity, percentage, pulsating_between, px,
  19};
  20use indoc::formatdoc;
  21use language::{
  22    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
  23    TextBuffer,
  24    language_settings::{self, FormatOnSave, SoftWrap},
  25};
  26use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
  27use markdown::{Markdown, MarkdownElement, MarkdownStyle};
  28use paths;
  29use project::{
  30    Project, ProjectPath,
  31    lsp_store::{FormatTrigger, LspFormatTarget},
  32};
  33use schemars::JsonSchema;
  34use serde::{Deserialize, Serialize};
  35use settings::Settings;
  36use std::{
  37    cmp::Reverse,
  38    collections::HashSet,
  39    ops::Range,
  40    path::{Path, PathBuf},
  41    sync::Arc,
  42    time::Duration,
  43};
  44use theme::ThemeSettings;
  45use ui::{Disclosure, Tooltip, prelude::*};
  46use util::ResultExt;
  47use workspace::Workspace;
  48
  49pub struct EditFileTool;
  50
  51#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
  52pub struct EditFileToolInput {
  53    /// A one-line, user-friendly markdown description of the edit. This will be
  54    /// shown in the UI and also passed to another model to perform the edit.
  55    ///
  56    /// Be terse, but also descriptive in what you want to achieve with this
  57    /// edit. Avoid generic instructions.
  58    ///
  59    /// NEVER mention the file path in this description.
  60    ///
  61    /// <example>Fix API endpoint URLs</example>
  62    /// <example>Update copyright year in `page_footer`</example>
  63    ///
  64    /// Make sure to include this field before all the others in the input object
  65    /// so that we can display it immediately.
  66    pub display_description: String,
  67
  68    /// The full path of the file to create or modify in the project.
  69    ///
  70    /// WARNING: When specifying which file path need changing, you MUST
  71    /// start each path with one of the project's root directories.
  72    ///
  73    /// The following examples assume we have two root directories in the project:
  74    /// - /a/b/backend
  75    /// - /c/d/frontend
  76    ///
  77    /// <example>
  78    /// `backend/src/main.rs`
  79    ///
  80    /// Notice how the file path starts with `backend`. Without that, the path
  81    /// would be ambiguous and the call would fail!
  82    /// </example>
  83    ///
  84    /// <example>
  85    /// `frontend/db.js`
  86    /// </example>
  87    pub path: PathBuf,
  88
  89    /// The mode of operation on the file. Possible values:
  90    /// - 'edit': Make granular edits to an existing file.
  91    /// - 'create': Create a new file if it doesn't exist.
  92    /// - 'overwrite': Replace the entire contents of an existing file.
  93    ///
  94    /// When a file already exists or you just created it, prefer editing
  95    /// it as opposed to recreating it from scratch.
  96    pub mode: EditFileMode,
  97}
  98
  99#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 100#[serde(rename_all = "lowercase")]
 101pub enum EditFileMode {
 102    Edit,
 103    Create,
 104    Overwrite,
 105}
 106
 107#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 108pub struct EditFileToolOutput {
 109    pub original_path: PathBuf,
 110    pub new_text: String,
 111    pub old_text: Arc<String>,
 112    pub raw_output: Option<EditAgentOutput>,
 113}
 114
 115#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 116struct PartialInput {
 117    #[serde(default)]
 118    path: String,
 119    #[serde(default)]
 120    display_description: String,
 121}
 122
 123const DEFAULT_UI_TEXT: &str = "Editing file";
 124
 125impl Tool for EditFileTool {
 126    fn name(&self) -> String {
 127        "edit_file".into()
 128    }
 129
 130    fn needs_confirmation(
 131        &self,
 132        input: &serde_json::Value,
 133        project: &Entity<Project>,
 134        cx: &App,
 135    ) -> bool {
 136        if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
 137            return false;
 138        }
 139
 140        let Ok(input) = serde_json::from_value::<EditFileToolInput>(input.clone()) else {
 141            // If it's not valid JSON, it's going to error and confirming won't do anything.
 142            return false;
 143        };
 144
 145        // If any path component matches the local settings folder, then this could affect
 146        // the editor in ways beyond the project source, so prompt.
 147        let local_settings_folder = paths::local_settings_folder_relative_path();
 148        let path = Path::new(&input.path);
 149        if path
 150            .components()
 151            .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
 152        {
 153            return true;
 154        }
 155
 156        // It's also possible that the global config dir is configured to be inside the project,
 157        // so check for that edge case too.
 158        if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
 159            if canonical_path.starts_with(paths::config_dir()) {
 160                return true;
 161            }
 162        }
 163
 164        // Check if path is inside the global config directory
 165        // First check if it's already inside project - if not, try to canonicalize
 166        let project_path = project.read(cx).find_project_path(&input.path, cx);
 167
 168        // If the path is inside the project, and it's not one of the above edge cases,
 169        // then no confirmation is necessary. Otherwise, confirmation is necessary.
 170        project_path.is_none()
 171    }
 172
 173    fn may_perform_edits(&self) -> bool {
 174        true
 175    }
 176
 177    fn description(&self) -> String {
 178        include_str!("edit_file_tool/description.md").to_string()
 179    }
 180
 181    fn icon(&self) -> IconName {
 182        IconName::ToolPencil
 183    }
 184
 185    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 186        json_schema_for::<EditFileToolInput>(format)
 187    }
 188
 189    fn ui_text(&self, input: &serde_json::Value) -> String {
 190        match serde_json::from_value::<EditFileToolInput>(input.clone()) {
 191            Ok(input) => {
 192                let path = Path::new(&input.path);
 193                let mut description = input.display_description.clone();
 194
 195                // Add context about why confirmation may be needed
 196                let local_settings_folder = paths::local_settings_folder_relative_path();
 197                if path
 198                    .components()
 199                    .any(|c| c.as_os_str() == local_settings_folder.as_os_str())
 200                {
 201                    description.push_str(" (local settings)");
 202                } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
 203                    if canonical_path.starts_with(paths::config_dir()) {
 204                        description.push_str(" (global settings)");
 205                    }
 206                }
 207
 208                description
 209            }
 210            Err(_) => "Editing file".to_string(),
 211        }
 212    }
 213
 214    fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
 215        if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
 216            let description = input.display_description.trim();
 217            if !description.is_empty() {
 218                return description.to_string();
 219            }
 220
 221            let path = input.path.trim();
 222            if !path.is_empty() {
 223                return path.to_string();
 224            }
 225        }
 226
 227        DEFAULT_UI_TEXT.to_string()
 228    }
 229
 230    fn run(
 231        self: Arc<Self>,
 232        input: serde_json::Value,
 233        request: Arc<LanguageModelRequest>,
 234        project: Entity<Project>,
 235        action_log: Entity<ActionLog>,
 236        model: Arc<dyn LanguageModel>,
 237        window: Option<AnyWindowHandle>,
 238        cx: &mut App,
 239    ) -> ToolResult {
 240        let input = match serde_json::from_value::<EditFileToolInput>(input) {
 241            Ok(input) => input,
 242            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 243        };
 244
 245        let project_path = match resolve_path(&input, project.clone(), cx) {
 246            Ok(path) => path,
 247            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 248        };
 249
 250        let card = window.and_then(|window| {
 251            window
 252                .update(cx, |_, window, cx| {
 253                    cx.new(|cx| {
 254                        EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
 255                    })
 256                })
 257                .ok()
 258        });
 259
 260        let card_clone = card.clone();
 261        let action_log_clone = action_log.clone();
 262        let task = cx.spawn(async move |cx: &mut AsyncApp| {
 263            let edit_format = EditFormat::from_model(model.clone())?;
 264            let edit_agent = EditAgent::new(
 265                model,
 266                project.clone(),
 267                action_log_clone,
 268                Templates::new(),
 269                edit_format,
 270            );
 271
 272            let buffer = project
 273                .update(cx, |project, cx| {
 274                    project.open_buffer(project_path.clone(), cx)
 275                })?
 276                .await?;
 277
 278            let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 279            let old_text = cx
 280                .background_spawn({
 281                    let old_snapshot = old_snapshot.clone();
 282                    async move { Arc::new(old_snapshot.text()) }
 283                })
 284                .await;
 285
 286            if let Some(card) = card_clone.as_ref() {
 287                card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?;
 288            }
 289
 290            let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
 291                edit_agent.edit(
 292                    buffer.clone(),
 293                    input.display_description.clone(),
 294                    &request,
 295                    cx,
 296                )
 297            } else {
 298                edit_agent.overwrite(
 299                    buffer.clone(),
 300                    input.display_description.clone(),
 301                    &request,
 302                    cx,
 303                )
 304            };
 305
 306            let mut hallucinated_old_text = false;
 307            let mut ambiguous_ranges = Vec::new();
 308            while let Some(event) = events.next().await {
 309                match event {
 310                    EditAgentOutputEvent::Edited { .. } => {
 311                        if let Some(card) = card_clone.as_ref() {
 312                            card.update(cx, |card, cx| card.update_diff(cx))?;
 313                        }
 314                    }
 315                    EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
 316                    EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
 317                    EditAgentOutputEvent::ResolvingEditRange(range) => {
 318                        if let Some(card) = card_clone.as_ref() {
 319                            card.update(cx, |card, cx| card.reveal_range(range, cx))?;
 320                        }
 321                    }
 322                }
 323            }
 324            let agent_output = output.await?;
 325
 326            // If format_on_save is enabled, format the buffer
 327            let format_on_save_enabled = buffer
 328                .read_with(cx, |buffer, cx| {
 329                    let settings = language_settings::language_settings(
 330                        buffer.language().map(|l| l.name()),
 331                        buffer.file(),
 332                        cx,
 333                    );
 334                    !matches!(settings.format_on_save, FormatOnSave::Off)
 335                })
 336                .unwrap_or(false);
 337
 338            if format_on_save_enabled {
 339                action_log.update(cx, |log, cx| {
 340                    log.buffer_edited(buffer.clone(), cx);
 341                })?;
 342                let format_task = project.update(cx, |project, cx| {
 343                    project.format(
 344                        HashSet::from_iter([buffer.clone()]),
 345                        LspFormatTarget::Buffers,
 346                        false, // Don't push to history since the tool did it.
 347                        FormatTrigger::Save,
 348                        cx,
 349                    )
 350                })?;
 351                format_task.await.log_err();
 352            }
 353
 354            project
 355                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
 356                .await?;
 357
 358            // Notify the action log that we've edited the buffer (*after* formatting has completed).
 359            action_log.update(cx, |log, cx| {
 360                log.buffer_edited(buffer.clone(), cx);
 361            })?;
 362
 363            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
 364            let (new_text, diff) = cx
 365                .background_spawn({
 366                    let new_snapshot = new_snapshot.clone();
 367                    let old_text = old_text.clone();
 368                    async move {
 369                        let new_text = new_snapshot.text();
 370                        let diff = language::unified_diff(&old_text, &new_text);
 371
 372                        (new_text, diff)
 373                    }
 374                })
 375                .await;
 376
 377            let output = EditFileToolOutput {
 378                original_path: project_path.path.to_path_buf(),
 379                new_text: new_text.clone(),
 380                old_text,
 381                raw_output: Some(agent_output),
 382            };
 383
 384            if let Some(card) = card_clone {
 385                card.update(cx, |card, cx| {
 386                    card.update_diff(cx);
 387                    card.finalize(cx)
 388                })
 389                .log_err();
 390            }
 391
 392            let input_path = input.path.display();
 393            if diff.is_empty() {
 394                anyhow::ensure!(
 395                    !hallucinated_old_text,
 396                    formatdoc! {"
 397                        Some edits were produced but none of them could be applied.
 398                        Read the relevant sections of {input_path} again so that
 399                        I can perform the requested edits.
 400                    "}
 401                );
 402                anyhow::ensure!(
 403                    ambiguous_ranges.is_empty(),
 404                    {
 405                        let line_numbers = ambiguous_ranges
 406                            .iter()
 407                            .map(|range| range.start.to_string())
 408                            .collect::<Vec<_>>()
 409                            .join(", ");
 410                        formatdoc! {"
 411                            <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
 412                            relevant sections of {input_path} again and extend <old_text> so
 413                            that I can perform the requested edits.
 414                        "}
 415                    }
 416                );
 417                Ok(ToolResultOutput {
 418                    content: ToolResultContent::Text("No edits were made.".into()),
 419                    output: serde_json::to_value(output).ok(),
 420                })
 421            } else {
 422                Ok(ToolResultOutput {
 423                    content: ToolResultContent::Text(format!(
 424                        "Edited {}:\n\n```diff\n{}\n```",
 425                        input_path, diff
 426                    )),
 427                    output: serde_json::to_value(output).ok(),
 428                })
 429            }
 430        });
 431
 432        ToolResult {
 433            output: task,
 434            card: card.map(AnyToolCard::from),
 435        }
 436    }
 437
 438    fn deserialize_card(
 439        self: Arc<Self>,
 440        output: serde_json::Value,
 441        project: Entity<Project>,
 442        window: &mut Window,
 443        cx: &mut App,
 444    ) -> Option<AnyToolCard> {
 445        let output = match serde_json::from_value::<EditFileToolOutput>(output) {
 446            Ok(output) => output,
 447            Err(_) => return None,
 448        };
 449
 450        let card = cx.new(|cx| {
 451            EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx)
 452        });
 453
 454        cx.spawn({
 455            let path: Arc<Path> = output.original_path.into();
 456            let language_registry = project.read(cx).languages().clone();
 457            let card = card.clone();
 458            async move |cx| {
 459                let buffer =
 460                    build_buffer(output.new_text, path.clone(), &language_registry, cx).await?;
 461                let buffer_diff =
 462                    build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx)
 463                        .await?;
 464                card.update(cx, |card, cx| {
 465                    card.multibuffer.update(cx, |multibuffer, cx| {
 466                        let snapshot = buffer.read(cx).snapshot();
 467                        let diff = buffer_diff.read(cx);
 468                        let diff_hunk_ranges = diff
 469                            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
 470                            .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
 471                            .collect::<Vec<_>>();
 472
 473                        multibuffer.set_excerpts_for_path(
 474                            PathKey::for_buffer(&buffer, cx),
 475                            buffer,
 476                            diff_hunk_ranges,
 477                            editor::DEFAULT_MULTIBUFFER_CONTEXT,
 478                            cx,
 479                        );
 480                        multibuffer.add_diff(buffer_diff, cx);
 481                        let end = multibuffer.len(cx);
 482                        card.total_lines =
 483                            Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1);
 484                    });
 485
 486                    cx.notify();
 487                })?;
 488                anyhow::Ok(())
 489            }
 490        })
 491        .detach_and_log_err(cx);
 492
 493        Some(card.into())
 494    }
 495}
 496
 497/// Validate that the file path is valid, meaning:
 498///
 499/// - For `edit` and `overwrite`, the path must point to an existing file.
 500/// - For `create`, the file must not already exist, but it's parent dir must exist.
 501fn resolve_path(
 502    input: &EditFileToolInput,
 503    project: Entity<Project>,
 504    cx: &mut App,
 505) -> Result<ProjectPath> {
 506    let project = project.read(cx);
 507
 508    match input.mode {
 509        EditFileMode::Edit | EditFileMode::Overwrite => {
 510            let path = project
 511                .find_project_path(&input.path, cx)
 512                .context("Can't edit file: path not found")?;
 513
 514            let entry = project
 515                .entry_for_path(&path, cx)
 516                .context("Can't edit file: path not found")?;
 517
 518            anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
 519            Ok(path)
 520        }
 521
 522        EditFileMode::Create => {
 523            if let Some(path) = project.find_project_path(&input.path, cx) {
 524                anyhow::ensure!(
 525                    project.entry_for_path(&path, cx).is_none(),
 526                    "Can't create file: file already exists"
 527                );
 528            }
 529
 530            let parent_path = input
 531                .path
 532                .parent()
 533                .context("Can't create file: incorrect path")?;
 534
 535            let parent_project_path = project.find_project_path(&parent_path, cx);
 536
 537            let parent_entry = parent_project_path
 538                .as_ref()
 539                .and_then(|path| project.entry_for_path(path, cx))
 540                .context("Can't create file: parent directory doesn't exist")?;
 541
 542            anyhow::ensure!(
 543                parent_entry.is_dir(),
 544                "Can't create file: parent is not a directory"
 545            );
 546
 547            let file_name = input
 548                .path
 549                .file_name()
 550                .context("Can't create file: invalid filename")?;
 551
 552            let new_file_path = parent_project_path.map(|parent| ProjectPath {
 553                path: Arc::from(parent.path.join(file_name)),
 554                ..parent
 555            });
 556
 557            new_file_path.context("Can't create file")
 558        }
 559    }
 560}
 561
 562pub struct EditFileToolCard {
 563    path: PathBuf,
 564    editor: Entity<Editor>,
 565    multibuffer: Entity<MultiBuffer>,
 566    project: Entity<Project>,
 567    buffer: Option<Entity<Buffer>>,
 568    base_text: Option<Arc<String>>,
 569    buffer_diff: Option<Entity<BufferDiff>>,
 570    revealed_ranges: Vec<Range<Anchor>>,
 571    diff_task: Option<Task<Result<()>>>,
 572    preview_expanded: bool,
 573    error_expanded: Option<Entity<Markdown>>,
 574    full_height_expanded: bool,
 575    total_lines: Option<u32>,
 576}
 577
 578impl EditFileToolCard {
 579    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
 580        let expand_edit_card = agent_settings::AgentSettings::get_global(cx).expand_edit_card;
 581        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
 582
 583        let editor = cx.new(|cx| {
 584            let mut editor = Editor::new(
 585                EditorMode::Full {
 586                    scale_ui_elements_with_buffer_font_size: false,
 587                    show_active_line_background: false,
 588                    sized_by_content: true,
 589                },
 590                multibuffer.clone(),
 591                Some(project.clone()),
 592                window,
 593                cx,
 594            );
 595            editor.set_show_gutter(false, cx);
 596            editor.disable_inline_diagnostics();
 597            editor.disable_expand_excerpt_buttons(cx);
 598            // Keep horizontal scrollbar so user can scroll horizontally if needed
 599            editor.set_show_vertical_scrollbar(false, cx);
 600            editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
 601            editor.set_soft_wrap_mode(SoftWrap::None, cx);
 602            editor.scroll_manager.set_forbid_vertical_scroll(true);
 603            editor.set_show_indent_guides(false, cx);
 604            editor.set_read_only(true);
 605            editor.set_show_breakpoints(false, cx);
 606            editor.set_show_code_actions(false, cx);
 607            editor.set_show_git_diff_gutter(false, cx);
 608            editor.set_expand_all_diff_hunks(cx);
 609            editor
 610        });
 611        Self {
 612            path,
 613            project,
 614            editor,
 615            multibuffer,
 616            buffer: None,
 617            base_text: None,
 618            buffer_diff: None,
 619            revealed_ranges: Vec::new(),
 620            diff_task: None,
 621            preview_expanded: true,
 622            error_expanded: None,
 623            full_height_expanded: expand_edit_card,
 624            total_lines: None,
 625        }
 626    }
 627
 628    pub fn initialize(&mut self, buffer: Entity<Buffer>, cx: &mut App) {
 629        let buffer_snapshot = buffer.read(cx).snapshot();
 630        let base_text = buffer_snapshot.text();
 631        let language_registry = buffer.read(cx).language_registry();
 632        let text_snapshot = buffer.read(cx).text_snapshot();
 633
 634        // Create a buffer diff with the current text as the base
 635        let buffer_diff = cx.new(|cx| {
 636            let mut diff = BufferDiff::new(&text_snapshot, cx);
 637            let _ = diff.set_base_text(
 638                buffer_snapshot.clone(),
 639                language_registry,
 640                text_snapshot,
 641                cx,
 642            );
 643            diff
 644        });
 645
 646        self.buffer = Some(buffer.clone());
 647        self.base_text = Some(base_text.into());
 648        self.buffer_diff = Some(buffer_diff.clone());
 649
 650        // Add the diff to the multibuffer
 651        self.multibuffer
 652            .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx));
 653    }
 654
 655    pub fn is_loading(&self) -> bool {
 656        self.total_lines.is_none()
 657    }
 658
 659    pub fn update_diff(&mut self, cx: &mut Context<Self>) {
 660        let Some(buffer) = self.buffer.as_ref() else {
 661            return;
 662        };
 663        let Some(buffer_diff) = self.buffer_diff.as_ref() else {
 664            return;
 665        };
 666
 667        let buffer = buffer.clone();
 668        let buffer_diff = buffer_diff.clone();
 669        let base_text = self.base_text.clone();
 670        self.diff_task = Some(cx.spawn(async move |this, cx| {
 671            let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
 672            let diff_snapshot = BufferDiff::update_diff(
 673                buffer_diff.clone(),
 674                text_snapshot.clone(),
 675                base_text,
 676                false,
 677                false,
 678                None,
 679                None,
 680                cx,
 681            )
 682            .await?;
 683            buffer_diff.update(cx, |diff, cx| {
 684                diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
 685            })?;
 686            this.update(cx, |this, cx| this.update_visible_ranges(cx))
 687        }));
 688    }
 689
 690    pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
 691        self.revealed_ranges.push(range);
 692        self.update_visible_ranges(cx);
 693    }
 694
 695    fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
 696        let Some(buffer) = self.buffer.as_ref() else {
 697            return;
 698        };
 699
 700        let ranges = self.excerpt_ranges(cx);
 701        self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| {
 702            multibuffer.set_excerpts_for_path(
 703                PathKey::for_buffer(buffer, cx),
 704                buffer.clone(),
 705                ranges,
 706                editor::DEFAULT_MULTIBUFFER_CONTEXT,
 707                cx,
 708            );
 709            let end = multibuffer.len(cx);
 710            Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
 711        });
 712        cx.notify();
 713    }
 714
 715    fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
 716        let Some(buffer) = self.buffer.as_ref() else {
 717            return Vec::new();
 718        };
 719        let Some(diff) = self.buffer_diff.as_ref() else {
 720            return Vec::new();
 721        };
 722
 723        let buffer = buffer.read(cx);
 724        let diff = diff.read(cx);
 725        let mut ranges = diff
 726            .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx)
 727            .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
 728            .collect::<Vec<_>>();
 729        ranges.extend(
 730            self.revealed_ranges
 731                .iter()
 732                .map(|range| range.to_point(buffer)),
 733        );
 734        ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
 735
 736        // Merge adjacent ranges
 737        let mut ranges = ranges.into_iter().peekable();
 738        let mut merged_ranges = Vec::new();
 739        while let Some(mut range) = ranges.next() {
 740            while let Some(next_range) = ranges.peek() {
 741                if range.end >= next_range.start {
 742                    range.end = range.end.max(next_range.end);
 743                    ranges.next();
 744                } else {
 745                    break;
 746                }
 747            }
 748
 749            merged_ranges.push(range);
 750        }
 751        merged_ranges
 752    }
 753
 754    pub fn finalize(&mut self, cx: &mut Context<Self>) -> Result<()> {
 755        let ranges = self.excerpt_ranges(cx);
 756        let buffer = self.buffer.take().context("card was already finalized")?;
 757        let base_text = self
 758            .base_text
 759            .take()
 760            .context("card was already finalized")?;
 761        let language_registry = self.project.read(cx).languages().clone();
 762
 763        // Replace the buffer in the multibuffer with the snapshot
 764        let buffer = cx.new(|cx| {
 765            let language = buffer.read(cx).language().cloned();
 766            let buffer = TextBuffer::new_normalized(
 767                0,
 768                cx.entity_id().as_non_zero_u64().into(),
 769                buffer.read(cx).line_ending(),
 770                buffer.read(cx).as_rope().clone(),
 771            );
 772            let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
 773            buffer.set_language(language, cx);
 774            buffer
 775        });
 776
 777        let buffer_diff = cx.spawn({
 778            let buffer = buffer.clone();
 779            let language_registry = language_registry.clone();
 780            async move |_this, cx| {
 781                build_buffer_diff(base_text, &buffer, &language_registry, cx).await
 782            }
 783        });
 784
 785        cx.spawn(async move |this, cx| {
 786            let buffer_diff = buffer_diff.await?;
 787            this.update(cx, |this, cx| {
 788                this.multibuffer.update(cx, |multibuffer, cx| {
 789                    let path_key = PathKey::for_buffer(&buffer, cx);
 790                    multibuffer.clear(cx);
 791                    multibuffer.set_excerpts_for_path(
 792                        path_key,
 793                        buffer,
 794                        ranges,
 795                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
 796                        cx,
 797                    );
 798                    multibuffer.add_diff(buffer_diff.clone(), cx);
 799                });
 800
 801                cx.notify();
 802            })
 803        })
 804        .detach_and_log_err(cx);
 805        Ok(())
 806    }
 807}
 808
 809impl ToolCard for EditFileToolCard {
 810    fn render(
 811        &mut self,
 812        status: &ToolUseStatus,
 813        window: &mut Window,
 814        workspace: WeakEntity<Workspace>,
 815        cx: &mut Context<Self>,
 816    ) -> impl IntoElement {
 817        let error_message = match status {
 818            ToolUseStatus::Error(err) => Some(err),
 819            _ => None,
 820        };
 821
 822        let running_or_pending = match status {
 823            ToolUseStatus::Running | ToolUseStatus::Pending => Some(()),
 824            _ => None,
 825        };
 826
 827        let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded;
 828
 829        let path_label_button = h_flex()
 830            .id(("edit-tool-path-label-button", self.editor.entity_id()))
 831            .w_full()
 832            .max_w_full()
 833            .px_1()
 834            .gap_0p5()
 835            .cursor_pointer()
 836            .rounded_sm()
 837            .opacity(0.8)
 838            .hover(|label| {
 839                label
 840                    .opacity(1.)
 841                    .bg(cx.theme().colors().element_hover.opacity(0.5))
 842            })
 843            .tooltip(Tooltip::text("Jump to File"))
 844            .child(
 845                h_flex()
 846                    .child(
 847                        Icon::new(IconName::ToolPencil)
 848                            .size(IconSize::Small)
 849                            .color(Color::Muted),
 850                    )
 851                    .child(
 852                        div()
 853                            .text_size(rems(0.8125))
 854                            .child(self.path.display().to_string())
 855                            .ml_1p5()
 856                            .mr_0p5(),
 857                    )
 858                    .child(
 859                        Icon::new(IconName::ArrowUpRight)
 860                            .size(IconSize::Small)
 861                            .color(Color::Ignored),
 862                    ),
 863            )
 864            .on_click({
 865                let path = self.path.clone();
 866                let workspace = workspace.clone();
 867                move |_, window, cx| {
 868                    workspace
 869                        .update(cx, {
 870                            |workspace, cx| {
 871                                let Some(project_path) =
 872                                    workspace.project().read(cx).find_project_path(&path, cx)
 873                                else {
 874                                    return;
 875                                };
 876                                let open_task =
 877                                    workspace.open_path(project_path, None, true, window, cx);
 878                                window
 879                                    .spawn(cx, async move |cx| {
 880                                        let item = open_task.await?;
 881                                        if let Some(active_editor) = item.downcast::<Editor>() {
 882                                            active_editor
 883                                                .update_in(cx, |editor, window, cx| {
 884                                                    let snapshot =
 885                                                        editor.buffer().read(cx).snapshot(cx);
 886                                                    let first_hunk = editor
 887                                                        .diff_hunks_in_ranges(
 888                                                            &[editor::Anchor::min()
 889                                                                ..editor::Anchor::max()],
 890                                                            &snapshot,
 891                                                        )
 892                                                        .next();
 893                                                    if let Some(first_hunk) = first_hunk {
 894                                                        let first_hunk_start =
 895                                                            first_hunk.multi_buffer_range().start;
 896                                                        editor.change_selections(
 897                                                            Default::default(),
 898                                                            window,
 899                                                            cx,
 900                                                            |selections| {
 901                                                                selections.select_anchor_ranges([
 902                                                                    first_hunk_start
 903                                                                        ..first_hunk_start,
 904                                                                ]);
 905                                                            },
 906                                                        )
 907                                                    }
 908                                                })
 909                                                .log_err();
 910                                        }
 911                                        anyhow::Ok(())
 912                                    })
 913                                    .detach_and_log_err(cx);
 914                            }
 915                        })
 916                        .ok();
 917                }
 918            })
 919            .into_any_element();
 920
 921        let codeblock_header_bg = cx
 922            .theme()
 923            .colors()
 924            .element_background
 925            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
 926
 927        let codeblock_header = h_flex()
 928            .flex_none()
 929            .p_1()
 930            .gap_1()
 931            .justify_between()
 932            .rounded_t_md()
 933            .when(error_message.is_none(), |header| {
 934                header.bg(codeblock_header_bg)
 935            })
 936            .child(path_label_button)
 937            .when(should_show_loading, |header| {
 938                header.pr_1p5().child(
 939                    Icon::new(IconName::ArrowCircle)
 940                        .size(IconSize::XSmall)
 941                        .color(Color::Info)
 942                        .with_animation(
 943                            "arrow-circle",
 944                            Animation::new(Duration::from_secs(2)).repeat(),
 945                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
 946                        ),
 947                )
 948            })
 949            .when_some(error_message, |header, error_message| {
 950                header.child(
 951                    h_flex()
 952                        .gap_1()
 953                        .child(
 954                            Icon::new(IconName::Close)
 955                                .size(IconSize::Small)
 956                                .color(Color::Error),
 957                        )
 958                        .child(
 959                            Disclosure::new(
 960                                ("edit-file-error-disclosure", self.editor.entity_id()),
 961                                self.error_expanded.is_some(),
 962                            )
 963                            .opened_icon(IconName::ChevronUp)
 964                            .closed_icon(IconName::ChevronDown)
 965                            .on_click(cx.listener({
 966                                let error_message = error_message.clone();
 967
 968                                move |this, _event, _window, cx| {
 969                                    if this.error_expanded.is_some() {
 970                                        this.error_expanded.take();
 971                                    } else {
 972                                        this.error_expanded = Some(cx.new(|cx| {
 973                                            Markdown::new(error_message.clone(), None, None, cx)
 974                                        }))
 975                                    }
 976                                    cx.notify();
 977                                }
 978                            })),
 979                        ),
 980                )
 981            })
 982            .when(error_message.is_none() && !self.is_loading(), |header| {
 983                header.child(
 984                    Disclosure::new(
 985                        ("edit-file-disclosure", self.editor.entity_id()),
 986                        self.preview_expanded,
 987                    )
 988                    .opened_icon(IconName::ChevronUp)
 989                    .closed_icon(IconName::ChevronDown)
 990                    .on_click(cx.listener(
 991                        move |this, _event, _window, _cx| {
 992                            this.preview_expanded = !this.preview_expanded;
 993                        },
 994                    )),
 995                )
 996            });
 997
 998        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
 999            let line_height = editor
1000                .style()
1001                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
1002                .unwrap_or_default();
1003
1004            editor.set_text_style_refinement(TextStyleRefinement {
1005                font_size: Some(
1006                    TextSize::Small
1007                        .rems(cx)
1008                        .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
1009                        .into(),
1010                ),
1011                ..TextStyleRefinement::default()
1012            });
1013            let element = editor.render(window, cx);
1014            (element.into_any_element(), line_height)
1015        });
1016
1017        let border_color = cx.theme().colors().border.opacity(0.6);
1018
1019        let waiting_for_diff = {
1020            let styles = [
1021                ("w_4_5", (0.1, 0.85), 2000),
1022                ("w_1_4", (0.2, 0.75), 2200),
1023                ("w_2_4", (0.15, 0.64), 1900),
1024                ("w_3_5", (0.25, 0.72), 2300),
1025                ("w_2_5", (0.3, 0.56), 1800),
1026            ];
1027
1028            let mut container = v_flex()
1029                .p_3()
1030                .gap_1()
1031                .border_t_1()
1032                .rounded_b_md()
1033                .border_color(border_color)
1034                .bg(cx.theme().colors().editor_background);
1035
1036            for (width_method, pulse_range, duration_ms) in styles.iter() {
1037                let (min_opacity, max_opacity) = *pulse_range;
1038                let placeholder = match *width_method {
1039                    "w_4_5" => div().w_3_4(),
1040                    "w_1_4" => div().w_1_4(),
1041                    "w_2_4" => div().w_2_4(),
1042                    "w_3_5" => div().w_3_5(),
1043                    "w_2_5" => div().w_2_5(),
1044                    _ => div().w_1_2(),
1045                }
1046                .id("loading_div")
1047                .h_1()
1048                .rounded_full()
1049                .bg(cx.theme().colors().element_active)
1050                .with_animation(
1051                    "loading_pulsate",
1052                    Animation::new(Duration::from_millis(*duration_ms))
1053                        .repeat()
1054                        .with_easing(pulsating_between(min_opacity, max_opacity)),
1055                    |label, delta| label.opacity(delta),
1056                );
1057
1058                container = container.child(placeholder);
1059            }
1060
1061            container
1062        };
1063
1064        v_flex()
1065            .mb_2()
1066            .border_1()
1067            .when(error_message.is_some(), |card| card.border_dashed())
1068            .border_color(border_color)
1069            .rounded_md()
1070            .overflow_hidden()
1071            .child(codeblock_header)
1072            .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
1073                card.child(
1074                    v_flex()
1075                        .p_2()
1076                        .gap_1()
1077                        .border_t_1()
1078                        .border_dashed()
1079                        .border_color(border_color)
1080                        .bg(cx.theme().colors().editor_background)
1081                        .rounded_b_md()
1082                        .child(
1083                            Label::new("Error")
1084                                .size(LabelSize::XSmall)
1085                                .color(Color::Error),
1086                        )
1087                        .child(
1088                            div()
1089                                .rounded_md()
1090                                .text_ui_sm(cx)
1091                                .bg(cx.theme().colors().editor_background)
1092                                .child(MarkdownElement::new(
1093                                    error_markdown.clone(),
1094                                    markdown_style(window, cx),
1095                                )),
1096                        ),
1097                )
1098            })
1099            .when(self.is_loading() && error_message.is_none(), |card| {
1100                card.child(waiting_for_diff)
1101            })
1102            .when(self.preview_expanded && !self.is_loading(), |card| {
1103                let editor_view = v_flex()
1104                    .relative()
1105                    .h_full()
1106                    .when(!self.full_height_expanded, |editor_container| {
1107                        editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
1108                    })
1109                    .overflow_hidden()
1110                    .border_t_1()
1111                    .border_color(border_color)
1112                    .bg(cx.theme().colors().editor_background)
1113                    .child(editor);
1114
1115                card.child(
1116                    ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
1117                        .with_total_lines(self.total_lines.unwrap_or(0) as usize)
1118                        .toggle_state(self.full_height_expanded)
1119                        .with_collapsed_fade()
1120                        .on_toggle({
1121                            let this = cx.entity().downgrade();
1122                            move |is_expanded, _window, cx| {
1123                                if let Some(this) = this.upgrade() {
1124                                    this.update(cx, |this, _cx| {
1125                                        this.full_height_expanded = is_expanded;
1126                                    });
1127                                }
1128                            }
1129                        }),
1130                )
1131            })
1132    }
1133}
1134
1135fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1136    let theme_settings = ThemeSettings::get_global(cx);
1137    let ui_font_size = TextSize::Default.rems(cx);
1138    let mut text_style = window.text_style();
1139
1140    text_style.refine(&TextStyleRefinement {
1141        font_family: Some(theme_settings.ui_font.family.clone()),
1142        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1143        font_features: Some(theme_settings.ui_font.features.clone()),
1144        font_size: Some(ui_font_size.into()),
1145        color: Some(cx.theme().colors().text),
1146        ..Default::default()
1147    });
1148
1149    MarkdownStyle {
1150        base_text_style: text_style.clone(),
1151        selection_background_color: cx.theme().colors().element_selection_background,
1152        ..Default::default()
1153    }
1154}
1155
1156async fn build_buffer(
1157    mut text: String,
1158    path: Arc<Path>,
1159    language_registry: &Arc<language::LanguageRegistry>,
1160    cx: &mut AsyncApp,
1161) -> Result<Entity<Buffer>> {
1162    let line_ending = LineEnding::detect(&text);
1163    LineEnding::normalize(&mut text);
1164    let text = Rope::from(text);
1165    let language = cx
1166        .update(|_cx| language_registry.language_for_file_path(&path))?
1167        .await
1168        .ok();
1169    let buffer = cx.new(|cx| {
1170        let buffer = TextBuffer::new_normalized(
1171            0,
1172            cx.entity_id().as_non_zero_u64().into(),
1173            line_ending,
1174            text,
1175        );
1176        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
1177        buffer.set_language(language, cx);
1178        buffer
1179    })?;
1180    Ok(buffer)
1181}
1182
1183async fn build_buffer_diff(
1184    old_text: Arc<String>,
1185    buffer: &Entity<Buffer>,
1186    language_registry: &Arc<LanguageRegistry>,
1187    cx: &mut AsyncApp,
1188) -> Result<Entity<BufferDiff>> {
1189    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
1190
1191    let old_text_rope = cx
1192        .background_spawn({
1193            let old_text = old_text.clone();
1194            async move { Rope::from(old_text.as_str()) }
1195        })
1196        .await;
1197    let base_buffer = cx
1198        .update(|cx| {
1199            Buffer::build_snapshot(
1200                old_text_rope,
1201                buffer.language().cloned(),
1202                Some(language_registry.clone()),
1203                cx,
1204            )
1205        })?
1206        .await;
1207
1208    let diff_snapshot = cx
1209        .update(|cx| {
1210            BufferDiffSnapshot::new_with_base_buffer(
1211                buffer.text.clone(),
1212                Some(old_text),
1213                base_buffer,
1214                cx,
1215            )
1216        })?
1217        .await;
1218
1219    let secondary_diff = cx.new(|cx| {
1220        let mut diff = BufferDiff::new(&buffer, cx);
1221        diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
1222        diff
1223    })?;
1224
1225    cx.new(|cx| {
1226        let mut diff = BufferDiff::new(&buffer.text, cx);
1227        diff.set_snapshot(diff_snapshot, &buffer, cx);
1228        diff.set_secondary_diff(secondary_diff);
1229        diff
1230    })
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236    use ::fs::Fs;
1237    use client::TelemetrySettings;
1238    use gpui::{TestAppContext, UpdateGlobal};
1239    use language_model::fake_provider::FakeLanguageModel;
1240    use serde_json::json;
1241    use settings::SettingsStore;
1242    use std::fs;
1243    use util::path;
1244
1245    #[gpui::test]
1246    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
1247        init_test(cx);
1248
1249        let fs = project::FakeFs::new(cx.executor());
1250        fs.insert_tree("/root", json!({})).await;
1251        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1252        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1253        let model = Arc::new(FakeLanguageModel::default());
1254        let result = cx
1255            .update(|cx| {
1256                let input = serde_json::to_value(EditFileToolInput {
1257                    display_description: "Some edit".into(),
1258                    path: "root/nonexistent_file.txt".into(),
1259                    mode: EditFileMode::Edit,
1260                })
1261                .unwrap();
1262                Arc::new(EditFileTool)
1263                    .run(
1264                        input,
1265                        Arc::default(),
1266                        project.clone(),
1267                        action_log,
1268                        model,
1269                        None,
1270                        cx,
1271                    )
1272                    .output
1273            })
1274            .await;
1275        assert_eq!(
1276            result.unwrap_err().to_string(),
1277            "Can't edit file: path not found"
1278        );
1279    }
1280
1281    #[gpui::test]
1282    async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
1283        let mode = &EditFileMode::Create;
1284
1285        let result = test_resolve_path(mode, "root/new.txt", cx);
1286        assert_resolved_path_eq(result.await, "new.txt");
1287
1288        let result = test_resolve_path(mode, "new.txt", cx);
1289        assert_resolved_path_eq(result.await, "new.txt");
1290
1291        let result = test_resolve_path(mode, "dir/new.txt", cx);
1292        assert_resolved_path_eq(result.await, "dir/new.txt");
1293
1294        let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
1295        assert_eq!(
1296            result.await.unwrap_err().to_string(),
1297            "Can't create file: file already exists"
1298        );
1299
1300        let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
1301        assert_eq!(
1302            result.await.unwrap_err().to_string(),
1303            "Can't create file: parent directory doesn't exist"
1304        );
1305    }
1306
1307    #[gpui::test]
1308    async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
1309        let mode = &EditFileMode::Edit;
1310
1311        let path_with_root = "root/dir/subdir/existing.txt";
1312        let path_without_root = "dir/subdir/existing.txt";
1313        let result = test_resolve_path(mode, path_with_root, cx);
1314        assert_resolved_path_eq(result.await, path_without_root);
1315
1316        let result = test_resolve_path(mode, path_without_root, cx);
1317        assert_resolved_path_eq(result.await, path_without_root);
1318
1319        let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1320        assert_eq!(
1321            result.await.unwrap_err().to_string(),
1322            "Can't edit file: path not found"
1323        );
1324
1325        let result = test_resolve_path(mode, "root/dir", cx);
1326        assert_eq!(
1327            result.await.unwrap_err().to_string(),
1328            "Can't edit file: path is a directory"
1329        );
1330    }
1331
1332    async fn test_resolve_path(
1333        mode: &EditFileMode,
1334        path: &str,
1335        cx: &mut TestAppContext,
1336    ) -> anyhow::Result<ProjectPath> {
1337        init_test(cx);
1338
1339        let fs = project::FakeFs::new(cx.executor());
1340        fs.insert_tree(
1341            "/root",
1342            json!({
1343                "dir": {
1344                    "subdir": {
1345                        "existing.txt": "hello"
1346                    }
1347                }
1348            }),
1349        )
1350        .await;
1351        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1352
1353        let input = EditFileToolInput {
1354            display_description: "Some edit".into(),
1355            path: path.into(),
1356            mode: mode.clone(),
1357        };
1358
1359        let result = cx.update(|cx| resolve_path(&input, project, cx));
1360        result
1361    }
1362
1363    fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1364        let actual = path
1365            .expect("Should return valid path")
1366            .path
1367            .to_str()
1368            .unwrap()
1369            .replace("\\", "/"); // Naive Windows paths normalization
1370        assert_eq!(actual, expected);
1371    }
1372
1373    #[test]
1374    fn still_streaming_ui_text_with_path() {
1375        let input = json!({
1376            "path": "src/main.rs",
1377            "display_description": "",
1378            "old_string": "old code",
1379            "new_string": "new code"
1380        });
1381
1382        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1383    }
1384
1385    #[test]
1386    fn still_streaming_ui_text_with_description() {
1387        let input = json!({
1388            "path": "",
1389            "display_description": "Fix error handling",
1390            "old_string": "old code",
1391            "new_string": "new code"
1392        });
1393
1394        assert_eq!(
1395            EditFileTool.still_streaming_ui_text(&input),
1396            "Fix error handling",
1397        );
1398    }
1399
1400    #[test]
1401    fn still_streaming_ui_text_with_path_and_description() {
1402        let input = json!({
1403            "path": "src/main.rs",
1404            "display_description": "Fix error handling",
1405            "old_string": "old code",
1406            "new_string": "new code"
1407        });
1408
1409        assert_eq!(
1410            EditFileTool.still_streaming_ui_text(&input),
1411            "Fix error handling",
1412        );
1413    }
1414
1415    #[test]
1416    fn still_streaming_ui_text_no_path_or_description() {
1417        let input = json!({
1418            "path": "",
1419            "display_description": "",
1420            "old_string": "old code",
1421            "new_string": "new code"
1422        });
1423
1424        assert_eq!(
1425            EditFileTool.still_streaming_ui_text(&input),
1426            DEFAULT_UI_TEXT,
1427        );
1428    }
1429
1430    #[test]
1431    fn still_streaming_ui_text_with_null() {
1432        let input = serde_json::Value::Null;
1433
1434        assert_eq!(
1435            EditFileTool.still_streaming_ui_text(&input),
1436            DEFAULT_UI_TEXT,
1437        );
1438    }
1439
1440    fn init_test(cx: &mut TestAppContext) {
1441        cx.update(|cx| {
1442            let settings_store = SettingsStore::test(cx);
1443            cx.set_global(settings_store);
1444            language::init(cx);
1445            TelemetrySettings::register(cx);
1446            agent_settings::AgentSettings::register(cx);
1447            Project::init_settings(cx);
1448        });
1449    }
1450
1451    fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) {
1452        cx.update(|cx| {
1453            // Set custom data directory (config will be under data_dir/config)
1454            paths::set_custom_data_dir(data_dir.to_str().unwrap());
1455
1456            let settings_store = SettingsStore::test(cx);
1457            cx.set_global(settings_store);
1458            language::init(cx);
1459            TelemetrySettings::register(cx);
1460            agent_settings::AgentSettings::register(cx);
1461            Project::init_settings(cx);
1462        });
1463    }
1464
1465    #[gpui::test]
1466    async fn test_format_on_save(cx: &mut TestAppContext) {
1467        init_test(cx);
1468
1469        let fs = project::FakeFs::new(cx.executor());
1470        fs.insert_tree("/root", json!({"src": {}})).await;
1471
1472        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1473
1474        // Set up a Rust language with LSP formatting support
1475        let rust_language = Arc::new(language::Language::new(
1476            language::LanguageConfig {
1477                name: "Rust".into(),
1478                matcher: language::LanguageMatcher {
1479                    path_suffixes: vec!["rs".to_string()],
1480                    ..Default::default()
1481                },
1482                ..Default::default()
1483            },
1484            None,
1485        ));
1486
1487        // Register the language and fake LSP
1488        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1489        language_registry.add(rust_language);
1490
1491        let mut fake_language_servers = language_registry.register_fake_lsp(
1492            "Rust",
1493            language::FakeLspAdapter {
1494                capabilities: lsp::ServerCapabilities {
1495                    document_formatting_provider: Some(lsp::OneOf::Left(true)),
1496                    ..Default::default()
1497                },
1498                ..Default::default()
1499            },
1500        );
1501
1502        // Create the file
1503        fs.save(
1504            path!("/root/src/main.rs").as_ref(),
1505            &"initial content".into(),
1506            language::LineEnding::Unix,
1507        )
1508        .await
1509        .unwrap();
1510
1511        // Open the buffer to trigger LSP initialization
1512        let buffer = project
1513            .update(cx, |project, cx| {
1514                project.open_local_buffer(path!("/root/src/main.rs"), cx)
1515            })
1516            .await
1517            .unwrap();
1518
1519        // Register the buffer with language servers
1520        let _handle = project.update(cx, |project, cx| {
1521            project.register_buffer_with_language_servers(&buffer, cx)
1522        });
1523
1524        const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
1525        const FORMATTED_CONTENT: &str =
1526            "This file was formatted by the fake formatter in the test.\n";
1527
1528        // Get the fake language server and set up formatting handler
1529        let fake_language_server = fake_language_servers.next().await.unwrap();
1530        fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
1531            |_, _| async move {
1532                Ok(Some(vec![lsp::TextEdit {
1533                    range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
1534                    new_text: FORMATTED_CONTENT.to_string(),
1535                }]))
1536            }
1537        });
1538
1539        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1540        let model = Arc::new(FakeLanguageModel::default());
1541
1542        // First, test with format_on_save enabled
1543        cx.update(|cx| {
1544            SettingsStore::update_global(cx, |store, cx| {
1545                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1546                    cx,
1547                    |settings| {
1548                        settings.defaults.format_on_save = Some(FormatOnSave::On);
1549                        settings.defaults.formatter =
1550                            Some(language::language_settings::SelectedFormatter::Auto);
1551                    },
1552                );
1553            });
1554        });
1555
1556        // Have the model stream unformatted content
1557        let edit_result = {
1558            let edit_task = cx.update(|cx| {
1559                let input = serde_json::to_value(EditFileToolInput {
1560                    display_description: "Create main function".into(),
1561                    path: "root/src/main.rs".into(),
1562                    mode: EditFileMode::Overwrite,
1563                })
1564                .unwrap();
1565                Arc::new(EditFileTool)
1566                    .run(
1567                        input,
1568                        Arc::default(),
1569                        project.clone(),
1570                        action_log.clone(),
1571                        model.clone(),
1572                        None,
1573                        cx,
1574                    )
1575                    .output
1576            });
1577
1578            // Stream the unformatted content
1579            cx.executor().run_until_parked();
1580            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
1581            model.end_last_completion_stream();
1582
1583            edit_task.await
1584        };
1585        assert!(edit_result.is_ok());
1586
1587        // Wait for any async operations (e.g. formatting) to complete
1588        cx.executor().run_until_parked();
1589
1590        // Read the file to verify it was formatted automatically
1591        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1592        assert_eq!(
1593            // Ignore carriage returns on Windows
1594            new_content.replace("\r\n", "\n"),
1595            FORMATTED_CONTENT,
1596            "Code should be formatted when format_on_save is enabled"
1597        );
1598
1599        let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
1600
1601        assert_eq!(
1602            stale_buffer_count, 0,
1603            "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
1604             This causes the agent to think the file was modified externally when it was just formatted.",
1605            stale_buffer_count
1606        );
1607
1608        // Next, test with format_on_save disabled
1609        cx.update(|cx| {
1610            SettingsStore::update_global(cx, |store, cx| {
1611                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1612                    cx,
1613                    |settings| {
1614                        settings.defaults.format_on_save = Some(FormatOnSave::Off);
1615                    },
1616                );
1617            });
1618        });
1619
1620        // Stream unformatted edits again
1621        let edit_result = {
1622            let edit_task = cx.update(|cx| {
1623                let input = serde_json::to_value(EditFileToolInput {
1624                    display_description: "Update main function".into(),
1625                    path: "root/src/main.rs".into(),
1626                    mode: EditFileMode::Overwrite,
1627                })
1628                .unwrap();
1629                Arc::new(EditFileTool)
1630                    .run(
1631                        input,
1632                        Arc::default(),
1633                        project.clone(),
1634                        action_log.clone(),
1635                        model.clone(),
1636                        None,
1637                        cx,
1638                    )
1639                    .output
1640            });
1641
1642            // Stream the unformatted content
1643            cx.executor().run_until_parked();
1644            model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
1645            model.end_last_completion_stream();
1646
1647            edit_task.await
1648        };
1649        assert!(edit_result.is_ok());
1650
1651        // Wait for any async operations (e.g. formatting) to complete
1652        cx.executor().run_until_parked();
1653
1654        // Verify the file was not formatted
1655        let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1656        assert_eq!(
1657            // Ignore carriage returns on Windows
1658            new_content.replace("\r\n", "\n"),
1659            UNFORMATTED_CONTENT,
1660            "Code should not be formatted when format_on_save is disabled"
1661        );
1662    }
1663
1664    #[gpui::test]
1665    async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1666        init_test(cx);
1667
1668        let fs = project::FakeFs::new(cx.executor());
1669        fs.insert_tree("/root", json!({"src": {}})).await;
1670
1671        // Create a simple file with trailing whitespace
1672        fs.save(
1673            path!("/root/src/main.rs").as_ref(),
1674            &"initial content".into(),
1675            language::LineEnding::Unix,
1676        )
1677        .await
1678        .unwrap();
1679
1680        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1681        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1682        let model = Arc::new(FakeLanguageModel::default());
1683
1684        // First, test with remove_trailing_whitespace_on_save enabled
1685        cx.update(|cx| {
1686            SettingsStore::update_global(cx, |store, cx| {
1687                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1688                    cx,
1689                    |settings| {
1690                        settings.defaults.remove_trailing_whitespace_on_save = Some(true);
1691                    },
1692                );
1693            });
1694        });
1695
1696        const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1697            "fn main() {  \n    println!(\"Hello!\");  \n}\n";
1698
1699        // Have the model stream content that contains trailing whitespace
1700        let edit_result = {
1701            let edit_task = cx.update(|cx| {
1702                let input = serde_json::to_value(EditFileToolInput {
1703                    display_description: "Create main function".into(),
1704                    path: "root/src/main.rs".into(),
1705                    mode: EditFileMode::Overwrite,
1706                })
1707                .unwrap();
1708                Arc::new(EditFileTool)
1709                    .run(
1710                        input,
1711                        Arc::default(),
1712                        project.clone(),
1713                        action_log.clone(),
1714                        model.clone(),
1715                        None,
1716                        cx,
1717                    )
1718                    .output
1719            });
1720
1721            // Stream the content with trailing whitespace
1722            cx.executor().run_until_parked();
1723            model.send_last_completion_stream_text_chunk(
1724                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1725            );
1726            model.end_last_completion_stream();
1727
1728            edit_task.await
1729        };
1730        assert!(edit_result.is_ok());
1731
1732        // Wait for any async operations (e.g. formatting) to complete
1733        cx.executor().run_until_parked();
1734
1735        // Read the file to verify trailing whitespace was removed automatically
1736        assert_eq!(
1737            // Ignore carriage returns on Windows
1738            fs.load(path!("/root/src/main.rs").as_ref())
1739                .await
1740                .unwrap()
1741                .replace("\r\n", "\n"),
1742            "fn main() {\n    println!(\"Hello!\");\n}\n",
1743            "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1744        );
1745
1746        // Next, test with remove_trailing_whitespace_on_save disabled
1747        cx.update(|cx| {
1748            SettingsStore::update_global(cx, |store, cx| {
1749                store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1750                    cx,
1751                    |settings| {
1752                        settings.defaults.remove_trailing_whitespace_on_save = Some(false);
1753                    },
1754                );
1755            });
1756        });
1757
1758        // Stream edits again with trailing whitespace
1759        let edit_result = {
1760            let edit_task = cx.update(|cx| {
1761                let input = serde_json::to_value(EditFileToolInput {
1762                    display_description: "Update main function".into(),
1763                    path: "root/src/main.rs".into(),
1764                    mode: EditFileMode::Overwrite,
1765                })
1766                .unwrap();
1767                Arc::new(EditFileTool)
1768                    .run(
1769                        input,
1770                        Arc::default(),
1771                        project.clone(),
1772                        action_log.clone(),
1773                        model.clone(),
1774                        None,
1775                        cx,
1776                    )
1777                    .output
1778            });
1779
1780            // Stream the content with trailing whitespace
1781            cx.executor().run_until_parked();
1782            model.send_last_completion_stream_text_chunk(
1783                CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1784            );
1785            model.end_last_completion_stream();
1786
1787            edit_task.await
1788        };
1789        assert!(edit_result.is_ok());
1790
1791        // Wait for any async operations (e.g. formatting) to complete
1792        cx.executor().run_until_parked();
1793
1794        // Verify the file still has trailing whitespace
1795        // Read the file again - it should still have trailing whitespace
1796        let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1797        assert_eq!(
1798            // Ignore carriage returns on Windows
1799            final_content.replace("\r\n", "\n"),
1800            CONTENT_WITH_TRAILING_WHITESPACE,
1801            "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1802        );
1803    }
1804
1805    #[gpui::test]
1806    async fn test_needs_confirmation(cx: &mut TestAppContext) {
1807        init_test(cx);
1808        let tool = Arc::new(EditFileTool);
1809        let fs = project::FakeFs::new(cx.executor());
1810        fs.insert_tree("/root", json!({})).await;
1811
1812        // Test 1: Path with .zed component should require confirmation
1813        let input_with_zed = json!({
1814            "display_description": "Edit settings",
1815            "path": ".zed/settings.json",
1816            "mode": "edit"
1817        });
1818        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1819        cx.update(|cx| {
1820            assert!(
1821                tool.needs_confirmation(&input_with_zed, &project, cx),
1822                "Path with .zed component should require confirmation"
1823            );
1824        });
1825
1826        // Test 2: Absolute path should require confirmation
1827        let input_absolute = json!({
1828            "display_description": "Edit file",
1829            "path": "/etc/hosts",
1830            "mode": "edit"
1831        });
1832        cx.update(|cx| {
1833            assert!(
1834                tool.needs_confirmation(&input_absolute, &project, cx),
1835                "Absolute path should require confirmation"
1836            );
1837        });
1838
1839        // Test 3: Relative path without .zed should not require confirmation
1840        let input_relative = json!({
1841            "display_description": "Edit file",
1842            "path": "root/src/main.rs",
1843            "mode": "edit"
1844        });
1845        cx.update(|cx| {
1846            assert!(
1847                !tool.needs_confirmation(&input_relative, &project, cx),
1848                "Relative path without .zed should not require confirmation"
1849            );
1850        });
1851
1852        // Test 4: Path with .zed in the middle should require confirmation
1853        let input_zed_middle = json!({
1854            "display_description": "Edit settings",
1855            "path": "root/.zed/tasks.json",
1856            "mode": "edit"
1857        });
1858        cx.update(|cx| {
1859            assert!(
1860                tool.needs_confirmation(&input_zed_middle, &project, cx),
1861                "Path with .zed in any component should require confirmation"
1862            );
1863        });
1864
1865        // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1866        cx.update(|cx| {
1867            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1868            settings.always_allow_tool_actions = true;
1869            agent_settings::AgentSettings::override_global(settings, cx);
1870
1871            assert!(
1872                !tool.needs_confirmation(&input_with_zed, &project, cx),
1873                "When always_allow_tool_actions is true, no confirmation should be needed"
1874            );
1875            assert!(
1876                !tool.needs_confirmation(&input_absolute, &project, cx),
1877                "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths"
1878            );
1879        });
1880    }
1881
1882    #[gpui::test]
1883    async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) {
1884        // Set up a custom config directory for testing
1885        let temp_dir = tempfile::tempdir().unwrap();
1886        init_test_with_config(cx, temp_dir.path());
1887
1888        let tool = Arc::new(EditFileTool);
1889
1890        // Test ui_text shows context for various paths
1891        let test_cases = vec![
1892            (
1893                json!({
1894                    "display_description": "Update config",
1895                    "path": ".zed/settings.json",
1896                    "mode": "edit"
1897                }),
1898                "Update config (local settings)",
1899                ".zed path should show local settings context",
1900            ),
1901            (
1902                json!({
1903                    "display_description": "Fix bug",
1904                    "path": "src/.zed/local.json",
1905                    "mode": "edit"
1906                }),
1907                "Fix bug (local settings)",
1908                "Nested .zed path should show local settings context",
1909            ),
1910            (
1911                json!({
1912                    "display_description": "Update readme",
1913                    "path": "README.md",
1914                    "mode": "edit"
1915                }),
1916                "Update readme",
1917                "Normal path should not show additional context",
1918            ),
1919            (
1920                json!({
1921                    "display_description": "Edit config",
1922                    "path": "config.zed",
1923                    "mode": "edit"
1924                }),
1925                "Edit config",
1926                ".zed as extension should not show context",
1927            ),
1928        ];
1929
1930        for (input, expected_text, description) in test_cases {
1931            cx.update(|_cx| {
1932                let ui_text = tool.ui_text(&input);
1933                assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
1934            });
1935        }
1936    }
1937
1938    #[gpui::test]
1939    async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) {
1940        init_test(cx);
1941        let tool = Arc::new(EditFileTool);
1942        let fs = project::FakeFs::new(cx.executor());
1943
1944        // Create a project in /project directory
1945        fs.insert_tree("/project", json!({})).await;
1946        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1947
1948        // Test file outside project requires confirmation
1949        let input_outside = json!({
1950            "display_description": "Edit file",
1951            "path": "/outside/file.txt",
1952            "mode": "edit"
1953        });
1954        cx.update(|cx| {
1955            assert!(
1956                tool.needs_confirmation(&input_outside, &project, cx),
1957                "File outside project should require confirmation"
1958            );
1959        });
1960
1961        // Test file inside project doesn't require confirmation
1962        let input_inside = json!({
1963            "display_description": "Edit file",
1964            "path": "project/file.txt",
1965            "mode": "edit"
1966        });
1967        cx.update(|cx| {
1968            assert!(
1969                !tool.needs_confirmation(&input_inside, &project, cx),
1970                "File inside project should not require confirmation"
1971            );
1972        });
1973    }
1974
1975    #[gpui::test]
1976    async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) {
1977        // Set up a custom data directory for testing
1978        let temp_dir = tempfile::tempdir().unwrap();
1979        init_test_with_config(cx, temp_dir.path());
1980
1981        let tool = Arc::new(EditFileTool);
1982        let fs = project::FakeFs::new(cx.executor());
1983        fs.insert_tree("/home/user/myproject", json!({})).await;
1984        let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
1985
1986        // Get the actual local settings folder name
1987        let local_settings_folder = paths::local_settings_folder_relative_path();
1988
1989        // Test various config path patterns
1990        let test_cases = vec![
1991            (
1992                format!("{}/settings.json", local_settings_folder.display()),
1993                true,
1994                "Top-level local settings file".to_string(),
1995            ),
1996            (
1997                format!(
1998                    "myproject/{}/settings.json",
1999                    local_settings_folder.display()
2000                ),
2001                true,
2002                "Local settings in project path".to_string(),
2003            ),
2004            (
2005                format!("src/{}/config.toml", local_settings_folder.display()),
2006                true,
2007                "Local settings in subdirectory".to_string(),
2008            ),
2009            (
2010                ".zed.backup/file.txt".to_string(),
2011                true,
2012                ".zed.backup is outside project".to_string(),
2013            ),
2014            (
2015                "my.zed/file.txt".to_string(),
2016                true,
2017                "my.zed is outside project".to_string(),
2018            ),
2019            (
2020                "myproject/src/file.zed".to_string(),
2021                false,
2022                ".zed as file extension".to_string(),
2023            ),
2024            (
2025                "myproject/normal/path/file.rs".to_string(),
2026                false,
2027                "Normal file without config paths".to_string(),
2028            ),
2029        ];
2030
2031        for (path, should_confirm, description) in test_cases {
2032            let input = json!({
2033                "display_description": "Edit file",
2034                "path": path,
2035                "mode": "edit"
2036            });
2037            cx.update(|cx| {
2038                assert_eq!(
2039                    tool.needs_confirmation(&input, &project, cx),
2040                    should_confirm,
2041                    "Failed for case: {} - path: {}",
2042                    description,
2043                    path
2044                );
2045            });
2046        }
2047    }
2048
2049    #[gpui::test]
2050    async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) {
2051        // Set up a custom data directory for testing
2052        let temp_dir = tempfile::tempdir().unwrap();
2053        init_test_with_config(cx, temp_dir.path());
2054
2055        let tool = Arc::new(EditFileTool);
2056        let fs = project::FakeFs::new(cx.executor());
2057
2058        // Create test files in the global config directory
2059        let global_config_dir = paths::config_dir();
2060        fs::create_dir_all(&global_config_dir).unwrap();
2061        let global_settings_path = global_config_dir.join("settings.json");
2062        fs::write(&global_settings_path, "{}").unwrap();
2063
2064        fs.insert_tree("/project", json!({})).await;
2065        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2066
2067        // Test global config paths
2068        let test_cases = vec![
2069            (
2070                global_settings_path.to_str().unwrap().to_string(),
2071                true,
2072                "Global settings file should require confirmation",
2073            ),
2074            (
2075                global_config_dir
2076                    .join("keymap.json")
2077                    .to_str()
2078                    .unwrap()
2079                    .to_string(),
2080                true,
2081                "Global keymap file should require confirmation",
2082            ),
2083            (
2084                "project/normal_file.rs".to_string(),
2085                false,
2086                "Normal project file should not require confirmation",
2087            ),
2088        ];
2089
2090        for (path, should_confirm, description) in test_cases {
2091            let input = json!({
2092                "display_description": "Edit file",
2093                "path": path,
2094                "mode": "edit"
2095            });
2096            cx.update(|cx| {
2097                assert_eq!(
2098                    tool.needs_confirmation(&input, &project, cx),
2099                    should_confirm,
2100                    "Failed for case: {}",
2101                    description
2102                );
2103            });
2104        }
2105    }
2106
2107    #[gpui::test]
2108    async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
2109        init_test(cx);
2110        let tool = Arc::new(EditFileTool);
2111        let fs = project::FakeFs::new(cx.executor());
2112
2113        // Create multiple worktree directories
2114        fs.insert_tree(
2115            "/workspace/frontend",
2116            json!({
2117                "src": {
2118                    "main.js": "console.log('frontend');"
2119                }
2120            }),
2121        )
2122        .await;
2123        fs.insert_tree(
2124            "/workspace/backend",
2125            json!({
2126                "src": {
2127                    "main.rs": "fn main() {}"
2128                }
2129            }),
2130        )
2131        .await;
2132        fs.insert_tree(
2133            "/workspace/shared",
2134            json!({
2135                ".zed": {
2136                    "settings.json": "{}"
2137                }
2138            }),
2139        )
2140        .await;
2141
2142        // Create project with multiple worktrees
2143        let project = Project::test(
2144            fs.clone(),
2145            [
2146                path!("/workspace/frontend").as_ref(),
2147                path!("/workspace/backend").as_ref(),
2148                path!("/workspace/shared").as_ref(),
2149            ],
2150            cx,
2151        )
2152        .await;
2153
2154        // Test files in different worktrees
2155        let test_cases = vec![
2156            ("frontend/src/main.js", false, "File in first worktree"),
2157            ("backend/src/main.rs", false, "File in second worktree"),
2158            (
2159                "shared/.zed/settings.json",
2160                true,
2161                ".zed file in third worktree",
2162            ),
2163            ("/etc/hosts", true, "Absolute path outside all worktrees"),
2164            (
2165                "../outside/file.txt",
2166                true,
2167                "Relative path outside worktrees",
2168            ),
2169        ];
2170
2171        for (path, should_confirm, description) in test_cases {
2172            let input = json!({
2173                "display_description": "Edit file",
2174                "path": path,
2175                "mode": "edit"
2176            });
2177            cx.update(|cx| {
2178                assert_eq!(
2179                    tool.needs_confirmation(&input, &project, cx),
2180                    should_confirm,
2181                    "Failed for case: {} - path: {}",
2182                    description,
2183                    path
2184                );
2185            });
2186        }
2187    }
2188
2189    #[gpui::test]
2190    async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
2191        init_test(cx);
2192        let tool = Arc::new(EditFileTool);
2193        let fs = project::FakeFs::new(cx.executor());
2194        fs.insert_tree(
2195            "/project",
2196            json!({
2197                ".zed": {
2198                    "settings.json": "{}"
2199                },
2200                "src": {
2201                    ".zed": {
2202                        "local.json": "{}"
2203                    }
2204                }
2205            }),
2206        )
2207        .await;
2208        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2209
2210        // Test edge cases
2211        let test_cases = vec![
2212            // Empty path - find_project_path returns Some for empty paths
2213            ("", false, "Empty path is treated as project root"),
2214            // Root directory
2215            ("/", true, "Root directory should be outside project"),
2216            // Parent directory references - find_project_path resolves these
2217            (
2218                "project/../other",
2219                false,
2220                "Path with .. is resolved by find_project_path",
2221            ),
2222            (
2223                "project/./src/file.rs",
2224                false,
2225                "Path with . should work normally",
2226            ),
2227            // Windows-style paths (if on Windows)
2228            #[cfg(target_os = "windows")]
2229            ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
2230            #[cfg(target_os = "windows")]
2231            ("project\\src\\main.rs", false, "Windows-style project path"),
2232        ];
2233
2234        for (path, should_confirm, description) in test_cases {
2235            let input = json!({
2236                "display_description": "Edit file",
2237                "path": path,
2238                "mode": "edit"
2239            });
2240            cx.update(|cx| {
2241                assert_eq!(
2242                    tool.needs_confirmation(&input, &project, cx),
2243                    should_confirm,
2244                    "Failed for case: {} - path: {}",
2245                    description,
2246                    path
2247                );
2248            });
2249        }
2250    }
2251
2252    #[gpui::test]
2253    async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) {
2254        init_test(cx);
2255        let tool = Arc::new(EditFileTool);
2256
2257        // Test UI text for various scenarios
2258        let test_cases = vec![
2259            (
2260                json!({
2261                    "display_description": "Update config",
2262                    "path": ".zed/settings.json",
2263                    "mode": "edit"
2264                }),
2265                "Update config (local settings)",
2266                ".zed path should show local settings context",
2267            ),
2268            (
2269                json!({
2270                    "display_description": "Fix bug",
2271                    "path": "src/.zed/local.json",
2272                    "mode": "edit"
2273                }),
2274                "Fix bug (local settings)",
2275                "Nested .zed path should show local settings context",
2276            ),
2277            (
2278                json!({
2279                    "display_description": "Update readme",
2280                    "path": "README.md",
2281                    "mode": "edit"
2282                }),
2283                "Update readme",
2284                "Normal path should not show additional context",
2285            ),
2286            (
2287                json!({
2288                    "display_description": "Edit config",
2289                    "path": "config.zed",
2290                    "mode": "edit"
2291                }),
2292                "Edit config",
2293                ".zed as extension should not show context",
2294            ),
2295        ];
2296
2297        for (input, expected_text, description) in test_cases {
2298            cx.update(|_cx| {
2299                let ui_text = tool.ui_text(&input);
2300                assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
2301            });
2302        }
2303    }
2304
2305    #[gpui::test]
2306    async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
2307        init_test(cx);
2308        let tool = Arc::new(EditFileTool);
2309        let fs = project::FakeFs::new(cx.executor());
2310        fs.insert_tree(
2311            "/project",
2312            json!({
2313                "existing.txt": "content",
2314                ".zed": {
2315                    "settings.json": "{}"
2316                }
2317            }),
2318        )
2319        .await;
2320        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2321
2322        // Test different EditFileMode values
2323        let modes = vec![
2324            EditFileMode::Edit,
2325            EditFileMode::Create,
2326            EditFileMode::Overwrite,
2327        ];
2328
2329        for mode in modes {
2330            // Test .zed path with different modes
2331            let input_zed = json!({
2332                "display_description": "Edit settings",
2333                "path": "project/.zed/settings.json",
2334                "mode": mode
2335            });
2336            cx.update(|cx| {
2337                assert!(
2338                    tool.needs_confirmation(&input_zed, &project, cx),
2339                    ".zed path should require confirmation regardless of mode: {:?}",
2340                    mode
2341                );
2342            });
2343
2344            // Test outside path with different modes
2345            let input_outside = json!({
2346                "display_description": "Edit file",
2347                "path": "/outside/file.txt",
2348                "mode": mode
2349            });
2350            cx.update(|cx| {
2351                assert!(
2352                    tool.needs_confirmation(&input_outside, &project, cx),
2353                    "Outside path should require confirmation regardless of mode: {:?}",
2354                    mode
2355                );
2356            });
2357
2358            // Test normal path with different modes
2359            let input_normal = json!({
2360                "display_description": "Edit file",
2361                "path": "project/normal.txt",
2362                "mode": mode
2363            });
2364            cx.update(|cx| {
2365                assert!(
2366                    !tool.needs_confirmation(&input_normal, &project, cx),
2367                    "Normal path should not require confirmation regardless of mode: {:?}",
2368                    mode
2369                );
2370            });
2371        }
2372    }
2373
2374    #[gpui::test]
2375    async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) {
2376        // Set up with custom directories for deterministic testing
2377        let temp_dir = tempfile::tempdir().unwrap();
2378        init_test_with_config(cx, temp_dir.path());
2379
2380        let tool = Arc::new(EditFileTool);
2381        let fs = project::FakeFs::new(cx.executor());
2382        fs.insert_tree("/project", json!({})).await;
2383        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2384
2385        // Enable always_allow_tool_actions
2386        cx.update(|cx| {
2387            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2388            settings.always_allow_tool_actions = true;
2389            agent_settings::AgentSettings::override_global(settings, cx);
2390        });
2391
2392        // Test that all paths that normally require confirmation are bypassed
2393        let global_settings_path = paths::config_dir().join("settings.json");
2394        fs::create_dir_all(paths::config_dir()).unwrap();
2395        fs::write(&global_settings_path, "{}").unwrap();
2396
2397        let test_cases = vec![
2398            ".zed/settings.json",
2399            "project/.zed/config.toml",
2400            global_settings_path.to_str().unwrap(),
2401            "/etc/hosts",
2402            "/absolute/path/file.txt",
2403            "../outside/project.txt",
2404        ];
2405
2406        for path in test_cases {
2407            let input = json!({
2408                "display_description": "Edit file",
2409                "path": path,
2410                "mode": "edit"
2411            });
2412            cx.update(|cx| {
2413                assert!(
2414                    !tool.needs_confirmation(&input, &project, cx),
2415                    "Path {} should not require confirmation when always_allow_tool_actions is true",
2416                    path
2417                );
2418            });
2419        }
2420
2421        // Disable always_allow_tool_actions and verify confirmation is required again
2422        cx.update(|cx| {
2423            let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2424            settings.always_allow_tool_actions = false;
2425            agent_settings::AgentSettings::override_global(settings, cx);
2426        });
2427
2428        // Verify .zed path requires confirmation again
2429        let input = json!({
2430            "display_description": "Edit file",
2431            "path": ".zed/settings.json",
2432            "mode": "edit"
2433        });
2434        cx.update(|cx| {
2435            assert!(
2436                tool.needs_confirmation(&input, &project, cx),
2437                ".zed path should require confirmation when always_allow_tool_actions is false"
2438            );
2439        });
2440    }
2441}