1use crate::{
2 Templates,
3 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
4 schema::json_schema_for,
5};
6use anyhow::{Context as _, 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, MinimapVisibility, MultiBuffer, PathKey};
13use futures::StreamExt;
14use gpui::{
15 Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
16 TextStyleRefinement, WeakEntity, pulsating_between,
17};
18use indoc::formatdoc;
19use language::{
20 Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
21 TextBuffer, language_settings::SoftWrap,
22};
23use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
24use markdown::{Markdown, MarkdownElement, MarkdownStyle};
25use project::{Project, ProjectPath};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use settings::Settings;
29use std::{
30 cmp::Reverse,
31 ops::Range,
32 path::{Path, PathBuf},
33 sync::Arc,
34 time::Duration,
35};
36use theme::ThemeSettings;
37use ui::{Disclosure, Tooltip, prelude::*};
38use util::ResultExt;
39use workspace::Workspace;
40
41pub struct EditFileTool;
42
43#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
44pub struct EditFileToolInput {
45 /// A one-line, user-friendly markdown description of the edit. This will be
46 /// shown in the UI and also passed to another model to perform the edit.
47 ///
48 /// Be terse, but also descriptive in what you want to achieve with this
49 /// edit. Avoid generic instructions.
50 ///
51 /// NEVER mention the file path in this description.
52 ///
53 /// <example>Fix API endpoint URLs</example>
54 /// <example>Update copyright year in `page_footer`</example>
55 ///
56 /// Make sure to include this field before all the others in the input object
57 /// so that we can display it immediately.
58 pub display_description: String,
59
60 /// The full path of the file to create or modify in the project.
61 ///
62 /// WARNING: When specifying which file path need changing, you MUST
63 /// start each path with one of the project's root directories.
64 ///
65 /// The following examples assume we have two root directories in the project:
66 /// - backend
67 /// - frontend
68 ///
69 /// <example>
70 /// `backend/src/main.rs`
71 ///
72 /// Notice how the file path starts with root-1. Without that, the path
73 /// would be ambiguous and the call would fail!
74 /// </example>
75 ///
76 /// <example>
77 /// `frontend/db.js`
78 /// </example>
79 pub path: PathBuf,
80
81 /// The mode of operation on the file. Possible values:
82 /// - 'edit': Make granular edits to an existing file.
83 /// - 'create': Create a new file if it doesn't exist.
84 /// - 'overwrite': Replace the entire contents of an existing file.
85 ///
86 /// When a file already exists or you just created it, prefer editing
87 /// it as opposed to recreating it from scratch.
88 pub mode: EditFileMode,
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "lowercase")]
93pub enum EditFileMode {
94 Edit,
95 Create,
96 Overwrite,
97}
98
99#[derive(Debug, Serialize, Deserialize, JsonSchema)]
100pub struct EditFileToolOutput {
101 pub original_path: PathBuf,
102 pub new_text: String,
103 pub old_text: Arc<String>,
104 pub raw_output: Option<EditAgentOutput>,
105}
106
107#[derive(Debug, Serialize, Deserialize, JsonSchema)]
108struct PartialInput {
109 #[serde(default)]
110 path: String,
111 #[serde(default)]
112 display_description: String,
113}
114
115const DEFAULT_UI_TEXT: &str = "Editing file";
116
117impl Tool for EditFileTool {
118 fn name(&self) -> String {
119 "edit_file".into()
120 }
121
122 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
123 false
124 }
125
126 fn description(&self) -> String {
127 include_str!("edit_file_tool/description.md").to_string()
128 }
129
130 fn icon(&self) -> IconName {
131 IconName::Pencil
132 }
133
134 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
135 json_schema_for::<EditFileToolInput>(format)
136 }
137
138 fn ui_text(&self, input: &serde_json::Value) -> String {
139 match serde_json::from_value::<EditFileToolInput>(input.clone()) {
140 Ok(input) => input.display_description,
141 Err(_) => "Editing file".to_string(),
142 }
143 }
144
145 fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
146 if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
147 let description = input.display_description.trim();
148 if !description.is_empty() {
149 return description.to_string();
150 }
151
152 let path = input.path.trim();
153 if !path.is_empty() {
154 return path.to_string();
155 }
156 }
157
158 DEFAULT_UI_TEXT.to_string()
159 }
160
161 fn run(
162 self: Arc<Self>,
163 input: serde_json::Value,
164 request: Arc<LanguageModelRequest>,
165 project: Entity<Project>,
166 action_log: Entity<ActionLog>,
167 model: Arc<dyn LanguageModel>,
168 window: Option<AnyWindowHandle>,
169 cx: &mut App,
170 ) -> ToolResult {
171 let input = match serde_json::from_value::<EditFileToolInput>(input) {
172 Ok(input) => input,
173 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
174 };
175
176 let project_path = match resolve_path(&input, project.clone(), cx) {
177 Ok(path) => path,
178 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
179 };
180
181 let card = window.and_then(|window| {
182 window
183 .update(cx, |_, window, cx| {
184 cx.new(|cx| {
185 EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
186 })
187 })
188 .ok()
189 });
190
191 let card_clone = card.clone();
192 let task = cx.spawn(async move |cx: &mut AsyncApp| {
193 let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new());
194
195 let buffer = project
196 .update(cx, |project, cx| {
197 project.open_buffer(project_path.clone(), cx)
198 })?
199 .await?;
200
201 let 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 { Arc::new(old_snapshot.text()) }
206 })
207 .await;
208
209 if let Some(card) = card_clone.as_ref() {
210 card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?;
211 }
212
213 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
214 edit_agent.edit(
215 buffer.clone(),
216 input.display_description.clone(),
217 &request,
218 cx,
219 )
220 } else {
221 edit_agent.overwrite(
222 buffer.clone(),
223 input.display_description.clone(),
224 &request,
225 cx,
226 )
227 };
228
229 let mut hallucinated_old_text = false;
230 while let Some(event) = events.next().await {
231 match event {
232 EditAgentOutputEvent::Edited => {
233 if let Some(card) = card_clone.as_ref() {
234 card.update(cx, |card, cx| card.update_diff(cx))?;
235 }
236 }
237 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
238 EditAgentOutputEvent::ResolvingEditRange(range) => {
239 if let Some(card) = card_clone.as_ref() {
240 card.update(cx, |card, cx| card.reveal_range(range, cx))?;
241 }
242 }
243 }
244 }
245 let agent_output = output.await?;
246
247 project
248 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
249 .await?;
250
251 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
252 let new_text = cx.background_spawn({
253 let new_snapshot = new_snapshot.clone();
254 async move { new_snapshot.text() }
255 });
256 let diff = cx.background_spawn(async move {
257 language::unified_diff(&old_snapshot.text(), &new_snapshot.text())
258 });
259 let (new_text, diff) = futures::join!(new_text, diff);
260
261 let output = EditFileToolOutput {
262 original_path: project_path.path.to_path_buf(),
263 new_text: new_text.clone(),
264 old_text,
265 raw_output: Some(agent_output),
266 };
267
268 if let Some(card) = card_clone {
269 card.update(cx, |card, cx| {
270 card.update_diff(cx);
271 card.finalize(cx)
272 })
273 .log_err();
274 }
275
276 let input_path = input.path.display();
277 if diff.is_empty() {
278 anyhow::ensure!(
279 !hallucinated_old_text,
280 formatdoc! {"
281 Some edits were produced but none of them could be applied.
282 Read the relevant sections of {input_path} again so that
283 I can perform the requested edits.
284 "}
285 );
286 Ok(ToolResultOutput {
287 content: ToolResultContent::Text("No edits were made.".into()),
288 output: serde_json::to_value(output).ok(),
289 })
290 } else {
291 Ok(ToolResultOutput {
292 content: ToolResultContent::Text(format!(
293 "Edited {}:\n\n```diff\n{}\n```",
294 input_path, diff
295 )),
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 EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx)
321 });
322
323 cx.spawn({
324 let path: Arc<Path> = output.original_path.into();
325 let language_registry = project.read(cx).languages().clone();
326 let card = card.clone();
327 async move |cx| {
328 let buffer =
329 build_buffer(output.new_text, path.clone(), &language_registry, cx).await?;
330 let buffer_diff =
331 build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx)
332 .await?;
333 card.update(cx, |card, cx| {
334 card.multibuffer.update(cx, |multibuffer, cx| {
335 let snapshot = buffer.read(cx).snapshot();
336 let diff = buffer_diff.read(cx);
337 let diff_hunk_ranges = diff
338 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
339 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
340 .collect::<Vec<_>>();
341
342 multibuffer.set_excerpts_for_path(
343 PathKey::for_buffer(&buffer, cx),
344 buffer,
345 diff_hunk_ranges,
346 editor::DEFAULT_MULTIBUFFER_CONTEXT,
347 cx,
348 );
349 multibuffer.add_diff(buffer_diff, cx);
350 let end = multibuffer.len(cx);
351 card.total_lines =
352 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1);
353 });
354
355 cx.notify();
356 })?;
357 anyhow::Ok(())
358 }
359 })
360 .detach_and_log_err(cx);
361
362 Some(card.into())
363 }
364}
365
366/// Validate that the file path is valid, meaning:
367///
368/// - For `edit` and `overwrite`, the path must point to an existing file.
369/// - For `create`, the file must not already exist, but it's parent dir must exist.
370fn resolve_path(
371 input: &EditFileToolInput,
372 project: Entity<Project>,
373 cx: &mut App,
374) -> Result<ProjectPath> {
375 let project = project.read(cx);
376
377 match input.mode {
378 EditFileMode::Edit | EditFileMode::Overwrite => {
379 let path = project
380 .find_project_path(&input.path, cx)
381 .context("Can't edit file: path not found")?;
382
383 let entry = project
384 .entry_for_path(&path, cx)
385 .context("Can't edit file: path not found")?;
386
387 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
388 Ok(path)
389 }
390
391 EditFileMode::Create => {
392 if let Some(path) = project.find_project_path(&input.path, cx) {
393 anyhow::ensure!(
394 project.entry_for_path(&path, cx).is_none(),
395 "Can't create file: file already exists"
396 );
397 }
398
399 let parent_path = input
400 .path
401 .parent()
402 .context("Can't create file: incorrect path")?;
403
404 let parent_project_path = project.find_project_path(&parent_path, cx);
405
406 let parent_entry = parent_project_path
407 .as_ref()
408 .and_then(|path| project.entry_for_path(&path, cx))
409 .context("Can't create file: parent directory doesn't exist")?;
410
411 anyhow::ensure!(
412 parent_entry.is_dir(),
413 "Can't create file: parent is not a directory"
414 );
415
416 let file_name = input
417 .path
418 .file_name()
419 .context("Can't create file: invalid filename")?;
420
421 let new_file_path = parent_project_path.map(|parent| ProjectPath {
422 path: Arc::from(parent.path.join(file_name)),
423 ..parent
424 });
425
426 new_file_path.context("Can't create file")
427 }
428 }
429}
430
431pub struct EditFileToolCard {
432 path: PathBuf,
433 editor: Entity<Editor>,
434 multibuffer: Entity<MultiBuffer>,
435 project: Entity<Project>,
436 buffer: Option<Entity<Buffer>>,
437 base_text: Option<Arc<String>>,
438 buffer_diff: Option<Entity<BufferDiff>>,
439 revealed_ranges: Vec<Range<Anchor>>,
440 diff_task: Option<Task<Result<()>>>,
441 preview_expanded: bool,
442 error_expanded: Option<Entity<Markdown>>,
443 full_height_expanded: bool,
444 total_lines: Option<u32>,
445}
446
447impl EditFileToolCard {
448 pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
449 let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
450 let editor = cx.new(|cx| {
451 let mut editor = Editor::new(
452 EditorMode::Full {
453 scale_ui_elements_with_buffer_font_size: false,
454 show_active_line_background: false,
455 sized_by_content: true,
456 },
457 multibuffer.clone(),
458 Some(project.clone()),
459 window,
460 cx,
461 );
462 editor.set_show_gutter(false, cx);
463 editor.disable_inline_diagnostics();
464 editor.disable_expand_excerpt_buttons(cx);
465 // Keep horizontal scrollbar so user can scroll horizontally if needed
466 editor.set_show_vertical_scrollbar(false, cx);
467 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
468 editor.set_soft_wrap_mode(SoftWrap::None, cx);
469 editor.scroll_manager.set_forbid_vertical_scroll(true);
470 editor.set_show_indent_guides(false, cx);
471 editor.set_read_only(true);
472 editor.set_show_breakpoints(false, cx);
473 editor.set_show_code_actions(false, cx);
474 editor.set_show_git_diff_gutter(false, cx);
475 editor.set_expand_all_diff_hunks(cx);
476 editor
477 });
478 Self {
479 path,
480 project,
481 editor,
482 multibuffer,
483 buffer: None,
484 base_text: None,
485 buffer_diff: None,
486 revealed_ranges: Vec::new(),
487 diff_task: None,
488 preview_expanded: true,
489 error_expanded: None,
490 full_height_expanded: true,
491 total_lines: None,
492 }
493 }
494
495 pub fn initialize(&mut self, buffer: Entity<Buffer>, cx: &mut App) {
496 let buffer_snapshot = buffer.read(cx).snapshot();
497 let base_text = buffer_snapshot.text();
498 let language_registry = buffer.read(cx).language_registry();
499 let text_snapshot = buffer.read(cx).text_snapshot();
500
501 // Create a buffer diff with the current text as the base
502 let buffer_diff = cx.new(|cx| {
503 let mut diff = BufferDiff::new(&text_snapshot, cx);
504 let _ = diff.set_base_text(
505 buffer_snapshot.clone(),
506 language_registry,
507 text_snapshot,
508 cx,
509 );
510 diff
511 });
512
513 self.buffer = Some(buffer.clone());
514 self.base_text = Some(base_text.into());
515 self.buffer_diff = Some(buffer_diff.clone());
516
517 // Add the diff to the multibuffer
518 self.multibuffer
519 .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx));
520 }
521
522 pub fn is_loading(&self) -> bool {
523 self.total_lines.is_none()
524 }
525
526 pub fn update_diff(&mut self, cx: &mut Context<Self>) {
527 let Some(buffer) = self.buffer.as_ref() else {
528 return;
529 };
530 let Some(buffer_diff) = self.buffer_diff.as_ref() else {
531 return;
532 };
533
534 let buffer = buffer.clone();
535 let buffer_diff = buffer_diff.clone();
536 let base_text = self.base_text.clone();
537 self.diff_task = Some(cx.spawn(async move |this, cx| {
538 let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
539 let diff_snapshot = BufferDiff::update_diff(
540 buffer_diff.clone(),
541 text_snapshot.clone(),
542 base_text,
543 false,
544 false,
545 None,
546 None,
547 cx,
548 )
549 .await?;
550 buffer_diff.update(cx, |diff, cx| {
551 diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
552 })?;
553 this.update(cx, |this, cx| this.update_visible_ranges(cx))
554 }));
555 }
556
557 pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
558 self.revealed_ranges.push(range);
559 self.update_visible_ranges(cx);
560 }
561
562 fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
563 let Some(buffer) = self.buffer.as_ref() else {
564 return;
565 };
566
567 let ranges = self.excerpt_ranges(cx);
568 self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| {
569 multibuffer.set_excerpts_for_path(
570 PathKey::for_buffer(buffer, cx),
571 buffer.clone(),
572 ranges,
573 editor::DEFAULT_MULTIBUFFER_CONTEXT,
574 cx,
575 );
576 let end = multibuffer.len(cx);
577 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
578 });
579 cx.notify();
580 }
581
582 fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
583 let Some(buffer) = self.buffer.as_ref() else {
584 return Vec::new();
585 };
586 let Some(diff) = self.buffer_diff.as_ref() else {
587 return Vec::new();
588 };
589
590 let buffer = buffer.read(cx);
591 let diff = diff.read(cx);
592 let mut ranges = diff
593 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
594 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
595 .collect::<Vec<_>>();
596 ranges.extend(
597 self.revealed_ranges
598 .iter()
599 .map(|range| range.to_point(&buffer)),
600 );
601 ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
602
603 // Merge adjacent ranges
604 let mut ranges = ranges.into_iter().peekable();
605 let mut merged_ranges = Vec::new();
606 while let Some(mut range) = ranges.next() {
607 while let Some(next_range) = ranges.peek() {
608 if range.end >= next_range.start {
609 range.end = range.end.max(next_range.end);
610 ranges.next();
611 } else {
612 break;
613 }
614 }
615
616 merged_ranges.push(range);
617 }
618 merged_ranges
619 }
620
621 pub fn finalize(&mut self, cx: &mut Context<Self>) -> Result<()> {
622 let ranges = self.excerpt_ranges(cx);
623 let buffer = self.buffer.take().context("card was already finalized")?;
624 let base_text = self
625 .base_text
626 .take()
627 .context("card was already finalized")?;
628 let language_registry = self.project.read(cx).languages().clone();
629
630 // Replace the buffer in the multibuffer with the snapshot
631 let buffer = cx.new(|cx| {
632 let language = buffer.read(cx).language().cloned();
633 let buffer = TextBuffer::new_normalized(
634 0,
635 cx.entity_id().as_non_zero_u64().into(),
636 buffer.read(cx).line_ending(),
637 buffer.read(cx).as_rope().clone(),
638 );
639 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
640 buffer.set_language(language, cx);
641 buffer
642 });
643
644 let buffer_diff = cx.spawn({
645 let buffer = buffer.clone();
646 let language_registry = language_registry.clone();
647 async move |_this, cx| {
648 build_buffer_diff(base_text, &buffer, &language_registry, cx).await
649 }
650 });
651
652 cx.spawn(async move |this, cx| {
653 let buffer_diff = buffer_diff.await?;
654 this.update(cx, |this, cx| {
655 this.multibuffer.update(cx, |multibuffer, cx| {
656 let path_key = PathKey::for_buffer(&buffer, cx);
657 multibuffer.clear(cx);
658 multibuffer.set_excerpts_for_path(
659 path_key,
660 buffer,
661 ranges,
662 editor::DEFAULT_MULTIBUFFER_CONTEXT,
663 cx,
664 );
665 multibuffer.add_diff(buffer_diff.clone(), cx);
666 });
667
668 cx.notify();
669 })
670 })
671 .detach_and_log_err(cx);
672 Ok(())
673 }
674}
675
676impl ToolCard for EditFileToolCard {
677 fn render(
678 &mut self,
679 status: &ToolUseStatus,
680 window: &mut Window,
681 workspace: WeakEntity<Workspace>,
682 cx: &mut Context<Self>,
683 ) -> impl IntoElement {
684 let error_message = match status {
685 ToolUseStatus::Error(err) => Some(err),
686 _ => None,
687 };
688
689 let path_label_button = h_flex()
690 .id(("edit-tool-path-label-button", self.editor.entity_id()))
691 .w_full()
692 .max_w_full()
693 .px_1()
694 .gap_0p5()
695 .cursor_pointer()
696 .rounded_sm()
697 .opacity(0.8)
698 .hover(|label| {
699 label
700 .opacity(1.)
701 .bg(cx.theme().colors().element_hover.opacity(0.5))
702 })
703 .tooltip(Tooltip::text("Jump to File"))
704 .child(
705 h_flex()
706 .child(
707 Icon::new(IconName::Pencil)
708 .size(IconSize::XSmall)
709 .color(Color::Muted),
710 )
711 .child(
712 div()
713 .text_size(rems(0.8125))
714 .child(self.path.display().to_string())
715 .ml_1p5()
716 .mr_0p5(),
717 )
718 .child(
719 Icon::new(IconName::ArrowUpRight)
720 .size(IconSize::XSmall)
721 .color(Color::Ignored),
722 ),
723 )
724 .on_click({
725 let path = self.path.clone();
726 let workspace = workspace.clone();
727 move |_, window, cx| {
728 workspace
729 .update(cx, {
730 |workspace, cx| {
731 let Some(project_path) =
732 workspace.project().read(cx).find_project_path(&path, cx)
733 else {
734 return;
735 };
736 let open_task =
737 workspace.open_path(project_path, None, true, window, cx);
738 window
739 .spawn(cx, async move |cx| {
740 let item = open_task.await?;
741 if let Some(active_editor) = item.downcast::<Editor>() {
742 active_editor
743 .update_in(cx, |editor, window, cx| {
744 editor.go_to_singleton_buffer_point(
745 language::Point::new(0, 0),
746 window,
747 cx,
748 );
749 })
750 .log_err();
751 }
752 anyhow::Ok(())
753 })
754 .detach_and_log_err(cx);
755 }
756 })
757 .ok();
758 }
759 })
760 .into_any_element();
761
762 let codeblock_header_bg = cx
763 .theme()
764 .colors()
765 .element_background
766 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
767
768 let codeblock_header = h_flex()
769 .flex_none()
770 .p_1()
771 .gap_1()
772 .justify_between()
773 .rounded_t_md()
774 .when(error_message.is_none(), |header| {
775 header.bg(codeblock_header_bg)
776 })
777 .child(path_label_button)
778 .when_some(error_message, |header, error_message| {
779 header.child(
780 h_flex()
781 .gap_1()
782 .child(
783 Icon::new(IconName::Close)
784 .size(IconSize::Small)
785 .color(Color::Error),
786 )
787 .child(
788 Disclosure::new(
789 ("edit-file-error-disclosure", self.editor.entity_id()),
790 self.error_expanded.is_some(),
791 )
792 .opened_icon(IconName::ChevronUp)
793 .closed_icon(IconName::ChevronDown)
794 .on_click(cx.listener({
795 let error_message = error_message.clone();
796
797 move |this, _event, _window, cx| {
798 if this.error_expanded.is_some() {
799 this.error_expanded.take();
800 } else {
801 this.error_expanded = Some(cx.new(|cx| {
802 Markdown::new(error_message.clone(), None, None, cx)
803 }))
804 }
805 cx.notify();
806 }
807 })),
808 ),
809 )
810 })
811 .when(error_message.is_none() && !self.is_loading(), |header| {
812 header.child(
813 Disclosure::new(
814 ("edit-file-disclosure", self.editor.entity_id()),
815 self.preview_expanded,
816 )
817 .opened_icon(IconName::ChevronUp)
818 .closed_icon(IconName::ChevronDown)
819 .on_click(cx.listener(
820 move |this, _event, _window, _cx| {
821 this.preview_expanded = !this.preview_expanded;
822 },
823 )),
824 )
825 });
826
827 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
828 let line_height = editor
829 .style()
830 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
831 .unwrap_or_default();
832
833 editor.set_text_style_refinement(TextStyleRefinement {
834 font_size: Some(
835 TextSize::Small
836 .rems(cx)
837 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
838 .into(),
839 ),
840 ..TextStyleRefinement::default()
841 });
842 let element = editor.render(window, cx);
843 (element.into_any_element(), line_height)
844 });
845
846 let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
847 (IconName::ChevronUp, "Collapse Code Block")
848 } else {
849 (IconName::ChevronDown, "Expand Code Block")
850 };
851
852 let gradient_overlay =
853 div()
854 .absolute()
855 .bottom_0()
856 .left_0()
857 .w_full()
858 .h_2_5()
859 .bg(gpui::linear_gradient(
860 0.,
861 gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
862 gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
863 ));
864
865 let border_color = cx.theme().colors().border.opacity(0.6);
866
867 const DEFAULT_COLLAPSED_LINES: u32 = 10;
868 let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
869
870 let waiting_for_diff = {
871 let styles = [
872 ("w_4_5", (0.1, 0.85), 2000),
873 ("w_1_4", (0.2, 0.75), 2200),
874 ("w_2_4", (0.15, 0.64), 1900),
875 ("w_3_5", (0.25, 0.72), 2300),
876 ("w_2_5", (0.3, 0.56), 1800),
877 ];
878
879 let mut container = v_flex()
880 .p_3()
881 .gap_1()
882 .border_t_1()
883 .rounded_b_md()
884 .border_color(border_color)
885 .bg(cx.theme().colors().editor_background);
886
887 for (width_method, pulse_range, duration_ms) in styles.iter() {
888 let (min_opacity, max_opacity) = *pulse_range;
889 let placeholder = match *width_method {
890 "w_4_5" => div().w_3_4(),
891 "w_1_4" => div().w_1_4(),
892 "w_2_4" => div().w_2_4(),
893 "w_3_5" => div().w_3_5(),
894 "w_2_5" => div().w_2_5(),
895 _ => div().w_1_2(),
896 }
897 .id("loading_div")
898 .h_1()
899 .rounded_full()
900 .bg(cx.theme().colors().element_active)
901 .with_animation(
902 "loading_pulsate",
903 Animation::new(Duration::from_millis(*duration_ms))
904 .repeat()
905 .with_easing(pulsating_between(min_opacity, max_opacity)),
906 |label, delta| label.opacity(delta),
907 );
908
909 container = container.child(placeholder);
910 }
911
912 container
913 };
914
915 v_flex()
916 .mb_2()
917 .border_1()
918 .when(error_message.is_some(), |card| card.border_dashed())
919 .border_color(border_color)
920 .rounded_md()
921 .overflow_hidden()
922 .child(codeblock_header)
923 .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
924 card.child(
925 v_flex()
926 .p_2()
927 .gap_1()
928 .border_t_1()
929 .border_dashed()
930 .border_color(border_color)
931 .bg(cx.theme().colors().editor_background)
932 .rounded_b_md()
933 .child(
934 Label::new("Error")
935 .size(LabelSize::XSmall)
936 .color(Color::Error),
937 )
938 .child(
939 div()
940 .rounded_md()
941 .text_ui_sm(cx)
942 .bg(cx.theme().colors().editor_background)
943 .child(MarkdownElement::new(
944 error_markdown.clone(),
945 markdown_style(window, cx),
946 )),
947 ),
948 )
949 })
950 .when(self.is_loading() && error_message.is_none(), |card| {
951 card.child(waiting_for_diff)
952 })
953 .when(self.preview_expanded && !self.is_loading(), |card| {
954 card.child(
955 v_flex()
956 .relative()
957 .h_full()
958 .when(!self.full_height_expanded, |editor_container| {
959 editor_container
960 .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
961 })
962 .overflow_hidden()
963 .border_t_1()
964 .border_color(border_color)
965 .bg(cx.theme().colors().editor_background)
966 .child(editor)
967 .when(
968 !self.full_height_expanded && is_collapsible,
969 |editor_container| editor_container.child(gradient_overlay),
970 ),
971 )
972 .when(is_collapsible, |card| {
973 card.child(
974 h_flex()
975 .id(("expand-button", self.editor.entity_id()))
976 .flex_none()
977 .cursor_pointer()
978 .h_5()
979 .justify_center()
980 .border_t_1()
981 .rounded_b_md()
982 .border_color(border_color)
983 .bg(cx.theme().colors().editor_background)
984 .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
985 .child(
986 Icon::new(full_height_icon)
987 .size(IconSize::Small)
988 .color(Color::Muted),
989 )
990 .tooltip(Tooltip::text(full_height_tooltip_label))
991 .on_click(cx.listener(move |this, _event, _window, _cx| {
992 this.full_height_expanded = !this.full_height_expanded;
993 })),
994 )
995 })
996 })
997 }
998}
999
1000fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1001 let theme_settings = ThemeSettings::get_global(cx);
1002 let ui_font_size = TextSize::Default.rems(cx);
1003 let mut text_style = window.text_style();
1004
1005 text_style.refine(&TextStyleRefinement {
1006 font_family: Some(theme_settings.ui_font.family.clone()),
1007 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1008 font_features: Some(theme_settings.ui_font.features.clone()),
1009 font_size: Some(ui_font_size.into()),
1010 color: Some(cx.theme().colors().text),
1011 ..Default::default()
1012 });
1013
1014 MarkdownStyle {
1015 base_text_style: text_style.clone(),
1016 selection_background_color: cx.theme().players().local().selection,
1017 ..Default::default()
1018 }
1019}
1020
1021async fn build_buffer(
1022 mut text: String,
1023 path: Arc<Path>,
1024 language_registry: &Arc<language::LanguageRegistry>,
1025 cx: &mut AsyncApp,
1026) -> Result<Entity<Buffer>> {
1027 let line_ending = LineEnding::detect(&text);
1028 LineEnding::normalize(&mut text);
1029 let text = Rope::from(text);
1030 let language = cx
1031 .update(|_cx| language_registry.language_for_file_path(&path))?
1032 .await
1033 .ok();
1034 let buffer = cx.new(|cx| {
1035 let buffer = TextBuffer::new_normalized(
1036 0,
1037 cx.entity_id().as_non_zero_u64().into(),
1038 line_ending,
1039 text,
1040 );
1041 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
1042 buffer.set_language(language, cx);
1043 buffer
1044 })?;
1045 Ok(buffer)
1046}
1047
1048async fn build_buffer_diff(
1049 old_text: Arc<String>,
1050 buffer: &Entity<Buffer>,
1051 language_registry: &Arc<LanguageRegistry>,
1052 cx: &mut AsyncApp,
1053) -> Result<Entity<BufferDiff>> {
1054 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
1055
1056 let old_text_rope = cx
1057 .background_spawn({
1058 let old_text = old_text.clone();
1059 async move { Rope::from(old_text.as_str()) }
1060 })
1061 .await;
1062 let base_buffer = cx
1063 .update(|cx| {
1064 Buffer::build_snapshot(
1065 old_text_rope,
1066 buffer.language().cloned(),
1067 Some(language_registry.clone()),
1068 cx,
1069 )
1070 })?
1071 .await;
1072
1073 let diff_snapshot = cx
1074 .update(|cx| {
1075 BufferDiffSnapshot::new_with_base_buffer(
1076 buffer.text.clone(),
1077 Some(old_text),
1078 base_buffer,
1079 cx,
1080 )
1081 })?
1082 .await;
1083
1084 let secondary_diff = cx.new(|cx| {
1085 let mut diff = BufferDiff::new(&buffer, cx);
1086 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
1087 diff
1088 })?;
1089
1090 cx.new(|cx| {
1091 let mut diff = BufferDiff::new(&buffer.text, cx);
1092 diff.set_snapshot(diff_snapshot, &buffer, cx);
1093 diff.set_secondary_diff(secondary_diff);
1094 diff
1095 })
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101 use client::TelemetrySettings;
1102 use fs::FakeFs;
1103 use gpui::TestAppContext;
1104 use language_model::fake_provider::FakeLanguageModel;
1105 use serde_json::json;
1106 use settings::SettingsStore;
1107 use util::path;
1108
1109 #[gpui::test]
1110 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
1111 init_test(cx);
1112
1113 let fs = FakeFs::new(cx.executor());
1114 fs.insert_tree("/root", json!({})).await;
1115 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1116 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1117 let model = Arc::new(FakeLanguageModel::default());
1118 let result = cx
1119 .update(|cx| {
1120 let input = serde_json::to_value(EditFileToolInput {
1121 display_description: "Some edit".into(),
1122 path: "root/nonexistent_file.txt".into(),
1123 mode: EditFileMode::Edit,
1124 })
1125 .unwrap();
1126 Arc::new(EditFileTool)
1127 .run(
1128 input,
1129 Arc::default(),
1130 project.clone(),
1131 action_log,
1132 model,
1133 None,
1134 cx,
1135 )
1136 .output
1137 })
1138 .await;
1139 assert_eq!(
1140 result.unwrap_err().to_string(),
1141 "Can't edit file: path not found"
1142 );
1143 }
1144
1145 #[gpui::test]
1146 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
1147 let mode = &EditFileMode::Create;
1148
1149 let result = test_resolve_path(mode, "root/new.txt", cx);
1150 assert_resolved_path_eq(result.await, "new.txt");
1151
1152 let result = test_resolve_path(mode, "new.txt", cx);
1153 assert_resolved_path_eq(result.await, "new.txt");
1154
1155 let result = test_resolve_path(mode, "dir/new.txt", cx);
1156 assert_resolved_path_eq(result.await, "dir/new.txt");
1157
1158 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
1159 assert_eq!(
1160 result.await.unwrap_err().to_string(),
1161 "Can't create file: file already exists"
1162 );
1163
1164 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
1165 assert_eq!(
1166 result.await.unwrap_err().to_string(),
1167 "Can't create file: parent directory doesn't exist"
1168 );
1169 }
1170
1171 #[gpui::test]
1172 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
1173 let mode = &EditFileMode::Edit;
1174
1175 let path_with_root = "root/dir/subdir/existing.txt";
1176 let path_without_root = "dir/subdir/existing.txt";
1177 let result = test_resolve_path(mode, path_with_root, cx);
1178 assert_resolved_path_eq(result.await, path_without_root);
1179
1180 let result = test_resolve_path(mode, path_without_root, cx);
1181 assert_resolved_path_eq(result.await, path_without_root);
1182
1183 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1184 assert_eq!(
1185 result.await.unwrap_err().to_string(),
1186 "Can't edit file: path not found"
1187 );
1188
1189 let result = test_resolve_path(mode, "root/dir", cx);
1190 assert_eq!(
1191 result.await.unwrap_err().to_string(),
1192 "Can't edit file: path is a directory"
1193 );
1194 }
1195
1196 async fn test_resolve_path(
1197 mode: &EditFileMode,
1198 path: &str,
1199 cx: &mut TestAppContext,
1200 ) -> anyhow::Result<ProjectPath> {
1201 init_test(cx);
1202
1203 let fs = FakeFs::new(cx.executor());
1204 fs.insert_tree(
1205 "/root",
1206 json!({
1207 "dir": {
1208 "subdir": {
1209 "existing.txt": "hello"
1210 }
1211 }
1212 }),
1213 )
1214 .await;
1215 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1216
1217 let input = EditFileToolInput {
1218 display_description: "Some edit".into(),
1219 path: path.into(),
1220 mode: mode.clone(),
1221 };
1222
1223 let result = cx.update(|cx| resolve_path(&input, project, cx));
1224 result
1225 }
1226
1227 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1228 let actual = path
1229 .expect("Should return valid path")
1230 .path
1231 .to_str()
1232 .unwrap()
1233 .replace("\\", "/"); // Naive Windows paths normalization
1234 assert_eq!(actual, expected);
1235 }
1236
1237 #[test]
1238 fn still_streaming_ui_text_with_path() {
1239 let input = json!({
1240 "path": "src/main.rs",
1241 "display_description": "",
1242 "old_string": "old code",
1243 "new_string": "new code"
1244 });
1245
1246 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1247 }
1248
1249 #[test]
1250 fn still_streaming_ui_text_with_description() {
1251 let input = json!({
1252 "path": "",
1253 "display_description": "Fix error handling",
1254 "old_string": "old code",
1255 "new_string": "new code"
1256 });
1257
1258 assert_eq!(
1259 EditFileTool.still_streaming_ui_text(&input),
1260 "Fix error handling",
1261 );
1262 }
1263
1264 #[test]
1265 fn still_streaming_ui_text_with_path_and_description() {
1266 let input = json!({
1267 "path": "src/main.rs",
1268 "display_description": "Fix error handling",
1269 "old_string": "old code",
1270 "new_string": "new code"
1271 });
1272
1273 assert_eq!(
1274 EditFileTool.still_streaming_ui_text(&input),
1275 "Fix error handling",
1276 );
1277 }
1278
1279 #[test]
1280 fn still_streaming_ui_text_no_path_or_description() {
1281 let input = json!({
1282 "path": "",
1283 "display_description": "",
1284 "old_string": "old code",
1285 "new_string": "new code"
1286 });
1287
1288 assert_eq!(
1289 EditFileTool.still_streaming_ui_text(&input),
1290 DEFAULT_UI_TEXT,
1291 );
1292 }
1293
1294 #[test]
1295 fn still_streaming_ui_text_with_null() {
1296 let input = serde_json::Value::Null;
1297
1298 assert_eq!(
1299 EditFileTool.still_streaming_ui_text(&input),
1300 DEFAULT_UI_TEXT,
1301 );
1302 }
1303
1304 fn init_test(cx: &mut TestAppContext) {
1305 cx.update(|cx| {
1306 let settings_store = SettingsStore::test(cx);
1307 cx.set_global(settings_store);
1308 language::init(cx);
1309 TelemetrySettings::register(cx);
1310 Project::init_settings(cx);
1311 });
1312 }
1313}