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, 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 line_height = editor
544                .style()
545                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
546                .unwrap_or_default();
547
548            let settings = ThemeSettings::get_global(cx);
549            let element = EditorElement::new(
550                &cx.entity(),
551                EditorStyle {
552                    background: cx.theme().colors().editor_background,
553                    horizontal_padding: rems(0.25).to_pixels(window.rem_size()),
554                    local_player: cx.theme().players().local(),
555                    text: TextStyle {
556                        color: cx.theme().colors().editor_foreground,
557                        font_family: settings.buffer_font.family.clone(),
558                        font_features: settings.buffer_font.features.clone(),
559                        font_fallbacks: settings.buffer_font.fallbacks.clone(),
560                        font_size: TextSize::Small
561                            .rems(cx)
562                            .to_pixels(settings.agent_font_size(cx))
563                            .into(),
564                        font_weight: settings.buffer_font.weight,
565                        line_height: relative(settings.buffer_line_height.value()),
566                        ..Default::default()
567                    },
568                    scrollbar_width: EditorElement::SCROLLBAR_WIDTH,
569                    syntax: cx.theme().syntax().clone(),
570                    status: cx.theme().status().clone(),
571                    ..Default::default()
572                },
573            );
574
575            (element.into_any_element(), line_height)
576        });
577
578        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
579            (IconName::ChevronUp, "Collapse Code Block")
580        } else {
581            (IconName::ChevronDown, "Expand Code Block")
582        };
583
584        let gradient_overlay =
585            div()
586                .absolute()
587                .bottom_0()
588                .left_0()
589                .w_full()
590                .h_2_5()
591                .bg(gpui::linear_gradient(
592                    0.,
593                    gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
594                    gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
595                ));
596
597        let border_color = cx.theme().colors().border.opacity(0.6);
598
599        const DEFAULT_COLLAPSED_LINES: u32 = 10;
600        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
601
602        let waiting_for_diff = {
603            let styles = [
604                ("w_4_5", (0.1, 0.85), 2000),
605                ("w_1_4", (0.2, 0.75), 2200),
606                ("w_2_4", (0.15, 0.64), 1900),
607                ("w_3_5", (0.25, 0.72), 2300),
608                ("w_2_5", (0.3, 0.56), 1800),
609            ];
610
611            let mut container = v_flex()
612                .p_3()
613                .gap_1()
614                .border_t_1()
615                .rounded_md()
616                .border_color(border_color)
617                .bg(cx.theme().colors().editor_background);
618
619            for (width_method, pulse_range, duration_ms) in styles.iter() {
620                let (min_opacity, max_opacity) = *pulse_range;
621                let placeholder = match *width_method {
622                    "w_4_5" => div().w_3_4(),
623                    "w_1_4" => div().w_1_4(),
624                    "w_2_4" => div().w_2_4(),
625                    "w_3_5" => div().w_3_5(),
626                    "w_2_5" => div().w_2_5(),
627                    _ => div().w_1_2(),
628                }
629                .id("loading_div")
630                .h_1()
631                .rounded_full()
632                .bg(cx.theme().colors().element_active)
633                .with_animation(
634                    "loading_pulsate",
635                    Animation::new(Duration::from_millis(*duration_ms))
636                        .repeat()
637                        .with_easing(pulsating_between(min_opacity, max_opacity)),
638                    |label, delta| label.opacity(delta),
639                );
640
641                container = container.child(placeholder);
642            }
643
644            container
645        };
646
647        v_flex()
648            .mb_2()
649            .border_1()
650            .when(failed, |card| card.border_dashed())
651            .border_color(border_color)
652            .rounded_md()
653            .overflow_hidden()
654            .child(codeblock_header)
655            .when(failed && self.error_expanded, |card| {
656                card.child(
657                    v_flex()
658                        .p_2()
659                        .gap_1()
660                        .border_t_1()
661                        .border_dashed()
662                        .border_color(border_color)
663                        .bg(cx.theme().colors().editor_background)
664                        .rounded_b_md()
665                        .child(
666                            Label::new("Error")
667                                .size(LabelSize::XSmall)
668                                .color(Color::Error),
669                        )
670                        .child(
671                            div()
672                                .rounded_md()
673                                .text_ui_sm(cx)
674                                .bg(cx.theme().colors().editor_background)
675                                .children(
676                                    error_message
677                                        .map(|error| div().child(error).into_any_element()),
678                                ),
679                        ),
680                )
681            })
682            .when(!self.has_diff() && !failed, |card| {
683                card.child(waiting_for_diff)
684            })
685            .when(
686                !failed && self.preview_expanded && self.has_diff(),
687                |card| {
688                    card.child(
689                        v_flex()
690                            .relative()
691                            .h_full()
692                            .when(!self.full_height_expanded, |editor_container| {
693                                editor_container
694                                    .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
695                            })
696                            .overflow_hidden()
697                            .border_t_1()
698                            .border_color(border_color)
699                            .bg(cx.theme().colors().editor_background)
700                            .child(editor)
701                            .when(
702                                !self.full_height_expanded && is_collapsible,
703                                |editor_container| editor_container.child(gradient_overlay),
704                            ),
705                    )
706                    .when(is_collapsible, |card| {
707                        card.child(
708                            h_flex()
709                                .id(("expand-button", self.editor_unique_id))
710                                .flex_none()
711                                .cursor_pointer()
712                                .h_5()
713                                .justify_center()
714                                .border_t_1()
715                                .rounded_b_md()
716                                .border_color(border_color)
717                                .bg(cx.theme().colors().editor_background)
718                                .hover(|style| {
719                                    style.bg(cx.theme().colors().element_hover.opacity(0.1))
720                                })
721                                .child(
722                                    Icon::new(full_height_icon)
723                                        .size(IconSize::Small)
724                                        .color(Color::Muted),
725                                )
726                                .tooltip(Tooltip::text(full_height_tooltip_label))
727                                .on_click(cx.listener(move |this, _event, _window, _cx| {
728                                    this.full_height_expanded = !this.full_height_expanded;
729                                })),
730                        )
731                    })
732                },
733            )
734    }
735}
736
737async fn build_buffer(
738    mut text: String,
739    path: Arc<Path>,
740    language_registry: &Arc<language::LanguageRegistry>,
741    cx: &mut AsyncApp,
742) -> Result<Entity<Buffer>> {
743    let line_ending = LineEnding::detect(&text);
744    LineEnding::normalize(&mut text);
745    let text = Rope::from(text);
746    let language = cx
747        .update(|_cx| language_registry.language_for_file_path(&path))?
748        .await
749        .ok();
750    let buffer = cx.new(|cx| {
751        let buffer = TextBuffer::new_normalized(
752            0,
753            cx.entity_id().as_non_zero_u64().into(),
754            line_ending,
755            text,
756        );
757        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
758        buffer.set_language(language, cx);
759        buffer
760    })?;
761    Ok(buffer)
762}
763
764async fn build_buffer_diff(
765    mut old_text: String,
766    buffer: &Entity<Buffer>,
767    language_registry: &Arc<LanguageRegistry>,
768    cx: &mut AsyncApp,
769) -> Result<Entity<BufferDiff>> {
770    LineEnding::normalize(&mut old_text);
771
772    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
773
774    let base_buffer = cx
775        .update(|cx| {
776            Buffer::build_snapshot(
777                old_text.clone().into(),
778                buffer.language().cloned(),
779                Some(language_registry.clone()),
780                cx,
781            )
782        })?
783        .await;
784
785    let diff_snapshot = cx
786        .update(|cx| {
787            BufferDiffSnapshot::new_with_base_buffer(
788                buffer.text.clone(),
789                Some(old_text.into()),
790                base_buffer,
791                cx,
792            )
793        })?
794        .await;
795
796    let secondary_diff = cx.new(|cx| {
797        let mut diff = BufferDiff::new(&buffer, cx);
798        diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
799        diff
800    })?;
801
802    cx.new(|cx| {
803        let mut diff = BufferDiff::new(&buffer.text, cx);
804        diff.set_snapshot(diff_snapshot, &buffer, cx);
805        diff.set_secondary_diff(secondary_diff);
806        diff
807    })
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use serde_json::json;
814
815    #[test]
816    fn still_streaming_ui_text_with_path() {
817        let input = json!({
818            "path": "src/main.rs",
819            "display_description": "",
820            "old_string": "old code",
821            "new_string": "new code"
822        });
823
824        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
825    }
826
827    #[test]
828    fn still_streaming_ui_text_with_description() {
829        let input = json!({
830            "path": "",
831            "display_description": "Fix error handling",
832            "old_string": "old code",
833            "new_string": "new code"
834        });
835
836        assert_eq!(
837            EditFileTool.still_streaming_ui_text(&input),
838            "Fix error handling",
839        );
840    }
841
842    #[test]
843    fn still_streaming_ui_text_with_path_and_description() {
844        let input = json!({
845            "path": "src/main.rs",
846            "display_description": "Fix error handling",
847            "old_string": "old code",
848            "new_string": "new code"
849        });
850
851        assert_eq!(
852            EditFileTool.still_streaming_ui_text(&input),
853            "Fix error handling",
854        );
855    }
856
857    #[test]
858    fn still_streaming_ui_text_no_path_or_description() {
859        let input = json!({
860            "path": "",
861            "display_description": "",
862            "old_string": "old code",
863            "new_string": "new code"
864        });
865
866        assert_eq!(
867            EditFileTool.still_streaming_ui_text(&input),
868            DEFAULT_UI_TEXT,
869        );
870    }
871
872    #[test]
873    fn still_streaming_ui_text_with_null() {
874        let input = serde_json::Value::Null;
875
876        assert_eq!(
877            EditFileTool.still_streaming_ui_text(&input),
878            DEFAULT_UI_TEXT,
879        );
880    }
881}