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_read_only(true);
308 editor.set_show_breakpoints(false, cx);
309 editor.set_show_code_actions(false, cx);
310 editor.set_show_git_diff_gutter(false, cx);
311 editor.set_expand_all_diff_hunks(cx);
312 editor
313 });
314 Self {
315 editor_unique_id: editor.entity_id(),
316 path,
317 project,
318 editor,
319 multibuffer,
320 diff_task: None,
321 preview_expanded: true,
322 error_expanded: false,
323 full_height_expanded: false,
324 total_lines: None,
325 }
326 }
327
328 pub fn has_diff(&self) -> bool {
329 self.total_lines.is_some()
330 }
331
332 pub fn set_diff(
333 &mut self,
334 path: Arc<Path>,
335 old_text: String,
336 new_text: String,
337 cx: &mut Context<Self>,
338 ) {
339 let language_registry = self.project.read(cx).languages().clone();
340 self.diff_task = Some(cx.spawn(async move |this, cx| {
341 let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
342 let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
343
344 this.update(cx, |this, cx| {
345 this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
346 let snapshot = buffer.read(cx).snapshot();
347 let diff = buffer_diff.read(cx);
348 let diff_hunk_ranges = diff
349 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
350 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
351 .collect::<Vec<_>>();
352 multibuffer.clear(cx);
353 let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
354 PathKey::for_buffer(&buffer, cx),
355 buffer,
356 diff_hunk_ranges,
357 editor::DEFAULT_MULTIBUFFER_CONTEXT,
358 cx,
359 );
360 debug_assert!(is_newly_added);
361 multibuffer.add_diff(buffer_diff, cx);
362 let end = multibuffer.len(cx);
363 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
364 });
365
366 cx.notify();
367 })
368 }));
369 }
370}
371
372impl ToolCard for EditFileToolCard {
373 fn render(
374 &mut self,
375 status: &ToolUseStatus,
376 window: &mut Window,
377 workspace: WeakEntity<Workspace>,
378 cx: &mut Context<Self>,
379 ) -> impl IntoElement {
380 let (failed, error_message) = match status {
381 ToolUseStatus::Error(err) => (true, Some(err.to_string())),
382 _ => (false, None),
383 };
384
385 let path_label_button = h_flex()
386 .id(("edit-tool-path-label-button", self.editor_unique_id))
387 .w_full()
388 .max_w_full()
389 .px_1()
390 .gap_0p5()
391 .cursor_pointer()
392 .rounded_sm()
393 .opacity(0.8)
394 .hover(|label| {
395 label
396 .opacity(1.)
397 .bg(cx.theme().colors().element_hover.opacity(0.5))
398 })
399 .tooltip(Tooltip::text("Jump to File"))
400 .child(
401 h_flex()
402 .child(
403 Icon::new(IconName::Pencil)
404 .size(IconSize::XSmall)
405 .color(Color::Muted),
406 )
407 .child(
408 div()
409 .text_size(rems(0.8125))
410 .child(self.path.display().to_string())
411 .ml_1p5()
412 .mr_0p5(),
413 )
414 .child(
415 Icon::new(IconName::ArrowUpRight)
416 .size(IconSize::XSmall)
417 .color(Color::Ignored),
418 ),
419 )
420 .on_click({
421 let path = self.path.clone();
422 let workspace = workspace.clone();
423 move |_, window, cx| {
424 workspace
425 .update(cx, {
426 |workspace, cx| {
427 let Some(project_path) =
428 workspace.project().read(cx).find_project_path(&path, cx)
429 else {
430 return;
431 };
432 let open_task =
433 workspace.open_path(project_path, None, true, window, cx);
434 window
435 .spawn(cx, async move |cx| {
436 let item = open_task.await?;
437 if let Some(active_editor) = item.downcast::<Editor>() {
438 active_editor
439 .update_in(cx, |editor, window, cx| {
440 editor.go_to_singleton_buffer_point(
441 language::Point::new(0, 0),
442 window,
443 cx,
444 );
445 })
446 .log_err();
447 }
448 anyhow::Ok(())
449 })
450 .detach_and_log_err(cx);
451 }
452 })
453 .ok();
454 }
455 })
456 .into_any_element();
457
458 let codeblock_header_bg = cx
459 .theme()
460 .colors()
461 .element_background
462 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
463
464 let codeblock_header = h_flex()
465 .flex_none()
466 .p_1()
467 .gap_1()
468 .justify_between()
469 .rounded_t_md()
470 .when(!failed, |header| header.bg(codeblock_header_bg))
471 .child(path_label_button)
472 .when(failed, |header| {
473 header.child(
474 h_flex()
475 .gap_1()
476 .child(
477 Icon::new(IconName::Close)
478 .size(IconSize::Small)
479 .color(Color::Error),
480 )
481 .child(
482 Disclosure::new(
483 ("edit-file-error-disclosure", self.editor_unique_id),
484 self.error_expanded,
485 )
486 .opened_icon(IconName::ChevronUp)
487 .closed_icon(IconName::ChevronDown)
488 .on_click(cx.listener(
489 move |this, _event, _window, _cx| {
490 this.error_expanded = !this.error_expanded;
491 },
492 )),
493 ),
494 )
495 })
496 .when(!failed && self.has_diff(), |header| {
497 header.child(
498 Disclosure::new(
499 ("edit-file-disclosure", self.editor_unique_id),
500 self.preview_expanded,
501 )
502 .opened_icon(IconName::ChevronUp)
503 .closed_icon(IconName::ChevronDown)
504 .on_click(cx.listener(
505 move |this, _event, _window, _cx| {
506 this.preview_expanded = !this.preview_expanded;
507 },
508 )),
509 )
510 });
511
512 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
513 let line_height = editor
514 .style()
515 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
516 .unwrap_or_default();
517
518 let element = editor.render(window, cx);
519 (element.into_any_element(), line_height)
520 });
521
522 let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
523 (IconName::ChevronUp, "Collapse Code Block")
524 } else {
525 (IconName::ChevronDown, "Expand Code Block")
526 };
527
528 let gradient_overlay = div()
529 .absolute()
530 .bottom_0()
531 .left_0()
532 .w_full()
533 .h_2_5()
534 .rounded_b_lg()
535 .bg(gpui::linear_gradient(
536 0.,
537 gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
538 gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
539 ));
540
541 let border_color = cx.theme().colors().border.opacity(0.6);
542
543 const DEFAULT_COLLAPSED_LINES: u32 = 10;
544 let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
545
546 let waiting_for_diff = {
547 let styles = [
548 ("w_4_5", (0.1, 0.85), 2000),
549 ("w_1_4", (0.2, 0.75), 2200),
550 ("w_2_4", (0.15, 0.64), 1900),
551 ("w_3_5", (0.25, 0.72), 2300),
552 ("w_2_5", (0.3, 0.56), 1800),
553 ];
554
555 let mut container = v_flex()
556 .p_3()
557 .gap_1p5()
558 .border_t_1()
559 .border_color(border_color)
560 .bg(cx.theme().colors().editor_background);
561
562 for (width_method, pulse_range, duration_ms) in styles.iter() {
563 let (min_opacity, max_opacity) = *pulse_range;
564 let placeholder = match *width_method {
565 "w_4_5" => div().w_3_4(),
566 "w_1_4" => div().w_1_4(),
567 "w_2_4" => div().w_2_4(),
568 "w_3_5" => div().w_3_5(),
569 "w_2_5" => div().w_2_5(),
570 _ => div().w_1_2(),
571 }
572 .id("loading_div")
573 .h_2()
574 .rounded_full()
575 .bg(cx.theme().colors().element_active)
576 .with_animation(
577 "loading_pulsate",
578 Animation::new(Duration::from_millis(*duration_ms))
579 .repeat()
580 .with_easing(pulsating_between(min_opacity, max_opacity)),
581 |label, delta| label.opacity(delta),
582 );
583
584 container = container.child(placeholder);
585 }
586
587 container
588 };
589
590 v_flex()
591 .mb_2()
592 .border_1()
593 .when(failed, |card| card.border_dashed())
594 .border_color(border_color)
595 .rounded_lg()
596 .overflow_hidden()
597 .child(codeblock_header)
598 .when(failed && self.error_expanded, |card| {
599 card.child(
600 v_flex()
601 .p_2()
602 .gap_1()
603 .border_t_1()
604 .border_dashed()
605 .border_color(border_color)
606 .bg(cx.theme().colors().editor_background)
607 .rounded_b_md()
608 .child(
609 Label::new("Error")
610 .size(LabelSize::XSmall)
611 .color(Color::Error),
612 )
613 .child(
614 div()
615 .rounded_md()
616 .text_ui_sm(cx)
617 .bg(cx.theme().colors().editor_background)
618 .children(
619 error_message
620 .map(|error| div().child(error).into_any_element()),
621 ),
622 ),
623 )
624 })
625 .when(!self.has_diff() && !failed, |card| {
626 card.child(waiting_for_diff)
627 })
628 .when(
629 !failed && self.preview_expanded && self.has_diff(),
630 |card| {
631 card.child(
632 v_flex()
633 .relative()
634 .h_full()
635 .when(!self.full_height_expanded, |editor_container| {
636 editor_container
637 .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
638 })
639 .overflow_hidden()
640 .border_t_1()
641 .border_color(border_color)
642 .bg(cx.theme().colors().editor_background)
643 .child(div().pl_1().child(editor))
644 .when(
645 !self.full_height_expanded && is_collapsible,
646 |editor_container| editor_container.child(gradient_overlay),
647 ),
648 )
649 .when(is_collapsible, |editor_container| {
650 editor_container.child(
651 h_flex()
652 .id(("expand-button", self.editor_unique_id))
653 .flex_none()
654 .cursor_pointer()
655 .h_5()
656 .justify_center()
657 .rounded_b_md()
658 .border_t_1()
659 .border_color(border_color)
660 .bg(cx.theme().colors().editor_background)
661 .hover(|style| {
662 style.bg(cx.theme().colors().element_hover.opacity(0.1))
663 })
664 .child(
665 Icon::new(full_height_icon)
666 .size(IconSize::Small)
667 .color(Color::Muted),
668 )
669 .tooltip(Tooltip::text(full_height_tooltip_label))
670 .on_click(cx.listener(move |this, _event, _window, _cx| {
671 this.full_height_expanded = !this.full_height_expanded;
672 })),
673 )
674 })
675 },
676 )
677 }
678}
679
680async fn build_buffer(
681 mut text: String,
682 path: Arc<Path>,
683 language_registry: &Arc<language::LanguageRegistry>,
684 cx: &mut AsyncApp,
685) -> Result<Entity<Buffer>> {
686 let line_ending = LineEnding::detect(&text);
687 LineEnding::normalize(&mut text);
688 let text = Rope::from(text);
689 let language = cx
690 .update(|_cx| language_registry.language_for_file_path(&path))?
691 .await
692 .ok();
693 let buffer = cx.new(|cx| {
694 let buffer = TextBuffer::new_normalized(
695 0,
696 cx.entity_id().as_non_zero_u64().into(),
697 line_ending,
698 text,
699 );
700 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
701 buffer.set_language(language, cx);
702 buffer
703 })?;
704 Ok(buffer)
705}
706
707async fn build_buffer_diff(
708 mut old_text: String,
709 buffer: &Entity<Buffer>,
710 language_registry: &Arc<LanguageRegistry>,
711 cx: &mut AsyncApp,
712) -> Result<Entity<BufferDiff>> {
713 LineEnding::normalize(&mut old_text);
714
715 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
716
717 let base_buffer = cx
718 .update(|cx| {
719 Buffer::build_snapshot(
720 old_text.clone().into(),
721 buffer.language().cloned(),
722 Some(language_registry.clone()),
723 cx,
724 )
725 })?
726 .await;
727
728 let diff_snapshot = cx
729 .update(|cx| {
730 BufferDiffSnapshot::new_with_base_buffer(
731 buffer.text.clone(),
732 Some(old_text.into()),
733 base_buffer,
734 cx,
735 )
736 })?
737 .await;
738
739 cx.new(|cx| {
740 let mut diff = BufferDiff::new(&buffer.text, cx);
741 diff.set_snapshot(diff_snapshot, &buffer.text, cx);
742 diff
743 })
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749 use serde_json::json;
750
751 #[test]
752 fn still_streaming_ui_text_with_path() {
753 let input = json!({
754 "path": "src/main.rs",
755 "display_description": "",
756 "old_string": "old code",
757 "new_string": "new code"
758 });
759
760 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
761 }
762
763 #[test]
764 fn still_streaming_ui_text_with_description() {
765 let input = json!({
766 "path": "",
767 "display_description": "Fix error handling",
768 "old_string": "old code",
769 "new_string": "new code"
770 });
771
772 assert_eq!(
773 EditFileTool.still_streaming_ui_text(&input),
774 "Fix error handling",
775 );
776 }
777
778 #[test]
779 fn still_streaming_ui_text_with_path_and_description() {
780 let input = json!({
781 "path": "src/main.rs",
782 "display_description": "Fix error handling",
783 "old_string": "old code",
784 "new_string": "new code"
785 });
786
787 assert_eq!(
788 EditFileTool.still_streaming_ui_text(&input),
789 "Fix error handling",
790 );
791 }
792
793 #[test]
794 fn still_streaming_ui_text_no_path_or_description() {
795 let input = json!({
796 "path": "",
797 "display_description": "",
798 "old_string": "old code",
799 "new_string": "new code"
800 });
801
802 assert_eq!(
803 EditFileTool.still_streaming_ui_text(&input),
804 DEFAULT_UI_TEXT,
805 );
806 }
807
808 #[test]
809 fn still_streaming_ui_text_with_null() {
810 let input = serde_json::Value::Null;
811
812 assert_eq!(
813 EditFileTool.still_streaming_ui_text(&input),
814 DEFAULT_UI_TEXT,
815 );
816 }
817}