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