edit_file_tool.rs

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