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