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