configure_context_server_modal.rs

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