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