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