edit_file_tool.rs

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