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