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