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 ¤t.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}