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