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