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