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