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