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