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