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