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