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