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, 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 editor.disable_scrollbars_and_minimap(window, cx);
432 editor.set_soft_wrap_mode(SoftWrap::None, cx);
433 editor.scroll_manager.set_forbid_vertical_scroll(true);
434 editor.set_show_indent_guides(false, cx);
435 editor.set_read_only(true);
436 editor.set_show_breakpoints(false, cx);
437 editor.set_show_code_actions(false, cx);
438 editor.set_show_git_diff_gutter(false, cx);
439 editor.set_expand_all_diff_hunks(cx);
440 editor
441 });
442 Self {
443 editor_unique_id: editor.entity_id(),
444 path,
445 project,
446 editor,
447 multibuffer,
448 diff_task: None,
449 preview_expanded: true,
450 error_expanded: None,
451 full_height_expanded: true,
452 total_lines: None,
453 }
454 }
455
456 pub fn has_diff(&self) -> bool {
457 self.total_lines.is_some()
458 }
459
460 pub fn set_diff(
461 &mut self,
462 path: Arc<Path>,
463 old_text: String,
464 new_text: String,
465 cx: &mut Context<Self>,
466 ) {
467 let language_registry = self.project.read(cx).languages().clone();
468 self.diff_task = Some(cx.spawn(async move |this, cx| {
469 let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
470 let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
471
472 this.update(cx, |this, cx| {
473 this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
474 let snapshot = buffer.read(cx).snapshot();
475 let diff = buffer_diff.read(cx);
476 let diff_hunk_ranges = diff
477 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
478 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
479 .collect::<Vec<_>>();
480 multibuffer.clear(cx);
481 multibuffer.set_excerpts_for_path(
482 PathKey::for_buffer(&buffer, cx),
483 buffer,
484 diff_hunk_ranges,
485 editor::DEFAULT_MULTIBUFFER_CONTEXT,
486 cx,
487 );
488 multibuffer.add_diff(buffer_diff, cx);
489 let end = multibuffer.len(cx);
490 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
491 });
492
493 cx.notify();
494 })
495 }));
496 }
497}
498
499impl ToolCard for EditFileToolCard {
500 fn render(
501 &mut self,
502 status: &ToolUseStatus,
503 window: &mut Window,
504 workspace: WeakEntity<Workspace>,
505 cx: &mut Context<Self>,
506 ) -> impl IntoElement {
507 let error_message = match status {
508 ToolUseStatus::Error(err) => Some(err),
509 _ => None,
510 };
511
512 let path_label_button = h_flex()
513 .id(("edit-tool-path-label-button", self.editor_unique_id))
514 .w_full()
515 .max_w_full()
516 .px_1()
517 .gap_0p5()
518 .cursor_pointer()
519 .rounded_sm()
520 .opacity(0.8)
521 .hover(|label| {
522 label
523 .opacity(1.)
524 .bg(cx.theme().colors().element_hover.opacity(0.5))
525 })
526 .tooltip(Tooltip::text("Jump to File"))
527 .child(
528 h_flex()
529 .child(
530 Icon::new(IconName::Pencil)
531 .size(IconSize::XSmall)
532 .color(Color::Muted),
533 )
534 .child(
535 div()
536 .text_size(rems(0.8125))
537 .child(self.path.display().to_string())
538 .ml_1p5()
539 .mr_0p5(),
540 )
541 .child(
542 Icon::new(IconName::ArrowUpRight)
543 .size(IconSize::XSmall)
544 .color(Color::Ignored),
545 ),
546 )
547 .on_click({
548 let path = self.path.clone();
549 let workspace = workspace.clone();
550 move |_, window, cx| {
551 workspace
552 .update(cx, {
553 |workspace, cx| {
554 let Some(project_path) =
555 workspace.project().read(cx).find_project_path(&path, cx)
556 else {
557 return;
558 };
559 let open_task =
560 workspace.open_path(project_path, None, true, window, cx);
561 window
562 .spawn(cx, async move |cx| {
563 let item = open_task.await?;
564 if let Some(active_editor) = item.downcast::<Editor>() {
565 active_editor
566 .update_in(cx, |editor, window, cx| {
567 editor.go_to_singleton_buffer_point(
568 language::Point::new(0, 0),
569 window,
570 cx,
571 );
572 })
573 .log_err();
574 }
575 anyhow::Ok(())
576 })
577 .detach_and_log_err(cx);
578 }
579 })
580 .ok();
581 }
582 })
583 .into_any_element();
584
585 let codeblock_header_bg = cx
586 .theme()
587 .colors()
588 .element_background
589 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
590
591 let codeblock_header = h_flex()
592 .flex_none()
593 .p_1()
594 .gap_1()
595 .justify_between()
596 .rounded_t_md()
597 .when(error_message.is_none(), |header| {
598 header.bg(codeblock_header_bg)
599 })
600 .child(path_label_button)
601 .when_some(error_message, |header, error_message| {
602 header.child(
603 h_flex()
604 .gap_1()
605 .child(
606 Icon::new(IconName::Close)
607 .size(IconSize::Small)
608 .color(Color::Error),
609 )
610 .child(
611 Disclosure::new(
612 ("edit-file-error-disclosure", self.editor_unique_id),
613 self.error_expanded.is_some(),
614 )
615 .opened_icon(IconName::ChevronUp)
616 .closed_icon(IconName::ChevronDown)
617 .on_click(cx.listener({
618 let error_message = error_message.clone();
619
620 move |this, _event, _window, cx| {
621 if this.error_expanded.is_some() {
622 this.error_expanded.take();
623 } else {
624 this.error_expanded = Some(cx.new(|cx| {
625 Markdown::new(error_message.clone(), None, None, cx)
626 }))
627 }
628 cx.notify();
629 }
630 })),
631 ),
632 )
633 })
634 .when(error_message.is_none() && self.has_diff(), |header| {
635 header.child(
636 Disclosure::new(
637 ("edit-file-disclosure", self.editor_unique_id),
638 self.preview_expanded,
639 )
640 .opened_icon(IconName::ChevronUp)
641 .closed_icon(IconName::ChevronDown)
642 .on_click(cx.listener(
643 move |this, _event, _window, _cx| {
644 this.preview_expanded = !this.preview_expanded;
645 },
646 )),
647 )
648 });
649
650 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
651 let line_height = editor
652 .style()
653 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
654 .unwrap_or_default();
655
656 editor.set_text_style_refinement(TextStyleRefinement {
657 font_size: Some(
658 TextSize::Small
659 .rems(cx)
660 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
661 .into(),
662 ),
663 ..TextStyleRefinement::default()
664 });
665 let element = editor.render(window, cx);
666 (element.into_any_element(), line_height)
667 });
668
669 let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
670 (IconName::ChevronUp, "Collapse Code Block")
671 } else {
672 (IconName::ChevronDown, "Expand Code Block")
673 };
674
675 let gradient_overlay =
676 div()
677 .absolute()
678 .bottom_0()
679 .left_0()
680 .w_full()
681 .h_2_5()
682 .bg(gpui::linear_gradient(
683 0.,
684 gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
685 gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
686 ));
687
688 let border_color = cx.theme().colors().border.opacity(0.6);
689
690 const DEFAULT_COLLAPSED_LINES: u32 = 10;
691 let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
692
693 let waiting_for_diff = {
694 let styles = [
695 ("w_4_5", (0.1, 0.85), 2000),
696 ("w_1_4", (0.2, 0.75), 2200),
697 ("w_2_4", (0.15, 0.64), 1900),
698 ("w_3_5", (0.25, 0.72), 2300),
699 ("w_2_5", (0.3, 0.56), 1800),
700 ];
701
702 let mut container = v_flex()
703 .p_3()
704 .gap_1()
705 .border_t_1()
706 .rounded_b_md()
707 .border_color(border_color)
708 .bg(cx.theme().colors().editor_background);
709
710 for (width_method, pulse_range, duration_ms) in styles.iter() {
711 let (min_opacity, max_opacity) = *pulse_range;
712 let placeholder = match *width_method {
713 "w_4_5" => div().w_3_4(),
714 "w_1_4" => div().w_1_4(),
715 "w_2_4" => div().w_2_4(),
716 "w_3_5" => div().w_3_5(),
717 "w_2_5" => div().w_2_5(),
718 _ => div().w_1_2(),
719 }
720 .id("loading_div")
721 .h_1()
722 .rounded_full()
723 .bg(cx.theme().colors().element_active)
724 .with_animation(
725 "loading_pulsate",
726 Animation::new(Duration::from_millis(*duration_ms))
727 .repeat()
728 .with_easing(pulsating_between(min_opacity, max_opacity)),
729 |label, delta| label.opacity(delta),
730 );
731
732 container = container.child(placeholder);
733 }
734
735 container
736 };
737
738 v_flex()
739 .mb_2()
740 .border_1()
741 .when(error_message.is_some(), |card| card.border_dashed())
742 .border_color(border_color)
743 .rounded_md()
744 .overflow_hidden()
745 .child(codeblock_header)
746 .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
747 card.child(
748 v_flex()
749 .p_2()
750 .gap_1()
751 .border_t_1()
752 .border_dashed()
753 .border_color(border_color)
754 .bg(cx.theme().colors().editor_background)
755 .rounded_b_md()
756 .child(
757 Label::new("Error")
758 .size(LabelSize::XSmall)
759 .color(Color::Error),
760 )
761 .child(
762 div()
763 .rounded_md()
764 .text_ui_sm(cx)
765 .bg(cx.theme().colors().editor_background)
766 .child(MarkdownElement::new(
767 error_markdown.clone(),
768 markdown_style(window, cx),
769 )),
770 ),
771 )
772 })
773 .when(!self.has_diff() && error_message.is_none(), |card| {
774 card.child(waiting_for_diff)
775 })
776 .when(self.preview_expanded && self.has_diff(), |card| {
777 card.child(
778 v_flex()
779 .relative()
780 .h_full()
781 .when(!self.full_height_expanded, |editor_container| {
782 editor_container
783 .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
784 })
785 .overflow_hidden()
786 .border_t_1()
787 .border_color(border_color)
788 .bg(cx.theme().colors().editor_background)
789 .child(editor)
790 .when(
791 !self.full_height_expanded && is_collapsible,
792 |editor_container| editor_container.child(gradient_overlay),
793 ),
794 )
795 .when(is_collapsible, |card| {
796 card.child(
797 h_flex()
798 .id(("expand-button", self.editor_unique_id))
799 .flex_none()
800 .cursor_pointer()
801 .h_5()
802 .justify_center()
803 .border_t_1()
804 .rounded_b_md()
805 .border_color(border_color)
806 .bg(cx.theme().colors().editor_background)
807 .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
808 .child(
809 Icon::new(full_height_icon)
810 .size(IconSize::Small)
811 .color(Color::Muted),
812 )
813 .tooltip(Tooltip::text(full_height_tooltip_label))
814 .on_click(cx.listener(move |this, _event, _window, _cx| {
815 this.full_height_expanded = !this.full_height_expanded;
816 })),
817 )
818 })
819 })
820 }
821}
822
823fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
824 let theme_settings = ThemeSettings::get_global(cx);
825 let ui_font_size = TextSize::Default.rems(cx);
826 let mut text_style = window.text_style();
827
828 text_style.refine(&TextStyleRefinement {
829 font_family: Some(theme_settings.ui_font.family.clone()),
830 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
831 font_features: Some(theme_settings.ui_font.features.clone()),
832 font_size: Some(ui_font_size.into()),
833 color: Some(cx.theme().colors().text),
834 ..Default::default()
835 });
836
837 MarkdownStyle {
838 base_text_style: text_style.clone(),
839 selection_background_color: cx.theme().players().local().selection,
840 ..Default::default()
841 }
842}
843
844async fn build_buffer(
845 mut text: String,
846 path: Arc<Path>,
847 language_registry: &Arc<language::LanguageRegistry>,
848 cx: &mut AsyncApp,
849) -> Result<Entity<Buffer>> {
850 let line_ending = LineEnding::detect(&text);
851 LineEnding::normalize(&mut text);
852 let text = Rope::from(text);
853 let language = cx
854 .update(|_cx| language_registry.language_for_file_path(&path))?
855 .await
856 .ok();
857 let buffer = cx.new(|cx| {
858 let buffer = TextBuffer::new_normalized(
859 0,
860 cx.entity_id().as_non_zero_u64().into(),
861 line_ending,
862 text,
863 );
864 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
865 buffer.set_language(language, cx);
866 buffer
867 })?;
868 Ok(buffer)
869}
870
871async fn build_buffer_diff(
872 mut old_text: String,
873 buffer: &Entity<Buffer>,
874 language_registry: &Arc<LanguageRegistry>,
875 cx: &mut AsyncApp,
876) -> Result<Entity<BufferDiff>> {
877 LineEnding::normalize(&mut old_text);
878
879 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
880
881 let base_buffer = cx
882 .update(|cx| {
883 Buffer::build_snapshot(
884 old_text.clone().into(),
885 buffer.language().cloned(),
886 Some(language_registry.clone()),
887 cx,
888 )
889 })?
890 .await;
891
892 let diff_snapshot = cx
893 .update(|cx| {
894 BufferDiffSnapshot::new_with_base_buffer(
895 buffer.text.clone(),
896 Some(old_text.into()),
897 base_buffer,
898 cx,
899 )
900 })?
901 .await;
902
903 let secondary_diff = cx.new(|cx| {
904 let mut diff = BufferDiff::new(&buffer, cx);
905 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
906 diff
907 })?;
908
909 cx.new(|cx| {
910 let mut diff = BufferDiff::new(&buffer.text, cx);
911 diff.set_snapshot(diff_snapshot, &buffer, cx);
912 diff.set_secondary_diff(secondary_diff);
913 diff
914 })
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920 use client::TelemetrySettings;
921 use fs::FakeFs;
922 use gpui::TestAppContext;
923 use language_model::fake_provider::FakeLanguageModel;
924 use serde_json::json;
925 use settings::SettingsStore;
926 use util::path;
927
928 #[gpui::test]
929 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
930 init_test(cx);
931
932 let fs = FakeFs::new(cx.executor());
933 fs.insert_tree("/root", json!({})).await;
934 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
935 let action_log = cx.new(|_| ActionLog::new(project.clone()));
936 let model = Arc::new(FakeLanguageModel::default());
937 let result = cx
938 .update(|cx| {
939 let input = serde_json::to_value(EditFileToolInput {
940 display_description: "Some edit".into(),
941 path: "root/nonexistent_file.txt".into(),
942 mode: EditFileMode::Edit,
943 })
944 .unwrap();
945 Arc::new(EditFileTool)
946 .run(
947 input,
948 Arc::default(),
949 project.clone(),
950 action_log,
951 model,
952 None,
953 cx,
954 )
955 .output
956 })
957 .await;
958 assert_eq!(
959 result.unwrap_err().to_string(),
960 "Can't edit file: path not found"
961 );
962 }
963
964 #[gpui::test]
965 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
966 let mode = &EditFileMode::Create;
967
968 let result = test_resolve_path(mode, "root/new.txt", cx);
969 assert_resolved_path_eq(result.await, "new.txt");
970
971 let result = test_resolve_path(mode, "new.txt", cx);
972 assert_resolved_path_eq(result.await, "new.txt");
973
974 let result = test_resolve_path(mode, "dir/new.txt", cx);
975 assert_resolved_path_eq(result.await, "dir/new.txt");
976
977 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
978 assert_eq!(
979 result.await.unwrap_err().to_string(),
980 "Can't create file: file already exists"
981 );
982
983 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
984 assert_eq!(
985 result.await.unwrap_err().to_string(),
986 "Can't create file: parent directory doesn't exist"
987 );
988 }
989
990 #[gpui::test]
991 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
992 let mode = &EditFileMode::Edit;
993
994 let path_with_root = "root/dir/subdir/existing.txt";
995 let path_without_root = "dir/subdir/existing.txt";
996 let result = test_resolve_path(mode, path_with_root, cx);
997 assert_resolved_path_eq(result.await, path_without_root);
998
999 let result = test_resolve_path(mode, path_without_root, cx);
1000 assert_resolved_path_eq(result.await, path_without_root);
1001
1002 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1003 assert_eq!(
1004 result.await.unwrap_err().to_string(),
1005 "Can't edit file: path not found"
1006 );
1007
1008 let result = test_resolve_path(mode, "root/dir", cx);
1009 assert_eq!(
1010 result.await.unwrap_err().to_string(),
1011 "Can't edit file: path is a directory"
1012 );
1013 }
1014
1015 async fn test_resolve_path(
1016 mode: &EditFileMode,
1017 path: &str,
1018 cx: &mut TestAppContext,
1019 ) -> anyhow::Result<ProjectPath> {
1020 init_test(cx);
1021
1022 let fs = FakeFs::new(cx.executor());
1023 fs.insert_tree(
1024 "/root",
1025 json!({
1026 "dir": {
1027 "subdir": {
1028 "existing.txt": "hello"
1029 }
1030 }
1031 }),
1032 )
1033 .await;
1034 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1035
1036 let input = EditFileToolInput {
1037 display_description: "Some edit".into(),
1038 path: path.into(),
1039 mode: mode.clone(),
1040 };
1041
1042 let result = cx.update(|cx| resolve_path(&input, project, cx));
1043 result
1044 }
1045
1046 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1047 let actual = path
1048 .expect("Should return valid path")
1049 .path
1050 .to_str()
1051 .unwrap()
1052 .replace("\\", "/"); // Naive Windows paths normalization
1053 assert_eq!(actual, expected);
1054 }
1055
1056 #[test]
1057 fn still_streaming_ui_text_with_path() {
1058 let input = json!({
1059 "path": "src/main.rs",
1060 "display_description": "",
1061 "old_string": "old code",
1062 "new_string": "new code"
1063 });
1064
1065 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1066 }
1067
1068 #[test]
1069 fn still_streaming_ui_text_with_description() {
1070 let input = json!({
1071 "path": "",
1072 "display_description": "Fix error handling",
1073 "old_string": "old code",
1074 "new_string": "new code"
1075 });
1076
1077 assert_eq!(
1078 EditFileTool.still_streaming_ui_text(&input),
1079 "Fix error handling",
1080 );
1081 }
1082
1083 #[test]
1084 fn still_streaming_ui_text_with_path_and_description() {
1085 let input = json!({
1086 "path": "src/main.rs",
1087 "display_description": "Fix error handling",
1088 "old_string": "old code",
1089 "new_string": "new code"
1090 });
1091
1092 assert_eq!(
1093 EditFileTool.still_streaming_ui_text(&input),
1094 "Fix error handling",
1095 );
1096 }
1097
1098 #[test]
1099 fn still_streaming_ui_text_no_path_or_description() {
1100 let input = json!({
1101 "path": "",
1102 "display_description": "",
1103 "old_string": "old code",
1104 "new_string": "new code"
1105 });
1106
1107 assert_eq!(
1108 EditFileTool.still_streaming_ui_text(&input),
1109 DEFAULT_UI_TEXT,
1110 );
1111 }
1112
1113 #[test]
1114 fn still_streaming_ui_text_with_null() {
1115 let input = serde_json::Value::Null;
1116
1117 assert_eq!(
1118 EditFileTool.still_streaming_ui_text(&input),
1119 DEFAULT_UI_TEXT,
1120 );
1121 }
1122
1123 fn init_test(cx: &mut TestAppContext) {
1124 cx.update(|cx| {
1125 let settings_store = SettingsStore::test(cx);
1126 cx.set_global(settings_store);
1127 language::init(cx);
1128 TelemetrySettings::register(cx);
1129 Project::init_settings(cx);
1130 });
1131 }
1132}