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, TextStyleRefinement, 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.track_buffer(buffer.clone(), cx));
243
244 let base_version = diff.base_version.clone();
245 let snapshot = buffer.update(cx, |buffer, cx| {
246 buffer.finalize_last_transaction();
247 buffer.apply_diff(diff, cx);
248 buffer.finalize_last_transaction();
249 buffer.snapshot()
250 });
251 action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
252
253 // Set the agent's location to the position of the first edit
254 if let Some(first_edit) = snapshot.edits_since::<usize>(&base_version).next() {
255 let position = snapshot.anchor_before(first_edit.new.start);
256 project.update(cx, |project, cx| {
257 project.set_agent_location(
258 Some(AgentLocation {
259 buffer: buffer.downgrade(),
260 position,
261 }),
262 cx,
263 );
264 })
265 }
266
267 snapshot
268 })?;
269
270 project
271 .update(cx, |project, cx| project.save_buffer(buffer, cx))?
272 .await?;
273
274 let new_text = snapshot.text();
275 let diff_str = cx
276 .background_spawn({
277 let old_text = old_text.clone();
278 let new_text = new_text.clone();
279 async move { language::unified_diff(&old_text, &new_text) }
280 })
281 .await;
282
283 if let Some(card) = card_clone {
284 card.update(cx, |card, cx| {
285 card.set_diff(project_path.path.clone(), old_text, new_text, cx);
286 })
287 .log_err();
288 }
289
290 Ok(format!(
291 "Edited {}:\n\n```diff\n{}\n```",
292 input.path.display(),
293 diff_str
294 ))
295 });
296
297 ToolResult {
298 output: task,
299 card: card.map(AnyToolCard::from),
300 }
301 }
302}
303
304pub struct EditFileToolCard {
305 path: PathBuf,
306 editor: Entity<Editor>,
307 multibuffer: Entity<MultiBuffer>,
308 project: Entity<Project>,
309 diff_task: Option<Task<Result<()>>>,
310 preview_expanded: bool,
311 error_expanded: bool,
312 full_height_expanded: bool,
313 total_lines: Option<u32>,
314 editor_unique_id: EntityId,
315}
316
317impl EditFileToolCard {
318 pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
319 let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
320 let editor = cx.new(|cx| {
321 let mut editor = Editor::new(
322 EditorMode::Full {
323 scale_ui_elements_with_buffer_font_size: false,
324 show_active_line_background: false,
325 sized_by_content: true,
326 },
327 multibuffer.clone(),
328 Some(project.clone()),
329 window,
330 cx,
331 );
332 editor.set_show_gutter(false, cx);
333 editor.disable_inline_diagnostics();
334 editor.disable_expand_excerpt_buttons(cx);
335 editor.set_soft_wrap_mode(SoftWrap::None, cx);
336 editor.scroll_manager.set_forbid_vertical_scroll(true);
337 editor.set_show_scrollbars(false, cx);
338 editor.set_show_indent_guides(false, cx);
339 editor.set_read_only(true);
340 editor.set_show_breakpoints(false, cx);
341 editor.set_show_code_actions(false, cx);
342 editor.set_show_git_diff_gutter(false, cx);
343 editor.set_expand_all_diff_hunks(cx);
344 editor
345 });
346 Self {
347 editor_unique_id: editor.entity_id(),
348 path,
349 project,
350 editor,
351 multibuffer,
352 diff_task: None,
353 preview_expanded: true,
354 error_expanded: false,
355 full_height_expanded: false,
356 total_lines: None,
357 }
358 }
359
360 pub fn has_diff(&self) -> bool {
361 self.total_lines.is_some()
362 }
363
364 pub fn set_diff(
365 &mut self,
366 path: Arc<Path>,
367 old_text: String,
368 new_text: String,
369 cx: &mut Context<Self>,
370 ) {
371 let language_registry = self.project.read(cx).languages().clone();
372 self.diff_task = Some(cx.spawn(async move |this, cx| {
373 let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
374 let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
375
376 this.update(cx, |this, cx| {
377 this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
378 let snapshot = buffer.read(cx).snapshot();
379 let diff = buffer_diff.read(cx);
380 let diff_hunk_ranges = diff
381 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
382 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
383 .collect::<Vec<_>>();
384 multibuffer.clear(cx);
385 multibuffer.set_excerpts_for_path(
386 PathKey::for_buffer(&buffer, cx),
387 buffer,
388 diff_hunk_ranges,
389 editor::DEFAULT_MULTIBUFFER_CONTEXT,
390 cx,
391 );
392 multibuffer.add_diff(buffer_diff, cx);
393 let end = multibuffer.len(cx);
394 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
395 });
396
397 cx.notify();
398 })
399 }));
400 }
401}
402
403impl ToolCard for EditFileToolCard {
404 fn render(
405 &mut self,
406 status: &ToolUseStatus,
407 window: &mut Window,
408 workspace: WeakEntity<Workspace>,
409 cx: &mut Context<Self>,
410 ) -> impl IntoElement {
411 let (failed, error_message) = match status {
412 ToolUseStatus::Error(err) => (true, Some(err.to_string())),
413 _ => (false, None),
414 };
415
416 let path_label_button = h_flex()
417 .id(("edit-tool-path-label-button", self.editor_unique_id))
418 .w_full()
419 .max_w_full()
420 .px_1()
421 .gap_0p5()
422 .cursor_pointer()
423 .rounded_sm()
424 .opacity(0.8)
425 .hover(|label| {
426 label
427 .opacity(1.)
428 .bg(cx.theme().colors().element_hover.opacity(0.5))
429 })
430 .tooltip(Tooltip::text("Jump to File"))
431 .child(
432 h_flex()
433 .child(
434 Icon::new(IconName::Pencil)
435 .size(IconSize::XSmall)
436 .color(Color::Muted),
437 )
438 .child(
439 div()
440 .text_size(rems(0.8125))
441 .child(self.path.display().to_string())
442 .ml_1p5()
443 .mr_0p5(),
444 )
445 .child(
446 Icon::new(IconName::ArrowUpRight)
447 .size(IconSize::XSmall)
448 .color(Color::Ignored),
449 ),
450 )
451 .on_click({
452 let path = self.path.clone();
453 let workspace = workspace.clone();
454 move |_, window, cx| {
455 workspace
456 .update(cx, {
457 |workspace, cx| {
458 let Some(project_path) =
459 workspace.project().read(cx).find_project_path(&path, cx)
460 else {
461 return;
462 };
463 let open_task =
464 workspace.open_path(project_path, None, true, window, cx);
465 window
466 .spawn(cx, async move |cx| {
467 let item = open_task.await?;
468 if let Some(active_editor) = item.downcast::<Editor>() {
469 active_editor
470 .update_in(cx, |editor, window, cx| {
471 editor.go_to_singleton_buffer_point(
472 language::Point::new(0, 0),
473 window,
474 cx,
475 );
476 })
477 .log_err();
478 }
479 anyhow::Ok(())
480 })
481 .detach_and_log_err(cx);
482 }
483 })
484 .ok();
485 }
486 })
487 .into_any_element();
488
489 let codeblock_header_bg = cx
490 .theme()
491 .colors()
492 .element_background
493 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
494
495 let codeblock_header = h_flex()
496 .flex_none()
497 .p_1()
498 .gap_1()
499 .justify_between()
500 .rounded_t_md()
501 .when(!failed, |header| header.bg(codeblock_header_bg))
502 .child(path_label_button)
503 .when(failed, |header| {
504 header.child(
505 h_flex()
506 .gap_1()
507 .child(
508 Icon::new(IconName::Close)
509 .size(IconSize::Small)
510 .color(Color::Error),
511 )
512 .child(
513 Disclosure::new(
514 ("edit-file-error-disclosure", self.editor_unique_id),
515 self.error_expanded,
516 )
517 .opened_icon(IconName::ChevronUp)
518 .closed_icon(IconName::ChevronDown)
519 .on_click(cx.listener(
520 move |this, _event, _window, _cx| {
521 this.error_expanded = !this.error_expanded;
522 },
523 )),
524 ),
525 )
526 })
527 .when(!failed && self.has_diff(), |header| {
528 header.child(
529 Disclosure::new(
530 ("edit-file-disclosure", self.editor_unique_id),
531 self.preview_expanded,
532 )
533 .opened_icon(IconName::ChevronUp)
534 .closed_icon(IconName::ChevronDown)
535 .on_click(cx.listener(
536 move |this, _event, _window, _cx| {
537 this.preview_expanded = !this.preview_expanded;
538 },
539 )),
540 )
541 });
542
543 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
544 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
545
546 editor.set_text_style_refinement(TextStyleRefinement {
547 font_size: Some(ui_font_size.into()),
548 ..TextStyleRefinement::default()
549 });
550
551 let line_height = editor
552 .style()
553 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
554 .unwrap_or_default();
555
556 let settings = ThemeSettings::get_global(cx);
557 let element = EditorElement::new(
558 &cx.entity(),
559 EditorStyle {
560 background: cx.theme().colors().editor_background,
561 horizontal_padding: rems(0.25).to_pixels(window.rem_size()),
562 local_player: cx.theme().players().local(),
563 text: TextStyle {
564 color: cx.theme().colors().editor_foreground,
565 font_family: settings.buffer_font.family.clone(),
566 font_features: settings.buffer_font.features.clone(),
567 font_fallbacks: settings.buffer_font.fallbacks.clone(),
568 font_size: settings.buffer_font_size(cx).into(),
569 font_weight: settings.buffer_font.weight,
570 line_height: relative(settings.buffer_line_height.value()),
571 ..Default::default()
572 },
573 scrollbar_width: EditorElement::SCROLLBAR_WIDTH,
574 syntax: cx.theme().syntax().clone(),
575 status: cx.theme().status().clone(),
576 ..Default::default()
577 },
578 );
579
580 (element.into_any_element(), line_height)
581 });
582
583 let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
584 (IconName::ChevronUp, "Collapse Code Block")
585 } else {
586 (IconName::ChevronDown, "Expand Code Block")
587 };
588
589 let gradient_overlay = div()
590 .absolute()
591 .bottom_0()
592 .left_0()
593 .w_full()
594 .h_2_5()
595 .rounded_b_lg()
596 .bg(gpui::linear_gradient(
597 0.,
598 gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
599 gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
600 ));
601
602 let border_color = cx.theme().colors().border.opacity(0.6);
603
604 const DEFAULT_COLLAPSED_LINES: u32 = 10;
605 let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
606
607 let waiting_for_diff = {
608 let styles = [
609 ("w_4_5", (0.1, 0.85), 2000),
610 ("w_1_4", (0.2, 0.75), 2200),
611 ("w_2_4", (0.15, 0.64), 1900),
612 ("w_3_5", (0.25, 0.72), 2300),
613 ("w_2_5", (0.3, 0.56), 1800),
614 ];
615
616 let mut container = v_flex()
617 .p_3()
618 .gap_1p5()
619 .border_t_1()
620 .border_color(border_color)
621 .bg(cx.theme().colors().editor_background);
622
623 for (width_method, pulse_range, duration_ms) in styles.iter() {
624 let (min_opacity, max_opacity) = *pulse_range;
625 let placeholder = match *width_method {
626 "w_4_5" => div().w_3_4(),
627 "w_1_4" => div().w_1_4(),
628 "w_2_4" => div().w_2_4(),
629 "w_3_5" => div().w_3_5(),
630 "w_2_5" => div().w_2_5(),
631 _ => div().w_1_2(),
632 }
633 .id("loading_div")
634 .h_2()
635 .rounded_full()
636 .bg(cx.theme().colors().element_active)
637 .with_animation(
638 "loading_pulsate",
639 Animation::new(Duration::from_millis(*duration_ms))
640 .repeat()
641 .with_easing(pulsating_between(min_opacity, max_opacity)),
642 |label, delta| label.opacity(delta),
643 );
644
645 container = container.child(placeholder);
646 }
647
648 container
649 };
650
651 v_flex()
652 .mb_2()
653 .border_1()
654 .when(failed, |card| card.border_dashed())
655 .border_color(border_color)
656 .rounded_lg()
657 .overflow_hidden()
658 .child(codeblock_header)
659 .when(failed && self.error_expanded, |card| {
660 card.child(
661 v_flex()
662 .p_2()
663 .gap_1()
664 .border_t_1()
665 .border_dashed()
666 .border_color(border_color)
667 .bg(cx.theme().colors().editor_background)
668 .rounded_b_md()
669 .child(
670 Label::new("Error")
671 .size(LabelSize::XSmall)
672 .color(Color::Error),
673 )
674 .child(
675 div()
676 .rounded_md()
677 .text_ui_sm(cx)
678 .bg(cx.theme().colors().editor_background)
679 .children(
680 error_message
681 .map(|error| div().child(error).into_any_element()),
682 ),
683 ),
684 )
685 })
686 .when(!self.has_diff() && !failed, |card| {
687 card.child(waiting_for_diff)
688 })
689 .when(
690 !failed && self.preview_expanded && self.has_diff(),
691 |card| {
692 card.child(
693 v_flex()
694 .relative()
695 .h_full()
696 .when(!self.full_height_expanded, |editor_container| {
697 editor_container
698 .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
699 })
700 .overflow_hidden()
701 .border_t_1()
702 .border_color(border_color)
703 .bg(cx.theme().colors().editor_background)
704 .child(editor)
705 .when(
706 !self.full_height_expanded && is_collapsible,
707 |editor_container| editor_container.child(gradient_overlay),
708 ),
709 )
710 .when(is_collapsible, |editor_container| {
711 editor_container.child(
712 h_flex()
713 .id(("expand-button", self.editor_unique_id))
714 .flex_none()
715 .cursor_pointer()
716 .h_5()
717 .justify_center()
718 .rounded_b_md()
719 .border_t_1()
720 .border_color(border_color)
721 .bg(cx.theme().colors().editor_background)
722 .hover(|style| {
723 style.bg(cx.theme().colors().element_hover.opacity(0.1))
724 })
725 .child(
726 Icon::new(full_height_icon)
727 .size(IconSize::Small)
728 .color(Color::Muted),
729 )
730 .tooltip(Tooltip::text(full_height_tooltip_label))
731 .on_click(cx.listener(move |this, _event, _window, _cx| {
732 this.full_height_expanded = !this.full_height_expanded;
733 })),
734 )
735 })
736 },
737 )
738 }
739}
740
741async fn build_buffer(
742 mut text: String,
743 path: Arc<Path>,
744 language_registry: &Arc<language::LanguageRegistry>,
745 cx: &mut AsyncApp,
746) -> Result<Entity<Buffer>> {
747 let line_ending = LineEnding::detect(&text);
748 LineEnding::normalize(&mut text);
749 let text = Rope::from(text);
750 let language = cx
751 .update(|_cx| language_registry.language_for_file_path(&path))?
752 .await
753 .ok();
754 let buffer = cx.new(|cx| {
755 let buffer = TextBuffer::new_normalized(
756 0,
757 cx.entity_id().as_non_zero_u64().into(),
758 line_ending,
759 text,
760 );
761 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
762 buffer.set_language(language, cx);
763 buffer
764 })?;
765 Ok(buffer)
766}
767
768async fn build_buffer_diff(
769 mut old_text: String,
770 buffer: &Entity<Buffer>,
771 language_registry: &Arc<LanguageRegistry>,
772 cx: &mut AsyncApp,
773) -> Result<Entity<BufferDiff>> {
774 LineEnding::normalize(&mut old_text);
775
776 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
777
778 let base_buffer = cx
779 .update(|cx| {
780 Buffer::build_snapshot(
781 old_text.clone().into(),
782 buffer.language().cloned(),
783 Some(language_registry.clone()),
784 cx,
785 )
786 })?
787 .await;
788
789 let diff_snapshot = cx
790 .update(|cx| {
791 BufferDiffSnapshot::new_with_base_buffer(
792 buffer.text.clone(),
793 Some(old_text.into()),
794 base_buffer,
795 cx,
796 )
797 })?
798 .await;
799
800 let secondary_diff = cx.new(|cx| {
801 let mut diff = BufferDiff::new(&buffer, cx);
802 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
803 diff
804 })?;
805
806 cx.new(|cx| {
807 let mut diff = BufferDiff::new(&buffer.text, cx);
808 diff.set_snapshot(diff_snapshot, &buffer, cx);
809 diff.set_secondary_diff(secondary_diff);
810 diff
811 })
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use serde_json::json;
818
819 #[test]
820 fn still_streaming_ui_text_with_path() {
821 let input = json!({
822 "path": "src/main.rs",
823 "display_description": "",
824 "old_string": "old code",
825 "new_string": "new code"
826 });
827
828 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
829 }
830
831 #[test]
832 fn still_streaming_ui_text_with_description() {
833 let input = json!({
834 "path": "",
835 "display_description": "Fix error handling",
836 "old_string": "old code",
837 "new_string": "new code"
838 });
839
840 assert_eq!(
841 EditFileTool.still_streaming_ui_text(&input),
842 "Fix error handling",
843 );
844 }
845
846 #[test]
847 fn still_streaming_ui_text_with_path_and_description() {
848 let input = json!({
849 "path": "src/main.rs",
850 "display_description": "Fix error handling",
851 "old_string": "old code",
852 "new_string": "new code"
853 });
854
855 assert_eq!(
856 EditFileTool.still_streaming_ui_text(&input),
857 "Fix error handling",
858 );
859 }
860
861 #[test]
862 fn still_streaming_ui_text_no_path_or_description() {
863 let input = json!({
864 "path": "",
865 "display_description": "",
866 "old_string": "old code",
867 "new_string": "new code"
868 });
869
870 assert_eq!(
871 EditFileTool.still_streaming_ui_text(&input),
872 DEFAULT_UI_TEXT,
873 );
874 }
875
876 #[test]
877 fn still_streaming_ui_text_with_null() {
878 let input = serde_json::Value::Null;
879
880 assert_eq!(
881 EditFileTool.still_streaming_ui_text(&input),
882 DEFAULT_UI_TEXT,
883 );
884 }
885}