1use crate::{
2 Templates,
3 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
4 schema::json_schema_for,
5 ui::{COLLAPSED_LINES, ToolOutputPreview},
6};
7use anyhow::{Context as _, Result, anyhow};
8use assistant_tool::{
9 ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput,
10 ToolUseStatus,
11};
12use buffer_diff::{BufferDiff, BufferDiffSnapshot};
13use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey, scroll::Autoscroll};
14use futures::StreamExt;
15use gpui::{
16 Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
17 TextStyleRefinement, WeakEntity, pulsating_between, px,
18};
19use indoc::formatdoc;
20use language::{
21 Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
22 TextBuffer,
23 language_settings::{self, FormatOnSave, SoftWrap},
24};
25use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
26use markdown::{Markdown, MarkdownElement, MarkdownStyle};
27use project::{
28 Project, ProjectPath,
29 lsp_store::{FormatTrigger, LspFormatTarget},
30};
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use settings::Settings;
34use std::{
35 cmp::Reverse,
36 collections::HashSet,
37 ops::Range,
38 path::{Path, PathBuf},
39 sync::Arc,
40 time::Duration,
41};
42use theme::ThemeSettings;
43use ui::{Disclosure, Tooltip, prelude::*};
44use util::ResultExt;
45use workspace::Workspace;
46
47pub struct EditFileTool;
48
49#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
50pub struct EditFileToolInput {
51 /// A one-line, user-friendly markdown description of the edit. This will be
52 /// shown in the UI and also passed to another model to perform the edit.
53 ///
54 /// Be terse, but also descriptive in what you want to achieve with this
55 /// edit. Avoid generic instructions.
56 ///
57 /// NEVER mention the file path in this description.
58 ///
59 /// <example>Fix API endpoint URLs</example>
60 /// <example>Update copyright year in `page_footer`</example>
61 ///
62 /// Make sure to include this field before all the others in the input object
63 /// so that we can display it immediately.
64 pub display_description: String,
65
66 /// The full path of the file to create or modify in the project.
67 ///
68 /// WARNING: When specifying which file path need changing, you MUST
69 /// start each path with one of the project's root directories.
70 ///
71 /// The following examples assume we have two root directories in the project:
72 /// - backend
73 /// - frontend
74 ///
75 /// <example>
76 /// `backend/src/main.rs`
77 ///
78 /// Notice how the file path starts with root-1. Without that, the path
79 /// would be ambiguous and the call would fail!
80 /// </example>
81 ///
82 /// <example>
83 /// `frontend/db.js`
84 /// </example>
85 pub path: PathBuf,
86
87 /// The mode of operation on the file. Possible values:
88 /// - 'edit': Make granular edits to an existing file.
89 /// - 'create': Create a new file if it doesn't exist.
90 /// - 'overwrite': Replace the entire contents of an existing file.
91 ///
92 /// When a file already exists or you just created it, prefer editing
93 /// it as opposed to recreating it from scratch.
94 pub mode: EditFileMode,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
98#[serde(rename_all = "lowercase")]
99pub enum EditFileMode {
100 Edit,
101 Create,
102 Overwrite,
103}
104
105#[derive(Debug, Serialize, Deserialize, JsonSchema)]
106pub struct EditFileToolOutput {
107 pub original_path: PathBuf,
108 pub new_text: String,
109 pub old_text: Arc<String>,
110 pub raw_output: Option<EditAgentOutput>,
111}
112
113#[derive(Debug, Serialize, Deserialize, JsonSchema)]
114struct PartialInput {
115 #[serde(default)]
116 path: String,
117 #[serde(default)]
118 display_description: String,
119}
120
121const DEFAULT_UI_TEXT: &str = "Editing file";
122
123impl Tool for EditFileTool {
124 fn name(&self) -> String {
125 "edit_file".into()
126 }
127
128 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
129 false
130 }
131
132 fn may_perform_edits(&self) -> bool {
133 true
134 }
135
136 fn description(&self) -> String {
137 include_str!("edit_file_tool/description.md").to_string()
138 }
139
140 fn icon(&self) -> IconName {
141 IconName::Pencil
142 }
143
144 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
145 json_schema_for::<EditFileToolInput>(format)
146 }
147
148 fn ui_text(&self, input: &serde_json::Value) -> String {
149 match serde_json::from_value::<EditFileToolInput>(input.clone()) {
150 Ok(input) => input.display_description,
151 Err(_) => "Editing file".to_string(),
152 }
153 }
154
155 fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
156 if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
157 let description = input.display_description.trim();
158 if !description.is_empty() {
159 return description.to_string();
160 }
161
162 let path = input.path.trim();
163 if !path.is_empty() {
164 return path.to_string();
165 }
166 }
167
168 DEFAULT_UI_TEXT.to_string()
169 }
170
171 fn run(
172 self: Arc<Self>,
173 input: serde_json::Value,
174 request: Arc<LanguageModelRequest>,
175 project: Entity<Project>,
176 action_log: Entity<ActionLog>,
177 model: Arc<dyn LanguageModel>,
178 window: Option<AnyWindowHandle>,
179 cx: &mut App,
180 ) -> ToolResult {
181 let input = match serde_json::from_value::<EditFileToolInput>(input) {
182 Ok(input) => input,
183 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
184 };
185
186 let project_path = match resolve_path(&input, project.clone(), cx) {
187 Ok(path) => path,
188 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
189 };
190
191 let card = window.and_then(|window| {
192 window
193 .update(cx, |_, window, cx| {
194 cx.new(|cx| {
195 EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
196 })
197 })
198 .ok()
199 });
200
201 let card_clone = card.clone();
202 let action_log_clone = action_log.clone();
203 let task = cx.spawn(async move |cx: &mut AsyncApp| {
204 let edit_agent =
205 EditAgent::new(model, project.clone(), action_log_clone, Templates::new());
206
207 let buffer = project
208 .update(cx, |project, cx| {
209 project.open_buffer(project_path.clone(), cx)
210 })?
211 .await?;
212
213 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
214 let old_text = cx
215 .background_spawn({
216 let old_snapshot = old_snapshot.clone();
217 async move { Arc::new(old_snapshot.text()) }
218 })
219 .await;
220
221 if let Some(card) = card_clone.as_ref() {
222 card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?;
223 }
224
225 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
226 edit_agent.edit(
227 buffer.clone(),
228 input.display_description.clone(),
229 &request,
230 cx,
231 )
232 } else {
233 edit_agent.overwrite(
234 buffer.clone(),
235 input.display_description.clone(),
236 &request,
237 cx,
238 )
239 };
240
241 let mut hallucinated_old_text = false;
242 let mut ambiguous_ranges = Vec::new();
243 while let Some(event) = events.next().await {
244 match event {
245 EditAgentOutputEvent::Edited => {
246 if let Some(card) = card_clone.as_ref() {
247 card.update(cx, |card, cx| card.update_diff(cx))?;
248 }
249 }
250 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
251 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
252 EditAgentOutputEvent::ResolvingEditRange(range) => {
253 if let Some(card) = card_clone.as_ref() {
254 card.update(cx, |card, cx| card.reveal_range(range, cx))?;
255 }
256 }
257 }
258 }
259 let agent_output = output.await?;
260
261 // If format_on_save is enabled, format the buffer
262 let format_on_save_enabled = buffer
263 .read_with(cx, |buffer, cx| {
264 let settings = language_settings::language_settings(
265 buffer.language().map(|l| l.name()),
266 buffer.file(),
267 cx,
268 );
269 !matches!(settings.format_on_save, FormatOnSave::Off)
270 })
271 .unwrap_or(false);
272
273 if format_on_save_enabled {
274 let format_task = project.update(cx, |project, cx| {
275 project.format(
276 HashSet::from_iter([buffer.clone()]),
277 LspFormatTarget::Buffers,
278 false, // Don't push to history since the tool did it.
279 FormatTrigger::Save,
280 cx,
281 )
282 })?;
283 format_task.await.log_err();
284 }
285
286 project
287 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
288 .await?;
289
290 // Notify the action log that we've edited the buffer (*after* formatting has completed).
291 action_log.update(cx, |log, cx| {
292 log.buffer_edited(buffer.clone(), cx);
293 })?;
294
295 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
296 let (new_text, diff) = cx
297 .background_spawn({
298 let new_snapshot = new_snapshot.clone();
299 let old_text = old_text.clone();
300 async move {
301 let new_text = new_snapshot.text();
302 let diff = language::unified_diff(&old_text, &new_text);
303
304 (new_text, diff)
305 }
306 })
307 .await;
308
309 let output = EditFileToolOutput {
310 original_path: project_path.path.to_path_buf(),
311 new_text: new_text.clone(),
312 old_text,
313 raw_output: Some(agent_output),
314 };
315
316 if let Some(card) = card_clone {
317 card.update(cx, |card, cx| {
318 card.update_diff(cx);
319 card.finalize(cx)
320 })
321 .log_err();
322 }
323
324 let input_path = input.path.display();
325 if diff.is_empty() {
326 anyhow::ensure!(
327 !hallucinated_old_text,
328 formatdoc! {"
329 Some edits were produced but none of them could be applied.
330 Read the relevant sections of {input_path} again so that
331 I can perform the requested edits.
332 "}
333 );
334 anyhow::ensure!(
335 ambiguous_ranges.is_empty(),
336 // TODO: Include ambiguous_ranges, converted to line numbers.
337 // This would work best if we add `line_hint` parameter
338 // to edit_file_tool
339 formatdoc! {"
340 <old_text> matches more than one position in the file. Read the
341 relevant sections of {input_path} again and extend <old_text> so
342 that I can perform the requested edits.
343 "}
344 );
345 Ok(ToolResultOutput {
346 content: ToolResultContent::Text("No edits were made.".into()),
347 output: serde_json::to_value(output).ok(),
348 })
349 } else {
350 Ok(ToolResultOutput {
351 content: ToolResultContent::Text(format!(
352 "Edited {}:\n\n```diff\n{}\n```",
353 input_path, diff
354 )),
355 output: serde_json::to_value(output).ok(),
356 })
357 }
358 });
359
360 ToolResult {
361 output: task,
362 card: card.map(AnyToolCard::from),
363 }
364 }
365
366 fn deserialize_card(
367 self: Arc<Self>,
368 output: serde_json::Value,
369 project: Entity<Project>,
370 window: &mut Window,
371 cx: &mut App,
372 ) -> Option<AnyToolCard> {
373 let output = match serde_json::from_value::<EditFileToolOutput>(output) {
374 Ok(output) => output,
375 Err(_) => return None,
376 };
377
378 let card = cx.new(|cx| {
379 EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx)
380 });
381
382 cx.spawn({
383 let path: Arc<Path> = output.original_path.into();
384 let language_registry = project.read(cx).languages().clone();
385 let card = card.clone();
386 async move |cx| {
387 let buffer =
388 build_buffer(output.new_text, path.clone(), &language_registry, cx).await?;
389 let buffer_diff =
390 build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx)
391 .await?;
392 card.update(cx, |card, cx| {
393 card.multibuffer.update(cx, |multibuffer, cx| {
394 let snapshot = buffer.read(cx).snapshot();
395 let diff = buffer_diff.read(cx);
396 let diff_hunk_ranges = diff
397 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
398 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
399 .collect::<Vec<_>>();
400
401 multibuffer.set_excerpts_for_path(
402 PathKey::for_buffer(&buffer, cx),
403 buffer,
404 diff_hunk_ranges,
405 editor::DEFAULT_MULTIBUFFER_CONTEXT,
406 cx,
407 );
408 multibuffer.add_diff(buffer_diff, cx);
409 let end = multibuffer.len(cx);
410 card.total_lines =
411 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1);
412 });
413
414 cx.notify();
415 })?;
416 anyhow::Ok(())
417 }
418 })
419 .detach_and_log_err(cx);
420
421 Some(card.into())
422 }
423}
424
425/// Validate that the file path is valid, meaning:
426///
427/// - For `edit` and `overwrite`, the path must point to an existing file.
428/// - For `create`, the file must not already exist, but it's parent dir must exist.
429fn resolve_path(
430 input: &EditFileToolInput,
431 project: Entity<Project>,
432 cx: &mut App,
433) -> Result<ProjectPath> {
434 let project = project.read(cx);
435
436 match input.mode {
437 EditFileMode::Edit | EditFileMode::Overwrite => {
438 let path = project
439 .find_project_path(&input.path, cx)
440 .context("Can't edit file: path not found")?;
441
442 let entry = project
443 .entry_for_path(&path, cx)
444 .context("Can't edit file: path not found")?;
445
446 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
447 Ok(path)
448 }
449
450 EditFileMode::Create => {
451 if let Some(path) = project.find_project_path(&input.path, cx) {
452 anyhow::ensure!(
453 project.entry_for_path(&path, cx).is_none(),
454 "Can't create file: file already exists"
455 );
456 }
457
458 let parent_path = input
459 .path
460 .parent()
461 .context("Can't create file: incorrect path")?;
462
463 let parent_project_path = project.find_project_path(&parent_path, cx);
464
465 let parent_entry = parent_project_path
466 .as_ref()
467 .and_then(|path| project.entry_for_path(&path, cx))
468 .context("Can't create file: parent directory doesn't exist")?;
469
470 anyhow::ensure!(
471 parent_entry.is_dir(),
472 "Can't create file: parent is not a directory"
473 );
474
475 let file_name = input
476 .path
477 .file_name()
478 .context("Can't create file: invalid filename")?;
479
480 let new_file_path = parent_project_path.map(|parent| ProjectPath {
481 path: Arc::from(parent.path.join(file_name)),
482 ..parent
483 });
484
485 new_file_path.context("Can't create file")
486 }
487 }
488}
489
490pub struct EditFileToolCard {
491 path: PathBuf,
492 editor: Entity<Editor>,
493 multibuffer: Entity<MultiBuffer>,
494 project: Entity<Project>,
495 buffer: Option<Entity<Buffer>>,
496 base_text: Option<Arc<String>>,
497 buffer_diff: Option<Entity<BufferDiff>>,
498 revealed_ranges: Vec<Range<Anchor>>,
499 diff_task: Option<Task<Result<()>>>,
500 preview_expanded: bool,
501 error_expanded: Option<Entity<Markdown>>,
502 full_height_expanded: bool,
503 total_lines: Option<u32>,
504}
505
506impl EditFileToolCard {
507 pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
508 let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
509 let editor = cx.new(|cx| {
510 let mut editor = Editor::new(
511 EditorMode::Full {
512 scale_ui_elements_with_buffer_font_size: false,
513 show_active_line_background: false,
514 sized_by_content: true,
515 },
516 multibuffer.clone(),
517 Some(project.clone()),
518 window,
519 cx,
520 );
521 editor.set_show_gutter(false, cx);
522 editor.disable_inline_diagnostics();
523 editor.disable_expand_excerpt_buttons(cx);
524 // Keep horizontal scrollbar so user can scroll horizontally if needed
525 editor.set_show_vertical_scrollbar(false, cx);
526 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
527 editor.set_soft_wrap_mode(SoftWrap::None, cx);
528 editor.scroll_manager.set_forbid_vertical_scroll(true);
529 editor.set_show_indent_guides(false, cx);
530 editor.set_read_only(true);
531 editor.set_show_breakpoints(false, cx);
532 editor.set_show_code_actions(false, cx);
533 editor.set_show_git_diff_gutter(false, cx);
534 editor.set_expand_all_diff_hunks(cx);
535 editor
536 });
537 Self {
538 path,
539 project,
540 editor,
541 multibuffer,
542 buffer: None,
543 base_text: None,
544 buffer_diff: None,
545 revealed_ranges: Vec::new(),
546 diff_task: None,
547 preview_expanded: true,
548 error_expanded: None,
549 full_height_expanded: true,
550 total_lines: None,
551 }
552 }
553
554 pub fn initialize(&mut self, buffer: Entity<Buffer>, cx: &mut App) {
555 let buffer_snapshot = buffer.read(cx).snapshot();
556 let base_text = buffer_snapshot.text();
557 let language_registry = buffer.read(cx).language_registry();
558 let text_snapshot = buffer.read(cx).text_snapshot();
559
560 // Create a buffer diff with the current text as the base
561 let buffer_diff = cx.new(|cx| {
562 let mut diff = BufferDiff::new(&text_snapshot, cx);
563 let _ = diff.set_base_text(
564 buffer_snapshot.clone(),
565 language_registry,
566 text_snapshot,
567 cx,
568 );
569 diff
570 });
571
572 self.buffer = Some(buffer.clone());
573 self.base_text = Some(base_text.into());
574 self.buffer_diff = Some(buffer_diff.clone());
575
576 // Add the diff to the multibuffer
577 self.multibuffer
578 .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx));
579 }
580
581 pub fn is_loading(&self) -> bool {
582 self.total_lines.is_none()
583 }
584
585 pub fn update_diff(&mut self, cx: &mut Context<Self>) {
586 let Some(buffer) = self.buffer.as_ref() else {
587 return;
588 };
589 let Some(buffer_diff) = self.buffer_diff.as_ref() else {
590 return;
591 };
592
593 let buffer = buffer.clone();
594 let buffer_diff = buffer_diff.clone();
595 let base_text = self.base_text.clone();
596 self.diff_task = Some(cx.spawn(async move |this, cx| {
597 let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
598 let diff_snapshot = BufferDiff::update_diff(
599 buffer_diff.clone(),
600 text_snapshot.clone(),
601 base_text,
602 false,
603 false,
604 None,
605 None,
606 cx,
607 )
608 .await?;
609 buffer_diff.update(cx, |diff, cx| {
610 diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
611 })?;
612 this.update(cx, |this, cx| this.update_visible_ranges(cx))
613 }));
614 }
615
616 pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
617 self.revealed_ranges.push(range);
618 self.update_visible_ranges(cx);
619 }
620
621 fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
622 let Some(buffer) = self.buffer.as_ref() else {
623 return;
624 };
625
626 let ranges = self.excerpt_ranges(cx);
627 self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| {
628 multibuffer.set_excerpts_for_path(
629 PathKey::for_buffer(buffer, cx),
630 buffer.clone(),
631 ranges,
632 editor::DEFAULT_MULTIBUFFER_CONTEXT,
633 cx,
634 );
635 let end = multibuffer.len(cx);
636 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
637 });
638 cx.notify();
639 }
640
641 fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
642 let Some(buffer) = self.buffer.as_ref() else {
643 return Vec::new();
644 };
645 let Some(diff) = self.buffer_diff.as_ref() else {
646 return Vec::new();
647 };
648
649 let buffer = buffer.read(cx);
650 let diff = diff.read(cx);
651 let mut ranges = diff
652 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
653 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
654 .collect::<Vec<_>>();
655 ranges.extend(
656 self.revealed_ranges
657 .iter()
658 .map(|range| range.to_point(&buffer)),
659 );
660 ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
661
662 // Merge adjacent ranges
663 let mut ranges = ranges.into_iter().peekable();
664 let mut merged_ranges = Vec::new();
665 while let Some(mut range) = ranges.next() {
666 while let Some(next_range) = ranges.peek() {
667 if range.end >= next_range.start {
668 range.end = range.end.max(next_range.end);
669 ranges.next();
670 } else {
671 break;
672 }
673 }
674
675 merged_ranges.push(range);
676 }
677 merged_ranges
678 }
679
680 pub fn finalize(&mut self, cx: &mut Context<Self>) -> Result<()> {
681 let ranges = self.excerpt_ranges(cx);
682 let buffer = self.buffer.take().context("card was already finalized")?;
683 let base_text = self
684 .base_text
685 .take()
686 .context("card was already finalized")?;
687 let language_registry = self.project.read(cx).languages().clone();
688
689 // Replace the buffer in the multibuffer with the snapshot
690 let buffer = cx.new(|cx| {
691 let language = buffer.read(cx).language().cloned();
692 let buffer = TextBuffer::new_normalized(
693 0,
694 cx.entity_id().as_non_zero_u64().into(),
695 buffer.read(cx).line_ending(),
696 buffer.read(cx).as_rope().clone(),
697 );
698 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
699 buffer.set_language(language, cx);
700 buffer
701 });
702
703 let buffer_diff = cx.spawn({
704 let buffer = buffer.clone();
705 let language_registry = language_registry.clone();
706 async move |_this, cx| {
707 build_buffer_diff(base_text, &buffer, &language_registry, cx).await
708 }
709 });
710
711 cx.spawn(async move |this, cx| {
712 let buffer_diff = buffer_diff.await?;
713 this.update(cx, |this, cx| {
714 this.multibuffer.update(cx, |multibuffer, cx| {
715 let path_key = PathKey::for_buffer(&buffer, cx);
716 multibuffer.clear(cx);
717 multibuffer.set_excerpts_for_path(
718 path_key,
719 buffer,
720 ranges,
721 editor::DEFAULT_MULTIBUFFER_CONTEXT,
722 cx,
723 );
724 multibuffer.add_diff(buffer_diff.clone(), cx);
725 });
726
727 cx.notify();
728 })
729 })
730 .detach_and_log_err(cx);
731 Ok(())
732 }
733}
734
735impl ToolCard for EditFileToolCard {
736 fn render(
737 &mut self,
738 status: &ToolUseStatus,
739 window: &mut Window,
740 workspace: WeakEntity<Workspace>,
741 cx: &mut Context<Self>,
742 ) -> impl IntoElement {
743 let error_message = match status {
744 ToolUseStatus::Error(err) => Some(err),
745 _ => None,
746 };
747
748 let path_label_button = h_flex()
749 .id(("edit-tool-path-label-button", self.editor.entity_id()))
750 .w_full()
751 .max_w_full()
752 .px_1()
753 .gap_0p5()
754 .cursor_pointer()
755 .rounded_sm()
756 .opacity(0.8)
757 .hover(|label| {
758 label
759 .opacity(1.)
760 .bg(cx.theme().colors().element_hover.opacity(0.5))
761 })
762 .tooltip(Tooltip::text("Jump to File"))
763 .child(
764 h_flex()
765 .child(
766 Icon::new(IconName::Pencil)
767 .size(IconSize::XSmall)
768 .color(Color::Muted),
769 )
770 .child(
771 div()
772 .text_size(rems(0.8125))
773 .child(self.path.display().to_string())
774 .ml_1p5()
775 .mr_0p5(),
776 )
777 .child(
778 Icon::new(IconName::ArrowUpRight)
779 .size(IconSize::XSmall)
780 .color(Color::Ignored),
781 ),
782 )
783 .on_click({
784 let path = self.path.clone();
785 let workspace = workspace.clone();
786 move |_, window, cx| {
787 workspace
788 .update(cx, {
789 |workspace, cx| {
790 let Some(project_path) =
791 workspace.project().read(cx).find_project_path(&path, cx)
792 else {
793 return;
794 };
795 let open_task =
796 workspace.open_path(project_path, None, true, window, cx);
797 window
798 .spawn(cx, async move |cx| {
799 let item = open_task.await?;
800 if let Some(active_editor) = item.downcast::<Editor>() {
801 active_editor
802 .update_in(cx, |editor, window, cx| {
803 let snapshot =
804 editor.buffer().read(cx).snapshot(cx);
805 let first_hunk = editor
806 .diff_hunks_in_ranges(
807 &[editor::Anchor::min()
808 ..editor::Anchor::max()],
809 &snapshot,
810 )
811 .next();
812 if let Some(first_hunk) = first_hunk {
813 let first_hunk_start =
814 first_hunk.multi_buffer_range().start;
815 editor.change_selections(
816 Some(Autoscroll::fit()),
817 window,
818 cx,
819 |selections| {
820 selections.select_anchor_ranges([
821 first_hunk_start
822 ..first_hunk_start,
823 ]);
824 },
825 )
826 }
827 })
828 .log_err();
829 }
830 anyhow::Ok(())
831 })
832 .detach_and_log_err(cx);
833 }
834 })
835 .ok();
836 }
837 })
838 .into_any_element();
839
840 let codeblock_header_bg = cx
841 .theme()
842 .colors()
843 .element_background
844 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
845
846 let codeblock_header = h_flex()
847 .flex_none()
848 .p_1()
849 .gap_1()
850 .justify_between()
851 .rounded_t_md()
852 .when(error_message.is_none(), |header| {
853 header.bg(codeblock_header_bg)
854 })
855 .child(path_label_button)
856 .when_some(error_message, |header, error_message| {
857 header.child(
858 h_flex()
859 .gap_1()
860 .child(
861 Icon::new(IconName::Close)
862 .size(IconSize::Small)
863 .color(Color::Error),
864 )
865 .child(
866 Disclosure::new(
867 ("edit-file-error-disclosure", self.editor.entity_id()),
868 self.error_expanded.is_some(),
869 )
870 .opened_icon(IconName::ChevronUp)
871 .closed_icon(IconName::ChevronDown)
872 .on_click(cx.listener({
873 let error_message = error_message.clone();
874
875 move |this, _event, _window, cx| {
876 if this.error_expanded.is_some() {
877 this.error_expanded.take();
878 } else {
879 this.error_expanded = Some(cx.new(|cx| {
880 Markdown::new(error_message.clone(), None, None, cx)
881 }))
882 }
883 cx.notify();
884 }
885 })),
886 ),
887 )
888 })
889 .when(error_message.is_none() && !self.is_loading(), |header| {
890 header.child(
891 Disclosure::new(
892 ("edit-file-disclosure", self.editor.entity_id()),
893 self.preview_expanded,
894 )
895 .opened_icon(IconName::ChevronUp)
896 .closed_icon(IconName::ChevronDown)
897 .on_click(cx.listener(
898 move |this, _event, _window, _cx| {
899 this.preview_expanded = !this.preview_expanded;
900 },
901 )),
902 )
903 });
904
905 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
906 let line_height = editor
907 .style()
908 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
909 .unwrap_or_default();
910
911 editor.set_text_style_refinement(TextStyleRefinement {
912 font_size: Some(
913 TextSize::Small
914 .rems(cx)
915 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
916 .into(),
917 ),
918 ..TextStyleRefinement::default()
919 });
920 let element = editor.render(window, cx);
921 (element.into_any_element(), line_height)
922 });
923
924 let border_color = cx.theme().colors().border.opacity(0.6);
925
926 let waiting_for_diff = {
927 let styles = [
928 ("w_4_5", (0.1, 0.85), 2000),
929 ("w_1_4", (0.2, 0.75), 2200),
930 ("w_2_4", (0.15, 0.64), 1900),
931 ("w_3_5", (0.25, 0.72), 2300),
932 ("w_2_5", (0.3, 0.56), 1800),
933 ];
934
935 let mut container = v_flex()
936 .p_3()
937 .gap_1()
938 .border_t_1()
939 .rounded_b_md()
940 .border_color(border_color)
941 .bg(cx.theme().colors().editor_background);
942
943 for (width_method, pulse_range, duration_ms) in styles.iter() {
944 let (min_opacity, max_opacity) = *pulse_range;
945 let placeholder = match *width_method {
946 "w_4_5" => div().w_3_4(),
947 "w_1_4" => div().w_1_4(),
948 "w_2_4" => div().w_2_4(),
949 "w_3_5" => div().w_3_5(),
950 "w_2_5" => div().w_2_5(),
951 _ => div().w_1_2(),
952 }
953 .id("loading_div")
954 .h_1()
955 .rounded_full()
956 .bg(cx.theme().colors().element_active)
957 .with_animation(
958 "loading_pulsate",
959 Animation::new(Duration::from_millis(*duration_ms))
960 .repeat()
961 .with_easing(pulsating_between(min_opacity, max_opacity)),
962 |label, delta| label.opacity(delta),
963 );
964
965 container = container.child(placeholder);
966 }
967
968 container
969 };
970
971 v_flex()
972 .mb_2()
973 .border_1()
974 .when(error_message.is_some(), |card| card.border_dashed())
975 .border_color(border_color)
976 .rounded_md()
977 .overflow_hidden()
978 .child(codeblock_header)
979 .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
980 card.child(
981 v_flex()
982 .p_2()
983 .gap_1()
984 .border_t_1()
985 .border_dashed()
986 .border_color(border_color)
987 .bg(cx.theme().colors().editor_background)
988 .rounded_b_md()
989 .child(
990 Label::new("Error")
991 .size(LabelSize::XSmall)
992 .color(Color::Error),
993 )
994 .child(
995 div()
996 .rounded_md()
997 .text_ui_sm(cx)
998 .bg(cx.theme().colors().editor_background)
999 .child(MarkdownElement::new(
1000 error_markdown.clone(),
1001 markdown_style(window, cx),
1002 )),
1003 ),
1004 )
1005 })
1006 .when(self.is_loading() && error_message.is_none(), |card| {
1007 card.child(waiting_for_diff)
1008 })
1009 .when(self.preview_expanded && !self.is_loading(), |card| {
1010 let editor_view = v_flex()
1011 .relative()
1012 .h_full()
1013 .when(!self.full_height_expanded, |editor_container| {
1014 editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
1015 })
1016 .overflow_hidden()
1017 .border_t_1()
1018 .border_color(border_color)
1019 .bg(cx.theme().colors().editor_background)
1020 .child(editor);
1021
1022 card.child(
1023 ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
1024 .with_total_lines(self.total_lines.unwrap_or(0) as usize)
1025 .toggle_state(self.full_height_expanded)
1026 .with_collapsed_fade()
1027 .on_toggle({
1028 let this = cx.entity().downgrade();
1029 move |is_expanded, _window, cx| {
1030 if let Some(this) = this.upgrade() {
1031 this.update(cx, |this, _cx| {
1032 this.full_height_expanded = is_expanded;
1033 });
1034 }
1035 }
1036 }),
1037 )
1038 })
1039 }
1040}
1041
1042fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1043 let theme_settings = ThemeSettings::get_global(cx);
1044 let ui_font_size = TextSize::Default.rems(cx);
1045 let mut text_style = window.text_style();
1046
1047 text_style.refine(&TextStyleRefinement {
1048 font_family: Some(theme_settings.ui_font.family.clone()),
1049 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1050 font_features: Some(theme_settings.ui_font.features.clone()),
1051 font_size: Some(ui_font_size.into()),
1052 color: Some(cx.theme().colors().text),
1053 ..Default::default()
1054 });
1055
1056 MarkdownStyle {
1057 base_text_style: text_style.clone(),
1058 selection_background_color: cx.theme().players().local().selection,
1059 ..Default::default()
1060 }
1061}
1062
1063async fn build_buffer(
1064 mut text: String,
1065 path: Arc<Path>,
1066 language_registry: &Arc<language::LanguageRegistry>,
1067 cx: &mut AsyncApp,
1068) -> Result<Entity<Buffer>> {
1069 let line_ending = LineEnding::detect(&text);
1070 LineEnding::normalize(&mut text);
1071 let text = Rope::from(text);
1072 let language = cx
1073 .update(|_cx| language_registry.language_for_file_path(&path))?
1074 .await
1075 .ok();
1076 let buffer = cx.new(|cx| {
1077 let buffer = TextBuffer::new_normalized(
1078 0,
1079 cx.entity_id().as_non_zero_u64().into(),
1080 line_ending,
1081 text,
1082 );
1083 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
1084 buffer.set_language(language, cx);
1085 buffer
1086 })?;
1087 Ok(buffer)
1088}
1089
1090async fn build_buffer_diff(
1091 old_text: Arc<String>,
1092 buffer: &Entity<Buffer>,
1093 language_registry: &Arc<LanguageRegistry>,
1094 cx: &mut AsyncApp,
1095) -> Result<Entity<BufferDiff>> {
1096 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
1097
1098 let old_text_rope = cx
1099 .background_spawn({
1100 let old_text = old_text.clone();
1101 async move { Rope::from(old_text.as_str()) }
1102 })
1103 .await;
1104 let base_buffer = cx
1105 .update(|cx| {
1106 Buffer::build_snapshot(
1107 old_text_rope,
1108 buffer.language().cloned(),
1109 Some(language_registry.clone()),
1110 cx,
1111 )
1112 })?
1113 .await;
1114
1115 let diff_snapshot = cx
1116 .update(|cx| {
1117 BufferDiffSnapshot::new_with_base_buffer(
1118 buffer.text.clone(),
1119 Some(old_text),
1120 base_buffer,
1121 cx,
1122 )
1123 })?
1124 .await;
1125
1126 let secondary_diff = cx.new(|cx| {
1127 let mut diff = BufferDiff::new(&buffer, cx);
1128 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
1129 diff
1130 })?;
1131
1132 cx.new(|cx| {
1133 let mut diff = BufferDiff::new(&buffer.text, cx);
1134 diff.set_snapshot(diff_snapshot, &buffer, cx);
1135 diff.set_secondary_diff(secondary_diff);
1136 diff
1137 })
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143 use client::TelemetrySettings;
1144 use fs::{FakeFs, Fs};
1145 use gpui::{TestAppContext, UpdateGlobal};
1146 use language_model::fake_provider::FakeLanguageModel;
1147 use serde_json::json;
1148 use settings::SettingsStore;
1149 use util::path;
1150
1151 #[gpui::test]
1152 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
1153 init_test(cx);
1154
1155 let fs = FakeFs::new(cx.executor());
1156 fs.insert_tree("/root", json!({})).await;
1157 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1158 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1159 let model = Arc::new(FakeLanguageModel::default());
1160 let result = cx
1161 .update(|cx| {
1162 let input = serde_json::to_value(EditFileToolInput {
1163 display_description: "Some edit".into(),
1164 path: "root/nonexistent_file.txt".into(),
1165 mode: EditFileMode::Edit,
1166 })
1167 .unwrap();
1168 Arc::new(EditFileTool)
1169 .run(
1170 input,
1171 Arc::default(),
1172 project.clone(),
1173 action_log,
1174 model,
1175 None,
1176 cx,
1177 )
1178 .output
1179 })
1180 .await;
1181 assert_eq!(
1182 result.unwrap_err().to_string(),
1183 "Can't edit file: path not found"
1184 );
1185 }
1186
1187 #[gpui::test]
1188 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
1189 let mode = &EditFileMode::Create;
1190
1191 let result = test_resolve_path(mode, "root/new.txt", cx);
1192 assert_resolved_path_eq(result.await, "new.txt");
1193
1194 let result = test_resolve_path(mode, "new.txt", cx);
1195 assert_resolved_path_eq(result.await, "new.txt");
1196
1197 let result = test_resolve_path(mode, "dir/new.txt", cx);
1198 assert_resolved_path_eq(result.await, "dir/new.txt");
1199
1200 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
1201 assert_eq!(
1202 result.await.unwrap_err().to_string(),
1203 "Can't create file: file already exists"
1204 );
1205
1206 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
1207 assert_eq!(
1208 result.await.unwrap_err().to_string(),
1209 "Can't create file: parent directory doesn't exist"
1210 );
1211 }
1212
1213 #[gpui::test]
1214 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
1215 let mode = &EditFileMode::Edit;
1216
1217 let path_with_root = "root/dir/subdir/existing.txt";
1218 let path_without_root = "dir/subdir/existing.txt";
1219 let result = test_resolve_path(mode, path_with_root, cx);
1220 assert_resolved_path_eq(result.await, path_without_root);
1221
1222 let result = test_resolve_path(mode, path_without_root, cx);
1223 assert_resolved_path_eq(result.await, path_without_root);
1224
1225 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1226 assert_eq!(
1227 result.await.unwrap_err().to_string(),
1228 "Can't edit file: path not found"
1229 );
1230
1231 let result = test_resolve_path(mode, "root/dir", cx);
1232 assert_eq!(
1233 result.await.unwrap_err().to_string(),
1234 "Can't edit file: path is a directory"
1235 );
1236 }
1237
1238 async fn test_resolve_path(
1239 mode: &EditFileMode,
1240 path: &str,
1241 cx: &mut TestAppContext,
1242 ) -> anyhow::Result<ProjectPath> {
1243 init_test(cx);
1244
1245 let fs = FakeFs::new(cx.executor());
1246 fs.insert_tree(
1247 "/root",
1248 json!({
1249 "dir": {
1250 "subdir": {
1251 "existing.txt": "hello"
1252 }
1253 }
1254 }),
1255 )
1256 .await;
1257 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1258
1259 let input = EditFileToolInput {
1260 display_description: "Some edit".into(),
1261 path: path.into(),
1262 mode: mode.clone(),
1263 };
1264
1265 let result = cx.update(|cx| resolve_path(&input, project, cx));
1266 result
1267 }
1268
1269 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1270 let actual = path
1271 .expect("Should return valid path")
1272 .path
1273 .to_str()
1274 .unwrap()
1275 .replace("\\", "/"); // Naive Windows paths normalization
1276 assert_eq!(actual, expected);
1277 }
1278
1279 #[test]
1280 fn still_streaming_ui_text_with_path() {
1281 let input = json!({
1282 "path": "src/main.rs",
1283 "display_description": "",
1284 "old_string": "old code",
1285 "new_string": "new code"
1286 });
1287
1288 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1289 }
1290
1291 #[test]
1292 fn still_streaming_ui_text_with_description() {
1293 let input = json!({
1294 "path": "",
1295 "display_description": "Fix error handling",
1296 "old_string": "old code",
1297 "new_string": "new code"
1298 });
1299
1300 assert_eq!(
1301 EditFileTool.still_streaming_ui_text(&input),
1302 "Fix error handling",
1303 );
1304 }
1305
1306 #[test]
1307 fn still_streaming_ui_text_with_path_and_description() {
1308 let input = json!({
1309 "path": "src/main.rs",
1310 "display_description": "Fix error handling",
1311 "old_string": "old code",
1312 "new_string": "new code"
1313 });
1314
1315 assert_eq!(
1316 EditFileTool.still_streaming_ui_text(&input),
1317 "Fix error handling",
1318 );
1319 }
1320
1321 #[test]
1322 fn still_streaming_ui_text_no_path_or_description() {
1323 let input = json!({
1324 "path": "",
1325 "display_description": "",
1326 "old_string": "old code",
1327 "new_string": "new code"
1328 });
1329
1330 assert_eq!(
1331 EditFileTool.still_streaming_ui_text(&input),
1332 DEFAULT_UI_TEXT,
1333 );
1334 }
1335
1336 #[test]
1337 fn still_streaming_ui_text_with_null() {
1338 let input = serde_json::Value::Null;
1339
1340 assert_eq!(
1341 EditFileTool.still_streaming_ui_text(&input),
1342 DEFAULT_UI_TEXT,
1343 );
1344 }
1345
1346 fn init_test(cx: &mut TestAppContext) {
1347 cx.update(|cx| {
1348 let settings_store = SettingsStore::test(cx);
1349 cx.set_global(settings_store);
1350 language::init(cx);
1351 TelemetrySettings::register(cx);
1352 Project::init_settings(cx);
1353 });
1354 }
1355
1356 #[gpui::test]
1357 async fn test_format_on_save(cx: &mut TestAppContext) {
1358 init_test(cx);
1359
1360 let fs = FakeFs::new(cx.executor());
1361 fs.insert_tree("/root", json!({"src": {}})).await;
1362
1363 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1364
1365 // Set up a Rust language with LSP formatting support
1366 let rust_language = Arc::new(language::Language::new(
1367 language::LanguageConfig {
1368 name: "Rust".into(),
1369 matcher: language::LanguageMatcher {
1370 path_suffixes: vec!["rs".to_string()],
1371 ..Default::default()
1372 },
1373 ..Default::default()
1374 },
1375 None,
1376 ));
1377
1378 // Register the language and fake LSP
1379 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1380 language_registry.add(rust_language);
1381
1382 let mut fake_language_servers = language_registry.register_fake_lsp(
1383 "Rust",
1384 language::FakeLspAdapter {
1385 capabilities: lsp::ServerCapabilities {
1386 document_formatting_provider: Some(lsp::OneOf::Left(true)),
1387 ..Default::default()
1388 },
1389 ..Default::default()
1390 },
1391 );
1392
1393 // Create the file
1394 fs.save(
1395 path!("/root/src/main.rs").as_ref(),
1396 &"initial content".into(),
1397 language::LineEnding::Unix,
1398 )
1399 .await
1400 .unwrap();
1401
1402 // Open the buffer to trigger LSP initialization
1403 let buffer = project
1404 .update(cx, |project, cx| {
1405 project.open_local_buffer(path!("/root/src/main.rs"), cx)
1406 })
1407 .await
1408 .unwrap();
1409
1410 // Register the buffer with language servers
1411 let _handle = project.update(cx, |project, cx| {
1412 project.register_buffer_with_language_servers(&buffer, cx)
1413 });
1414
1415 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
1416 const FORMATTED_CONTENT: &str =
1417 "This file was formatted by the fake formatter in the test.\n";
1418
1419 // Get the fake language server and set up formatting handler
1420 let fake_language_server = fake_language_servers.next().await.unwrap();
1421 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
1422 |_, _| async move {
1423 Ok(Some(vec![lsp::TextEdit {
1424 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
1425 new_text: FORMATTED_CONTENT.to_string(),
1426 }]))
1427 }
1428 });
1429
1430 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1431 let model = Arc::new(FakeLanguageModel::default());
1432
1433 // First, test with format_on_save enabled
1434 cx.update(|cx| {
1435 SettingsStore::update_global(cx, |store, cx| {
1436 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1437 cx,
1438 |settings| {
1439 settings.defaults.format_on_save = Some(FormatOnSave::On);
1440 settings.defaults.formatter =
1441 Some(language::language_settings::SelectedFormatter::Auto);
1442 },
1443 );
1444 });
1445 });
1446
1447 // Have the model stream unformatted content
1448 let edit_result = {
1449 let edit_task = cx.update(|cx| {
1450 let input = serde_json::to_value(EditFileToolInput {
1451 display_description: "Create main function".into(),
1452 path: "root/src/main.rs".into(),
1453 mode: EditFileMode::Overwrite,
1454 })
1455 .unwrap();
1456 Arc::new(EditFileTool)
1457 .run(
1458 input,
1459 Arc::default(),
1460 project.clone(),
1461 action_log.clone(),
1462 model.clone(),
1463 None,
1464 cx,
1465 )
1466 .output
1467 });
1468
1469 // Stream the unformatted content
1470 cx.executor().run_until_parked();
1471 model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
1472 model.end_last_completion_stream();
1473
1474 edit_task.await
1475 };
1476 assert!(edit_result.is_ok());
1477
1478 // Wait for any async operations (e.g. formatting) to complete
1479 cx.executor().run_until_parked();
1480
1481 // Read the file to verify it was formatted automatically
1482 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1483 assert_eq!(
1484 // Ignore carriage returns on Windows
1485 new_content.replace("\r\n", "\n"),
1486 FORMATTED_CONTENT,
1487 "Code should be formatted when format_on_save is enabled"
1488 );
1489
1490 let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
1491
1492 assert_eq!(
1493 stale_buffer_count, 0,
1494 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
1495 This causes the agent to think the file was modified externally when it was just formatted.",
1496 stale_buffer_count
1497 );
1498
1499 // Next, test with format_on_save disabled
1500 cx.update(|cx| {
1501 SettingsStore::update_global(cx, |store, cx| {
1502 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1503 cx,
1504 |settings| {
1505 settings.defaults.format_on_save = Some(FormatOnSave::Off);
1506 },
1507 );
1508 });
1509 });
1510
1511 // Stream unformatted edits again
1512 let edit_result = {
1513 let edit_task = cx.update(|cx| {
1514 let input = serde_json::to_value(EditFileToolInput {
1515 display_description: "Update main function".into(),
1516 path: "root/src/main.rs".into(),
1517 mode: EditFileMode::Overwrite,
1518 })
1519 .unwrap();
1520 Arc::new(EditFileTool)
1521 .run(
1522 input,
1523 Arc::default(),
1524 project.clone(),
1525 action_log.clone(),
1526 model.clone(),
1527 None,
1528 cx,
1529 )
1530 .output
1531 });
1532
1533 // Stream the unformatted content
1534 cx.executor().run_until_parked();
1535 model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
1536 model.end_last_completion_stream();
1537
1538 edit_task.await
1539 };
1540 assert!(edit_result.is_ok());
1541
1542 // Wait for any async operations (e.g. formatting) to complete
1543 cx.executor().run_until_parked();
1544
1545 // Verify the file was not formatted
1546 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1547 assert_eq!(
1548 // Ignore carriage returns on Windows
1549 new_content.replace("\r\n", "\n"),
1550 UNFORMATTED_CONTENT,
1551 "Code should not be formatted when format_on_save is disabled"
1552 );
1553 }
1554
1555 #[gpui::test]
1556 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1557 init_test(cx);
1558
1559 let fs = FakeFs::new(cx.executor());
1560 fs.insert_tree("/root", json!({"src": {}})).await;
1561
1562 // Create a simple file with trailing whitespace
1563 fs.save(
1564 path!("/root/src/main.rs").as_ref(),
1565 &"initial content".into(),
1566 language::LineEnding::Unix,
1567 )
1568 .await
1569 .unwrap();
1570
1571 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1572 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1573 let model = Arc::new(FakeLanguageModel::default());
1574
1575 // First, test with remove_trailing_whitespace_on_save enabled
1576 cx.update(|cx| {
1577 SettingsStore::update_global(cx, |store, cx| {
1578 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1579 cx,
1580 |settings| {
1581 settings.defaults.remove_trailing_whitespace_on_save = Some(true);
1582 },
1583 );
1584 });
1585 });
1586
1587 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1588 "fn main() { \n println!(\"Hello!\"); \n}\n";
1589
1590 // Have the model stream content that contains trailing whitespace
1591 let edit_result = {
1592 let edit_task = cx.update(|cx| {
1593 let input = serde_json::to_value(EditFileToolInput {
1594 display_description: "Create main function".into(),
1595 path: "root/src/main.rs".into(),
1596 mode: EditFileMode::Overwrite,
1597 })
1598 .unwrap();
1599 Arc::new(EditFileTool)
1600 .run(
1601 input,
1602 Arc::default(),
1603 project.clone(),
1604 action_log.clone(),
1605 model.clone(),
1606 None,
1607 cx,
1608 )
1609 .output
1610 });
1611
1612 // Stream the content with trailing whitespace
1613 cx.executor().run_until_parked();
1614 model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
1615 model.end_last_completion_stream();
1616
1617 edit_task.await
1618 };
1619 assert!(edit_result.is_ok());
1620
1621 // Wait for any async operations (e.g. formatting) to complete
1622 cx.executor().run_until_parked();
1623
1624 // Read the file to verify trailing whitespace was removed automatically
1625 assert_eq!(
1626 // Ignore carriage returns on Windows
1627 fs.load(path!("/root/src/main.rs").as_ref())
1628 .await
1629 .unwrap()
1630 .replace("\r\n", "\n"),
1631 "fn main() {\n println!(\"Hello!\");\n}\n",
1632 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1633 );
1634
1635 // Next, test with remove_trailing_whitespace_on_save disabled
1636 cx.update(|cx| {
1637 SettingsStore::update_global(cx, |store, cx| {
1638 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1639 cx,
1640 |settings| {
1641 settings.defaults.remove_trailing_whitespace_on_save = Some(false);
1642 },
1643 );
1644 });
1645 });
1646
1647 // Stream edits again with trailing whitespace
1648 let edit_result = {
1649 let edit_task = cx.update(|cx| {
1650 let input = serde_json::to_value(EditFileToolInput {
1651 display_description: "Update main function".into(),
1652 path: "root/src/main.rs".into(),
1653 mode: EditFileMode::Overwrite,
1654 })
1655 .unwrap();
1656 Arc::new(EditFileTool)
1657 .run(
1658 input,
1659 Arc::default(),
1660 project.clone(),
1661 action_log.clone(),
1662 model.clone(),
1663 None,
1664 cx,
1665 )
1666 .output
1667 });
1668
1669 // Stream the content with trailing whitespace
1670 cx.executor().run_until_parked();
1671 model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
1672 model.end_last_completion_stream();
1673
1674 edit_task.await
1675 };
1676 assert!(edit_result.is_ok());
1677
1678 // Wait for any async operations (e.g. formatting) to complete
1679 cx.executor().run_until_parked();
1680
1681 // Verify the file still has trailing whitespace
1682 // Read the file again - it should still have trailing whitespace
1683 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1684 assert_eq!(
1685 // Ignore carriage returns on Windows
1686 final_content.replace("\r\n", "\n"),
1687 CONTENT_WITH_TRAILING_WHITESPACE,
1688 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1689 );
1690 }
1691}