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