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 paths;
29use project::{
30 Project, ProjectPath,
31 lsp_store::{FormatTrigger, LspFormatTarget},
32};
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35use settings::Settings;
36use std::{
37 cmp::Reverse,
38 collections::HashSet,
39 ops::Range,
40 path::{Path, PathBuf},
41 sync::Arc,
42 time::Duration,
43};
44use theme::ThemeSettings;
45use ui::{Disclosure, Tooltip, prelude::*};
46use util::ResultExt;
47use workspace::Workspace;
48
49pub struct EditFileTool;
50
51#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
52pub struct EditFileToolInput {
53 /// A one-line, user-friendly markdown description of the edit. This will be
54 /// shown in the UI and also passed to another model to perform the edit.
55 ///
56 /// Be terse, but also descriptive in what you want to achieve with this
57 /// edit. Avoid generic instructions.
58 ///
59 /// NEVER mention the file path in this description.
60 ///
61 /// <example>Fix API endpoint URLs</example>
62 /// <example>Update copyright year in `page_footer`</example>
63 ///
64 /// Make sure to include this field before all the others in the input object
65 /// so that we can display it immediately.
66 pub display_description: String,
67
68 /// The full path of the file to create or modify in the project.
69 ///
70 /// WARNING: When specifying which file path need changing, you MUST
71 /// start each path with one of the project's root directories.
72 ///
73 /// The following examples assume we have two root directories in the project:
74 /// - /a/b/backend
75 /// - /c/d/frontend
76 ///
77 /// <example>
78 /// `backend/src/main.rs`
79 ///
80 /// Notice how the file path starts with `backend`. Without that, the path
81 /// would be ambiguous and the call would fail!
82 /// </example>
83 ///
84 /// <example>
85 /// `frontend/db.js`
86 /// </example>
87 pub path: PathBuf,
88
89 /// The mode of operation on the file. Possible values:
90 /// - 'edit': Make granular edits to an existing file.
91 /// - 'create': Create a new file if it doesn't exist.
92 /// - 'overwrite': Replace the entire contents of an existing file.
93 ///
94 /// When a file already exists or you just created it, prefer editing
95 /// it as opposed to recreating it from scratch.
96 pub mode: EditFileMode,
97}
98
99#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
100#[serde(rename_all = "lowercase")]
101pub enum EditFileMode {
102 Edit,
103 Create,
104 Overwrite,
105}
106
107#[derive(Debug, Serialize, Deserialize, JsonSchema)]
108pub struct EditFileToolOutput {
109 pub original_path: PathBuf,
110 pub new_text: String,
111 pub old_text: Arc<String>,
112 pub raw_output: Option<EditAgentOutput>,
113}
114
115#[derive(Debug, Serialize, Deserialize, JsonSchema)]
116struct PartialInput {
117 #[serde(default)]
118 path: String,
119 #[serde(default)]
120 display_description: String,
121}
122
123impl Tool for EditFileTool {
124 fn name(&self) -> String {
125 "edit_file".into()
126 }
127
128 fn needs_confirmation(
129 &self,
130 input: &serde_json::Value,
131 project: &Entity<Project>,
132 cx: &App,
133 ) -> bool {
134 if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
135 return false;
136 }
137
138 let Ok(input) = serde_json::from_value::<EditFileToolInput>(input.clone()) else {
139 // If it's not valid JSON, it's going to error and confirming won't do anything.
140 return false;
141 };
142
143 // If any path component matches the local settings folder, then this could affect
144 // the editor in ways beyond the project source, so prompt.
145 let local_settings_folder = paths::local_settings_folder_relative_path();
146 let path = Path::new(&input.path);
147 if path
148 .components()
149 .any(|component| component.as_os_str() == local_settings_folder.as_os_str())
150 {
151 return true;
152 }
153
154 // It's also possible that the global config dir is configured to be inside the project,
155 // so check for that edge case too.
156 if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
157 if canonical_path.starts_with(paths::config_dir()) {
158 return true;
159 }
160 }
161
162 // Check if path is inside the global config directory
163 // First check if it's already inside project - if not, try to canonicalize
164 let project_path = project.read(cx).find_project_path(&input.path, cx);
165
166 // If the path is inside the project, and it's not one of the above edge cases,
167 // then no confirmation is necessary. Otherwise, confirmation is necessary.
168 project_path.is_none()
169 }
170
171 fn may_perform_edits(&self) -> bool {
172 true
173 }
174
175 fn description(&self) -> String {
176 include_str!("edit_file_tool/description.md").to_string()
177 }
178
179 fn icon(&self) -> IconName {
180 IconName::ToolPencil
181 }
182
183 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
184 json_schema_for::<EditFileToolInput>(format)
185 }
186
187 fn ui_text(&self, input: &serde_json::Value) -> String {
188 match serde_json::from_value::<EditFileToolInput>(input.clone()) {
189 Ok(input) => {
190 let path = Path::new(&input.path);
191 let mut description = input.display_description.clone();
192
193 // Add context about why confirmation may be needed
194 let local_settings_folder = paths::local_settings_folder_relative_path();
195 if path
196 .components()
197 .any(|c| c.as_os_str() == local_settings_folder.as_os_str())
198 {
199 description.push_str(" (local settings)");
200 } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
201 if canonical_path.starts_with(paths::config_dir()) {
202 description.push_str(" (global settings)");
203 }
204 }
205
206 description
207 }
208 Err(_) => "Editing file".to_string(),
209 }
210 }
211
212 fn run(
213 self: Arc<Self>,
214 input: serde_json::Value,
215 request: Arc<LanguageModelRequest>,
216 project: Entity<Project>,
217 action_log: Entity<ActionLog>,
218 model: Arc<dyn LanguageModel>,
219 window: Option<AnyWindowHandle>,
220 cx: &mut App,
221 ) -> ToolResult {
222 let input = match serde_json::from_value::<EditFileToolInput>(input) {
223 Ok(input) => input,
224 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
225 };
226
227 let project_path = match resolve_path(&input, project.clone(), cx) {
228 Ok(path) => path,
229 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
230 };
231
232 let card = window.and_then(|window| {
233 window
234 .update(cx, |_, window, cx| {
235 cx.new(|cx| {
236 EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
237 })
238 })
239 .ok()
240 });
241
242 let card_clone = card.clone();
243 let action_log_clone = action_log.clone();
244 let task = cx.spawn(async move |cx: &mut AsyncApp| {
245 let edit_format = EditFormat::from_model(model.clone())?;
246 let edit_agent = EditAgent::new(
247 model,
248 project.clone(),
249 action_log_clone,
250 Templates::new(),
251 edit_format,
252 );
253
254 let buffer = project
255 .update(cx, |project, cx| {
256 project.open_buffer(project_path.clone(), cx)
257 })?
258 .await?;
259
260 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
261 let old_text = cx
262 .background_spawn({
263 let old_snapshot = old_snapshot.clone();
264 async move { Arc::new(old_snapshot.text()) }
265 })
266 .await;
267
268 if let Some(card) = card_clone.as_ref() {
269 card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?;
270 }
271
272 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
273 edit_agent.edit(
274 buffer.clone(),
275 input.display_description.clone(),
276 &request,
277 cx,
278 )
279 } else {
280 edit_agent.overwrite(
281 buffer.clone(),
282 input.display_description.clone(),
283 &request,
284 cx,
285 )
286 };
287
288 let mut hallucinated_old_text = false;
289 let mut ambiguous_ranges = Vec::new();
290 while let Some(event) = events.next().await {
291 match event {
292 EditAgentOutputEvent::Edited => {
293 if let Some(card) = card_clone.as_ref() {
294 card.update(cx, |card, cx| card.update_diff(cx))?;
295 }
296 }
297 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
298 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
299 EditAgentOutputEvent::ResolvingEditRange(range) => {
300 if let Some(card) = card_clone.as_ref() {
301 card.update(cx, |card, cx| card.reveal_range(range, cx))?;
302 }
303 }
304 }
305 }
306 let agent_output = output.await?;
307
308 // If format_on_save is enabled, format the buffer
309 let format_on_save_enabled = buffer
310 .read_with(cx, |buffer, cx| {
311 let settings = language_settings::language_settings(
312 buffer.language().map(|l| l.name()),
313 buffer.file(),
314 cx,
315 );
316 !matches!(settings.format_on_save, FormatOnSave::Off)
317 })
318 .unwrap_or(false);
319
320 if format_on_save_enabled {
321 action_log.update(cx, |log, cx| {
322 log.buffer_edited(buffer.clone(), cx);
323 })?;
324 let format_task = project.update(cx, |project, cx| {
325 project.format(
326 HashSet::from_iter([buffer.clone()]),
327 LspFormatTarget::Buffers,
328 false, // Don't push to history since the tool did it.
329 FormatTrigger::Save,
330 cx,
331 )
332 })?;
333 format_task.await.log_err();
334 }
335
336 project
337 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
338 .await?;
339
340 // Notify the action log that we've edited the buffer (*after* formatting has completed).
341 action_log.update(cx, |log, cx| {
342 log.buffer_edited(buffer.clone(), cx);
343 })?;
344
345 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
346 let (new_text, diff) = cx
347 .background_spawn({
348 let new_snapshot = new_snapshot.clone();
349 let old_text = old_text.clone();
350 async move {
351 let new_text = new_snapshot.text();
352 let diff = language::unified_diff(&old_text, &new_text);
353
354 (new_text, diff)
355 }
356 })
357 .await;
358
359 let output = EditFileToolOutput {
360 original_path: project_path.path.to_path_buf(),
361 new_text: new_text.clone(),
362 old_text,
363 raw_output: Some(agent_output),
364 };
365
366 if let Some(card) = card_clone {
367 card.update(cx, |card, cx| {
368 card.update_diff(cx);
369 card.finalize(cx)
370 })
371 .log_err();
372 }
373
374 let input_path = input.path.display();
375 if diff.is_empty() {
376 anyhow::ensure!(
377 !hallucinated_old_text,
378 formatdoc! {"
379 Some edits were produced but none of them could be applied.
380 Read the relevant sections of {input_path} again so that
381 I can perform the requested edits.
382 "}
383 );
384 anyhow::ensure!(
385 ambiguous_ranges.is_empty(),
386 {
387 let line_numbers = ambiguous_ranges
388 .iter()
389 .map(|range| range.start.to_string())
390 .collect::<Vec<_>>()
391 .join(", ");
392 formatdoc! {"
393 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
394 relevant sections of {input_path} again and extend <old_text> so
395 that I can perform the requested edits.
396 "}
397 }
398 );
399 Ok(ToolResultOutput {
400 content: ToolResultContent::Text("No edits were made.".into()),
401 output: serde_json::to_value(output).ok(),
402 })
403 } else {
404 Ok(ToolResultOutput {
405 content: ToolResultContent::Text(format!(
406 "Edited {}:\n\n```diff\n{}\n```",
407 input_path, diff
408 )),
409 output: serde_json::to_value(output).ok(),
410 })
411 }
412 });
413
414 ToolResult {
415 output: task,
416 card: card.map(AnyToolCard::from),
417 }
418 }
419
420 fn deserialize_card(
421 self: Arc<Self>,
422 output: serde_json::Value,
423 project: Entity<Project>,
424 window: &mut Window,
425 cx: &mut App,
426 ) -> Option<AnyToolCard> {
427 let output = match serde_json::from_value::<EditFileToolOutput>(output) {
428 Ok(output) => output,
429 Err(_) => return None,
430 };
431
432 let card = cx.new(|cx| {
433 EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx)
434 });
435
436 cx.spawn({
437 let path: Arc<Path> = output.original_path.into();
438 let language_registry = project.read(cx).languages().clone();
439 let card = card.clone();
440 async move |cx| {
441 let buffer =
442 build_buffer(output.new_text, path.clone(), &language_registry, cx).await?;
443 let buffer_diff =
444 build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx)
445 .await?;
446 card.update(cx, |card, cx| {
447 card.multibuffer.update(cx, |multibuffer, cx| {
448 let snapshot = buffer.read(cx).snapshot();
449 let diff = buffer_diff.read(cx);
450 let diff_hunk_ranges = diff
451 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
452 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
453 .collect::<Vec<_>>();
454
455 multibuffer.set_excerpts_for_path(
456 PathKey::for_buffer(&buffer, cx),
457 buffer,
458 diff_hunk_ranges,
459 editor::DEFAULT_MULTIBUFFER_CONTEXT,
460 cx,
461 );
462 multibuffer.add_diff(buffer_diff, cx);
463 let end = multibuffer.len(cx);
464 card.total_lines =
465 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1);
466 });
467
468 cx.notify();
469 })?;
470 anyhow::Ok(())
471 }
472 })
473 .detach_and_log_err(cx);
474
475 Some(card.into())
476 }
477}
478
479/// Validate that the file path is valid, meaning:
480///
481/// - For `edit` and `overwrite`, the path must point to an existing file.
482/// - For `create`, the file must not already exist, but it's parent dir must exist.
483fn resolve_path(
484 input: &EditFileToolInput,
485 project: Entity<Project>,
486 cx: &mut App,
487) -> Result<ProjectPath> {
488 let project = project.read(cx);
489
490 match input.mode {
491 EditFileMode::Edit | EditFileMode::Overwrite => {
492 let path = project
493 .find_project_path(&input.path, cx)
494 .context("Can't edit file: path not found")?;
495
496 let entry = project
497 .entry_for_path(&path, cx)
498 .context("Can't edit file: path not found")?;
499
500 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
501 Ok(path)
502 }
503
504 EditFileMode::Create => {
505 if let Some(path) = project.find_project_path(&input.path, cx) {
506 anyhow::ensure!(
507 project.entry_for_path(&path, cx).is_none(),
508 "Can't create file: file already exists"
509 );
510 }
511
512 let parent_path = input
513 .path
514 .parent()
515 .context("Can't create file: incorrect path")?;
516
517 let parent_project_path = project.find_project_path(&parent_path, cx);
518
519 let parent_entry = parent_project_path
520 .as_ref()
521 .and_then(|path| project.entry_for_path(&path, cx))
522 .context("Can't create file: parent directory doesn't exist")?;
523
524 anyhow::ensure!(
525 parent_entry.is_dir(),
526 "Can't create file: parent is not a directory"
527 );
528
529 let file_name = input
530 .path
531 .file_name()
532 .context("Can't create file: invalid filename")?;
533
534 let new_file_path = parent_project_path.map(|parent| ProjectPath {
535 path: Arc::from(parent.path.join(file_name)),
536 ..parent
537 });
538
539 new_file_path.context("Can't create file")
540 }
541 }
542}
543
544pub struct EditFileToolCard {
545 path: PathBuf,
546 editor: Entity<Editor>,
547 multibuffer: Entity<MultiBuffer>,
548 project: Entity<Project>,
549 buffer: Option<Entity<Buffer>>,
550 base_text: Option<Arc<String>>,
551 buffer_diff: Option<Entity<BufferDiff>>,
552 revealed_ranges: Vec<Range<Anchor>>,
553 diff_task: Option<Task<Result<()>>>,
554 preview_expanded: bool,
555 error_expanded: Option<Entity<Markdown>>,
556 full_height_expanded: bool,
557 total_lines: Option<u32>,
558}
559
560impl EditFileToolCard {
561 pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
562 let expand_edit_card = agent_settings::AgentSettings::get_global(cx).expand_edit_card;
563 let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
564
565 let editor = cx.new(|cx| {
566 let mut editor = Editor::new(
567 EditorMode::Full {
568 scale_ui_elements_with_buffer_font_size: false,
569 show_active_line_background: false,
570 sized_by_content: true,
571 },
572 multibuffer.clone(),
573 Some(project.clone()),
574 window,
575 cx,
576 );
577 editor.set_show_gutter(false, cx);
578 editor.disable_inline_diagnostics();
579 editor.disable_expand_excerpt_buttons(cx);
580 // Keep horizontal scrollbar so user can scroll horizontally if needed
581 editor.set_show_vertical_scrollbar(false, cx);
582 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
583 editor.set_soft_wrap_mode(SoftWrap::None, cx);
584 editor.scroll_manager.set_forbid_vertical_scroll(true);
585 editor.set_show_indent_guides(false, cx);
586 editor.set_read_only(true);
587 editor.set_show_breakpoints(false, cx);
588 editor.set_show_code_actions(false, cx);
589 editor.set_show_git_diff_gutter(false, cx);
590 editor.set_expand_all_diff_hunks(cx);
591 editor
592 });
593 Self {
594 path,
595 project,
596 editor,
597 multibuffer,
598 buffer: None,
599 base_text: None,
600 buffer_diff: None,
601 revealed_ranges: Vec::new(),
602 diff_task: None,
603 preview_expanded: true,
604 error_expanded: None,
605 full_height_expanded: expand_edit_card,
606 total_lines: None,
607 }
608 }
609
610 pub fn initialize(&mut self, buffer: Entity<Buffer>, cx: &mut App) {
611 let buffer_snapshot = buffer.read(cx).snapshot();
612 let base_text = buffer_snapshot.text();
613 let language_registry = buffer.read(cx).language_registry();
614 let text_snapshot = buffer.read(cx).text_snapshot();
615
616 // Create a buffer diff with the current text as the base
617 let buffer_diff = cx.new(|cx| {
618 let mut diff = BufferDiff::new(&text_snapshot, cx);
619 let _ = diff.set_base_text(
620 buffer_snapshot.clone(),
621 language_registry,
622 text_snapshot,
623 cx,
624 );
625 diff
626 });
627
628 self.buffer = Some(buffer.clone());
629 self.base_text = Some(base_text.into());
630 self.buffer_diff = Some(buffer_diff.clone());
631
632 // Add the diff to the multibuffer
633 self.multibuffer
634 .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx));
635 }
636
637 pub fn is_loading(&self) -> bool {
638 self.total_lines.is_none()
639 }
640
641 pub fn update_diff(&mut self, cx: &mut Context<Self>) {
642 let Some(buffer) = self.buffer.as_ref() else {
643 return;
644 };
645 let Some(buffer_diff) = self.buffer_diff.as_ref() else {
646 return;
647 };
648
649 let buffer = buffer.clone();
650 let buffer_diff = buffer_diff.clone();
651 let base_text = self.base_text.clone();
652 self.diff_task = Some(cx.spawn(async move |this, cx| {
653 let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
654 let diff_snapshot = BufferDiff::update_diff(
655 buffer_diff.clone(),
656 text_snapshot.clone(),
657 base_text,
658 false,
659 false,
660 None,
661 None,
662 cx,
663 )
664 .await?;
665 buffer_diff.update(cx, |diff, cx| {
666 diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
667 })?;
668 this.update(cx, |this, cx| this.update_visible_ranges(cx))
669 }));
670 }
671
672 pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
673 self.revealed_ranges.push(range);
674 self.update_visible_ranges(cx);
675 }
676
677 fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
678 let Some(buffer) = self.buffer.as_ref() else {
679 return;
680 };
681
682 let ranges = self.excerpt_ranges(cx);
683 self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| {
684 multibuffer.set_excerpts_for_path(
685 PathKey::for_buffer(buffer, cx),
686 buffer.clone(),
687 ranges,
688 editor::DEFAULT_MULTIBUFFER_CONTEXT,
689 cx,
690 );
691 let end = multibuffer.len(cx);
692 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
693 });
694 cx.notify();
695 }
696
697 fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
698 let Some(buffer) = self.buffer.as_ref() else {
699 return Vec::new();
700 };
701 let Some(diff) = self.buffer_diff.as_ref() else {
702 return Vec::new();
703 };
704
705 let buffer = buffer.read(cx);
706 let diff = diff.read(cx);
707 let mut ranges = diff
708 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
709 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
710 .collect::<Vec<_>>();
711 ranges.extend(
712 self.revealed_ranges
713 .iter()
714 .map(|range| range.to_point(&buffer)),
715 );
716 ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
717
718 // Merge adjacent ranges
719 let mut ranges = ranges.into_iter().peekable();
720 let mut merged_ranges = Vec::new();
721 while let Some(mut range) = ranges.next() {
722 while let Some(next_range) = ranges.peek() {
723 if range.end >= next_range.start {
724 range.end = range.end.max(next_range.end);
725 ranges.next();
726 } else {
727 break;
728 }
729 }
730
731 merged_ranges.push(range);
732 }
733 merged_ranges
734 }
735
736 pub fn finalize(&mut self, cx: &mut Context<Self>) -> Result<()> {
737 let ranges = self.excerpt_ranges(cx);
738 let buffer = self.buffer.take().context("card was already finalized")?;
739 let base_text = self
740 .base_text
741 .take()
742 .context("card was already finalized")?;
743 let language_registry = self.project.read(cx).languages().clone();
744
745 // Replace the buffer in the multibuffer with the snapshot
746 let buffer = cx.new(|cx| {
747 let language = buffer.read(cx).language().cloned();
748 let buffer = TextBuffer::new_normalized(
749 0,
750 cx.entity_id().as_non_zero_u64().into(),
751 buffer.read(cx).line_ending(),
752 buffer.read(cx).as_rope().clone(),
753 );
754 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
755 buffer.set_language(language, cx);
756 buffer
757 });
758
759 let buffer_diff = cx.spawn({
760 let buffer = buffer.clone();
761 let language_registry = language_registry.clone();
762 async move |_this, cx| {
763 build_buffer_diff(base_text, &buffer, &language_registry, cx).await
764 }
765 });
766
767 cx.spawn(async move |this, cx| {
768 let buffer_diff = buffer_diff.await?;
769 this.update(cx, |this, cx| {
770 this.multibuffer.update(cx, |multibuffer, cx| {
771 let path_key = PathKey::for_buffer(&buffer, cx);
772 multibuffer.clear(cx);
773 multibuffer.set_excerpts_for_path(
774 path_key,
775 buffer,
776 ranges,
777 editor::DEFAULT_MULTIBUFFER_CONTEXT,
778 cx,
779 );
780 multibuffer.add_diff(buffer_diff.clone(), cx);
781 });
782
783 cx.notify();
784 })
785 })
786 .detach_and_log_err(cx);
787 Ok(())
788 }
789}
790
791impl ToolCard for EditFileToolCard {
792 fn render(
793 &mut self,
794 status: &ToolUseStatus,
795 window: &mut Window,
796 workspace: WeakEntity<Workspace>,
797 cx: &mut Context<Self>,
798 ) -> impl IntoElement {
799 let error_message = match status {
800 ToolUseStatus::Error(err) => Some(err),
801 _ => None,
802 };
803
804 let running_or_pending = match status {
805 ToolUseStatus::Running | ToolUseStatus::Pending => Some(()),
806 _ => None,
807 };
808
809 let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded;
810
811 let path_label_button = h_flex()
812 .id(("edit-tool-path-label-button", self.editor.entity_id()))
813 .w_full()
814 .max_w_full()
815 .px_1()
816 .gap_0p5()
817 .cursor_pointer()
818 .rounded_sm()
819 .opacity(0.8)
820 .hover(|label| {
821 label
822 .opacity(1.)
823 .bg(cx.theme().colors().element_hover.opacity(0.5))
824 })
825 .tooltip(Tooltip::text("Jump to File"))
826 .child(
827 h_flex()
828 .child(
829 Icon::new(IconName::ToolPencil)
830 .size(IconSize::Small)
831 .color(Color::Muted),
832 )
833 .child(
834 div()
835 .text_size(rems(0.8125))
836 .child(self.path.display().to_string())
837 .ml_1p5()
838 .mr_0p5(),
839 )
840 .child(
841 Icon::new(IconName::ArrowUpRight)
842 .size(IconSize::XSmall)
843 .color(Color::Ignored),
844 ),
845 )
846 .on_click({
847 let path = self.path.clone();
848 let workspace = workspace.clone();
849 move |_, window, cx| {
850 workspace
851 .update(cx, {
852 |workspace, cx| {
853 let Some(project_path) =
854 workspace.project().read(cx).find_project_path(&path, cx)
855 else {
856 return;
857 };
858 let open_task =
859 workspace.open_path(project_path, None, true, window, cx);
860 window
861 .spawn(cx, async move |cx| {
862 let item = open_task.await?;
863 if let Some(active_editor) = item.downcast::<Editor>() {
864 active_editor
865 .update_in(cx, |editor, window, cx| {
866 let snapshot =
867 editor.buffer().read(cx).snapshot(cx);
868 let first_hunk = editor
869 .diff_hunks_in_ranges(
870 &[editor::Anchor::min()
871 ..editor::Anchor::max()],
872 &snapshot,
873 )
874 .next();
875 if let Some(first_hunk) = first_hunk {
876 let first_hunk_start =
877 first_hunk.multi_buffer_range().start;
878 editor.change_selections(
879 Default::default(),
880 window,
881 cx,
882 |selections| {
883 selections.select_anchor_ranges([
884 first_hunk_start
885 ..first_hunk_start,
886 ]);
887 },
888 )
889 }
890 })
891 .log_err();
892 }
893 anyhow::Ok(())
894 })
895 .detach_and_log_err(cx);
896 }
897 })
898 .ok();
899 }
900 })
901 .into_any_element();
902
903 let codeblock_header_bg = cx
904 .theme()
905 .colors()
906 .element_background
907 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
908
909 let codeblock_header = h_flex()
910 .flex_none()
911 .p_1()
912 .gap_1()
913 .justify_between()
914 .rounded_t_md()
915 .when(error_message.is_none(), |header| {
916 header.bg(codeblock_header_bg)
917 })
918 .child(path_label_button)
919 .when(should_show_loading, |header| {
920 header.pr_1p5().child(
921 Icon::new(IconName::ArrowCircle)
922 .size(IconSize::XSmall)
923 .color(Color::Info)
924 .with_animation(
925 "arrow-circle",
926 Animation::new(Duration::from_secs(2)).repeat(),
927 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
928 ),
929 )
930 })
931 .when_some(error_message, |header, error_message| {
932 header.child(
933 h_flex()
934 .gap_1()
935 .child(
936 Icon::new(IconName::Close)
937 .size(IconSize::Small)
938 .color(Color::Error),
939 )
940 .child(
941 Disclosure::new(
942 ("edit-file-error-disclosure", self.editor.entity_id()),
943 self.error_expanded.is_some(),
944 )
945 .opened_icon(IconName::ChevronUp)
946 .closed_icon(IconName::ChevronDown)
947 .on_click(cx.listener({
948 let error_message = error_message.clone();
949
950 move |this, _event, _window, cx| {
951 if this.error_expanded.is_some() {
952 this.error_expanded.take();
953 } else {
954 this.error_expanded = Some(cx.new(|cx| {
955 Markdown::new(error_message.clone(), None, None, cx)
956 }))
957 }
958 cx.notify();
959 }
960 })),
961 ),
962 )
963 })
964 .when(error_message.is_none() && !self.is_loading(), |header| {
965 header.child(
966 Disclosure::new(
967 ("edit-file-disclosure", self.editor.entity_id()),
968 self.preview_expanded,
969 )
970 .opened_icon(IconName::ChevronUp)
971 .closed_icon(IconName::ChevronDown)
972 .on_click(cx.listener(
973 move |this, _event, _window, _cx| {
974 this.preview_expanded = !this.preview_expanded;
975 },
976 )),
977 )
978 });
979
980 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
981 let line_height = editor
982 .style()
983 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
984 .unwrap_or_default();
985
986 editor.set_text_style_refinement(TextStyleRefinement {
987 font_size: Some(
988 TextSize::Small
989 .rems(cx)
990 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
991 .into(),
992 ),
993 ..TextStyleRefinement::default()
994 });
995 let element = editor.render(window, cx);
996 (element.into_any_element(), line_height)
997 });
998
999 let border_color = cx.theme().colors().border.opacity(0.6);
1000
1001 let waiting_for_diff = {
1002 let styles = [
1003 ("w_4_5", (0.1, 0.85), 2000),
1004 ("w_1_4", (0.2, 0.75), 2200),
1005 ("w_2_4", (0.15, 0.64), 1900),
1006 ("w_3_5", (0.25, 0.72), 2300),
1007 ("w_2_5", (0.3, 0.56), 1800),
1008 ];
1009
1010 let mut container = v_flex()
1011 .p_3()
1012 .gap_1()
1013 .border_t_1()
1014 .rounded_b_md()
1015 .border_color(border_color)
1016 .bg(cx.theme().colors().editor_background);
1017
1018 for (width_method, pulse_range, duration_ms) in styles.iter() {
1019 let (min_opacity, max_opacity) = *pulse_range;
1020 let placeholder = match *width_method {
1021 "w_4_5" => div().w_3_4(),
1022 "w_1_4" => div().w_1_4(),
1023 "w_2_4" => div().w_2_4(),
1024 "w_3_5" => div().w_3_5(),
1025 "w_2_5" => div().w_2_5(),
1026 _ => div().w_1_2(),
1027 }
1028 .id("loading_div")
1029 .h_1()
1030 .rounded_full()
1031 .bg(cx.theme().colors().element_active)
1032 .with_animation(
1033 "loading_pulsate",
1034 Animation::new(Duration::from_millis(*duration_ms))
1035 .repeat()
1036 .with_easing(pulsating_between(min_opacity, max_opacity)),
1037 |label, delta| label.opacity(delta),
1038 );
1039
1040 container = container.child(placeholder);
1041 }
1042
1043 container
1044 };
1045
1046 v_flex()
1047 .mb_2()
1048 .border_1()
1049 .when(error_message.is_some(), |card| card.border_dashed())
1050 .border_color(border_color)
1051 .rounded_md()
1052 .overflow_hidden()
1053 .child(codeblock_header)
1054 .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
1055 card.child(
1056 v_flex()
1057 .p_2()
1058 .gap_1()
1059 .border_t_1()
1060 .border_dashed()
1061 .border_color(border_color)
1062 .bg(cx.theme().colors().editor_background)
1063 .rounded_b_md()
1064 .child(
1065 Label::new("Error")
1066 .size(LabelSize::XSmall)
1067 .color(Color::Error),
1068 )
1069 .child(
1070 div()
1071 .rounded_md()
1072 .text_ui_sm(cx)
1073 .bg(cx.theme().colors().editor_background)
1074 .child(MarkdownElement::new(
1075 error_markdown.clone(),
1076 markdown_style(window, cx),
1077 )),
1078 ),
1079 )
1080 })
1081 .when(self.is_loading() && error_message.is_none(), |card| {
1082 card.child(waiting_for_diff)
1083 })
1084 .when(self.preview_expanded && !self.is_loading(), |card| {
1085 let editor_view = v_flex()
1086 .relative()
1087 .h_full()
1088 .when(!self.full_height_expanded, |editor_container| {
1089 editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
1090 })
1091 .overflow_hidden()
1092 .border_t_1()
1093 .border_color(border_color)
1094 .bg(cx.theme().colors().editor_background)
1095 .child(editor);
1096
1097 card.child(
1098 ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
1099 .with_total_lines(self.total_lines.unwrap_or(0) as usize)
1100 .toggle_state(self.full_height_expanded)
1101 .with_collapsed_fade()
1102 .on_toggle({
1103 let this = cx.entity().downgrade();
1104 move |is_expanded, _window, cx| {
1105 if let Some(this) = this.upgrade() {
1106 this.update(cx, |this, _cx| {
1107 this.full_height_expanded = is_expanded;
1108 });
1109 }
1110 }
1111 }),
1112 )
1113 })
1114 }
1115}
1116
1117fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1118 let theme_settings = ThemeSettings::get_global(cx);
1119 let ui_font_size = TextSize::Default.rems(cx);
1120 let mut text_style = window.text_style();
1121
1122 text_style.refine(&TextStyleRefinement {
1123 font_family: Some(theme_settings.ui_font.family.clone()),
1124 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1125 font_features: Some(theme_settings.ui_font.features.clone()),
1126 font_size: Some(ui_font_size.into()),
1127 color: Some(cx.theme().colors().text),
1128 ..Default::default()
1129 });
1130
1131 MarkdownStyle {
1132 base_text_style: text_style.clone(),
1133 selection_background_color: cx.theme().colors().element_selection_background,
1134 ..Default::default()
1135 }
1136}
1137
1138async fn build_buffer(
1139 mut text: String,
1140 path: Arc<Path>,
1141 language_registry: &Arc<language::LanguageRegistry>,
1142 cx: &mut AsyncApp,
1143) -> Result<Entity<Buffer>> {
1144 let line_ending = LineEnding::detect(&text);
1145 LineEnding::normalize(&mut text);
1146 let text = Rope::from(text);
1147 let language = cx
1148 .update(|_cx| language_registry.language_for_file_path(&path))?
1149 .await
1150 .ok();
1151 let buffer = cx.new(|cx| {
1152 let buffer = TextBuffer::new_normalized(
1153 0,
1154 cx.entity_id().as_non_zero_u64().into(),
1155 line_ending,
1156 text,
1157 );
1158 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
1159 buffer.set_language(language, cx);
1160 buffer
1161 })?;
1162 Ok(buffer)
1163}
1164
1165async fn build_buffer_diff(
1166 old_text: Arc<String>,
1167 buffer: &Entity<Buffer>,
1168 language_registry: &Arc<LanguageRegistry>,
1169 cx: &mut AsyncApp,
1170) -> Result<Entity<BufferDiff>> {
1171 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
1172
1173 let old_text_rope = cx
1174 .background_spawn({
1175 let old_text = old_text.clone();
1176 async move { Rope::from(old_text.as_str()) }
1177 })
1178 .await;
1179 let base_buffer = cx
1180 .update(|cx| {
1181 Buffer::build_snapshot(
1182 old_text_rope,
1183 buffer.language().cloned(),
1184 Some(language_registry.clone()),
1185 cx,
1186 )
1187 })?
1188 .await;
1189
1190 let diff_snapshot = cx
1191 .update(|cx| {
1192 BufferDiffSnapshot::new_with_base_buffer(
1193 buffer.text.clone(),
1194 Some(old_text),
1195 base_buffer,
1196 cx,
1197 )
1198 })?
1199 .await;
1200
1201 let secondary_diff = cx.new(|cx| {
1202 let mut diff = BufferDiff::new(&buffer, cx);
1203 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
1204 diff
1205 })?;
1206
1207 cx.new(|cx| {
1208 let mut diff = BufferDiff::new(&buffer.text, cx);
1209 diff.set_snapshot(diff_snapshot, &buffer, cx);
1210 diff.set_secondary_diff(secondary_diff);
1211 diff
1212 })
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218 use ::fs::Fs;
1219 use client::TelemetrySettings;
1220 use gpui::{TestAppContext, UpdateGlobal};
1221 use language_model::fake_provider::FakeLanguageModel;
1222 use serde_json::json;
1223 use settings::SettingsStore;
1224 use std::fs;
1225 use util::path;
1226
1227 #[gpui::test]
1228 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
1229 init_test(cx);
1230
1231 let fs = project::FakeFs::new(cx.executor());
1232 fs.insert_tree("/root", json!({})).await;
1233 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1234 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1235 let model = Arc::new(FakeLanguageModel::default());
1236 let result = cx
1237 .update(|cx| {
1238 let input = serde_json::to_value(EditFileToolInput {
1239 display_description: "Some edit".into(),
1240 path: "root/nonexistent_file.txt".into(),
1241 mode: EditFileMode::Edit,
1242 })
1243 .unwrap();
1244 Arc::new(EditFileTool)
1245 .run(
1246 input,
1247 Arc::default(),
1248 project.clone(),
1249 action_log,
1250 model,
1251 None,
1252 cx,
1253 )
1254 .output
1255 })
1256 .await;
1257 assert_eq!(
1258 result.unwrap_err().to_string(),
1259 "Can't edit file: path not found"
1260 );
1261 }
1262
1263 #[gpui::test]
1264 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
1265 let mode = &EditFileMode::Create;
1266
1267 let result = test_resolve_path(mode, "root/new.txt", cx);
1268 assert_resolved_path_eq(result.await, "new.txt");
1269
1270 let result = test_resolve_path(mode, "new.txt", cx);
1271 assert_resolved_path_eq(result.await, "new.txt");
1272
1273 let result = test_resolve_path(mode, "dir/new.txt", cx);
1274 assert_resolved_path_eq(result.await, "dir/new.txt");
1275
1276 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
1277 assert_eq!(
1278 result.await.unwrap_err().to_string(),
1279 "Can't create file: file already exists"
1280 );
1281
1282 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
1283 assert_eq!(
1284 result.await.unwrap_err().to_string(),
1285 "Can't create file: parent directory doesn't exist"
1286 );
1287 }
1288
1289 #[gpui::test]
1290 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
1291 let mode = &EditFileMode::Edit;
1292
1293 let path_with_root = "root/dir/subdir/existing.txt";
1294 let path_without_root = "dir/subdir/existing.txt";
1295 let result = test_resolve_path(mode, path_with_root, cx);
1296 assert_resolved_path_eq(result.await, path_without_root);
1297
1298 let result = test_resolve_path(mode, path_without_root, cx);
1299 assert_resolved_path_eq(result.await, path_without_root);
1300
1301 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1302 assert_eq!(
1303 result.await.unwrap_err().to_string(),
1304 "Can't edit file: path not found"
1305 );
1306
1307 let result = test_resolve_path(mode, "root/dir", cx);
1308 assert_eq!(
1309 result.await.unwrap_err().to_string(),
1310 "Can't edit file: path is a directory"
1311 );
1312 }
1313
1314 async fn test_resolve_path(
1315 mode: &EditFileMode,
1316 path: &str,
1317 cx: &mut TestAppContext,
1318 ) -> anyhow::Result<ProjectPath> {
1319 init_test(cx);
1320
1321 let fs = project::FakeFs::new(cx.executor());
1322 fs.insert_tree(
1323 "/root",
1324 json!({
1325 "dir": {
1326 "subdir": {
1327 "existing.txt": "hello"
1328 }
1329 }
1330 }),
1331 )
1332 .await;
1333 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1334
1335 let input = EditFileToolInput {
1336 display_description: "Some edit".into(),
1337 path: path.into(),
1338 mode: mode.clone(),
1339 };
1340
1341 let result = cx.update(|cx| resolve_path(&input, project, cx));
1342 result
1343 }
1344
1345 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1346 let actual = path
1347 .expect("Should return valid path")
1348 .path
1349 .to_str()
1350 .unwrap()
1351 .replace("\\", "/"); // Naive Windows paths normalization
1352 assert_eq!(actual, expected);
1353 }
1354
1355 fn init_test(cx: &mut TestAppContext) {
1356 cx.update(|cx| {
1357 let settings_store = SettingsStore::test(cx);
1358 cx.set_global(settings_store);
1359 language::init(cx);
1360 TelemetrySettings::register(cx);
1361 agent_settings::AgentSettings::register(cx);
1362 Project::init_settings(cx);
1363 });
1364 }
1365
1366 fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) {
1367 cx.update(|cx| {
1368 // Set custom data directory (config will be under data_dir/config)
1369 paths::set_custom_data_dir(data_dir.to_str().unwrap());
1370
1371 let settings_store = SettingsStore::test(cx);
1372 cx.set_global(settings_store);
1373 language::init(cx);
1374 TelemetrySettings::register(cx);
1375 agent_settings::AgentSettings::register(cx);
1376 Project::init_settings(cx);
1377 });
1378 }
1379
1380 #[gpui::test]
1381 async fn test_format_on_save(cx: &mut TestAppContext) {
1382 init_test(cx);
1383
1384 let fs = project::FakeFs::new(cx.executor());
1385 fs.insert_tree("/root", json!({"src": {}})).await;
1386
1387 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1388
1389 // Set up a Rust language with LSP formatting support
1390 let rust_language = Arc::new(language::Language::new(
1391 language::LanguageConfig {
1392 name: "Rust".into(),
1393 matcher: language::LanguageMatcher {
1394 path_suffixes: vec!["rs".to_string()],
1395 ..Default::default()
1396 },
1397 ..Default::default()
1398 },
1399 None,
1400 ));
1401
1402 // Register the language and fake LSP
1403 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1404 language_registry.add(rust_language);
1405
1406 let mut fake_language_servers = language_registry.register_fake_lsp(
1407 "Rust",
1408 language::FakeLspAdapter {
1409 capabilities: lsp::ServerCapabilities {
1410 document_formatting_provider: Some(lsp::OneOf::Left(true)),
1411 ..Default::default()
1412 },
1413 ..Default::default()
1414 },
1415 );
1416
1417 // Create the file
1418 fs.save(
1419 path!("/root/src/main.rs").as_ref(),
1420 &"initial content".into(),
1421 language::LineEnding::Unix,
1422 )
1423 .await
1424 .unwrap();
1425
1426 // Open the buffer to trigger LSP initialization
1427 let buffer = project
1428 .update(cx, |project, cx| {
1429 project.open_local_buffer(path!("/root/src/main.rs"), cx)
1430 })
1431 .await
1432 .unwrap();
1433
1434 // Register the buffer with language servers
1435 let _handle = project.update(cx, |project, cx| {
1436 project.register_buffer_with_language_servers(&buffer, cx)
1437 });
1438
1439 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
1440 const FORMATTED_CONTENT: &str =
1441 "This file was formatted by the fake formatter in the test.\n";
1442
1443 // Get the fake language server and set up formatting handler
1444 let fake_language_server = fake_language_servers.next().await.unwrap();
1445 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
1446 |_, _| async move {
1447 Ok(Some(vec![lsp::TextEdit {
1448 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
1449 new_text: FORMATTED_CONTENT.to_string(),
1450 }]))
1451 }
1452 });
1453
1454 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1455 let model = Arc::new(FakeLanguageModel::default());
1456
1457 // First, test with format_on_save enabled
1458 cx.update(|cx| {
1459 SettingsStore::update_global(cx, |store, cx| {
1460 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1461 cx,
1462 |settings| {
1463 settings.defaults.format_on_save = Some(FormatOnSave::On);
1464 settings.defaults.formatter =
1465 Some(language::language_settings::SelectedFormatter::Auto);
1466 },
1467 );
1468 });
1469 });
1470
1471 // Have the model stream unformatted content
1472 let edit_result = {
1473 let edit_task = cx.update(|cx| {
1474 let input = serde_json::to_value(EditFileToolInput {
1475 display_description: "Create main function".into(),
1476 path: "root/src/main.rs".into(),
1477 mode: EditFileMode::Overwrite,
1478 })
1479 .unwrap();
1480 Arc::new(EditFileTool)
1481 .run(
1482 input,
1483 Arc::default(),
1484 project.clone(),
1485 action_log.clone(),
1486 model.clone(),
1487 None,
1488 cx,
1489 )
1490 .output
1491 });
1492
1493 // Stream the unformatted content
1494 cx.executor().run_until_parked();
1495 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
1496 model.end_last_completion_stream();
1497
1498 edit_task.await
1499 };
1500 assert!(edit_result.is_ok());
1501
1502 // Wait for any async operations (e.g. formatting) to complete
1503 cx.executor().run_until_parked();
1504
1505 // Read the file to verify it was formatted automatically
1506 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1507 assert_eq!(
1508 // Ignore carriage returns on Windows
1509 new_content.replace("\r\n", "\n"),
1510 FORMATTED_CONTENT,
1511 "Code should be formatted when format_on_save is enabled"
1512 );
1513
1514 let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
1515
1516 assert_eq!(
1517 stale_buffer_count, 0,
1518 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
1519 This causes the agent to think the file was modified externally when it was just formatted.",
1520 stale_buffer_count
1521 );
1522
1523 // Next, test with format_on_save disabled
1524 cx.update(|cx| {
1525 SettingsStore::update_global(cx, |store, cx| {
1526 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1527 cx,
1528 |settings| {
1529 settings.defaults.format_on_save = Some(FormatOnSave::Off);
1530 },
1531 );
1532 });
1533 });
1534
1535 // Stream unformatted edits again
1536 let edit_result = {
1537 let edit_task = cx.update(|cx| {
1538 let input = serde_json::to_value(EditFileToolInput {
1539 display_description: "Update main function".into(),
1540 path: "root/src/main.rs".into(),
1541 mode: EditFileMode::Overwrite,
1542 })
1543 .unwrap();
1544 Arc::new(EditFileTool)
1545 .run(
1546 input,
1547 Arc::default(),
1548 project.clone(),
1549 action_log.clone(),
1550 model.clone(),
1551 None,
1552 cx,
1553 )
1554 .output
1555 });
1556
1557 // Stream the unformatted content
1558 cx.executor().run_until_parked();
1559 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
1560 model.end_last_completion_stream();
1561
1562 edit_task.await
1563 };
1564 assert!(edit_result.is_ok());
1565
1566 // Wait for any async operations (e.g. formatting) to complete
1567 cx.executor().run_until_parked();
1568
1569 // Verify the file was not formatted
1570 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1571 assert_eq!(
1572 // Ignore carriage returns on Windows
1573 new_content.replace("\r\n", "\n"),
1574 UNFORMATTED_CONTENT,
1575 "Code should not be formatted when format_on_save is disabled"
1576 );
1577 }
1578
1579 #[gpui::test]
1580 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1581 init_test(cx);
1582
1583 let fs = project::FakeFs::new(cx.executor());
1584 fs.insert_tree("/root", json!({"src": {}})).await;
1585
1586 // Create a simple file with trailing whitespace
1587 fs.save(
1588 path!("/root/src/main.rs").as_ref(),
1589 &"initial content".into(),
1590 language::LineEnding::Unix,
1591 )
1592 .await
1593 .unwrap();
1594
1595 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1596 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1597 let model = Arc::new(FakeLanguageModel::default());
1598
1599 // First, test with remove_trailing_whitespace_on_save enabled
1600 cx.update(|cx| {
1601 SettingsStore::update_global(cx, |store, cx| {
1602 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1603 cx,
1604 |settings| {
1605 settings.defaults.remove_trailing_whitespace_on_save = Some(true);
1606 },
1607 );
1608 });
1609 });
1610
1611 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1612 "fn main() { \n println!(\"Hello!\"); \n}\n";
1613
1614 // Have the model stream content that contains trailing whitespace
1615 let edit_result = {
1616 let edit_task = cx.update(|cx| {
1617 let input = serde_json::to_value(EditFileToolInput {
1618 display_description: "Create main function".into(),
1619 path: "root/src/main.rs".into(),
1620 mode: EditFileMode::Overwrite,
1621 })
1622 .unwrap();
1623 Arc::new(EditFileTool)
1624 .run(
1625 input,
1626 Arc::default(),
1627 project.clone(),
1628 action_log.clone(),
1629 model.clone(),
1630 None,
1631 cx,
1632 )
1633 .output
1634 });
1635
1636 // Stream the content with trailing whitespace
1637 cx.executor().run_until_parked();
1638 model.send_last_completion_stream_text_chunk(
1639 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1640 );
1641 model.end_last_completion_stream();
1642
1643 edit_task.await
1644 };
1645 assert!(edit_result.is_ok());
1646
1647 // Wait for any async operations (e.g. formatting) to complete
1648 cx.executor().run_until_parked();
1649
1650 // Read the file to verify trailing whitespace was removed automatically
1651 assert_eq!(
1652 // Ignore carriage returns on Windows
1653 fs.load(path!("/root/src/main.rs").as_ref())
1654 .await
1655 .unwrap()
1656 .replace("\r\n", "\n"),
1657 "fn main() {\n println!(\"Hello!\");\n}\n",
1658 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1659 );
1660
1661 // Next, test with remove_trailing_whitespace_on_save disabled
1662 cx.update(|cx| {
1663 SettingsStore::update_global(cx, |store, cx| {
1664 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1665 cx,
1666 |settings| {
1667 settings.defaults.remove_trailing_whitespace_on_save = Some(false);
1668 },
1669 );
1670 });
1671 });
1672
1673 // Stream edits again with trailing whitespace
1674 let edit_result = {
1675 let edit_task = cx.update(|cx| {
1676 let input = serde_json::to_value(EditFileToolInput {
1677 display_description: "Update main function".into(),
1678 path: "root/src/main.rs".into(),
1679 mode: EditFileMode::Overwrite,
1680 })
1681 .unwrap();
1682 Arc::new(EditFileTool)
1683 .run(
1684 input,
1685 Arc::default(),
1686 project.clone(),
1687 action_log.clone(),
1688 model.clone(),
1689 None,
1690 cx,
1691 )
1692 .output
1693 });
1694
1695 // Stream the content with trailing whitespace
1696 cx.executor().run_until_parked();
1697 model.send_last_completion_stream_text_chunk(
1698 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1699 );
1700 model.end_last_completion_stream();
1701
1702 edit_task.await
1703 };
1704 assert!(edit_result.is_ok());
1705
1706 // Wait for any async operations (e.g. formatting) to complete
1707 cx.executor().run_until_parked();
1708
1709 // Verify the file still has trailing whitespace
1710 // Read the file again - it should still have trailing whitespace
1711 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1712 assert_eq!(
1713 // Ignore carriage returns on Windows
1714 final_content.replace("\r\n", "\n"),
1715 CONTENT_WITH_TRAILING_WHITESPACE,
1716 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1717 );
1718 }
1719
1720 #[gpui::test]
1721 async fn test_needs_confirmation(cx: &mut TestAppContext) {
1722 init_test(cx);
1723 let tool = Arc::new(EditFileTool);
1724 let fs = project::FakeFs::new(cx.executor());
1725 fs.insert_tree("/root", json!({})).await;
1726
1727 // Test 1: Path with .zed component should require confirmation
1728 let input_with_zed = json!({
1729 "display_description": "Edit settings",
1730 "path": ".zed/settings.json",
1731 "mode": "edit"
1732 });
1733 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1734 cx.update(|cx| {
1735 assert!(
1736 tool.needs_confirmation(&input_with_zed, &project, cx),
1737 "Path with .zed component should require confirmation"
1738 );
1739 });
1740
1741 // Test 2: Absolute path should require confirmation
1742 let input_absolute = json!({
1743 "display_description": "Edit file",
1744 "path": "/etc/hosts",
1745 "mode": "edit"
1746 });
1747 cx.update(|cx| {
1748 assert!(
1749 tool.needs_confirmation(&input_absolute, &project, cx),
1750 "Absolute path should require confirmation"
1751 );
1752 });
1753
1754 // Test 3: Relative path without .zed should not require confirmation
1755 let input_relative = json!({
1756 "display_description": "Edit file",
1757 "path": "root/src/main.rs",
1758 "mode": "edit"
1759 });
1760 cx.update(|cx| {
1761 assert!(
1762 !tool.needs_confirmation(&input_relative, &project, cx),
1763 "Relative path without .zed should not require confirmation"
1764 );
1765 });
1766
1767 // Test 4: Path with .zed in the middle should require confirmation
1768 let input_zed_middle = json!({
1769 "display_description": "Edit settings",
1770 "path": "root/.zed/tasks.json",
1771 "mode": "edit"
1772 });
1773 cx.update(|cx| {
1774 assert!(
1775 tool.needs_confirmation(&input_zed_middle, &project, cx),
1776 "Path with .zed in any component should require confirmation"
1777 );
1778 });
1779
1780 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1781 cx.update(|cx| {
1782 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1783 settings.always_allow_tool_actions = true;
1784 agent_settings::AgentSettings::override_global(settings, cx);
1785
1786 assert!(
1787 !tool.needs_confirmation(&input_with_zed, &project, cx),
1788 "When always_allow_tool_actions is true, no confirmation should be needed"
1789 );
1790 assert!(
1791 !tool.needs_confirmation(&input_absolute, &project, cx),
1792 "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths"
1793 );
1794 });
1795 }
1796
1797 #[gpui::test]
1798 async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) {
1799 // Set up a custom config directory for testing
1800 let temp_dir = tempfile::tempdir().unwrap();
1801 init_test_with_config(cx, temp_dir.path());
1802
1803 let tool = Arc::new(EditFileTool);
1804
1805 // Test ui_text shows context for various paths
1806 let test_cases = vec![
1807 (
1808 json!({
1809 "display_description": "Update config",
1810 "path": ".zed/settings.json",
1811 "mode": "edit"
1812 }),
1813 "Update config (local settings)",
1814 ".zed path should show local settings context",
1815 ),
1816 (
1817 json!({
1818 "display_description": "Fix bug",
1819 "path": "src/.zed/local.json",
1820 "mode": "edit"
1821 }),
1822 "Fix bug (local settings)",
1823 "Nested .zed path should show local settings context",
1824 ),
1825 (
1826 json!({
1827 "display_description": "Update readme",
1828 "path": "README.md",
1829 "mode": "edit"
1830 }),
1831 "Update readme",
1832 "Normal path should not show additional context",
1833 ),
1834 (
1835 json!({
1836 "display_description": "Edit config",
1837 "path": "config.zed",
1838 "mode": "edit"
1839 }),
1840 "Edit config",
1841 ".zed as extension should not show context",
1842 ),
1843 ];
1844
1845 for (input, expected_text, description) in test_cases {
1846 cx.update(|_cx| {
1847 let ui_text = tool.ui_text(&input);
1848 assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
1849 });
1850 }
1851 }
1852
1853 #[gpui::test]
1854 async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) {
1855 init_test(cx);
1856 let tool = Arc::new(EditFileTool);
1857 let fs = project::FakeFs::new(cx.executor());
1858
1859 // Create a project in /project directory
1860 fs.insert_tree("/project", json!({})).await;
1861 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1862
1863 // Test file outside project requires confirmation
1864 let input_outside = json!({
1865 "display_description": "Edit file",
1866 "path": "/outside/file.txt",
1867 "mode": "edit"
1868 });
1869 cx.update(|cx| {
1870 assert!(
1871 tool.needs_confirmation(&input_outside, &project, cx),
1872 "File outside project should require confirmation"
1873 );
1874 });
1875
1876 // Test file inside project doesn't require confirmation
1877 let input_inside = json!({
1878 "display_description": "Edit file",
1879 "path": "project/file.txt",
1880 "mode": "edit"
1881 });
1882 cx.update(|cx| {
1883 assert!(
1884 !tool.needs_confirmation(&input_inside, &project, cx),
1885 "File inside project should not require confirmation"
1886 );
1887 });
1888 }
1889
1890 #[gpui::test]
1891 async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) {
1892 // Set up a custom data directory for testing
1893 let temp_dir = tempfile::tempdir().unwrap();
1894 init_test_with_config(cx, temp_dir.path());
1895
1896 let tool = Arc::new(EditFileTool);
1897 let fs = project::FakeFs::new(cx.executor());
1898 fs.insert_tree("/home/user/myproject", json!({})).await;
1899 let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
1900
1901 // Get the actual local settings folder name
1902 let local_settings_folder = paths::local_settings_folder_relative_path();
1903
1904 // Test various config path patterns
1905 let test_cases = vec![
1906 (
1907 format!("{}/settings.json", local_settings_folder.display()),
1908 true,
1909 "Top-level local settings file".to_string(),
1910 ),
1911 (
1912 format!(
1913 "myproject/{}/settings.json",
1914 local_settings_folder.display()
1915 ),
1916 true,
1917 "Local settings in project path".to_string(),
1918 ),
1919 (
1920 format!("src/{}/config.toml", local_settings_folder.display()),
1921 true,
1922 "Local settings in subdirectory".to_string(),
1923 ),
1924 (
1925 ".zed.backup/file.txt".to_string(),
1926 true,
1927 ".zed.backup is outside project".to_string(),
1928 ),
1929 (
1930 "my.zed/file.txt".to_string(),
1931 true,
1932 "my.zed is outside project".to_string(),
1933 ),
1934 (
1935 "myproject/src/file.zed".to_string(),
1936 false,
1937 ".zed as file extension".to_string(),
1938 ),
1939 (
1940 "myproject/normal/path/file.rs".to_string(),
1941 false,
1942 "Normal file without config paths".to_string(),
1943 ),
1944 ];
1945
1946 for (path, should_confirm, description) in test_cases {
1947 let input = json!({
1948 "display_description": "Edit file",
1949 "path": path,
1950 "mode": "edit"
1951 });
1952 cx.update(|cx| {
1953 assert_eq!(
1954 tool.needs_confirmation(&input, &project, cx),
1955 should_confirm,
1956 "Failed for case: {} - path: {}",
1957 description,
1958 path
1959 );
1960 });
1961 }
1962 }
1963
1964 #[gpui::test]
1965 async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) {
1966 // Set up a custom data directory for testing
1967 let temp_dir = tempfile::tempdir().unwrap();
1968 init_test_with_config(cx, temp_dir.path());
1969
1970 let tool = Arc::new(EditFileTool);
1971 let fs = project::FakeFs::new(cx.executor());
1972
1973 // Create test files in the global config directory
1974 let global_config_dir = paths::config_dir();
1975 fs::create_dir_all(&global_config_dir).unwrap();
1976 let global_settings_path = global_config_dir.join("settings.json");
1977 fs::write(&global_settings_path, "{}").unwrap();
1978
1979 fs.insert_tree("/project", json!({})).await;
1980 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1981
1982 // Test global config paths
1983 let test_cases = vec![
1984 (
1985 global_settings_path.to_str().unwrap().to_string(),
1986 true,
1987 "Global settings file should require confirmation",
1988 ),
1989 (
1990 global_config_dir
1991 .join("keymap.json")
1992 .to_str()
1993 .unwrap()
1994 .to_string(),
1995 true,
1996 "Global keymap file should require confirmation",
1997 ),
1998 (
1999 "project/normal_file.rs".to_string(),
2000 false,
2001 "Normal project file should not require confirmation",
2002 ),
2003 ];
2004
2005 for (path, should_confirm, description) in test_cases {
2006 let input = json!({
2007 "display_description": "Edit file",
2008 "path": path,
2009 "mode": "edit"
2010 });
2011 cx.update(|cx| {
2012 assert_eq!(
2013 tool.needs_confirmation(&input, &project, cx),
2014 should_confirm,
2015 "Failed for case: {}",
2016 description
2017 );
2018 });
2019 }
2020 }
2021
2022 #[gpui::test]
2023 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
2024 init_test(cx);
2025 let tool = Arc::new(EditFileTool);
2026 let fs = project::FakeFs::new(cx.executor());
2027
2028 // Create multiple worktree directories
2029 fs.insert_tree(
2030 "/workspace/frontend",
2031 json!({
2032 "src": {
2033 "main.js": "console.log('frontend');"
2034 }
2035 }),
2036 )
2037 .await;
2038 fs.insert_tree(
2039 "/workspace/backend",
2040 json!({
2041 "src": {
2042 "main.rs": "fn main() {}"
2043 }
2044 }),
2045 )
2046 .await;
2047 fs.insert_tree(
2048 "/workspace/shared",
2049 json!({
2050 ".zed": {
2051 "settings.json": "{}"
2052 }
2053 }),
2054 )
2055 .await;
2056
2057 // Create project with multiple worktrees
2058 let project = Project::test(
2059 fs.clone(),
2060 [
2061 path!("/workspace/frontend").as_ref(),
2062 path!("/workspace/backend").as_ref(),
2063 path!("/workspace/shared").as_ref(),
2064 ],
2065 cx,
2066 )
2067 .await;
2068
2069 // Test files in different worktrees
2070 let test_cases = vec![
2071 ("frontend/src/main.js", false, "File in first worktree"),
2072 ("backend/src/main.rs", false, "File in second worktree"),
2073 (
2074 "shared/.zed/settings.json",
2075 true,
2076 ".zed file in third worktree",
2077 ),
2078 ("/etc/hosts", true, "Absolute path outside all worktrees"),
2079 (
2080 "../outside/file.txt",
2081 true,
2082 "Relative path outside worktrees",
2083 ),
2084 ];
2085
2086 for (path, should_confirm, description) in test_cases {
2087 let input = json!({
2088 "display_description": "Edit file",
2089 "path": path,
2090 "mode": "edit"
2091 });
2092 cx.update(|cx| {
2093 assert_eq!(
2094 tool.needs_confirmation(&input, &project, cx),
2095 should_confirm,
2096 "Failed for case: {} - path: {}",
2097 description,
2098 path
2099 );
2100 });
2101 }
2102 }
2103
2104 #[gpui::test]
2105 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
2106 init_test(cx);
2107 let tool = Arc::new(EditFileTool);
2108 let fs = project::FakeFs::new(cx.executor());
2109 fs.insert_tree(
2110 "/project",
2111 json!({
2112 ".zed": {
2113 "settings.json": "{}"
2114 },
2115 "src": {
2116 ".zed": {
2117 "local.json": "{}"
2118 }
2119 }
2120 }),
2121 )
2122 .await;
2123 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2124
2125 // Test edge cases
2126 let test_cases = vec![
2127 // Empty path - find_project_path returns Some for empty paths
2128 ("", false, "Empty path is treated as project root"),
2129 // Root directory
2130 ("/", true, "Root directory should be outside project"),
2131 // Parent directory references - find_project_path resolves these
2132 (
2133 "project/../other",
2134 false,
2135 "Path with .. is resolved by find_project_path",
2136 ),
2137 (
2138 "project/./src/file.rs",
2139 false,
2140 "Path with . should work normally",
2141 ),
2142 // Windows-style paths (if on Windows)
2143 #[cfg(target_os = "windows")]
2144 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
2145 #[cfg(target_os = "windows")]
2146 ("project\\src\\main.rs", false, "Windows-style project path"),
2147 ];
2148
2149 for (path, should_confirm, description) in test_cases {
2150 let input = json!({
2151 "display_description": "Edit file",
2152 "path": path,
2153 "mode": "edit"
2154 });
2155 cx.update(|cx| {
2156 assert_eq!(
2157 tool.needs_confirmation(&input, &project, cx),
2158 should_confirm,
2159 "Failed for case: {} - path: {}",
2160 description,
2161 path
2162 );
2163 });
2164 }
2165 }
2166
2167 #[gpui::test]
2168 async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) {
2169 init_test(cx);
2170 let tool = Arc::new(EditFileTool);
2171
2172 // Test UI text for various scenarios
2173 let test_cases = vec![
2174 (
2175 json!({
2176 "display_description": "Update config",
2177 "path": ".zed/settings.json",
2178 "mode": "edit"
2179 }),
2180 "Update config (local settings)",
2181 ".zed path should show local settings context",
2182 ),
2183 (
2184 json!({
2185 "display_description": "Fix bug",
2186 "path": "src/.zed/local.json",
2187 "mode": "edit"
2188 }),
2189 "Fix bug (local settings)",
2190 "Nested .zed path should show local settings context",
2191 ),
2192 (
2193 json!({
2194 "display_description": "Update readme",
2195 "path": "README.md",
2196 "mode": "edit"
2197 }),
2198 "Update readme",
2199 "Normal path should not show additional context",
2200 ),
2201 (
2202 json!({
2203 "display_description": "Edit config",
2204 "path": "config.zed",
2205 "mode": "edit"
2206 }),
2207 "Edit config",
2208 ".zed as extension should not show context",
2209 ),
2210 ];
2211
2212 for (input, expected_text, description) in test_cases {
2213 cx.update(|_cx| {
2214 let ui_text = tool.ui_text(&input);
2215 assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
2216 });
2217 }
2218 }
2219
2220 #[gpui::test]
2221 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
2222 init_test(cx);
2223 let tool = Arc::new(EditFileTool);
2224 let fs = project::FakeFs::new(cx.executor());
2225 fs.insert_tree(
2226 "/project",
2227 json!({
2228 "existing.txt": "content",
2229 ".zed": {
2230 "settings.json": "{}"
2231 }
2232 }),
2233 )
2234 .await;
2235 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2236
2237 // Test different EditFileMode values
2238 let modes = vec![
2239 EditFileMode::Edit,
2240 EditFileMode::Create,
2241 EditFileMode::Overwrite,
2242 ];
2243
2244 for mode in modes {
2245 // Test .zed path with different modes
2246 let input_zed = json!({
2247 "display_description": "Edit settings",
2248 "path": "project/.zed/settings.json",
2249 "mode": mode
2250 });
2251 cx.update(|cx| {
2252 assert!(
2253 tool.needs_confirmation(&input_zed, &project, cx),
2254 ".zed path should require confirmation regardless of mode: {:?}",
2255 mode
2256 );
2257 });
2258
2259 // Test outside path with different modes
2260 let input_outside = json!({
2261 "display_description": "Edit file",
2262 "path": "/outside/file.txt",
2263 "mode": mode
2264 });
2265 cx.update(|cx| {
2266 assert!(
2267 tool.needs_confirmation(&input_outside, &project, cx),
2268 "Outside path should require confirmation regardless of mode: {:?}",
2269 mode
2270 );
2271 });
2272
2273 // Test normal path with different modes
2274 let input_normal = json!({
2275 "display_description": "Edit file",
2276 "path": "project/normal.txt",
2277 "mode": mode
2278 });
2279 cx.update(|cx| {
2280 assert!(
2281 !tool.needs_confirmation(&input_normal, &project, cx),
2282 "Normal path should not require confirmation regardless of mode: {:?}",
2283 mode
2284 );
2285 });
2286 }
2287 }
2288
2289 #[gpui::test]
2290 async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) {
2291 // Set up with custom directories for deterministic testing
2292 let temp_dir = tempfile::tempdir().unwrap();
2293 init_test_with_config(cx, temp_dir.path());
2294
2295 let tool = Arc::new(EditFileTool);
2296 let fs = project::FakeFs::new(cx.executor());
2297 fs.insert_tree("/project", json!({})).await;
2298 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2299
2300 // Enable always_allow_tool_actions
2301 cx.update(|cx| {
2302 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2303 settings.always_allow_tool_actions = true;
2304 agent_settings::AgentSettings::override_global(settings, cx);
2305 });
2306
2307 // Test that all paths that normally require confirmation are bypassed
2308 let global_settings_path = paths::config_dir().join("settings.json");
2309 fs::create_dir_all(paths::config_dir()).unwrap();
2310 fs::write(&global_settings_path, "{}").unwrap();
2311
2312 let test_cases = vec![
2313 ".zed/settings.json",
2314 "project/.zed/config.toml",
2315 global_settings_path.to_str().unwrap(),
2316 "/etc/hosts",
2317 "/absolute/path/file.txt",
2318 "../outside/project.txt",
2319 ];
2320
2321 for path in test_cases {
2322 let input = json!({
2323 "display_description": "Edit file",
2324 "path": path,
2325 "mode": "edit"
2326 });
2327 cx.update(|cx| {
2328 assert!(
2329 !tool.needs_confirmation(&input, &project, cx),
2330 "Path {} should not require confirmation when always_allow_tool_actions is true",
2331 path
2332 );
2333 });
2334 }
2335
2336 // Disable always_allow_tool_actions and verify confirmation is required again
2337 cx.update(|cx| {
2338 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2339 settings.always_allow_tool_actions = false;
2340 agent_settings::AgentSettings::override_global(settings, cx);
2341 });
2342
2343 // Verify .zed path requires confirmation again
2344 let input = json!({
2345 "display_description": "Edit file",
2346 "path": ".zed/settings.json",
2347 "mode": "edit"
2348 });
2349 cx.update(|cx| {
2350 assert!(
2351 tool.needs_confirmation(&input, &project, cx),
2352 ".zed path should require confirmation when always_allow_tool_actions is false"
2353 );
2354 });
2355 }
2356}