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