edit_file_tool.rs

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