configure_context_server_modal.rs

  1use std::{
  2    sync::{Arc, Mutex},
  3    time::Duration,
  4};
  5
  6use anyhow::Context as _;
  7use context_server::ContextServerId;
  8use editor::{Editor, EditorElement, EditorStyle};
  9use gpui::{
 10    Animation, AnimationExt, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task,
 11    TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, percentage,
 12};
 13use language::{Language, LanguageRegistry};
 14use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 15use notifications::status_toast::{StatusToast, ToastIcon};
 16use project::{
 17    context_server_store::{ContextServerStatus, ContextServerStore},
 18    project_settings::{ContextServerConfiguration, ProjectSettings},
 19};
 20use settings::{Settings as _, update_settings_file};
 21use theme::ThemeSettings;
 22use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
 23use util::ResultExt;
 24use workspace::{ModalView, Workspace};
 25
 26pub(crate) struct ConfigureContextServerModal {
 27    workspace: WeakEntity<Workspace>,
 28    focus_handle: FocusHandle,
 29    context_servers_to_setup: Vec<ContextServerSetup>,
 30    context_server_store: Entity<ContextServerStore>,
 31}
 32
 33enum Configuration {
 34    NotAvailable,
 35    Required(ConfigurationRequiredState),
 36}
 37
 38struct ConfigurationRequiredState {
 39    installation_instructions: Entity<markdown::Markdown>,
 40    settings_validator: Option<jsonschema::Validator>,
 41    settings_editor: Entity<Editor>,
 42    last_error: Option<SharedString>,
 43    waiting_for_context_server: bool,
 44}
 45
 46struct ContextServerSetup {
 47    id: ContextServerId,
 48    repository_url: Option<SharedString>,
 49    configuration: Configuration,
 50}
 51
 52impl ConfigureContextServerModal {
 53    pub fn new(
 54        configurations: impl Iterator<Item = crate::context_server_configuration::Configuration>,
 55        context_server_store: Entity<ContextServerStore>,
 56        jsonc_language: Option<Arc<Language>>,
 57        language_registry: Arc<LanguageRegistry>,
 58        workspace: WeakEntity<Workspace>,
 59        window: &mut Window,
 60        cx: &mut Context<Self>,
 61    ) -> Self {
 62        let context_servers_to_setup = configurations
 63            .map(|config| match config {
 64                crate::context_server_configuration::Configuration::NotAvailable(
 65                    context_server_id,
 66                    repository_url,
 67                ) => ContextServerSetup {
 68                    id: context_server_id,
 69                    repository_url,
 70                    configuration: Configuration::NotAvailable,
 71                },
 72                crate::context_server_configuration::Configuration::Required(
 73                    context_server_id,
 74                    repository_url,
 75                    config,
 76                ) => {
 77                    let jsonc_language = jsonc_language.clone();
 78                    let settings_validator = jsonschema::validator_for(&config.settings_schema)
 79                        .context("Failed to load JSON schema for context server settings")
 80                        .log_err();
 81                    let state = ConfigurationRequiredState {
 82                        installation_instructions: cx.new(|cx| {
 83                            Markdown::new(
 84                                config.installation_instructions.clone().into(),
 85                                Some(language_registry.clone()),
 86                                None,
 87                                cx,
 88                            )
 89                        }),
 90                        settings_validator,
 91                        settings_editor: cx.new(|cx| {
 92                            let mut editor = Editor::auto_height(16, window, cx);
 93                            editor.set_text(config.default_settings.trim(), window, cx);
 94                            editor.set_show_gutter(false, cx);
 95                            editor.set_soft_wrap_mode(
 96                                language::language_settings::SoftWrap::None,
 97                                cx,
 98                            );
 99                            if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
100                                buffer.update(cx, |buffer, cx| {
101                                    buffer.set_language(jsonc_language, cx)
102                                })
103                            }
104                            editor
105                        }),
106                        waiting_for_context_server: false,
107                        last_error: None,
108                    };
109                    ContextServerSetup {
110                        id: context_server_id,
111                        repository_url,
112                        configuration: Configuration::Required(state),
113                    }
114                }
115            })
116            .collect::<Vec<_>>();
117
118        Self {
119            workspace,
120            focus_handle: cx.focus_handle(),
121            context_servers_to_setup,
122            context_server_store,
123        }
124    }
125}
126
127impl ConfigureContextServerModal {
128    pub fn confirm(&mut self, cx: &mut Context<Self>) {
129        if self.context_servers_to_setup.is_empty() {
130            self.dismiss(cx);
131            return;
132        }
133
134        let Some(workspace) = self.workspace.upgrade() else {
135            return;
136        };
137
138        let id = self.context_servers_to_setup[0].id.clone();
139        let configuration = match &mut self.context_servers_to_setup[0].configuration {
140            Configuration::NotAvailable => {
141                self.context_servers_to_setup.remove(0);
142                if self.context_servers_to_setup.is_empty() {
143                    self.dismiss(cx);
144                }
145                return;
146            }
147            Configuration::Required(state) => state,
148        };
149
150        configuration.last_error.take();
151        if configuration.waiting_for_context_server {
152            return;
153        }
154
155        let settings_value = match serde_json_lenient::from_str::<serde_json::Value>(
156            &configuration.settings_editor.read(cx).text(cx),
157        ) {
158            Ok(value) => value,
159            Err(error) => {
160                configuration.last_error = Some(error.to_string().into());
161                cx.notify();
162                return;
163            }
164        };
165
166        if let Some(validator) = configuration.settings_validator.as_ref() {
167            if let Err(error) = validator.validate(&settings_value) {
168                configuration.last_error = Some(error.to_string().into());
169                cx.notify();
170                return;
171            }
172        }
173        let id = id.clone();
174
175        let settings_changed = ProjectSettings::get_global(cx)
176            .context_servers
177            .get(&id.0)
178            .map_or(true, |config| {
179                config.settings.as_ref() != Some(&settings_value)
180            });
181
182        let is_running = self.context_server_store.read(cx).status_for_server(&id)
183            == Some(ContextServerStatus::Running);
184
185        if !settings_changed && is_running {
186            self.complete_setup(id, cx);
187            return;
188        }
189
190        configuration.waiting_for_context_server = true;
191
192        let task = wait_for_context_server(&self.context_server_store, id.clone(), cx);
193        cx.spawn({
194            let id = id.clone();
195            async move |this, cx| {
196                let result = task.await;
197                this.update(cx, |this, cx| match result {
198                    Ok(_) => {
199                        this.complete_setup(id, cx);
200                    }
201                    Err(err) => {
202                        if let Some(setup) = this.context_servers_to_setup.get_mut(0) {
203                            match &mut setup.configuration {
204                                Configuration::NotAvailable => {}
205                                Configuration::Required(state) => {
206                                    state.last_error = Some(err.into());
207                                    state.waiting_for_context_server = false;
208                                }
209                            }
210                        } else {
211                            this.dismiss(cx);
212                        }
213                        cx.notify();
214                    }
215                })
216            }
217        })
218        .detach();
219
220        // When we write the settings to the file, the context server will be restarted.
221        update_settings_file::<ProjectSettings>(workspace.read(cx).app_state().fs.clone(), cx, {
222            let id = id.clone();
223            |settings, _| {
224                if let Some(server_config) = settings.context_servers.get_mut(&id.0) {
225                    server_config.settings = Some(settings_value);
226                } else {
227                    settings.context_servers.insert(
228                        id.0,
229                        ContextServerConfiguration {
230                            settings: Some(settings_value),
231                            ..Default::default()
232                        },
233                    );
234                }
235            }
236        });
237    }
238
239    fn complete_setup(&mut self, id: ContextServerId, cx: &mut Context<Self>) {
240        self.context_servers_to_setup.remove(0);
241        cx.notify();
242
243        if !self.context_servers_to_setup.is_empty() {
244            return;
245        }
246
247        self.workspace
248            .update(cx, {
249                |workspace, cx| {
250                    let status_toast = StatusToast::new(
251                        format!("{} configured successfully.", id),
252                        cx,
253                        |this, _cx| {
254                            this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted))
255                                .action("Dismiss", |_, _| {})
256                        },
257                    );
258
259                    workspace.toggle_status_toast(status_toast, cx);
260                }
261            })
262            .log_err();
263
264        self.dismiss(cx);
265    }
266
267    fn dismiss(&self, cx: &mut Context<Self>) {
268        cx.emit(DismissEvent);
269    }
270}
271
272fn wait_for_context_server(
273    context_server_store: &Entity<ContextServerStore>,
274    context_server_id: ContextServerId,
275    cx: &mut App,
276) -> Task<Result<(), Arc<str>>> {
277    let (tx, rx) = futures::channel::oneshot::channel();
278    let tx = Arc::new(Mutex::new(Some(tx)));
279
280    let subscription = cx.subscribe(context_server_store, move |_, event, _cx| match event {
281        project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
282            match status {
283                ContextServerStatus::Running => {
284                    if server_id == &context_server_id {
285                        if let Some(tx) = tx.lock().unwrap().take() {
286                            let _ = tx.send(Ok(()));
287                        }
288                    }
289                }
290                ContextServerStatus::Stopped => {
291                    if server_id == &context_server_id {
292                        if let Some(tx) = tx.lock().unwrap().take() {
293                            let _ = tx.send(Err("Context server stopped running".into()));
294                        }
295                    }
296                }
297                ContextServerStatus::Error(error) => {
298                    if server_id == &context_server_id {
299                        if let Some(tx) = tx.lock().unwrap().take() {
300                            let _ = tx.send(Err(error.clone()));
301                        }
302                    }
303                }
304                _ => {}
305            }
306        }
307    });
308
309    cx.spawn(async move |_cx| {
310        let result = rx.await.unwrap();
311        drop(subscription);
312        result
313    })
314}
315
316impl Render for ConfigureContextServerModal {
317    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
318        let Some(setup) = self.context_servers_to_setup.first() else {
319            return div().into_any_element();
320        };
321
322        let focus_handle = self.focus_handle(cx);
323
324        div()
325            .elevation_3(cx)
326            .w(rems(42.))
327            .key_context("ConfigureContextServerModal")
328            .track_focus(&focus_handle)
329            .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.confirm(cx)))
330            .on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx)))
331            .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
332                this.focus_handle(cx).focus(window);
333            }))
334            .child(
335                Modal::new("configure-context-server", None)
336                    .header(ModalHeader::new().headline(format!("Configure {}", setup.id)))
337                    .section(match &setup.configuration {
338                        Configuration::NotAvailable => Section::new().child(
339                            Label::new(
340                                "No configuration options available for this context server. Visit the Repository for any further instructions.",
341                            )
342                            .color(Color::Muted),
343                        ),
344                        Configuration::Required(configuration) => Section::new()
345                            .child(div().pb_2().text_sm().child(MarkdownElement::new(
346                                configuration.installation_instructions.clone(),
347                                default_markdown_style(window, cx),
348                            )))
349                            .child(
350                                div()
351                                    .p_2()
352                                    .rounded_md()
353                                    .border_1()
354                                    .border_color(cx.theme().colors().border_variant)
355                                    .bg(cx.theme().colors().editor_background)
356                                    .gap_1()
357                                    .child({
358                                        let settings = ThemeSettings::get_global(cx);
359                                        let text_style = TextStyle {
360                                            color: cx.theme().colors().text,
361                                            font_family: settings.buffer_font.family.clone(),
362                                            font_fallbacks: settings.buffer_font.fallbacks.clone(),
363                                            font_size: settings.buffer_font_size(cx).into(),
364                                            font_weight: settings.buffer_font.weight,
365                                            line_height: relative(
366                                                settings.buffer_line_height.value(),
367                                            ),
368                                            ..Default::default()
369                                        };
370                                        EditorElement::new(
371                                            &configuration.settings_editor,
372                                            EditorStyle {
373                                                background: cx.theme().colors().editor_background,
374                                                local_player: cx.theme().players().local(),
375                                                text: text_style,
376                                                syntax: cx.theme().syntax().clone(),
377                                                ..Default::default()
378                                            },
379                                        )
380                                    })
381                                    .when_some(configuration.last_error.clone(), |this, error| {
382                                        this.child(
383                                            h_flex()
384                                                .gap_2()
385                                                .px_2()
386                                                .py_1()
387                                                .child(
388                                                    Icon::new(IconName::Warning)
389                                                        .size(IconSize::XSmall)
390                                                        .color(Color::Warning),
391                                                )
392                                                .child(
393                                                    div().w_full().child(
394                                                        Label::new(error)
395                                                            .size(LabelSize::Small)
396                                                            .color(Color::Muted),
397                                                    ),
398                                                ),
399                                        )
400                                    }),
401                            )
402                            .when(configuration.waiting_for_context_server, |this| {
403                                this.child(
404                                    h_flex()
405                                        .gap_1p5()
406                                        .child(
407                                            Icon::new(IconName::ArrowCircle)
408                                                .size(IconSize::XSmall)
409                                                .color(Color::Info)
410                                                .with_animation(
411                                                    "arrow-circle",
412                                                    Animation::new(Duration::from_secs(2)).repeat(),
413                                                    |icon, delta| {
414                                                        icon.transform(Transformation::rotate(
415                                                            percentage(delta),
416                                                        ))
417                                                    },
418                                                )
419                                                .into_any_element(),
420                                        )
421                                        .child(
422                                            Label::new("Waiting for Context Server")
423                                                .size(LabelSize::Small)
424                                                .color(Color::Muted),
425                                        ),
426                                )
427                            }),
428                    })
429                    .footer(
430                        ModalFooter::new()
431                            .when_some(setup.repository_url.clone(), |this, repository_url| {
432                                this.start_slot(
433                                    h_flex().w_full().child(
434                                        Button::new("open-repository", "Open Repository")
435                                            .icon(IconName::ArrowUpRight)
436                                            .icon_color(Color::Muted)
437                                            .icon_size(IconSize::XSmall)
438                                            .tooltip({
439                                                let repository_url = repository_url.clone();
440                                                move |window, cx| {
441                                                    Tooltip::with_meta(
442                                                        "Open Repository",
443                                                        None,
444                                                        repository_url.clone(),
445                                                        window,
446                                                        cx,
447                                                    )
448                                                }
449                                            })
450                                            .on_click(move |_, _, cx| cx.open_url(&repository_url)),
451                                    ),
452                                )
453                            })
454                            .end_slot(match &setup.configuration {
455                                Configuration::NotAvailable => Button::new("dismiss", "Dismiss")
456                                    .key_binding(
457                                        KeyBinding::for_action_in(
458                                            &menu::Cancel,
459                                            &focus_handle,
460                                            window,
461                                            cx,
462                                        )
463                                        .map(|kb| kb.size(rems_from_px(12.))),
464                                    )
465                                    .on_click(
466                                        cx.listener(|this, _event, _window, cx| this.dismiss(cx)),
467                                    )
468                                    .into_any_element(),
469                                Configuration::Required(state) => h_flex()
470                                    .gap_2()
471                                    .child(
472                                        Button::new("cancel", "Cancel")
473                                            .key_binding(
474                                                KeyBinding::for_action_in(
475                                                    &menu::Cancel,
476                                                    &focus_handle,
477                                                    window,
478                                                    cx,
479                                                )
480                                                .map(|kb| kb.size(rems_from_px(12.))),
481                                            )
482                                            .on_click(cx.listener(|this, _event, _window, cx| {
483                                                this.dismiss(cx)
484                                            })),
485                                    )
486                                    .child(
487                                        Button::new("configure-server", "Configure MCP")
488                                            .disabled(state.waiting_for_context_server)
489                                            .key_binding(
490                                                KeyBinding::for_action_in(
491                                                    &menu::Confirm,
492                                                    &focus_handle,
493                                                    window,
494                                                    cx,
495                                                )
496                                                .map(|kb| kb.size(rems_from_px(12.))),
497                                            )
498                                            .on_click(cx.listener(|this, _event, _window, cx| {
499                                                this.confirm(cx)
500                                            })),
501                                    )
502                                    .into_any_element(),
503                            }),
504                    ),
505            ).into_any_element()
506    }
507}
508
509pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
510    let theme_settings = ThemeSettings::get_global(cx);
511    let colors = cx.theme().colors();
512    let mut text_style = window.text_style();
513    text_style.refine(&TextStyleRefinement {
514        font_family: Some(theme_settings.ui_font.family.clone()),
515        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
516        font_features: Some(theme_settings.ui_font.features.clone()),
517        font_size: Some(TextSize::XSmall.rems(cx).into()),
518        color: Some(colors.text_muted),
519        ..Default::default()
520    });
521
522    MarkdownStyle {
523        base_text_style: text_style.clone(),
524        selection_background_color: cx.theme().players().local().selection,
525        link: TextStyleRefinement {
526            background_color: Some(colors.editor_foreground.opacity(0.025)),
527            underline: Some(UnderlineStyle {
528                color: Some(colors.text_accent.opacity(0.5)),
529                thickness: px(1.),
530                ..Default::default()
531            }),
532            ..Default::default()
533        },
534        ..Default::default()
535    }
536}
537
538impl ModalView for ConfigureContextServerModal {}
539impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
540impl Focusable for ConfigureContextServerModal {
541    fn focus_handle(&self, cx: &App) -> FocusHandle {
542        if let Some(current) = self.context_servers_to_setup.first() {
543            match &current.configuration {
544                Configuration::NotAvailable => self.focus_handle.clone(),
545                Configuration::Required(configuration) => {
546                    configuration.settings_editor.read(cx).focus_handle(cx)
547                }
548            }
549        } else {
550            self.focus_handle.clone()
551        }
552    }
553}