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 line_height = editor
544 .style()
545 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
546 .unwrap_or_default();
547
548 let settings = ThemeSettings::get_global(cx);
549 let element = EditorElement::new(
550 &cx.entity(),
551 EditorStyle {
552 background: cx.theme().colors().editor_background,
553 horizontal_padding: rems(0.25).to_pixels(window.rem_size()),
554 local_player: cx.theme().players().local(),
555 text: TextStyle {
556 color: cx.theme().colors().editor_foreground,
557 font_family: settings.buffer_font.family.clone(),
558 font_features: settings.buffer_font.features.clone(),
559 font_fallbacks: settings.buffer_font.fallbacks.clone(),
560 font_size: TextSize::Small
561 .rems(cx)
562 .to_pixels(settings.agent_font_size(cx))
563 .into(),
564 font_weight: settings.buffer_font.weight,
565 line_height: relative(settings.buffer_line_height.value()),
566 ..Default::default()
567 },
568 scrollbar_width: EditorElement::SCROLLBAR_WIDTH,
569 syntax: cx.theme().syntax().clone(),
570 status: cx.theme().status().clone(),
571 ..Default::default()
572 },
573 );
574
575 (element.into_any_element(), line_height)
576 });
577
578 let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
579 (IconName::ChevronUp, "Collapse Code Block")
580 } else {
581 (IconName::ChevronDown, "Expand Code Block")
582 };
583
584 let gradient_overlay = div()
585 .absolute()
586 .bottom_0()
587 .left_0()
588 .w_full()
589 .h_2_5()
590 .rounded_b_lg()
591 .bg(gpui::linear_gradient(
592 0.,
593 gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
594 gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
595 ));
596
597 let border_color = cx.theme().colors().border.opacity(0.6);
598
599 const DEFAULT_COLLAPSED_LINES: u32 = 10;
600 let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
601
602 let waiting_for_diff = {
603 let styles = [
604 ("w_4_5", (0.1, 0.85), 2000),
605 ("w_1_4", (0.2, 0.75), 2200),
606 ("w_2_4", (0.15, 0.64), 1900),
607 ("w_3_5", (0.25, 0.72), 2300),
608 ("w_2_5", (0.3, 0.56), 1800),
609 ];
610
611 let mut container = v_flex()
612 .p_3()
613 .gap_1p5()
614 .border_t_1()
615 .border_color(border_color)
616 .bg(cx.theme().colors().editor_background);
617
618 for (width_method, pulse_range, duration_ms) in styles.iter() {
619 let (min_opacity, max_opacity) = *pulse_range;
620 let placeholder = match *width_method {
621 "w_4_5" => div().w_3_4(),
622 "w_1_4" => div().w_1_4(),
623 "w_2_4" => div().w_2_4(),
624 "w_3_5" => div().w_3_5(),
625 "w_2_5" => div().w_2_5(),
626 _ => div().w_1_2(),
627 }
628 .id("loading_div")
629 .h_2()
630 .rounded_full()
631 .bg(cx.theme().colors().element_active)
632 .with_animation(
633 "loading_pulsate",
634 Animation::new(Duration::from_millis(*duration_ms))
635 .repeat()
636 .with_easing(pulsating_between(min_opacity, max_opacity)),
637 |label, delta| label.opacity(delta),
638 );
639
640 container = container.child(placeholder);
641 }
642
643 container
644 };
645
646 v_flex()
647 .mb_2()
648 .border_1()
649 .when(failed, |card| card.border_dashed())
650 .border_color(border_color)
651 .rounded_lg()
652 .overflow_hidden()
653 .child(codeblock_header)
654 .when(failed && self.error_expanded, |card| {
655 card.child(
656 v_flex()
657 .p_2()
658 .gap_1()
659 .border_t_1()
660 .border_dashed()
661 .border_color(border_color)
662 .bg(cx.theme().colors().editor_background)
663 .rounded_b_md()
664 .child(
665 Label::new("Error")
666 .size(LabelSize::XSmall)
667 .color(Color::Error),
668 )
669 .child(
670 div()
671 .rounded_md()
672 .text_ui_sm(cx)
673 .bg(cx.theme().colors().editor_background)
674 .children(
675 error_message
676 .map(|error| div().child(error).into_any_element()),
677 ),
678 ),
679 )
680 })
681 .when(!self.has_diff() && !failed, |card| {
682 card.child(waiting_for_diff)
683 })
684 .when(
685 !failed && self.preview_expanded && self.has_diff(),
686 |card| {
687 card.child(
688 v_flex()
689 .relative()
690 .h_full()
691 .when(!self.full_height_expanded, |editor_container| {
692 editor_container
693 .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
694 })
695 .overflow_hidden()
696 .border_t_1()
697 .border_color(border_color)
698 .bg(cx.theme().colors().editor_background)
699 .child(editor)
700 .when(
701 !self.full_height_expanded && is_collapsible,
702 |editor_container| editor_container.child(gradient_overlay),
703 ),
704 )
705 .when(is_collapsible, |editor_container| {
706 editor_container.child(
707 h_flex()
708 .id(("expand-button", self.editor_unique_id))
709 .flex_none()
710 .cursor_pointer()
711 .h_5()
712 .justify_center()
713 .border_t_1()
714 .border_color(border_color)
715 .bg(cx.theme().colors().editor_background)
716 .hover(|style| {
717 style.bg(cx.theme().colors().element_hover.opacity(0.1))
718 })
719 .child(
720 Icon::new(full_height_icon)
721 .size(IconSize::Small)
722 .color(Color::Muted),
723 )
724 .tooltip(Tooltip::text(full_height_tooltip_label))
725 .on_click(cx.listener(move |this, _event, _window, _cx| {
726 this.full_height_expanded = !this.full_height_expanded;
727 })),
728 )
729 })
730 },
731 )
732 }
733}
734
735async fn build_buffer(
736 mut text: String,
737 path: Arc<Path>,
738 language_registry: &Arc<language::LanguageRegistry>,
739 cx: &mut AsyncApp,
740) -> Result<Entity<Buffer>> {
741 let line_ending = LineEnding::detect(&text);
742 LineEnding::normalize(&mut text);
743 let text = Rope::from(text);
744 let language = cx
745 .update(|_cx| language_registry.language_for_file_path(&path))?
746 .await
747 .ok();
748 let buffer = cx.new(|cx| {
749 let buffer = TextBuffer::new_normalized(
750 0,
751 cx.entity_id().as_non_zero_u64().into(),
752 line_ending,
753 text,
754 );
755 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
756 buffer.set_language(language, cx);
757 buffer
758 })?;
759 Ok(buffer)
760}
761
762async fn build_buffer_diff(
763 mut old_text: String,
764 buffer: &Entity<Buffer>,
765 language_registry: &Arc<LanguageRegistry>,
766 cx: &mut AsyncApp,
767) -> Result<Entity<BufferDiff>> {
768 LineEnding::normalize(&mut old_text);
769
770 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
771
772 let base_buffer = cx
773 .update(|cx| {
774 Buffer::build_snapshot(
775 old_text.clone().into(),
776 buffer.language().cloned(),
777 Some(language_registry.clone()),
778 cx,
779 )
780 })?
781 .await;
782
783 let diff_snapshot = cx
784 .update(|cx| {
785 BufferDiffSnapshot::new_with_base_buffer(
786 buffer.text.clone(),
787 Some(old_text.into()),
788 base_buffer,
789 cx,
790 )
791 })?
792 .await;
793
794 let secondary_diff = cx.new(|cx| {
795 let mut diff = BufferDiff::new(&buffer, cx);
796 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
797 diff
798 })?;
799
800 cx.new(|cx| {
801 let mut diff = BufferDiff::new(&buffer.text, cx);
802 diff.set_snapshot(diff_snapshot, &buffer, cx);
803 diff.set_secondary_diff(secondary_diff);
804 diff
805 })
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811 use serde_json::json;
812
813 #[test]
814 fn still_streaming_ui_text_with_path() {
815 let input = json!({
816 "path": "src/main.rs",
817 "display_description": "",
818 "old_string": "old code",
819 "new_string": "new code"
820 });
821
822 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
823 }
824
825 #[test]
826 fn still_streaming_ui_text_with_description() {
827 let input = json!({
828 "path": "",
829 "display_description": "Fix error handling",
830 "old_string": "old code",
831 "new_string": "new code"
832 });
833
834 assert_eq!(
835 EditFileTool.still_streaming_ui_text(&input),
836 "Fix error handling",
837 );
838 }
839
840 #[test]
841 fn still_streaming_ui_text_with_path_and_description() {
842 let input = json!({
843 "path": "src/main.rs",
844 "display_description": "Fix error handling",
845 "old_string": "old code",
846 "new_string": "new code"
847 });
848
849 assert_eq!(
850 EditFileTool.still_streaming_ui_text(&input),
851 "Fix error handling",
852 );
853 }
854
855 #[test]
856 fn still_streaming_ui_text_no_path_or_description() {
857 let input = json!({
858 "path": "",
859 "display_description": "",
860 "old_string": "old code",
861 "new_string": "new code"
862 });
863
864 assert_eq!(
865 EditFileTool.still_streaming_ui_text(&input),
866 DEFAULT_UI_TEXT,
867 );
868 }
869
870 #[test]
871 fn still_streaming_ui_text_with_null() {
872 let input = serde_json::Value::Null;
873
874 assert_eq!(
875 EditFileTool.still_streaming_ui_text(&input),
876 DEFAULT_UI_TEXT,
877 );
878 }
879}