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