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