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