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.track_buffer(buffer.clone(), cx));
243
244                let base_version = diff.base_version.clone();
245                let snapshot = buffer.update(cx, |buffer, cx| {
246                    buffer.finalize_last_transaction();
247                    buffer.apply_diff(diff, cx);
248                    buffer.finalize_last_transaction();
249                    buffer.snapshot()
250                });
251                action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
252
253                // Set the agent's location to the position of the first edit
254                if let Some(first_edit) = snapshot.edits_since::<usize>(&base_version).next() {
255                    let position = snapshot.anchor_before(first_edit.new.start);
256                    project.update(cx, |project, cx| {
257                        project.set_agent_location(
258                            Some(AgentLocation {
259                                buffer: buffer.downgrade(),
260                                position,
261                            }),
262                            cx,
263                        );
264                    })
265                }
266
267                snapshot
268            })?;
269
270            project
271                .update(cx, |project, cx| project.save_buffer(buffer, cx))?
272                .await?;
273
274            let new_text = snapshot.text();
275            let diff_str = cx
276                .background_spawn({
277                    let old_text = old_text.clone();
278                    let new_text = new_text.clone();
279                    async move { language::unified_diff(&old_text, &new_text) }
280                })
281                .await;
282
283            if let Some(card) = card_clone {
284                card.update(cx, |card, cx| {
285                    card.set_diff(project_path.path.clone(), old_text, new_text, cx);
286                })
287                .log_err();
288            }
289
290            Ok(format!(
291                "Edited {}:\n\n```diff\n{}\n```",
292                input.path.display(),
293                diff_str
294            ))
295        });
296
297        ToolResult {
298            output: task,
299            card: card.map(AnyToolCard::from),
300        }
301    }
302}
303
304pub struct EditFileToolCard {
305    path: PathBuf,
306    editor: Entity<Editor>,
307    multibuffer: Entity<MultiBuffer>,
308    project: Entity<Project>,
309    diff_task: Option<Task<Result<()>>>,
310    preview_expanded: bool,
311    error_expanded: bool,
312    full_height_expanded: bool,
313    total_lines: Option<u32>,
314    editor_unique_id: EntityId,
315}
316
317impl EditFileToolCard {
318    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
319        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
320        let editor = cx.new(|cx| {
321            let mut editor = Editor::new(
322                EditorMode::Full {
323                    scale_ui_elements_with_buffer_font_size: false,
324                    show_active_line_background: false,
325                    sized_by_content: true,
326                },
327                multibuffer.clone(),
328                Some(project.clone()),
329                window,
330                cx,
331            );
332            editor.set_show_gutter(false, cx);
333            editor.disable_inline_diagnostics();
334            editor.disable_expand_excerpt_buttons(cx);
335            editor.set_soft_wrap_mode(SoftWrap::None, cx);
336            editor.scroll_manager.set_forbid_vertical_scroll(true);
337            editor.set_show_scrollbars(false, cx);
338            editor.set_show_indent_guides(false, cx);
339            editor.set_read_only(true);
340            editor.set_show_breakpoints(false, cx);
341            editor.set_show_code_actions(false, cx);
342            editor.set_show_git_diff_gutter(false, cx);
343            editor.set_expand_all_diff_hunks(cx);
344            editor
345        });
346        Self {
347            editor_unique_id: editor.entity_id(),
348            path,
349            project,
350            editor,
351            multibuffer,
352            diff_task: None,
353            preview_expanded: true,
354            error_expanded: false,
355            full_height_expanded: false,
356            total_lines: None,
357        }
358    }
359
360    pub fn has_diff(&self) -> bool {
361        self.total_lines.is_some()
362    }
363
364    pub fn set_diff(
365        &mut self,
366        path: Arc<Path>,
367        old_text: String,
368        new_text: String,
369        cx: &mut Context<Self>,
370    ) {
371        let language_registry = self.project.read(cx).languages().clone();
372        self.diff_task = Some(cx.spawn(async move |this, cx| {
373            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
374            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
375
376            this.update(cx, |this, cx| {
377                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
378                    let snapshot = buffer.read(cx).snapshot();
379                    let diff = buffer_diff.read(cx);
380                    let diff_hunk_ranges = diff
381                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
382                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
383                        .collect::<Vec<_>>();
384                    multibuffer.clear(cx);
385                    multibuffer.set_excerpts_for_path(
386                        PathKey::for_buffer(&buffer, cx),
387                        buffer,
388                        diff_hunk_ranges,
389                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
390                        cx,
391                    );
392                    multibuffer.add_diff(buffer_diff, cx);
393                    let end = multibuffer.len(cx);
394                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
395                });
396
397                cx.notify();
398            })
399        }));
400    }
401}
402
403impl ToolCard for EditFileToolCard {
404    fn render(
405        &mut self,
406        status: &ToolUseStatus,
407        window: &mut Window,
408        workspace: WeakEntity<Workspace>,
409        cx: &mut Context<Self>,
410    ) -> impl IntoElement {
411        let (failed, error_message) = match status {
412            ToolUseStatus::Error(err) => (true, Some(err.to_string())),
413            _ => (false, None),
414        };
415
416        let path_label_button = h_flex()
417            .id(("edit-tool-path-label-button", self.editor_unique_id))
418            .w_full()
419            .max_w_full()
420            .px_1()
421            .gap_0p5()
422            .cursor_pointer()
423            .rounded_sm()
424            .opacity(0.8)
425            .hover(|label| {
426                label
427                    .opacity(1.)
428                    .bg(cx.theme().colors().element_hover.opacity(0.5))
429            })
430            .tooltip(Tooltip::text("Jump to File"))
431            .child(
432                h_flex()
433                    .child(
434                        Icon::new(IconName::Pencil)
435                            .size(IconSize::XSmall)
436                            .color(Color::Muted),
437                    )
438                    .child(
439                        div()
440                            .text_size(rems(0.8125))
441                            .child(self.path.display().to_string())
442                            .ml_1p5()
443                            .mr_0p5(),
444                    )
445                    .child(
446                        Icon::new(IconName::ArrowUpRight)
447                            .size(IconSize::XSmall)
448                            .color(Color::Ignored),
449                    ),
450            )
451            .on_click({
452                let path = self.path.clone();
453                let workspace = workspace.clone();
454                move |_, window, cx| {
455                    workspace
456                        .update(cx, {
457                            |workspace, cx| {
458                                let Some(project_path) =
459                                    workspace.project().read(cx).find_project_path(&path, cx)
460                                else {
461                                    return;
462                                };
463                                let open_task =
464                                    workspace.open_path(project_path, None, true, window, cx);
465                                window
466                                    .spawn(cx, async move |cx| {
467                                        let item = open_task.await?;
468                                        if let Some(active_editor) = item.downcast::<Editor>() {
469                                            active_editor
470                                                .update_in(cx, |editor, window, cx| {
471                                                    editor.go_to_singleton_buffer_point(
472                                                        language::Point::new(0, 0),
473                                                        window,
474                                                        cx,
475                                                    );
476                                                })
477                                                .log_err();
478                                        }
479                                        anyhow::Ok(())
480                                    })
481                                    .detach_and_log_err(cx);
482                            }
483                        })
484                        .ok();
485                }
486            })
487            .into_any_element();
488
489        let codeblock_header_bg = cx
490            .theme()
491            .colors()
492            .element_background
493            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
494
495        let codeblock_header = h_flex()
496            .flex_none()
497            .p_1()
498            .gap_1()
499            .justify_between()
500            .rounded_t_md()
501            .when(!failed, |header| header.bg(codeblock_header_bg))
502            .child(path_label_button)
503            .when(failed, |header| {
504                header.child(
505                    h_flex()
506                        .gap_1()
507                        .child(
508                            Icon::new(IconName::Close)
509                                .size(IconSize::Small)
510                                .color(Color::Error),
511                        )
512                        .child(
513                            Disclosure::new(
514                                ("edit-file-error-disclosure", self.editor_unique_id),
515                                self.error_expanded,
516                            )
517                            .opened_icon(IconName::ChevronUp)
518                            .closed_icon(IconName::ChevronDown)
519                            .on_click(cx.listener(
520                                move |this, _event, _window, _cx| {
521                                    this.error_expanded = !this.error_expanded;
522                                },
523                            )),
524                        ),
525                )
526            })
527            .when(!failed && self.has_diff(), |header| {
528                header.child(
529                    Disclosure::new(
530                        ("edit-file-disclosure", self.editor_unique_id),
531                        self.preview_expanded,
532                    )
533                    .opened_icon(IconName::ChevronUp)
534                    .closed_icon(IconName::ChevronDown)
535                    .on_click(cx.listener(
536                        move |this, _event, _window, _cx| {
537                            this.preview_expanded = !this.preview_expanded;
538                        },
539                    )),
540                )
541            });
542
543        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
544            let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
545
546            editor.set_text_style_refinement(TextStyleRefinement {
547                font_size: Some(ui_font_size.into()),
548                ..TextStyleRefinement::default()
549            });
550
551            let line_height = editor
552                .style()
553                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
554                .unwrap_or_default();
555
556            let settings = ThemeSettings::get_global(cx);
557            let element = EditorElement::new(
558                &cx.entity(),
559                EditorStyle {
560                    background: cx.theme().colors().editor_background,
561                    horizontal_padding: rems(0.25).to_pixels(window.rem_size()),
562                    local_player: cx.theme().players().local(),
563                    text: TextStyle {
564                        color: cx.theme().colors().editor_foreground,
565                        font_family: settings.buffer_font.family.clone(),
566                        font_features: settings.buffer_font.features.clone(),
567                        font_fallbacks: settings.buffer_font.fallbacks.clone(),
568                        font_size: settings.buffer_font_size(cx).into(),
569                        font_weight: settings.buffer_font.weight,
570                        line_height: relative(settings.buffer_line_height.value()),
571                        ..Default::default()
572                    },
573                    scrollbar_width: EditorElement::SCROLLBAR_WIDTH,
574                    syntax: cx.theme().syntax().clone(),
575                    status: cx.theme().status().clone(),
576                    ..Default::default()
577                },
578            );
579
580            (element.into_any_element(), line_height)
581        });
582
583        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
584            (IconName::ChevronUp, "Collapse Code Block")
585        } else {
586            (IconName::ChevronDown, "Expand Code Block")
587        };
588
589        let gradient_overlay = div()
590            .absolute()
591            .bottom_0()
592            .left_0()
593            .w_full()
594            .h_2_5()
595            .rounded_b_lg()
596            .bg(gpui::linear_gradient(
597                0.,
598                gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
599                gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
600            ));
601
602        let border_color = cx.theme().colors().border.opacity(0.6);
603
604        const DEFAULT_COLLAPSED_LINES: u32 = 10;
605        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
606
607        let waiting_for_diff = {
608            let styles = [
609                ("w_4_5", (0.1, 0.85), 2000),
610                ("w_1_4", (0.2, 0.75), 2200),
611                ("w_2_4", (0.15, 0.64), 1900),
612                ("w_3_5", (0.25, 0.72), 2300),
613                ("w_2_5", (0.3, 0.56), 1800),
614            ];
615
616            let mut container = v_flex()
617                .p_3()
618                .gap_1p5()
619                .border_t_1()
620                .border_color(border_color)
621                .bg(cx.theme().colors().editor_background);
622
623            for (width_method, pulse_range, duration_ms) in styles.iter() {
624                let (min_opacity, max_opacity) = *pulse_range;
625                let placeholder = match *width_method {
626                    "w_4_5" => div().w_3_4(),
627                    "w_1_4" => div().w_1_4(),
628                    "w_2_4" => div().w_2_4(),
629                    "w_3_5" => div().w_3_5(),
630                    "w_2_5" => div().w_2_5(),
631                    _ => div().w_1_2(),
632                }
633                .id("loading_div")
634                .h_2()
635                .rounded_full()
636                .bg(cx.theme().colors().element_active)
637                .with_animation(
638                    "loading_pulsate",
639                    Animation::new(Duration::from_millis(*duration_ms))
640                        .repeat()
641                        .with_easing(pulsating_between(min_opacity, max_opacity)),
642                    |label, delta| label.opacity(delta),
643                );
644
645                container = container.child(placeholder);
646            }
647
648            container
649        };
650
651        v_flex()
652            .mb_2()
653            .border_1()
654            .when(failed, |card| card.border_dashed())
655            .border_color(border_color)
656            .rounded_lg()
657            .overflow_hidden()
658            .child(codeblock_header)
659            .when(failed && self.error_expanded, |card| {
660                card.child(
661                    v_flex()
662                        .p_2()
663                        .gap_1()
664                        .border_t_1()
665                        .border_dashed()
666                        .border_color(border_color)
667                        .bg(cx.theme().colors().editor_background)
668                        .rounded_b_md()
669                        .child(
670                            Label::new("Error")
671                                .size(LabelSize::XSmall)
672                                .color(Color::Error),
673                        )
674                        .child(
675                            div()
676                                .rounded_md()
677                                .text_ui_sm(cx)
678                                .bg(cx.theme().colors().editor_background)
679                                .children(
680                                    error_message
681                                        .map(|error| div().child(error).into_any_element()),
682                                ),
683                        ),
684                )
685            })
686            .when(!self.has_diff() && !failed, |card| {
687                card.child(waiting_for_diff)
688            })
689            .when(
690                !failed && self.preview_expanded && self.has_diff(),
691                |card| {
692                    card.child(
693                        v_flex()
694                            .relative()
695                            .h_full()
696                            .when(!self.full_height_expanded, |editor_container| {
697                                editor_container
698                                    .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
699                            })
700                            .overflow_hidden()
701                            .border_t_1()
702                            .border_color(border_color)
703                            .bg(cx.theme().colors().editor_background)
704                            .child(editor)
705                            .when(
706                                !self.full_height_expanded && is_collapsible,
707                                |editor_container| editor_container.child(gradient_overlay),
708                            ),
709                    )
710                    .when(is_collapsible, |editor_container| {
711                        editor_container.child(
712                            h_flex()
713                                .id(("expand-button", self.editor_unique_id))
714                                .flex_none()
715                                .cursor_pointer()
716                                .h_5()
717                                .justify_center()
718                                .rounded_b_md()
719                                .border_t_1()
720                                .border_color(border_color)
721                                .bg(cx.theme().colors().editor_background)
722                                .hover(|style| {
723                                    style.bg(cx.theme().colors().element_hover.opacity(0.1))
724                                })
725                                .child(
726                                    Icon::new(full_height_icon)
727                                        .size(IconSize::Small)
728                                        .color(Color::Muted),
729                                )
730                                .tooltip(Tooltip::text(full_height_tooltip_label))
731                                .on_click(cx.listener(move |this, _event, _window, _cx| {
732                                    this.full_height_expanded = !this.full_height_expanded;
733                                })),
734                        )
735                    })
736                },
737            )
738    }
739}
740
741async fn build_buffer(
742    mut text: String,
743    path: Arc<Path>,
744    language_registry: &Arc<language::LanguageRegistry>,
745    cx: &mut AsyncApp,
746) -> Result<Entity<Buffer>> {
747    let line_ending = LineEnding::detect(&text);
748    LineEnding::normalize(&mut text);
749    let text = Rope::from(text);
750    let language = cx
751        .update(|_cx| language_registry.language_for_file_path(&path))?
752        .await
753        .ok();
754    let buffer = cx.new(|cx| {
755        let buffer = TextBuffer::new_normalized(
756            0,
757            cx.entity_id().as_non_zero_u64().into(),
758            line_ending,
759            text,
760        );
761        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
762        buffer.set_language(language, cx);
763        buffer
764    })?;
765    Ok(buffer)
766}
767
768async fn build_buffer_diff(
769    mut old_text: String,
770    buffer: &Entity<Buffer>,
771    language_registry: &Arc<LanguageRegistry>,
772    cx: &mut AsyncApp,
773) -> Result<Entity<BufferDiff>> {
774    LineEnding::normalize(&mut old_text);
775
776    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
777
778    let base_buffer = cx
779        .update(|cx| {
780            Buffer::build_snapshot(
781                old_text.clone().into(),
782                buffer.language().cloned(),
783                Some(language_registry.clone()),
784                cx,
785            )
786        })?
787        .await;
788
789    let diff_snapshot = cx
790        .update(|cx| {
791            BufferDiffSnapshot::new_with_base_buffer(
792                buffer.text.clone(),
793                Some(old_text.into()),
794                base_buffer,
795                cx,
796            )
797        })?
798        .await;
799
800    let secondary_diff = cx.new(|cx| {
801        let mut diff = BufferDiff::new(&buffer, cx);
802        diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
803        diff
804    })?;
805
806    cx.new(|cx| {
807        let mut diff = BufferDiff::new(&buffer.text, cx);
808        diff.set_snapshot(diff_snapshot, &buffer, cx);
809        diff.set_secondary_diff(secondary_diff);
810        diff
811    })
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use serde_json::json;
818
819    #[test]
820    fn still_streaming_ui_text_with_path() {
821        let input = json!({
822            "path": "src/main.rs",
823            "display_description": "",
824            "old_string": "old code",
825            "new_string": "new code"
826        });
827
828        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
829    }
830
831    #[test]
832    fn still_streaming_ui_text_with_description() {
833        let input = json!({
834            "path": "",
835            "display_description": "Fix error handling",
836            "old_string": "old code",
837            "new_string": "new code"
838        });
839
840        assert_eq!(
841            EditFileTool.still_streaming_ui_text(&input),
842            "Fix error handling",
843        );
844    }
845
846    #[test]
847    fn still_streaming_ui_text_with_path_and_description() {
848        let input = json!({
849            "path": "src/main.rs",
850            "display_description": "Fix error handling",
851            "old_string": "old code",
852            "new_string": "new code"
853        });
854
855        assert_eq!(
856            EditFileTool.still_streaming_ui_text(&input),
857            "Fix error handling",
858        );
859    }
860
861    #[test]
862    fn still_streaming_ui_text_no_path_or_description() {
863        let input = json!({
864            "path": "",
865            "display_description": "",
866            "old_string": "old code",
867            "new_string": "new code"
868        });
869
870        assert_eq!(
871            EditFileTool.still_streaming_ui_text(&input),
872            DEFAULT_UI_TEXT,
873        );
874    }
875
876    #[test]
877    fn still_streaming_ui_text_with_null() {
878        let input = serde_json::Value::Null;
879
880        assert_eq!(
881            EditFileTool.still_streaming_ui_text(&input),
882            DEFAULT_UI_TEXT,
883        );
884    }
885}