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