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