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