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