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