edit_file_tool.rs

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