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