edit_file_tool.rs

  1use crate::{
  2    Templates,
  3    edit_agent::{EditAgent, EditAgentOutputEvent},
  4    schema::json_schema_for,
  5};
  6use anyhow::{Result, anyhow};
  7use assistant_tool::{
  8    ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultOutput, ToolUseStatus,
  9};
 10use buffer_diff::{BufferDiff, BufferDiffSnapshot};
 11use editor::{Editor, EditorElement, EditorMode, EditorStyle, MultiBuffer, PathKey};
 12use futures::StreamExt;
 13use gpui::{
 14    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, EntityId, Task,
 15    TextStyle, WeakEntity, pulsating_between,
 16};
 17use indoc::formatdoc;
 18use language::{
 19    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Rope, TextBuffer,
 20    language_settings::SoftWrap,
 21};
 22use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
 23use project::Project;
 24use schemars::JsonSchema;
 25use serde::{Deserialize, Serialize};
 26use settings::Settings;
 27use std::{
 28    path::{Path, PathBuf},
 29    sync::Arc,
 30    time::Duration,
 31};
 32use theme::ThemeSettings;
 33use ui::{Disclosure, Tooltip, prelude::*};
 34use util::ResultExt;
 35use workspace::Workspace;
 36
 37pub struct EditFileTool;
 38
 39#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 40pub struct EditFileToolInput {
 41    /// A one-line, user-friendly markdown description of the edit. This will be
 42    /// shown in the UI and also passed to another model to perform the edit.
 43    ///
 44    /// Be terse, but also descriptive in what you want to achieve with this
 45    /// edit. Avoid generic instructions.
 46    ///
 47    /// NEVER mention the file path in this description.
 48    ///
 49    /// <example>Fix API endpoint URLs</example>
 50    /// <example>Update copyright year in `page_footer`</example>
 51    ///
 52    /// Make sure to include this field before all the others in the input object
 53    /// so that we can display it immediately.
 54    pub display_description: String,
 55
 56    /// The full path of the file to create or modify in the project.
 57    ///
 58    /// WARNING: When specifying which file path need changing, you MUST
 59    /// start each path with one of the project's root directories.
 60    ///
 61    /// The following examples assume we have two root directories in the project:
 62    /// - backend
 63    /// - frontend
 64    ///
 65    /// <example>
 66    /// `backend/src/main.rs`
 67    ///
 68    /// Notice how the file path starts with root-1. Without that, the path
 69    /// would be ambiguous and the call would fail!
 70    /// </example>
 71    ///
 72    /// <example>
 73    /// `frontend/db.js`
 74    /// </example>
 75    pub path: PathBuf,
 76
 77    /// If true, this tool will recreate the file from scratch.
 78    /// If false, this tool will produce granular edits to an existing file.
 79    ///
 80    /// When a file already exists or you just created it, always prefer editing
 81    /// it as opposed to recreating it from scratch.
 82    pub create_or_overwrite: bool,
 83}
 84
 85#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 86pub struct EditFileToolOutput {
 87    pub original_path: PathBuf,
 88    pub new_text: String,
 89    pub old_text: String,
 90}
 91
 92#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 93struct PartialInput {
 94    #[serde(default)]
 95    path: String,
 96    #[serde(default)]
 97    display_description: String,
 98}
 99
100const DEFAULT_UI_TEXT: &str = "Editing file";
101
102impl Tool for EditFileTool {
103    fn name(&self) -> String {
104        "edit_file".into()
105    }
106
107    fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
108        false
109    }
110
111    fn description(&self) -> String {
112        include_str!("edit_file_tool/description.md").to_string()
113    }
114
115    fn icon(&self) -> IconName {
116        IconName::Pencil
117    }
118
119    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
120        json_schema_for::<EditFileToolInput>(format)
121    }
122
123    fn ui_text(&self, input: &serde_json::Value) -> String {
124        match serde_json::from_value::<EditFileToolInput>(input.clone()) {
125            Ok(input) => input.display_description,
126            Err(_) => "Editing file".to_string(),
127        }
128    }
129
130    fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
131        if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
132            let description = input.display_description.trim();
133            if !description.is_empty() {
134                return description.to_string();
135            }
136
137            let path = input.path.trim();
138            if !path.is_empty() {
139                return path.to_string();
140            }
141        }
142
143        DEFAULT_UI_TEXT.to_string()
144    }
145
146    fn run(
147        self: Arc<Self>,
148        input: serde_json::Value,
149        request: Arc<LanguageModelRequest>,
150        project: Entity<Project>,
151        action_log: Entity<ActionLog>,
152        model: Arc<dyn LanguageModel>,
153        window: Option<AnyWindowHandle>,
154        cx: &mut App,
155    ) -> ToolResult {
156        let input = match serde_json::from_value::<EditFileToolInput>(input) {
157            Ok(input) => input,
158            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
159        };
160
161        let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
162            return Task::ready(Err(anyhow!(
163                "Path {} not found in project",
164                input.path.display()
165            )))
166            .into();
167        };
168
169        let card = window.and_then(|window| {
170            window
171                .update(cx, |_, window, cx| {
172                    cx.new(|cx| {
173                        EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
174                    })
175                })
176                .ok()
177        });
178
179        let card_clone = card.clone();
180        let task = cx.spawn(async move |cx: &mut AsyncApp| {
181            let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new());
182
183            let buffer = project
184                .update(cx, |project, cx| {
185                    project.open_buffer(project_path.clone(), cx)
186                })?
187                .await?;
188
189            let exists = buffer.read_with(cx, |buffer, _| {
190                buffer
191                    .file()
192                    .as_ref()
193                    .map_or(false, |file| file.disk_state().exists())
194            })?;
195            if !input.create_or_overwrite && !exists {
196                return Err(anyhow!("{} not found", input.path.display()));
197            }
198
199            let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
200            let old_text = cx
201                .background_spawn({
202                    let old_snapshot = old_snapshot.clone();
203                    async move { old_snapshot.text() }
204                })
205                .await;
206
207            let (output, mut events) = if input.create_or_overwrite {
208                edit_agent.overwrite(
209                    buffer.clone(),
210                    input.display_description.clone(),
211                    &request,
212                    cx,
213                )
214            } else {
215                edit_agent.edit(
216                    buffer.clone(),
217                    input.display_description.clone(),
218                    &request,
219                    cx,
220                )
221            };
222
223            let mut hallucinated_old_text = false;
224            while let Some(event) = events.next().await {
225                match event {
226                    EditAgentOutputEvent::Edited => {
227                        if let Some(card) = card_clone.as_ref() {
228                            let new_snapshot =
229                                buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
230                            let new_text = cx
231                                .background_spawn({
232                                    let new_snapshot = new_snapshot.clone();
233                                    async move { new_snapshot.text() }
234                                })
235                                .await;
236                            card.update(cx, |card, cx| {
237                                card.set_diff(
238                                    project_path.path.clone(),
239                                    old_text.clone(),
240                                    new_text,
241                                    cx,
242                                );
243                            })
244                            .log_err();
245                        }
246                    }
247                    EditAgentOutputEvent::OldTextNotFound(_) => hallucinated_old_text = true,
248                }
249            }
250            output.await?;
251
252            project
253                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
254                .await?;
255
256            let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
257            let new_text = cx.background_spawn({
258                let new_snapshot = new_snapshot.clone();
259                async move { new_snapshot.text() }
260            });
261            let diff = cx.background_spawn(async move {
262                language::unified_diff(&old_snapshot.text(), &new_snapshot.text())
263            });
264            let (new_text, diff) = futures::join!(new_text, diff);
265
266            let output = EditFileToolOutput {
267                original_path: project_path.path.to_path_buf(),
268                new_text: new_text.clone(),
269                old_text: old_text.clone(),
270            };
271
272            if let Some(card) = card_clone {
273                card.update(cx, |card, cx| {
274                    card.set_diff(project_path.path.clone(), old_text, new_text, cx);
275                })
276                .log_err();
277            }
278
279            let input_path = input.path.display();
280            if diff.is_empty() {
281                if hallucinated_old_text {
282                    Err(anyhow!(formatdoc! {"
283                        Some edits were produced but none of them could be applied.
284                        Read the relevant sections of {input_path} again so that
285                        I can perform the requested edits.
286                    "}))
287                } else {
288                    Ok("No edits were made.".to_string().into())
289                }
290            } else {
291                Ok(ToolResultOutput {
292                    content: format!("Edited {}:\n\n```diff\n{}\n```", input_path, diff),
293                    output: serde_json::to_value(output).ok(),
294                })
295            }
296        });
297
298        ToolResult {
299            output: task,
300            card: card.map(AnyToolCard::from),
301        }
302    }
303
304    fn deserialize_card(
305        self: Arc<Self>,
306        output: serde_json::Value,
307        project: Entity<Project>,
308        window: &mut Window,
309        cx: &mut App,
310    ) -> Option<AnyToolCard> {
311        let output = match serde_json::from_value::<EditFileToolOutput>(output) {
312            Ok(output) => output,
313            Err(_) => return None,
314        };
315
316        let card = cx.new(|cx| {
317            let mut card = EditFileToolCard::new(output.original_path.clone(), project, window, cx);
318            card.set_diff(
319                output.original_path.into(),
320                output.old_text,
321                output.new_text,
322                cx,
323            );
324            card
325        });
326
327        Some(card.into())
328    }
329}
330
331pub struct EditFileToolCard {
332    path: PathBuf,
333    editor: Entity<Editor>,
334    multibuffer: Entity<MultiBuffer>,
335    project: Entity<Project>,
336    diff_task: Option<Task<Result<()>>>,
337    preview_expanded: bool,
338    error_expanded: bool,
339    full_height_expanded: bool,
340    total_lines: Option<u32>,
341    editor_unique_id: EntityId,
342}
343
344impl EditFileToolCard {
345    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
346        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
347        let editor = cx.new(|cx| {
348            let mut editor = Editor::new(
349                EditorMode::Full {
350                    scale_ui_elements_with_buffer_font_size: false,
351                    show_active_line_background: false,
352                    sized_by_content: true,
353                },
354                multibuffer.clone(),
355                Some(project.clone()),
356                window,
357                cx,
358            );
359            editor.set_show_gutter(false, cx);
360            editor.disable_inline_diagnostics();
361            editor.disable_expand_excerpt_buttons(cx);
362            editor.set_soft_wrap_mode(SoftWrap::None, cx);
363            editor.scroll_manager.set_forbid_vertical_scroll(true);
364            editor.set_show_scrollbars(false, cx);
365            editor.set_show_indent_guides(false, cx);
366            editor.set_read_only(true);
367            editor.set_show_breakpoints(false, cx);
368            editor.set_show_code_actions(false, cx);
369            editor.set_show_git_diff_gutter(false, cx);
370            editor.set_expand_all_diff_hunks(cx);
371            editor
372        });
373        Self {
374            editor_unique_id: editor.entity_id(),
375            path,
376            project,
377            editor,
378            multibuffer,
379            diff_task: None,
380            preview_expanded: true,
381            error_expanded: false,
382            full_height_expanded: false,
383            total_lines: None,
384        }
385    }
386
387    pub fn has_diff(&self) -> bool {
388        self.total_lines.is_some()
389    }
390
391    pub fn set_diff(
392        &mut self,
393        path: Arc<Path>,
394        old_text: String,
395        new_text: String,
396        cx: &mut Context<Self>,
397    ) {
398        let language_registry = self.project.read(cx).languages().clone();
399        self.diff_task = Some(cx.spawn(async move |this, cx| {
400            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
401            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
402
403            this.update(cx, |this, cx| {
404                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
405                    let snapshot = buffer.read(cx).snapshot();
406                    let diff = buffer_diff.read(cx);
407                    let diff_hunk_ranges = diff
408                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
409                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
410                        .collect::<Vec<_>>();
411                    multibuffer.clear(cx);
412                    multibuffer.set_excerpts_for_path(
413                        PathKey::for_buffer(&buffer, cx),
414                        buffer,
415                        diff_hunk_ranges,
416                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
417                        cx,
418                    );
419                    multibuffer.add_diff(buffer_diff, cx);
420                    let end = multibuffer.len(cx);
421                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
422                });
423
424                cx.notify();
425            })
426        }));
427    }
428}
429
430impl ToolCard for EditFileToolCard {
431    fn render(
432        &mut self,
433        status: &ToolUseStatus,
434        window: &mut Window,
435        workspace: WeakEntity<Workspace>,
436        cx: &mut Context<Self>,
437    ) -> impl IntoElement {
438        let (failed, error_message) = match status {
439            ToolUseStatus::Error(err) => (true, Some(err.to_string())),
440            _ => (false, None),
441        };
442
443        let path_label_button = h_flex()
444            .id(("edit-tool-path-label-button", self.editor_unique_id))
445            .w_full()
446            .max_w_full()
447            .px_1()
448            .gap_0p5()
449            .cursor_pointer()
450            .rounded_sm()
451            .opacity(0.8)
452            .hover(|label| {
453                label
454                    .opacity(1.)
455                    .bg(cx.theme().colors().element_hover.opacity(0.5))
456            })
457            .tooltip(Tooltip::text("Jump to File"))
458            .child(
459                h_flex()
460                    .child(
461                        Icon::new(IconName::Pencil)
462                            .size(IconSize::XSmall)
463                            .color(Color::Muted),
464                    )
465                    .child(
466                        div()
467                            .text_size(rems(0.8125))
468                            .child(self.path.display().to_string())
469                            .ml_1p5()
470                            .mr_0p5(),
471                    )
472                    .child(
473                        Icon::new(IconName::ArrowUpRight)
474                            .size(IconSize::XSmall)
475                            .color(Color::Ignored),
476                    ),
477            )
478            .on_click({
479                let path = self.path.clone();
480                let workspace = workspace.clone();
481                move |_, window, cx| {
482                    workspace
483                        .update(cx, {
484                            |workspace, cx| {
485                                let Some(project_path) =
486                                    workspace.project().read(cx).find_project_path(&path, cx)
487                                else {
488                                    return;
489                                };
490                                let open_task =
491                                    workspace.open_path(project_path, None, true, window, cx);
492                                window
493                                    .spawn(cx, async move |cx| {
494                                        let item = open_task.await?;
495                                        if let Some(active_editor) = item.downcast::<Editor>() {
496                                            active_editor
497                                                .update_in(cx, |editor, window, cx| {
498                                                    editor.go_to_singleton_buffer_point(
499                                                        language::Point::new(0, 0),
500                                                        window,
501                                                        cx,
502                                                    );
503                                                })
504                                                .log_err();
505                                        }
506                                        anyhow::Ok(())
507                                    })
508                                    .detach_and_log_err(cx);
509                            }
510                        })
511                        .ok();
512                }
513            })
514            .into_any_element();
515
516        let codeblock_header_bg = cx
517            .theme()
518            .colors()
519            .element_background
520            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
521
522        let codeblock_header = h_flex()
523            .flex_none()
524            .p_1()
525            .gap_1()
526            .justify_between()
527            .rounded_t_md()
528            .when(!failed, |header| header.bg(codeblock_header_bg))
529            .child(path_label_button)
530            .when(failed, |header| {
531                header.child(
532                    h_flex()
533                        .gap_1()
534                        .child(
535                            Icon::new(IconName::Close)
536                                .size(IconSize::Small)
537                                .color(Color::Error),
538                        )
539                        .child(
540                            Disclosure::new(
541                                ("edit-file-error-disclosure", self.editor_unique_id),
542                                self.error_expanded,
543                            )
544                            .opened_icon(IconName::ChevronUp)
545                            .closed_icon(IconName::ChevronDown)
546                            .on_click(cx.listener(
547                                move |this, _event, _window, _cx| {
548                                    this.error_expanded = !this.error_expanded;
549                                },
550                            )),
551                        ),
552                )
553            })
554            .when(!failed && self.has_diff(), |header| {
555                header.child(
556                    Disclosure::new(
557                        ("edit-file-disclosure", self.editor_unique_id),
558                        self.preview_expanded,
559                    )
560                    .opened_icon(IconName::ChevronUp)
561                    .closed_icon(IconName::ChevronDown)
562                    .on_click(cx.listener(
563                        move |this, _event, _window, _cx| {
564                            this.preview_expanded = !this.preview_expanded;
565                        },
566                    )),
567                )
568            });
569
570        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
571            let line_height = editor
572                .style()
573                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
574                .unwrap_or_default();
575
576            let settings = ThemeSettings::get_global(cx);
577            let element = EditorElement::new(
578                &cx.entity(),
579                EditorStyle {
580                    background: cx.theme().colors().editor_background,
581                    horizontal_padding: rems(0.25).to_pixels(window.rem_size()),
582                    local_player: cx.theme().players().local(),
583                    text: TextStyle {
584                        color: cx.theme().colors().editor_foreground,
585                        font_family: settings.buffer_font.family.clone(),
586                        font_features: settings.buffer_font.features.clone(),
587                        font_fallbacks: settings.buffer_font.fallbacks.clone(),
588                        font_size: TextSize::Small
589                            .rems(cx)
590                            .to_pixels(settings.agent_font_size(cx))
591                            .into(),
592                        font_weight: settings.buffer_font.weight,
593                        line_height: relative(settings.buffer_line_height.value()),
594                        ..Default::default()
595                    },
596                    scrollbar_width: EditorElement::SCROLLBAR_WIDTH,
597                    syntax: cx.theme().syntax().clone(),
598                    status: cx.theme().status().clone(),
599                    ..Default::default()
600                },
601            );
602
603            (element.into_any_element(), line_height)
604        });
605
606        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
607            (IconName::ChevronUp, "Collapse Code Block")
608        } else {
609            (IconName::ChevronDown, "Expand Code Block")
610        };
611
612        let gradient_overlay =
613            div()
614                .absolute()
615                .bottom_0()
616                .left_0()
617                .w_full()
618                .h_2_5()
619                .bg(gpui::linear_gradient(
620                    0.,
621                    gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
622                    gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
623                ));
624
625        let border_color = cx.theme().colors().border.opacity(0.6);
626
627        const DEFAULT_COLLAPSED_LINES: u32 = 10;
628        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
629
630        let waiting_for_diff = {
631            let styles = [
632                ("w_4_5", (0.1, 0.85), 2000),
633                ("w_1_4", (0.2, 0.75), 2200),
634                ("w_2_4", (0.15, 0.64), 1900),
635                ("w_3_5", (0.25, 0.72), 2300),
636                ("w_2_5", (0.3, 0.56), 1800),
637            ];
638
639            let mut container = v_flex()
640                .p_3()
641                .gap_1()
642                .border_t_1()
643                .rounded_md()
644                .border_color(border_color)
645                .bg(cx.theme().colors().editor_background);
646
647            for (width_method, pulse_range, duration_ms) in styles.iter() {
648                let (min_opacity, max_opacity) = *pulse_range;
649                let placeholder = match *width_method {
650                    "w_4_5" => div().w_3_4(),
651                    "w_1_4" => div().w_1_4(),
652                    "w_2_4" => div().w_2_4(),
653                    "w_3_5" => div().w_3_5(),
654                    "w_2_5" => div().w_2_5(),
655                    _ => div().w_1_2(),
656                }
657                .id("loading_div")
658                .h_1()
659                .rounded_full()
660                .bg(cx.theme().colors().element_active)
661                .with_animation(
662                    "loading_pulsate",
663                    Animation::new(Duration::from_millis(*duration_ms))
664                        .repeat()
665                        .with_easing(pulsating_between(min_opacity, max_opacity)),
666                    |label, delta| label.opacity(delta),
667                );
668
669                container = container.child(placeholder);
670            }
671
672            container
673        };
674
675        v_flex()
676            .mb_2()
677            .border_1()
678            .when(failed, |card| card.border_dashed())
679            .border_color(border_color)
680            .rounded_md()
681            .overflow_hidden()
682            .child(codeblock_header)
683            .when(failed && self.error_expanded, |card| {
684                card.child(
685                    v_flex()
686                        .p_2()
687                        .gap_1()
688                        .border_t_1()
689                        .border_dashed()
690                        .border_color(border_color)
691                        .bg(cx.theme().colors().editor_background)
692                        .rounded_b_md()
693                        .child(
694                            Label::new("Error")
695                                .size(LabelSize::XSmall)
696                                .color(Color::Error),
697                        )
698                        .child(
699                            div()
700                                .rounded_md()
701                                .text_ui_sm(cx)
702                                .bg(cx.theme().colors().editor_background)
703                                .children(
704                                    error_message
705                                        .map(|error| div().child(error).into_any_element()),
706                                ),
707                        ),
708                )
709            })
710            .when(!self.has_diff() && !failed, |card| {
711                card.child(waiting_for_diff)
712            })
713            .when(
714                !failed && self.preview_expanded && self.has_diff(),
715                |card| {
716                    card.child(
717                        v_flex()
718                            .relative()
719                            .h_full()
720                            .when(!self.full_height_expanded, |editor_container| {
721                                editor_container
722                                    .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
723                            })
724                            .overflow_hidden()
725                            .border_t_1()
726                            .border_color(border_color)
727                            .bg(cx.theme().colors().editor_background)
728                            .child(editor)
729                            .when(
730                                !self.full_height_expanded && is_collapsible,
731                                |editor_container| editor_container.child(gradient_overlay),
732                            ),
733                    )
734                    .when(is_collapsible, |card| {
735                        card.child(
736                            h_flex()
737                                .id(("expand-button", self.editor_unique_id))
738                                .flex_none()
739                                .cursor_pointer()
740                                .h_5()
741                                .justify_center()
742                                .border_t_1()
743                                .rounded_b_md()
744                                .border_color(border_color)
745                                .bg(cx.theme().colors().editor_background)
746                                .hover(|style| {
747                                    style.bg(cx.theme().colors().element_hover.opacity(0.1))
748                                })
749                                .child(
750                                    Icon::new(full_height_icon)
751                                        .size(IconSize::Small)
752                                        .color(Color::Muted),
753                                )
754                                .tooltip(Tooltip::text(full_height_tooltip_label))
755                                .on_click(cx.listener(move |this, _event, _window, _cx| {
756                                    this.full_height_expanded = !this.full_height_expanded;
757                                })),
758                        )
759                    })
760                },
761            )
762    }
763}
764
765async fn build_buffer(
766    mut text: String,
767    path: Arc<Path>,
768    language_registry: &Arc<language::LanguageRegistry>,
769    cx: &mut AsyncApp,
770) -> Result<Entity<Buffer>> {
771    let line_ending = LineEnding::detect(&text);
772    LineEnding::normalize(&mut text);
773    let text = Rope::from(text);
774    let language = cx
775        .update(|_cx| language_registry.language_for_file_path(&path))?
776        .await
777        .ok();
778    let buffer = cx.new(|cx| {
779        let buffer = TextBuffer::new_normalized(
780            0,
781            cx.entity_id().as_non_zero_u64().into(),
782            line_ending,
783            text,
784        );
785        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
786        buffer.set_language(language, cx);
787        buffer
788    })?;
789    Ok(buffer)
790}
791
792async fn build_buffer_diff(
793    mut old_text: String,
794    buffer: &Entity<Buffer>,
795    language_registry: &Arc<LanguageRegistry>,
796    cx: &mut AsyncApp,
797) -> Result<Entity<BufferDiff>> {
798    LineEnding::normalize(&mut old_text);
799
800    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
801
802    let base_buffer = cx
803        .update(|cx| {
804            Buffer::build_snapshot(
805                old_text.clone().into(),
806                buffer.language().cloned(),
807                Some(language_registry.clone()),
808                cx,
809            )
810        })?
811        .await;
812
813    let diff_snapshot = cx
814        .update(|cx| {
815            BufferDiffSnapshot::new_with_base_buffer(
816                buffer.text.clone(),
817                Some(old_text.into()),
818                base_buffer,
819                cx,
820            )
821        })?
822        .await;
823
824    let secondary_diff = cx.new(|cx| {
825        let mut diff = BufferDiff::new(&buffer, cx);
826        diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
827        diff
828    })?;
829
830    cx.new(|cx| {
831        let mut diff = BufferDiff::new(&buffer.text, cx);
832        diff.set_snapshot(diff_snapshot, &buffer, cx);
833        diff.set_secondary_diff(secondary_diff);
834        diff
835    })
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use fs::FakeFs;
842    use gpui::TestAppContext;
843    use language_model::fake_provider::FakeLanguageModel;
844    use serde_json::json;
845    use settings::SettingsStore;
846    use util::path;
847
848    #[gpui::test]
849    async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
850        init_test(cx);
851
852        let fs = FakeFs::new(cx.executor());
853        fs.insert_tree("/root", json!({})).await;
854        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
855        let action_log = cx.new(|_| ActionLog::new(project.clone()));
856        let model = Arc::new(FakeLanguageModel::default());
857        let result = cx
858            .update(|cx| {
859                let input = serde_json::to_value(EditFileToolInput {
860                    display_description: "Some edit".into(),
861                    path: "root/nonexistent_file.txt".into(),
862                    create_or_overwrite: false,
863                })
864                .unwrap();
865                Arc::new(EditFileTool)
866                    .run(
867                        input,
868                        Arc::default(),
869                        project.clone(),
870                        action_log,
871                        model,
872                        None,
873                        cx,
874                    )
875                    .output
876            })
877            .await;
878        assert_eq!(
879            result.unwrap_err().to_string(),
880            "root/nonexistent_file.txt not found"
881        );
882    }
883
884    #[test]
885    fn still_streaming_ui_text_with_path() {
886        let input = json!({
887            "path": "src/main.rs",
888            "display_description": "",
889            "old_string": "old code",
890            "new_string": "new code"
891        });
892
893        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
894    }
895
896    #[test]
897    fn still_streaming_ui_text_with_description() {
898        let input = json!({
899            "path": "",
900            "display_description": "Fix error handling",
901            "old_string": "old code",
902            "new_string": "new code"
903        });
904
905        assert_eq!(
906            EditFileTool.still_streaming_ui_text(&input),
907            "Fix error handling",
908        );
909    }
910
911    #[test]
912    fn still_streaming_ui_text_with_path_and_description() {
913        let input = json!({
914            "path": "src/main.rs",
915            "display_description": "Fix error handling",
916            "old_string": "old code",
917            "new_string": "new code"
918        });
919
920        assert_eq!(
921            EditFileTool.still_streaming_ui_text(&input),
922            "Fix error handling",
923        );
924    }
925
926    #[test]
927    fn still_streaming_ui_text_no_path_or_description() {
928        let input = json!({
929            "path": "",
930            "display_description": "",
931            "old_string": "old code",
932            "new_string": "new code"
933        });
934
935        assert_eq!(
936            EditFileTool.still_streaming_ui_text(&input),
937            DEFAULT_UI_TEXT,
938        );
939    }
940
941    #[test]
942    fn still_streaming_ui_text_with_null() {
943        let input = serde_json::Value::Null;
944
945        assert_eq!(
946            EditFileTool.still_streaming_ui_text(&input),
947            DEFAULT_UI_TEXT,
948        );
949    }
950
951    fn init_test(cx: &mut TestAppContext) {
952        cx.update(|cx| {
953            let settings_store = SettingsStore::test(cx);
954            cx.set_global(settings_store);
955            language::init(cx);
956            Project::init_settings(cx);
957        });
958    }
959}