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