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