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