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