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