1use crate::{
2 Templates,
3 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
4 schema::json_schema_for,
5};
6use anyhow::{Context as _, Result, anyhow};
7use assistant_tool::{
8 ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput,
9 ToolUseStatus,
10};
11use buffer_diff::{BufferDiff, BufferDiffSnapshot};
12use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
13use futures::StreamExt;
14use gpui::{
15 Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, EntityId, Task,
16 TextStyleRefinement, WeakEntity, pulsating_between,
17};
18use indoc::formatdoc;
19use language::{
20 Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Rope, TextBuffer,
21 language_settings::SoftWrap,
22};
23use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
24use markdown::{Markdown, MarkdownElement, MarkdownStyle};
25use project::{Project, ProjectPath};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use settings::Settings;
29use std::{
30 path::{Path, PathBuf},
31 sync::Arc,
32 time::Duration,
33};
34use theme::ThemeSettings;
35use ui::{Disclosure, Tooltip, prelude::*};
36use util::ResultExt;
37use workspace::Workspace;
38
39pub struct EditFileTool;
40
41#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
42pub struct EditFileToolInput {
43 /// A one-line, user-friendly markdown description of the edit. This will be
44 /// shown in the UI and also passed to another model to perform the edit.
45 ///
46 /// Be terse, but also descriptive in what you want to achieve with this
47 /// edit. Avoid generic instructions.
48 ///
49 /// NEVER mention the file path in this description.
50 ///
51 /// <example>Fix API endpoint URLs</example>
52 /// <example>Update copyright year in `page_footer`</example>
53 ///
54 /// Make sure to include this field before all the others in the input object
55 /// so that we can display it immediately.
56 pub display_description: String,
57
58 /// The full path of the file to create or modify in the project.
59 ///
60 /// WARNING: When specifying which file path need changing, you MUST
61 /// start each path with one of the project's root directories.
62 ///
63 /// The following examples assume we have two root directories in the project:
64 /// - backend
65 /// - frontend
66 ///
67 /// <example>
68 /// `backend/src/main.rs`
69 ///
70 /// Notice how the file path starts with root-1. Without that, the path
71 /// would be ambiguous and the call would fail!
72 /// </example>
73 ///
74 /// <example>
75 /// `frontend/db.js`
76 /// </example>
77 pub path: PathBuf,
78
79 /// The mode of operation on the file. Possible values:
80 /// - 'edit': Make granular edits to an existing file.
81 /// - 'create': Create a new file if it doesn't exist.
82 /// - 'overwrite': Replace the entire contents of an existing file.
83 ///
84 /// When a file already exists or you just created it, prefer editing
85 /// it as opposed to recreating it from scratch.
86 pub mode: EditFileMode,
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
90#[serde(rename_all = "lowercase")]
91pub enum EditFileMode {
92 Edit,
93 Create,
94 Overwrite,
95}
96
97#[derive(Debug, Serialize, Deserialize, JsonSchema)]
98pub struct EditFileToolOutput {
99 pub original_path: PathBuf,
100 pub new_text: String,
101 pub old_text: String,
102 pub raw_output: Option<EditAgentOutput>,
103}
104
105#[derive(Debug, Serialize, Deserialize, JsonSchema)]
106struct PartialInput {
107 #[serde(default)]
108 path: String,
109 #[serde(default)]
110 display_description: String,
111}
112
113const DEFAULT_UI_TEXT: &str = "Editing file";
114
115impl Tool for EditFileTool {
116 fn name(&self) -> String {
117 "edit_file".into()
118 }
119
120 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
121 false
122 }
123
124 fn description(&self) -> String {
125 include_str!("edit_file_tool/description.md").to_string()
126 }
127
128 fn icon(&self) -> IconName {
129 IconName::Pencil
130 }
131
132 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
133 json_schema_for::<EditFileToolInput>(format)
134 }
135
136 fn ui_text(&self, input: &serde_json::Value) -> String {
137 match serde_json::from_value::<EditFileToolInput>(input.clone()) {
138 Ok(input) => input.display_description,
139 Err(_) => "Editing file".to_string(),
140 }
141 }
142
143 fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
144 if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
145 let description = input.display_description.trim();
146 if !description.is_empty() {
147 return description.to_string();
148 }
149
150 let path = input.path.trim();
151 if !path.is_empty() {
152 return path.to_string();
153 }
154 }
155
156 DEFAULT_UI_TEXT.to_string()
157 }
158
159 fn run(
160 self: Arc<Self>,
161 input: serde_json::Value,
162 request: Arc<LanguageModelRequest>,
163 project: Entity<Project>,
164 action_log: Entity<ActionLog>,
165 model: Arc<dyn LanguageModel>,
166 window: Option<AnyWindowHandle>,
167 cx: &mut App,
168 ) -> ToolResult {
169 let input = match serde_json::from_value::<EditFileToolInput>(input) {
170 Ok(input) => input,
171 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
172 };
173
174 let project_path = match resolve_path(&input, project.clone(), cx) {
175 Ok(path) => path,
176 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
177 };
178
179 let card = window.and_then(|window| {
180 window
181 .update(cx, |_, window, cx| {
182 cx.new(|cx| {
183 EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
184 })
185 })
186 .ok()
187 });
188
189 let card_clone = card.clone();
190 let task = cx.spawn(async move |cx: &mut AsyncApp| {
191 let edit_agent = EditAgent::new(model, project.clone(), action_log, Templates::new());
192
193 let buffer = project
194 .update(cx, |project, cx| {
195 project.open_buffer(project_path.clone(), cx)
196 })?
197 .await?;
198
199 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
200 let old_text = cx
201 .background_spawn({
202 let old_snapshot = old_snapshot.clone();
203 async move { old_snapshot.text() }
204 })
205 .await;
206
207 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
208 edit_agent.edit(
209 buffer.clone(),
210 input.display_description.clone(),
211 &request,
212 cx,
213 )
214 } else {
215 edit_agent.overwrite(
216 buffer.clone(),
217 input.display_description.clone(),
218 &request,
219 cx,
220 )
221 };
222
223 let mut hallucinated_old_text = false;
224 while let Some(event) = events.next().await {
225 match event {
226 EditAgentOutputEvent::Edited => {
227 if let Some(card) = card_clone.as_ref() {
228 let new_snapshot =
229 buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
230 let new_text = cx
231 .background_spawn({
232 let new_snapshot = new_snapshot.clone();
233 async move { new_snapshot.text() }
234 })
235 .await;
236 card.update(cx, |card, cx| {
237 card.set_diff(
238 project_path.path.clone(),
239 old_text.clone(),
240 new_text,
241 cx,
242 );
243 })
244 .log_err();
245 }
246 }
247 EditAgentOutputEvent::OldTextNotFound(_) => hallucinated_old_text = true,
248 }
249 }
250 let agent_output = output.await?;
251
252 project
253 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
254 .await?;
255
256 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
257 let new_text = cx.background_spawn({
258 let new_snapshot = new_snapshot.clone();
259 async move { new_snapshot.text() }
260 });
261 let diff = cx.background_spawn(async move {
262 language::unified_diff(&old_snapshot.text(), &new_snapshot.text())
263 });
264 let (new_text, diff) = futures::join!(new_text, diff);
265
266 let output = EditFileToolOutput {
267 original_path: project_path.path.to_path_buf(),
268 new_text: new_text.clone(),
269 old_text: old_text.clone(),
270 raw_output: Some(agent_output),
271 };
272
273 if let Some(card) = card_clone {
274 card.update(cx, |card, cx| {
275 card.set_diff(project_path.path.clone(), old_text, new_text, cx);
276 })
277 .log_err();
278 }
279
280 let input_path = input.path.display();
281 if diff.is_empty() {
282 anyhow::ensure!(
283 !hallucinated_old_text,
284 formatdoc! {"
285 Some edits were produced but none of them could be applied.
286 Read the relevant sections of {input_path} again so that
287 I can perform the requested edits.
288 "}
289 );
290 Ok("No edits were made.".to_string().into())
291 } else {
292 Ok(ToolResultOutput {
293 content: ToolResultContent::Text(format!(
294 "Edited {}:\n\n```diff\n{}\n```",
295 input_path, diff
296 )),
297 output: serde_json::to_value(output).ok(),
298 })
299 }
300 });
301
302 ToolResult {
303 output: task,
304 card: card.map(AnyToolCard::from),
305 }
306 }
307
308 fn deserialize_card(
309 self: Arc<Self>,
310 output: serde_json::Value,
311 project: Entity<Project>,
312 window: &mut Window,
313 cx: &mut App,
314 ) -> Option<AnyToolCard> {
315 let output = match serde_json::from_value::<EditFileToolOutput>(output) {
316 Ok(output) => output,
317 Err(_) => return None,
318 };
319
320 let card = cx.new(|cx| {
321 let mut card = EditFileToolCard::new(output.original_path.clone(), project, window, cx);
322 card.set_diff(
323 output.original_path.into(),
324 output.old_text,
325 output.new_text,
326 cx,
327 );
328 card
329 });
330
331 Some(card.into())
332 }
333}
334
335/// Validate that the file path is valid, meaning:
336///
337/// - For `edit` and `overwrite`, the path must point to an existing file.
338/// - For `create`, the file must not already exist, but it's parent dir must exist.
339fn resolve_path(
340 input: &EditFileToolInput,
341 project: Entity<Project>,
342 cx: &mut App,
343) -> Result<ProjectPath> {
344 let project = project.read(cx);
345
346 match input.mode {
347 EditFileMode::Edit | EditFileMode::Overwrite => {
348 let path = project
349 .find_project_path(&input.path, cx)
350 .context("Can't edit file: path not found")?;
351
352 let entry = project
353 .entry_for_path(&path, cx)
354 .context("Can't edit file: path not found")?;
355
356 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
357 Ok(path)
358 }
359
360 EditFileMode::Create => {
361 if let Some(path) = project.find_project_path(&input.path, cx) {
362 anyhow::ensure!(
363 project.entry_for_path(&path, cx).is_none(),
364 "Can't create file: file already exists"
365 );
366 }
367
368 let parent_path = input
369 .path
370 .parent()
371 .context("Can't create file: incorrect path")?;
372
373 let parent_project_path = project.find_project_path(&parent_path, cx);
374
375 let parent_entry = parent_project_path
376 .as_ref()
377 .and_then(|path| project.entry_for_path(&path, cx))
378 .context("Can't create file: parent directory doesn't exist")?;
379
380 anyhow::ensure!(
381 parent_entry.is_dir(),
382 "Can't create file: parent is not a directory"
383 );
384
385 let file_name = input
386 .path
387 .file_name()
388 .context("Can't create file: invalid filename")?;
389
390 let new_file_path = parent_project_path.map(|parent| ProjectPath {
391 path: Arc::from(parent.path.join(file_name)),
392 ..parent
393 });
394
395 new_file_path.context("Can't create file")
396 }
397 }
398}
399
400pub struct EditFileToolCard {
401 path: PathBuf,
402 editor: Entity<Editor>,
403 multibuffer: Entity<MultiBuffer>,
404 project: Entity<Project>,
405 diff_task: Option<Task<Result<()>>>,
406 preview_expanded: bool,
407 error_expanded: Option<Entity<Markdown>>,
408 full_height_expanded: bool,
409 total_lines: Option<u32>,
410 editor_unique_id: EntityId,
411}
412
413impl EditFileToolCard {
414 pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
415 let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
416 let editor = cx.new(|cx| {
417 let mut editor = Editor::new(
418 EditorMode::Full {
419 scale_ui_elements_with_buffer_font_size: false,
420 show_active_line_background: false,
421 sized_by_content: true,
422 },
423 multibuffer.clone(),
424 Some(project.clone()),
425 window,
426 cx,
427 );
428 editor.set_show_gutter(false, cx);
429 editor.disable_inline_diagnostics();
430 editor.disable_expand_excerpt_buttons(cx);
431 // Keep horizontal scrollbar so user can scroll horizontally if needed
432 editor.set_show_vertical_scrollbar(false, cx);
433 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
434 editor.set_soft_wrap_mode(SoftWrap::None, cx);
435 editor.scroll_manager.set_forbid_vertical_scroll(true);
436 editor.set_show_indent_guides(false, cx);
437 editor.set_read_only(true);
438 editor.set_show_breakpoints(false, cx);
439 editor.set_show_code_actions(false, cx);
440 editor.set_show_git_diff_gutter(false, cx);
441 editor.set_expand_all_diff_hunks(cx);
442 editor
443 });
444 Self {
445 editor_unique_id: editor.entity_id(),
446 path,
447 project,
448 editor,
449 multibuffer,
450 diff_task: None,
451 preview_expanded: true,
452 error_expanded: None,
453 full_height_expanded: true,
454 total_lines: None,
455 }
456 }
457
458 pub fn has_diff(&self) -> bool {
459 self.total_lines.is_some()
460 }
461
462 pub fn set_diff(
463 &mut self,
464 path: Arc<Path>,
465 old_text: String,
466 new_text: String,
467 cx: &mut Context<Self>,
468 ) {
469 let language_registry = self.project.read(cx).languages().clone();
470 self.diff_task = Some(cx.spawn(async move |this, cx| {
471 let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
472 let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
473
474 this.update(cx, |this, cx| {
475 this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
476 let snapshot = buffer.read(cx).snapshot();
477 let diff = buffer_diff.read(cx);
478 let diff_hunk_ranges = diff
479 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
480 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
481 .collect::<Vec<_>>();
482 multibuffer.clear(cx);
483 multibuffer.set_excerpts_for_path(
484 PathKey::for_buffer(&buffer, cx),
485 buffer,
486 diff_hunk_ranges,
487 editor::DEFAULT_MULTIBUFFER_CONTEXT,
488 cx,
489 );
490 multibuffer.add_diff(buffer_diff, cx);
491 let end = multibuffer.len(cx);
492 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
493 });
494
495 cx.notify();
496 })
497 }));
498 }
499}
500
501impl ToolCard for EditFileToolCard {
502 fn render(
503 &mut self,
504 status: &ToolUseStatus,
505 window: &mut Window,
506 workspace: WeakEntity<Workspace>,
507 cx: &mut Context<Self>,
508 ) -> impl IntoElement {
509 let error_message = match status {
510 ToolUseStatus::Error(err) => Some(err),
511 _ => None,
512 };
513
514 let path_label_button = h_flex()
515 .id(("edit-tool-path-label-button", self.editor_unique_id))
516 .w_full()
517 .max_w_full()
518 .px_1()
519 .gap_0p5()
520 .cursor_pointer()
521 .rounded_sm()
522 .opacity(0.8)
523 .hover(|label| {
524 label
525 .opacity(1.)
526 .bg(cx.theme().colors().element_hover.opacity(0.5))
527 })
528 .tooltip(Tooltip::text("Jump to File"))
529 .child(
530 h_flex()
531 .child(
532 Icon::new(IconName::Pencil)
533 .size(IconSize::XSmall)
534 .color(Color::Muted),
535 )
536 .child(
537 div()
538 .text_size(rems(0.8125))
539 .child(self.path.display().to_string())
540 .ml_1p5()
541 .mr_0p5(),
542 )
543 .child(
544 Icon::new(IconName::ArrowUpRight)
545 .size(IconSize::XSmall)
546 .color(Color::Ignored),
547 ),
548 )
549 .on_click({
550 let path = self.path.clone();
551 let workspace = workspace.clone();
552 move |_, window, cx| {
553 workspace
554 .update(cx, {
555 |workspace, cx| {
556 let Some(project_path) =
557 workspace.project().read(cx).find_project_path(&path, cx)
558 else {
559 return;
560 };
561 let open_task =
562 workspace.open_path(project_path, None, true, window, cx);
563 window
564 .spawn(cx, async move |cx| {
565 let item = open_task.await?;
566 if let Some(active_editor) = item.downcast::<Editor>() {
567 active_editor
568 .update_in(cx, |editor, window, cx| {
569 editor.go_to_singleton_buffer_point(
570 language::Point::new(0, 0),
571 window,
572 cx,
573 );
574 })
575 .log_err();
576 }
577 anyhow::Ok(())
578 })
579 .detach_and_log_err(cx);
580 }
581 })
582 .ok();
583 }
584 })
585 .into_any_element();
586
587 let codeblock_header_bg = cx
588 .theme()
589 .colors()
590 .element_background
591 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
592
593 let codeblock_header = h_flex()
594 .flex_none()
595 .p_1()
596 .gap_1()
597 .justify_between()
598 .rounded_t_md()
599 .when(error_message.is_none(), |header| {
600 header.bg(codeblock_header_bg)
601 })
602 .child(path_label_button)
603 .when_some(error_message, |header, error_message| {
604 header.child(
605 h_flex()
606 .gap_1()
607 .child(
608 Icon::new(IconName::Close)
609 .size(IconSize::Small)
610 .color(Color::Error),
611 )
612 .child(
613 Disclosure::new(
614 ("edit-file-error-disclosure", self.editor_unique_id),
615 self.error_expanded.is_some(),
616 )
617 .opened_icon(IconName::ChevronUp)
618 .closed_icon(IconName::ChevronDown)
619 .on_click(cx.listener({
620 let error_message = error_message.clone();
621
622 move |this, _event, _window, cx| {
623 if this.error_expanded.is_some() {
624 this.error_expanded.take();
625 } else {
626 this.error_expanded = Some(cx.new(|cx| {
627 Markdown::new(error_message.clone(), None, None, cx)
628 }))
629 }
630 cx.notify();
631 }
632 })),
633 ),
634 )
635 })
636 .when(error_message.is_none() && self.has_diff(), |header| {
637 header.child(
638 Disclosure::new(
639 ("edit-file-disclosure", self.editor_unique_id),
640 self.preview_expanded,
641 )
642 .opened_icon(IconName::ChevronUp)
643 .closed_icon(IconName::ChevronDown)
644 .on_click(cx.listener(
645 move |this, _event, _window, _cx| {
646 this.preview_expanded = !this.preview_expanded;
647 },
648 )),
649 )
650 });
651
652 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
653 let line_height = editor
654 .style()
655 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
656 .unwrap_or_default();
657
658 editor.set_text_style_refinement(TextStyleRefinement {
659 font_size: Some(
660 TextSize::Small
661 .rems(cx)
662 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
663 .into(),
664 ),
665 ..TextStyleRefinement::default()
666 });
667 let element = editor.render(window, cx);
668 (element.into_any_element(), line_height)
669 });
670
671 let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
672 (IconName::ChevronUp, "Collapse Code Block")
673 } else {
674 (IconName::ChevronDown, "Expand Code Block")
675 };
676
677 let gradient_overlay =
678 div()
679 .absolute()
680 .bottom_0()
681 .left_0()
682 .w_full()
683 .h_2_5()
684 .bg(gpui::linear_gradient(
685 0.,
686 gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
687 gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
688 ));
689
690 let border_color = cx.theme().colors().border.opacity(0.6);
691
692 const DEFAULT_COLLAPSED_LINES: u32 = 10;
693 let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
694
695 let waiting_for_diff = {
696 let styles = [
697 ("w_4_5", (0.1, 0.85), 2000),
698 ("w_1_4", (0.2, 0.75), 2200),
699 ("w_2_4", (0.15, 0.64), 1900),
700 ("w_3_5", (0.25, 0.72), 2300),
701 ("w_2_5", (0.3, 0.56), 1800),
702 ];
703
704 let mut container = v_flex()
705 .p_3()
706 .gap_1()
707 .border_t_1()
708 .rounded_b_md()
709 .border_color(border_color)
710 .bg(cx.theme().colors().editor_background);
711
712 for (width_method, pulse_range, duration_ms) in styles.iter() {
713 let (min_opacity, max_opacity) = *pulse_range;
714 let placeholder = match *width_method {
715 "w_4_5" => div().w_3_4(),
716 "w_1_4" => div().w_1_4(),
717 "w_2_4" => div().w_2_4(),
718 "w_3_5" => div().w_3_5(),
719 "w_2_5" => div().w_2_5(),
720 _ => div().w_1_2(),
721 }
722 .id("loading_div")
723 .h_1()
724 .rounded_full()
725 .bg(cx.theme().colors().element_active)
726 .with_animation(
727 "loading_pulsate",
728 Animation::new(Duration::from_millis(*duration_ms))
729 .repeat()
730 .with_easing(pulsating_between(min_opacity, max_opacity)),
731 |label, delta| label.opacity(delta),
732 );
733
734 container = container.child(placeholder);
735 }
736
737 container
738 };
739
740 v_flex()
741 .mb_2()
742 .border_1()
743 .when(error_message.is_some(), |card| card.border_dashed())
744 .border_color(border_color)
745 .rounded_md()
746 .overflow_hidden()
747 .child(codeblock_header)
748 .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
749 card.child(
750 v_flex()
751 .p_2()
752 .gap_1()
753 .border_t_1()
754 .border_dashed()
755 .border_color(border_color)
756 .bg(cx.theme().colors().editor_background)
757 .rounded_b_md()
758 .child(
759 Label::new("Error")
760 .size(LabelSize::XSmall)
761 .color(Color::Error),
762 )
763 .child(
764 div()
765 .rounded_md()
766 .text_ui_sm(cx)
767 .bg(cx.theme().colors().editor_background)
768 .child(MarkdownElement::new(
769 error_markdown.clone(),
770 markdown_style(window, cx),
771 )),
772 ),
773 )
774 })
775 .when(!self.has_diff() && error_message.is_none(), |card| {
776 card.child(waiting_for_diff)
777 })
778 .when(self.preview_expanded && self.has_diff(), |card| {
779 card.child(
780 v_flex()
781 .relative()
782 .h_full()
783 .when(!self.full_height_expanded, |editor_container| {
784 editor_container
785 .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
786 })
787 .overflow_hidden()
788 .border_t_1()
789 .border_color(border_color)
790 .bg(cx.theme().colors().editor_background)
791 .child(editor)
792 .when(
793 !self.full_height_expanded && is_collapsible,
794 |editor_container| editor_container.child(gradient_overlay),
795 ),
796 )
797 .when(is_collapsible, |card| {
798 card.child(
799 h_flex()
800 .id(("expand-button", self.editor_unique_id))
801 .flex_none()
802 .cursor_pointer()
803 .h_5()
804 .justify_center()
805 .border_t_1()
806 .rounded_b_md()
807 .border_color(border_color)
808 .bg(cx.theme().colors().editor_background)
809 .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
810 .child(
811 Icon::new(full_height_icon)
812 .size(IconSize::Small)
813 .color(Color::Muted),
814 )
815 .tooltip(Tooltip::text(full_height_tooltip_label))
816 .on_click(cx.listener(move |this, _event, _window, _cx| {
817 this.full_height_expanded = !this.full_height_expanded;
818 })),
819 )
820 })
821 })
822 }
823}
824
825fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
826 let theme_settings = ThemeSettings::get_global(cx);
827 let ui_font_size = TextSize::Default.rems(cx);
828 let mut text_style = window.text_style();
829
830 text_style.refine(&TextStyleRefinement {
831 font_family: Some(theme_settings.ui_font.family.clone()),
832 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
833 font_features: Some(theme_settings.ui_font.features.clone()),
834 font_size: Some(ui_font_size.into()),
835 color: Some(cx.theme().colors().text),
836 ..Default::default()
837 });
838
839 MarkdownStyle {
840 base_text_style: text_style.clone(),
841 selection_background_color: cx.theme().players().local().selection,
842 ..Default::default()
843 }
844}
845
846async fn build_buffer(
847 mut text: String,
848 path: Arc<Path>,
849 language_registry: &Arc<language::LanguageRegistry>,
850 cx: &mut AsyncApp,
851) -> Result<Entity<Buffer>> {
852 let line_ending = LineEnding::detect(&text);
853 LineEnding::normalize(&mut text);
854 let text = Rope::from(text);
855 let language = cx
856 .update(|_cx| language_registry.language_for_file_path(&path))?
857 .await
858 .ok();
859 let buffer = cx.new(|cx| {
860 let buffer = TextBuffer::new_normalized(
861 0,
862 cx.entity_id().as_non_zero_u64().into(),
863 line_ending,
864 text,
865 );
866 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
867 buffer.set_language(language, cx);
868 buffer
869 })?;
870 Ok(buffer)
871}
872
873async fn build_buffer_diff(
874 mut old_text: String,
875 buffer: &Entity<Buffer>,
876 language_registry: &Arc<LanguageRegistry>,
877 cx: &mut AsyncApp,
878) -> Result<Entity<BufferDiff>> {
879 LineEnding::normalize(&mut old_text);
880
881 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
882
883 let base_buffer = cx
884 .update(|cx| {
885 Buffer::build_snapshot(
886 old_text.clone().into(),
887 buffer.language().cloned(),
888 Some(language_registry.clone()),
889 cx,
890 )
891 })?
892 .await;
893
894 let diff_snapshot = cx
895 .update(|cx| {
896 BufferDiffSnapshot::new_with_base_buffer(
897 buffer.text.clone(),
898 Some(old_text.into()),
899 base_buffer,
900 cx,
901 )
902 })?
903 .await;
904
905 let secondary_diff = cx.new(|cx| {
906 let mut diff = BufferDiff::new(&buffer, cx);
907 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
908 diff
909 })?;
910
911 cx.new(|cx| {
912 let mut diff = BufferDiff::new(&buffer.text, cx);
913 diff.set_snapshot(diff_snapshot, &buffer, cx);
914 diff.set_secondary_diff(secondary_diff);
915 diff
916 })
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use client::TelemetrySettings;
923 use fs::FakeFs;
924 use gpui::TestAppContext;
925 use language_model::fake_provider::FakeLanguageModel;
926 use serde_json::json;
927 use settings::SettingsStore;
928 use util::path;
929
930 #[gpui::test]
931 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
932 init_test(cx);
933
934 let fs = FakeFs::new(cx.executor());
935 fs.insert_tree("/root", json!({})).await;
936 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
937 let action_log = cx.new(|_| ActionLog::new(project.clone()));
938 let model = Arc::new(FakeLanguageModel::default());
939 let result = cx
940 .update(|cx| {
941 let input = serde_json::to_value(EditFileToolInput {
942 display_description: "Some edit".into(),
943 path: "root/nonexistent_file.txt".into(),
944 mode: EditFileMode::Edit,
945 })
946 .unwrap();
947 Arc::new(EditFileTool)
948 .run(
949 input,
950 Arc::default(),
951 project.clone(),
952 action_log,
953 model,
954 None,
955 cx,
956 )
957 .output
958 })
959 .await;
960 assert_eq!(
961 result.unwrap_err().to_string(),
962 "Can't edit file: path not found"
963 );
964 }
965
966 #[gpui::test]
967 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
968 let mode = &EditFileMode::Create;
969
970 let result = test_resolve_path(mode, "root/new.txt", cx);
971 assert_resolved_path_eq(result.await, "new.txt");
972
973 let result = test_resolve_path(mode, "new.txt", cx);
974 assert_resolved_path_eq(result.await, "new.txt");
975
976 let result = test_resolve_path(mode, "dir/new.txt", cx);
977 assert_resolved_path_eq(result.await, "dir/new.txt");
978
979 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
980 assert_eq!(
981 result.await.unwrap_err().to_string(),
982 "Can't create file: file already exists"
983 );
984
985 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
986 assert_eq!(
987 result.await.unwrap_err().to_string(),
988 "Can't create file: parent directory doesn't exist"
989 );
990 }
991
992 #[gpui::test]
993 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
994 let mode = &EditFileMode::Edit;
995
996 let path_with_root = "root/dir/subdir/existing.txt";
997 let path_without_root = "dir/subdir/existing.txt";
998 let result = test_resolve_path(mode, path_with_root, cx);
999 assert_resolved_path_eq(result.await, path_without_root);
1000
1001 let result = test_resolve_path(mode, path_without_root, cx);
1002 assert_resolved_path_eq(result.await, path_without_root);
1003
1004 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1005 assert_eq!(
1006 result.await.unwrap_err().to_string(),
1007 "Can't edit file: path not found"
1008 );
1009
1010 let result = test_resolve_path(mode, "root/dir", cx);
1011 assert_eq!(
1012 result.await.unwrap_err().to_string(),
1013 "Can't edit file: path is a directory"
1014 );
1015 }
1016
1017 async fn test_resolve_path(
1018 mode: &EditFileMode,
1019 path: &str,
1020 cx: &mut TestAppContext,
1021 ) -> anyhow::Result<ProjectPath> {
1022 init_test(cx);
1023
1024 let fs = FakeFs::new(cx.executor());
1025 fs.insert_tree(
1026 "/root",
1027 json!({
1028 "dir": {
1029 "subdir": {
1030 "existing.txt": "hello"
1031 }
1032 }
1033 }),
1034 )
1035 .await;
1036 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1037
1038 let input = EditFileToolInput {
1039 display_description: "Some edit".into(),
1040 path: path.into(),
1041 mode: mode.clone(),
1042 };
1043
1044 let result = cx.update(|cx| resolve_path(&input, project, cx));
1045 result
1046 }
1047
1048 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1049 let actual = path
1050 .expect("Should return valid path")
1051 .path
1052 .to_str()
1053 .unwrap()
1054 .replace("\\", "/"); // Naive Windows paths normalization
1055 assert_eq!(actual, expected);
1056 }
1057
1058 #[test]
1059 fn still_streaming_ui_text_with_path() {
1060 let input = json!({
1061 "path": "src/main.rs",
1062 "display_description": "",
1063 "old_string": "old code",
1064 "new_string": "new code"
1065 });
1066
1067 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1068 }
1069
1070 #[test]
1071 fn still_streaming_ui_text_with_description() {
1072 let input = json!({
1073 "path": "",
1074 "display_description": "Fix error handling",
1075 "old_string": "old code",
1076 "new_string": "new code"
1077 });
1078
1079 assert_eq!(
1080 EditFileTool.still_streaming_ui_text(&input),
1081 "Fix error handling",
1082 );
1083 }
1084
1085 #[test]
1086 fn still_streaming_ui_text_with_path_and_description() {
1087 let input = json!({
1088 "path": "src/main.rs",
1089 "display_description": "Fix error handling",
1090 "old_string": "old code",
1091 "new_string": "new code"
1092 });
1093
1094 assert_eq!(
1095 EditFileTool.still_streaming_ui_text(&input),
1096 "Fix error handling",
1097 );
1098 }
1099
1100 #[test]
1101 fn still_streaming_ui_text_no_path_or_description() {
1102 let input = json!({
1103 "path": "",
1104 "display_description": "",
1105 "old_string": "old code",
1106 "new_string": "new code"
1107 });
1108
1109 assert_eq!(
1110 EditFileTool.still_streaming_ui_text(&input),
1111 DEFAULT_UI_TEXT,
1112 );
1113 }
1114
1115 #[test]
1116 fn still_streaming_ui_text_with_null() {
1117 let input = serde_json::Value::Null;
1118
1119 assert_eq!(
1120 EditFileTool.still_streaming_ui_text(&input),
1121 DEFAULT_UI_TEXT,
1122 );
1123 }
1124
1125 fn init_test(cx: &mut TestAppContext) {
1126 cx.update(|cx| {
1127 let settings_store = SettingsStore::test(cx);
1128 cx.set_global(settings_store);
1129 language::init(cx);
1130 TelemetrySettings::register(cx);
1131 Project::init_settings(cx);
1132 });
1133 }
1134}