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,
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);
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 async move |_this, cx| {
780 build_buffer_diff(base_text, &buffer, &language_registry, cx).await
781 }
782 });
783
784 cx.spawn(async move |this, cx| {
785 let buffer_diff = buffer_diff.await?;
786 this.update(cx, |this, cx| {
787 this.multibuffer.update(cx, |multibuffer, cx| {
788 let path_key = PathKey::for_buffer(&buffer, cx);
789 multibuffer.clear(cx);
790 multibuffer.set_excerpts_for_path(
791 path_key,
792 buffer,
793 ranges,
794 editor::DEFAULT_MULTIBUFFER_CONTEXT,
795 cx,
796 );
797 multibuffer.add_diff(buffer_diff.clone(), cx);
798 });
799
800 cx.notify();
801 })
802 })
803 .detach_and_log_err(cx);
804 Ok(())
805 }
806}
807
808impl ToolCard for EditFileToolCard {
809 fn render(
810 &mut self,
811 status: &ToolUseStatus,
812 window: &mut Window,
813 workspace: WeakEntity<Workspace>,
814 cx: &mut Context<Self>,
815 ) -> impl IntoElement {
816 let error_message = match status {
817 ToolUseStatus::Error(err) => Some(err),
818 _ => None,
819 };
820
821 let running_or_pending = match status {
822 ToolUseStatus::Running | ToolUseStatus::Pending => Some(()),
823 _ => None,
824 };
825
826 let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded;
827
828 let path_label_button = h_flex()
829 .id(("edit-tool-path-label-button", self.editor.entity_id()))
830 .w_full()
831 .max_w_full()
832 .px_1()
833 .gap_0p5()
834 .cursor_pointer()
835 .rounded_sm()
836 .opacity(0.8)
837 .hover(|label| {
838 label
839 .opacity(1.)
840 .bg(cx.theme().colors().element_hover.opacity(0.5))
841 })
842 .tooltip(Tooltip::text("Jump to File"))
843 .child(
844 h_flex()
845 .child(
846 Icon::new(IconName::ToolPencil)
847 .size(IconSize::Small)
848 .color(Color::Muted),
849 )
850 .child(
851 div()
852 .text_size(rems(0.8125))
853 .child(self.path.display().to_string())
854 .ml_1p5()
855 .mr_0p5(),
856 )
857 .child(
858 Icon::new(IconName::ArrowUpRight)
859 .size(IconSize::Small)
860 .color(Color::Ignored),
861 ),
862 )
863 .on_click({
864 let path = self.path.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 cx.update(|cx| resolve_path(&input, project, cx))
1358 }
1359
1360 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1361 let actual = path
1362 .expect("Should return valid path")
1363 .path
1364 .to_str()
1365 .unwrap()
1366 .replace("\\", "/"); // Naive Windows paths normalization
1367 assert_eq!(actual, expected);
1368 }
1369
1370 #[test]
1371 fn still_streaming_ui_text_with_path() {
1372 let input = json!({
1373 "path": "src/main.rs",
1374 "display_description": "",
1375 "old_string": "old code",
1376 "new_string": "new code"
1377 });
1378
1379 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1380 }
1381
1382 #[test]
1383 fn still_streaming_ui_text_with_description() {
1384 let input = json!({
1385 "path": "",
1386 "display_description": "Fix error handling",
1387 "old_string": "old code",
1388 "new_string": "new code"
1389 });
1390
1391 assert_eq!(
1392 EditFileTool.still_streaming_ui_text(&input),
1393 "Fix error handling",
1394 );
1395 }
1396
1397 #[test]
1398 fn still_streaming_ui_text_with_path_and_description() {
1399 let input = json!({
1400 "path": "src/main.rs",
1401 "display_description": "Fix error handling",
1402 "old_string": "old code",
1403 "new_string": "new code"
1404 });
1405
1406 assert_eq!(
1407 EditFileTool.still_streaming_ui_text(&input),
1408 "Fix error handling",
1409 );
1410 }
1411
1412 #[test]
1413 fn still_streaming_ui_text_no_path_or_description() {
1414 let input = json!({
1415 "path": "",
1416 "display_description": "",
1417 "old_string": "old code",
1418 "new_string": "new code"
1419 });
1420
1421 assert_eq!(
1422 EditFileTool.still_streaming_ui_text(&input),
1423 DEFAULT_UI_TEXT,
1424 );
1425 }
1426
1427 #[test]
1428 fn still_streaming_ui_text_with_null() {
1429 let input = serde_json::Value::Null;
1430
1431 assert_eq!(
1432 EditFileTool.still_streaming_ui_text(&input),
1433 DEFAULT_UI_TEXT,
1434 );
1435 }
1436
1437 fn init_test(cx: &mut TestAppContext) {
1438 cx.update(|cx| {
1439 let settings_store = SettingsStore::test(cx);
1440 cx.set_global(settings_store);
1441 language::init(cx);
1442 TelemetrySettings::register(cx);
1443 agent_settings::AgentSettings::register(cx);
1444 Project::init_settings(cx);
1445 });
1446 }
1447
1448 fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) {
1449 cx.update(|cx| {
1450 // Set custom data directory (config will be under data_dir/config)
1451 paths::set_custom_data_dir(data_dir.to_str().unwrap());
1452
1453 let settings_store = SettingsStore::test(cx);
1454 cx.set_global(settings_store);
1455 language::init(cx);
1456 TelemetrySettings::register(cx);
1457 agent_settings::AgentSettings::register(cx);
1458 Project::init_settings(cx);
1459 });
1460 }
1461
1462 #[gpui::test]
1463 async fn test_format_on_save(cx: &mut TestAppContext) {
1464 init_test(cx);
1465
1466 let fs = project::FakeFs::new(cx.executor());
1467 fs.insert_tree("/root", json!({"src": {}})).await;
1468
1469 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1470
1471 // Set up a Rust language with LSP formatting support
1472 let rust_language = Arc::new(language::Language::new(
1473 language::LanguageConfig {
1474 name: "Rust".into(),
1475 matcher: language::LanguageMatcher {
1476 path_suffixes: vec!["rs".to_string()],
1477 ..Default::default()
1478 },
1479 ..Default::default()
1480 },
1481 None,
1482 ));
1483
1484 // Register the language and fake LSP
1485 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1486 language_registry.add(rust_language);
1487
1488 let mut fake_language_servers = language_registry.register_fake_lsp(
1489 "Rust",
1490 language::FakeLspAdapter {
1491 capabilities: lsp::ServerCapabilities {
1492 document_formatting_provider: Some(lsp::OneOf::Left(true)),
1493 ..Default::default()
1494 },
1495 ..Default::default()
1496 },
1497 );
1498
1499 // Create the file
1500 fs.save(
1501 path!("/root/src/main.rs").as_ref(),
1502 &"initial content".into(),
1503 language::LineEnding::Unix,
1504 )
1505 .await
1506 .unwrap();
1507
1508 // Open the buffer to trigger LSP initialization
1509 let buffer = project
1510 .update(cx, |project, cx| {
1511 project.open_local_buffer(path!("/root/src/main.rs"), cx)
1512 })
1513 .await
1514 .unwrap();
1515
1516 // Register the buffer with language servers
1517 let _handle = project.update(cx, |project, cx| {
1518 project.register_buffer_with_language_servers(&buffer, cx)
1519 });
1520
1521 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
1522 const FORMATTED_CONTENT: &str =
1523 "This file was formatted by the fake formatter in the test.\n";
1524
1525 // Get the fake language server and set up formatting handler
1526 let fake_language_server = fake_language_servers.next().await.unwrap();
1527 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
1528 |_, _| async move {
1529 Ok(Some(vec![lsp::TextEdit {
1530 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
1531 new_text: FORMATTED_CONTENT.to_string(),
1532 }]))
1533 }
1534 });
1535
1536 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1537 let model = Arc::new(FakeLanguageModel::default());
1538
1539 // First, test with format_on_save enabled
1540 cx.update(|cx| {
1541 SettingsStore::update_global(cx, |store, cx| {
1542 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1543 cx,
1544 |settings| {
1545 settings.defaults.format_on_save = Some(FormatOnSave::On);
1546 settings.defaults.formatter =
1547 Some(language::language_settings::SelectedFormatter::Auto);
1548 },
1549 );
1550 });
1551 });
1552
1553 // Have the model stream unformatted content
1554 let edit_result = {
1555 let edit_task = cx.update(|cx| {
1556 let input = serde_json::to_value(EditFileToolInput {
1557 display_description: "Create main function".into(),
1558 path: "root/src/main.rs".into(),
1559 mode: EditFileMode::Overwrite,
1560 })
1561 .unwrap();
1562 Arc::new(EditFileTool)
1563 .run(
1564 input,
1565 Arc::default(),
1566 project.clone(),
1567 action_log.clone(),
1568 model.clone(),
1569 None,
1570 cx,
1571 )
1572 .output
1573 });
1574
1575 // Stream the unformatted content
1576 cx.executor().run_until_parked();
1577 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
1578 model.end_last_completion_stream();
1579
1580 edit_task.await
1581 };
1582 assert!(edit_result.is_ok());
1583
1584 // Wait for any async operations (e.g. formatting) to complete
1585 cx.executor().run_until_parked();
1586
1587 // Read the file to verify it was formatted automatically
1588 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1589 assert_eq!(
1590 // Ignore carriage returns on Windows
1591 new_content.replace("\r\n", "\n"),
1592 FORMATTED_CONTENT,
1593 "Code should be formatted when format_on_save is enabled"
1594 );
1595
1596 let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
1597
1598 assert_eq!(
1599 stale_buffer_count, 0,
1600 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
1601 This causes the agent to think the file was modified externally when it was just formatted.",
1602 stale_buffer_count
1603 );
1604
1605 // Next, test with format_on_save disabled
1606 cx.update(|cx| {
1607 SettingsStore::update_global(cx, |store, cx| {
1608 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1609 cx,
1610 |settings| {
1611 settings.defaults.format_on_save = Some(FormatOnSave::Off);
1612 },
1613 );
1614 });
1615 });
1616
1617 // Stream unformatted edits again
1618 let edit_result = {
1619 let edit_task = cx.update(|cx| {
1620 let input = serde_json::to_value(EditFileToolInput {
1621 display_description: "Update main function".into(),
1622 path: "root/src/main.rs".into(),
1623 mode: EditFileMode::Overwrite,
1624 })
1625 .unwrap();
1626 Arc::new(EditFileTool)
1627 .run(
1628 input,
1629 Arc::default(),
1630 project.clone(),
1631 action_log.clone(),
1632 model.clone(),
1633 None,
1634 cx,
1635 )
1636 .output
1637 });
1638
1639 // Stream the unformatted content
1640 cx.executor().run_until_parked();
1641 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
1642 model.end_last_completion_stream();
1643
1644 edit_task.await
1645 };
1646 assert!(edit_result.is_ok());
1647
1648 // Wait for any async operations (e.g. formatting) to complete
1649 cx.executor().run_until_parked();
1650
1651 // Verify the file was not formatted
1652 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1653 assert_eq!(
1654 // Ignore carriage returns on Windows
1655 new_content.replace("\r\n", "\n"),
1656 UNFORMATTED_CONTENT,
1657 "Code should not be formatted when format_on_save is disabled"
1658 );
1659 }
1660
1661 #[gpui::test]
1662 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1663 init_test(cx);
1664
1665 let fs = project::FakeFs::new(cx.executor());
1666 fs.insert_tree("/root", json!({"src": {}})).await;
1667
1668 // Create a simple file with trailing whitespace
1669 fs.save(
1670 path!("/root/src/main.rs").as_ref(),
1671 &"initial content".into(),
1672 language::LineEnding::Unix,
1673 )
1674 .await
1675 .unwrap();
1676
1677 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1678 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1679 let model = Arc::new(FakeLanguageModel::default());
1680
1681 // First, test with remove_trailing_whitespace_on_save enabled
1682 cx.update(|cx| {
1683 SettingsStore::update_global(cx, |store, cx| {
1684 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1685 cx,
1686 |settings| {
1687 settings.defaults.remove_trailing_whitespace_on_save = Some(true);
1688 },
1689 );
1690 });
1691 });
1692
1693 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1694 "fn main() { \n println!(\"Hello!\"); \n}\n";
1695
1696 // Have the model stream content that contains trailing whitespace
1697 let edit_result = {
1698 let edit_task = cx.update(|cx| {
1699 let input = serde_json::to_value(EditFileToolInput {
1700 display_description: "Create main function".into(),
1701 path: "root/src/main.rs".into(),
1702 mode: EditFileMode::Overwrite,
1703 })
1704 .unwrap();
1705 Arc::new(EditFileTool)
1706 .run(
1707 input,
1708 Arc::default(),
1709 project.clone(),
1710 action_log.clone(),
1711 model.clone(),
1712 None,
1713 cx,
1714 )
1715 .output
1716 });
1717
1718 // Stream the content with trailing whitespace
1719 cx.executor().run_until_parked();
1720 model.send_last_completion_stream_text_chunk(
1721 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1722 );
1723 model.end_last_completion_stream();
1724
1725 edit_task.await
1726 };
1727 assert!(edit_result.is_ok());
1728
1729 // Wait for any async operations (e.g. formatting) to complete
1730 cx.executor().run_until_parked();
1731
1732 // Read the file to verify trailing whitespace was removed automatically
1733 assert_eq!(
1734 // Ignore carriage returns on Windows
1735 fs.load(path!("/root/src/main.rs").as_ref())
1736 .await
1737 .unwrap()
1738 .replace("\r\n", "\n"),
1739 "fn main() {\n println!(\"Hello!\");\n}\n",
1740 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1741 );
1742
1743 // Next, test with remove_trailing_whitespace_on_save disabled
1744 cx.update(|cx| {
1745 SettingsStore::update_global(cx, |store, cx| {
1746 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1747 cx,
1748 |settings| {
1749 settings.defaults.remove_trailing_whitespace_on_save = Some(false);
1750 },
1751 );
1752 });
1753 });
1754
1755 // Stream edits again with trailing whitespace
1756 let edit_result = {
1757 let edit_task = cx.update(|cx| {
1758 let input = serde_json::to_value(EditFileToolInput {
1759 display_description: "Update main function".into(),
1760 path: "root/src/main.rs".into(),
1761 mode: EditFileMode::Overwrite,
1762 })
1763 .unwrap();
1764 Arc::new(EditFileTool)
1765 .run(
1766 input,
1767 Arc::default(),
1768 project.clone(),
1769 action_log.clone(),
1770 model.clone(),
1771 None,
1772 cx,
1773 )
1774 .output
1775 });
1776
1777 // Stream the content with trailing whitespace
1778 cx.executor().run_until_parked();
1779 model.send_last_completion_stream_text_chunk(
1780 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1781 );
1782 model.end_last_completion_stream();
1783
1784 edit_task.await
1785 };
1786 assert!(edit_result.is_ok());
1787
1788 // Wait for any async operations (e.g. formatting) to complete
1789 cx.executor().run_until_parked();
1790
1791 // Verify the file still has trailing whitespace
1792 // Read the file again - it should still have trailing whitespace
1793 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1794 assert_eq!(
1795 // Ignore carriage returns on Windows
1796 final_content.replace("\r\n", "\n"),
1797 CONTENT_WITH_TRAILING_WHITESPACE,
1798 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1799 );
1800 }
1801
1802 #[gpui::test]
1803 async fn test_needs_confirmation(cx: &mut TestAppContext) {
1804 init_test(cx);
1805 let tool = Arc::new(EditFileTool);
1806 let fs = project::FakeFs::new(cx.executor());
1807 fs.insert_tree("/root", json!({})).await;
1808
1809 // Test 1: Path with .zed component should require confirmation
1810 let input_with_zed = json!({
1811 "display_description": "Edit settings",
1812 "path": ".zed/settings.json",
1813 "mode": "edit"
1814 });
1815 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1816 cx.update(|cx| {
1817 assert!(
1818 tool.needs_confirmation(&input_with_zed, &project, cx),
1819 "Path with .zed component should require confirmation"
1820 );
1821 });
1822
1823 // Test 2: Absolute path should require confirmation
1824 let input_absolute = json!({
1825 "display_description": "Edit file",
1826 "path": "/etc/hosts",
1827 "mode": "edit"
1828 });
1829 cx.update(|cx| {
1830 assert!(
1831 tool.needs_confirmation(&input_absolute, &project, cx),
1832 "Absolute path should require confirmation"
1833 );
1834 });
1835
1836 // Test 3: Relative path without .zed should not require confirmation
1837 let input_relative = json!({
1838 "display_description": "Edit file",
1839 "path": "root/src/main.rs",
1840 "mode": "edit"
1841 });
1842 cx.update(|cx| {
1843 assert!(
1844 !tool.needs_confirmation(&input_relative, &project, cx),
1845 "Relative path without .zed should not require confirmation"
1846 );
1847 });
1848
1849 // Test 4: Path with .zed in the middle should require confirmation
1850 let input_zed_middle = json!({
1851 "display_description": "Edit settings",
1852 "path": "root/.zed/tasks.json",
1853 "mode": "edit"
1854 });
1855 cx.update(|cx| {
1856 assert!(
1857 tool.needs_confirmation(&input_zed_middle, &project, cx),
1858 "Path with .zed in any component should require confirmation"
1859 );
1860 });
1861
1862 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1863 cx.update(|cx| {
1864 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1865 settings.always_allow_tool_actions = true;
1866 agent_settings::AgentSettings::override_global(settings, cx);
1867
1868 assert!(
1869 !tool.needs_confirmation(&input_with_zed, &project, cx),
1870 "When always_allow_tool_actions is true, no confirmation should be needed"
1871 );
1872 assert!(
1873 !tool.needs_confirmation(&input_absolute, &project, cx),
1874 "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths"
1875 );
1876 });
1877 }
1878
1879 #[gpui::test]
1880 async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) {
1881 // Set up a custom config directory for testing
1882 let temp_dir = tempfile::tempdir().unwrap();
1883 init_test_with_config(cx, temp_dir.path());
1884
1885 let tool = Arc::new(EditFileTool);
1886
1887 // Test ui_text shows context for various paths
1888 let test_cases = vec![
1889 (
1890 json!({
1891 "display_description": "Update config",
1892 "path": ".zed/settings.json",
1893 "mode": "edit"
1894 }),
1895 "Update config (local settings)",
1896 ".zed path should show local settings context",
1897 ),
1898 (
1899 json!({
1900 "display_description": "Fix bug",
1901 "path": "src/.zed/local.json",
1902 "mode": "edit"
1903 }),
1904 "Fix bug (local settings)",
1905 "Nested .zed path should show local settings context",
1906 ),
1907 (
1908 json!({
1909 "display_description": "Update readme",
1910 "path": "README.md",
1911 "mode": "edit"
1912 }),
1913 "Update readme",
1914 "Normal path should not show additional context",
1915 ),
1916 (
1917 json!({
1918 "display_description": "Edit config",
1919 "path": "config.zed",
1920 "mode": "edit"
1921 }),
1922 "Edit config",
1923 ".zed as extension should not show context",
1924 ),
1925 ];
1926
1927 for (input, expected_text, description) in test_cases {
1928 cx.update(|_cx| {
1929 let ui_text = tool.ui_text(&input);
1930 assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
1931 });
1932 }
1933 }
1934
1935 #[gpui::test]
1936 async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) {
1937 init_test(cx);
1938 let tool = Arc::new(EditFileTool);
1939 let fs = project::FakeFs::new(cx.executor());
1940
1941 // Create a project in /project directory
1942 fs.insert_tree("/project", json!({})).await;
1943 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1944
1945 // Test file outside project requires confirmation
1946 let input_outside = json!({
1947 "display_description": "Edit file",
1948 "path": "/outside/file.txt",
1949 "mode": "edit"
1950 });
1951 cx.update(|cx| {
1952 assert!(
1953 tool.needs_confirmation(&input_outside, &project, cx),
1954 "File outside project should require confirmation"
1955 );
1956 });
1957
1958 // Test file inside project doesn't require confirmation
1959 let input_inside = json!({
1960 "display_description": "Edit file",
1961 "path": "project/file.txt",
1962 "mode": "edit"
1963 });
1964 cx.update(|cx| {
1965 assert!(
1966 !tool.needs_confirmation(&input_inside, &project, cx),
1967 "File inside project should not require confirmation"
1968 );
1969 });
1970 }
1971
1972 #[gpui::test]
1973 async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) {
1974 // Set up a custom data directory for testing
1975 let temp_dir = tempfile::tempdir().unwrap();
1976 init_test_with_config(cx, temp_dir.path());
1977
1978 let tool = Arc::new(EditFileTool);
1979 let fs = project::FakeFs::new(cx.executor());
1980 fs.insert_tree("/home/user/myproject", json!({})).await;
1981 let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
1982
1983 // Get the actual local settings folder name
1984 let local_settings_folder = paths::local_settings_folder_relative_path();
1985
1986 // Test various config path patterns
1987 let test_cases = vec![
1988 (
1989 format!("{}/settings.json", local_settings_folder.display()),
1990 true,
1991 "Top-level local settings file".to_string(),
1992 ),
1993 (
1994 format!(
1995 "myproject/{}/settings.json",
1996 local_settings_folder.display()
1997 ),
1998 true,
1999 "Local settings in project path".to_string(),
2000 ),
2001 (
2002 format!("src/{}/config.toml", local_settings_folder.display()),
2003 true,
2004 "Local settings in subdirectory".to_string(),
2005 ),
2006 (
2007 ".zed.backup/file.txt".to_string(),
2008 true,
2009 ".zed.backup is outside project".to_string(),
2010 ),
2011 (
2012 "my.zed/file.txt".to_string(),
2013 true,
2014 "my.zed is outside project".to_string(),
2015 ),
2016 (
2017 "myproject/src/file.zed".to_string(),
2018 false,
2019 ".zed as file extension".to_string(),
2020 ),
2021 (
2022 "myproject/normal/path/file.rs".to_string(),
2023 false,
2024 "Normal file without config paths".to_string(),
2025 ),
2026 ];
2027
2028 for (path, should_confirm, description) in test_cases {
2029 let input = json!({
2030 "display_description": "Edit file",
2031 "path": path,
2032 "mode": "edit"
2033 });
2034 cx.update(|cx| {
2035 assert_eq!(
2036 tool.needs_confirmation(&input, &project, cx),
2037 should_confirm,
2038 "Failed for case: {} - path: {}",
2039 description,
2040 path
2041 );
2042 });
2043 }
2044 }
2045
2046 #[gpui::test]
2047 async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) {
2048 // Set up a custom data directory for testing
2049 let temp_dir = tempfile::tempdir().unwrap();
2050 init_test_with_config(cx, temp_dir.path());
2051
2052 let tool = Arc::new(EditFileTool);
2053 let fs = project::FakeFs::new(cx.executor());
2054
2055 // Create test files in the global config directory
2056 let global_config_dir = paths::config_dir();
2057 fs::create_dir_all(&global_config_dir).unwrap();
2058 let global_settings_path = global_config_dir.join("settings.json");
2059 fs::write(&global_settings_path, "{}").unwrap();
2060
2061 fs.insert_tree("/project", json!({})).await;
2062 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2063
2064 // Test global config paths
2065 let test_cases = vec![
2066 (
2067 global_settings_path.to_str().unwrap().to_string(),
2068 true,
2069 "Global settings file should require confirmation",
2070 ),
2071 (
2072 global_config_dir
2073 .join("keymap.json")
2074 .to_str()
2075 .unwrap()
2076 .to_string(),
2077 true,
2078 "Global keymap file should require confirmation",
2079 ),
2080 (
2081 "project/normal_file.rs".to_string(),
2082 false,
2083 "Normal project file should not require confirmation",
2084 ),
2085 ];
2086
2087 for (path, should_confirm, description) in test_cases {
2088 let input = json!({
2089 "display_description": "Edit file",
2090 "path": path,
2091 "mode": "edit"
2092 });
2093 cx.update(|cx| {
2094 assert_eq!(
2095 tool.needs_confirmation(&input, &project, cx),
2096 should_confirm,
2097 "Failed for case: {}",
2098 description
2099 );
2100 });
2101 }
2102 }
2103
2104 #[gpui::test]
2105 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
2106 init_test(cx);
2107 let tool = Arc::new(EditFileTool);
2108 let fs = project::FakeFs::new(cx.executor());
2109
2110 // Create multiple worktree directories
2111 fs.insert_tree(
2112 "/workspace/frontend",
2113 json!({
2114 "src": {
2115 "main.js": "console.log('frontend');"
2116 }
2117 }),
2118 )
2119 .await;
2120 fs.insert_tree(
2121 "/workspace/backend",
2122 json!({
2123 "src": {
2124 "main.rs": "fn main() {}"
2125 }
2126 }),
2127 )
2128 .await;
2129 fs.insert_tree(
2130 "/workspace/shared",
2131 json!({
2132 ".zed": {
2133 "settings.json": "{}"
2134 }
2135 }),
2136 )
2137 .await;
2138
2139 // Create project with multiple worktrees
2140 let project = Project::test(
2141 fs.clone(),
2142 [
2143 path!("/workspace/frontend").as_ref(),
2144 path!("/workspace/backend").as_ref(),
2145 path!("/workspace/shared").as_ref(),
2146 ],
2147 cx,
2148 )
2149 .await;
2150
2151 // Test files in different worktrees
2152 let test_cases = vec![
2153 ("frontend/src/main.js", false, "File in first worktree"),
2154 ("backend/src/main.rs", false, "File in second worktree"),
2155 (
2156 "shared/.zed/settings.json",
2157 true,
2158 ".zed file in third worktree",
2159 ),
2160 ("/etc/hosts", true, "Absolute path outside all worktrees"),
2161 (
2162 "../outside/file.txt",
2163 true,
2164 "Relative path outside worktrees",
2165 ),
2166 ];
2167
2168 for (path, should_confirm, description) in test_cases {
2169 let input = json!({
2170 "display_description": "Edit file",
2171 "path": path,
2172 "mode": "edit"
2173 });
2174 cx.update(|cx| {
2175 assert_eq!(
2176 tool.needs_confirmation(&input, &project, cx),
2177 should_confirm,
2178 "Failed for case: {} - path: {}",
2179 description,
2180 path
2181 );
2182 });
2183 }
2184 }
2185
2186 #[gpui::test]
2187 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
2188 init_test(cx);
2189 let tool = Arc::new(EditFileTool);
2190 let fs = project::FakeFs::new(cx.executor());
2191 fs.insert_tree(
2192 "/project",
2193 json!({
2194 ".zed": {
2195 "settings.json": "{}"
2196 },
2197 "src": {
2198 ".zed": {
2199 "local.json": "{}"
2200 }
2201 }
2202 }),
2203 )
2204 .await;
2205 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2206
2207 // Test edge cases
2208 let test_cases = vec![
2209 // Empty path - find_project_path returns Some for empty paths
2210 ("", false, "Empty path is treated as project root"),
2211 // Root directory
2212 ("/", true, "Root directory should be outside project"),
2213 // Parent directory references - find_project_path resolves these
2214 (
2215 "project/../other",
2216 false,
2217 "Path with .. is resolved by find_project_path",
2218 ),
2219 (
2220 "project/./src/file.rs",
2221 false,
2222 "Path with . should work normally",
2223 ),
2224 // Windows-style paths (if on Windows)
2225 #[cfg(target_os = "windows")]
2226 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
2227 #[cfg(target_os = "windows")]
2228 ("project\\src\\main.rs", false, "Windows-style project path"),
2229 ];
2230
2231 for (path, should_confirm, description) in test_cases {
2232 let input = json!({
2233 "display_description": "Edit file",
2234 "path": path,
2235 "mode": "edit"
2236 });
2237 cx.update(|cx| {
2238 assert_eq!(
2239 tool.needs_confirmation(&input, &project, cx),
2240 should_confirm,
2241 "Failed for case: {} - path: {}",
2242 description,
2243 path
2244 );
2245 });
2246 }
2247 }
2248
2249 #[gpui::test]
2250 async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) {
2251 init_test(cx);
2252 let tool = Arc::new(EditFileTool);
2253
2254 // Test UI text for various scenarios
2255 let test_cases = vec![
2256 (
2257 json!({
2258 "display_description": "Update config",
2259 "path": ".zed/settings.json",
2260 "mode": "edit"
2261 }),
2262 "Update config (local settings)",
2263 ".zed path should show local settings context",
2264 ),
2265 (
2266 json!({
2267 "display_description": "Fix bug",
2268 "path": "src/.zed/local.json",
2269 "mode": "edit"
2270 }),
2271 "Fix bug (local settings)",
2272 "Nested .zed path should show local settings context",
2273 ),
2274 (
2275 json!({
2276 "display_description": "Update readme",
2277 "path": "README.md",
2278 "mode": "edit"
2279 }),
2280 "Update readme",
2281 "Normal path should not show additional context",
2282 ),
2283 (
2284 json!({
2285 "display_description": "Edit config",
2286 "path": "config.zed",
2287 "mode": "edit"
2288 }),
2289 "Edit config",
2290 ".zed as extension should not show context",
2291 ),
2292 ];
2293
2294 for (input, expected_text, description) in test_cases {
2295 cx.update(|_cx| {
2296 let ui_text = tool.ui_text(&input);
2297 assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
2298 });
2299 }
2300 }
2301
2302 #[gpui::test]
2303 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
2304 init_test(cx);
2305 let tool = Arc::new(EditFileTool);
2306 let fs = project::FakeFs::new(cx.executor());
2307 fs.insert_tree(
2308 "/project",
2309 json!({
2310 "existing.txt": "content",
2311 ".zed": {
2312 "settings.json": "{}"
2313 }
2314 }),
2315 )
2316 .await;
2317 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2318
2319 // Test different EditFileMode values
2320 let modes = vec![
2321 EditFileMode::Edit,
2322 EditFileMode::Create,
2323 EditFileMode::Overwrite,
2324 ];
2325
2326 for mode in modes {
2327 // Test .zed path with different modes
2328 let input_zed = json!({
2329 "display_description": "Edit settings",
2330 "path": "project/.zed/settings.json",
2331 "mode": mode
2332 });
2333 cx.update(|cx| {
2334 assert!(
2335 tool.needs_confirmation(&input_zed, &project, cx),
2336 ".zed path should require confirmation regardless of mode: {:?}",
2337 mode
2338 );
2339 });
2340
2341 // Test outside path with different modes
2342 let input_outside = json!({
2343 "display_description": "Edit file",
2344 "path": "/outside/file.txt",
2345 "mode": mode
2346 });
2347 cx.update(|cx| {
2348 assert!(
2349 tool.needs_confirmation(&input_outside, &project, cx),
2350 "Outside path should require confirmation regardless of mode: {:?}",
2351 mode
2352 );
2353 });
2354
2355 // Test normal path with different modes
2356 let input_normal = json!({
2357 "display_description": "Edit file",
2358 "path": "project/normal.txt",
2359 "mode": mode
2360 });
2361 cx.update(|cx| {
2362 assert!(
2363 !tool.needs_confirmation(&input_normal, &project, cx),
2364 "Normal path should not require confirmation regardless of mode: {:?}",
2365 mode
2366 );
2367 });
2368 }
2369 }
2370
2371 #[gpui::test]
2372 async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) {
2373 // Set up with custom directories for deterministic testing
2374 let temp_dir = tempfile::tempdir().unwrap();
2375 init_test_with_config(cx, temp_dir.path());
2376
2377 let tool = Arc::new(EditFileTool);
2378 let fs = project::FakeFs::new(cx.executor());
2379 fs.insert_tree("/project", json!({})).await;
2380 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2381
2382 // Enable always_allow_tool_actions
2383 cx.update(|cx| {
2384 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2385 settings.always_allow_tool_actions = true;
2386 agent_settings::AgentSettings::override_global(settings, cx);
2387 });
2388
2389 // Test that all paths that normally require confirmation are bypassed
2390 let global_settings_path = paths::config_dir().join("settings.json");
2391 fs::create_dir_all(paths::config_dir()).unwrap();
2392 fs::write(&global_settings_path, "{}").unwrap();
2393
2394 let test_cases = vec![
2395 ".zed/settings.json",
2396 "project/.zed/config.toml",
2397 global_settings_path.to_str().unwrap(),
2398 "/etc/hosts",
2399 "/absolute/path/file.txt",
2400 "../outside/project.txt",
2401 ];
2402
2403 for path in test_cases {
2404 let input = json!({
2405 "display_description": "Edit file",
2406 "path": path,
2407 "mode": "edit"
2408 });
2409 cx.update(|cx| {
2410 assert!(
2411 !tool.needs_confirmation(&input, &project, cx),
2412 "Path {} should not require confirmation when always_allow_tool_actions is true",
2413 path
2414 );
2415 });
2416 }
2417
2418 // Disable always_allow_tool_actions and verify confirmation is required again
2419 cx.update(|cx| {
2420 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2421 settings.always_allow_tool_actions = false;
2422 agent_settings::AgentSettings::override_global(settings, cx);
2423 });
2424
2425 // Verify .zed path requires confirmation again
2426 let input = json!({
2427 "display_description": "Edit file",
2428 "path": ".zed/settings.json",
2429 "mode": "edit"
2430 });
2431 cx.update(|cx| {
2432 assert!(
2433 tool.needs_confirmation(&input, &project, cx),
2434 ".zed path should require confirmation when always_allow_tool_actions is false"
2435 );
2436 });
2437 }
2438}