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