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::{ContextServerSettings, 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(1, 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, |settings| match settings {
179 ContextServerSettings::Custom { .. } => false,
180 ContextServerSettings::Extension { settings } => settings != &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 settings.context_servers.insert(
226 id.0,
227 ContextServerSettings::Extension {
228 settings: settings_value,
229 },
230 );
231 }
232 });
233 }
234
235 fn complete_setup(&mut self, id: ContextServerId, cx: &mut Context<Self>) {
236 self.context_servers_to_setup.remove(0);
237 cx.notify();
238
239 if !self.context_servers_to_setup.is_empty() {
240 return;
241 }
242
243 self.workspace
244 .update(cx, {
245 |workspace, cx| {
246 let status_toast = StatusToast::new(
247 format!("{} configured successfully.", id),
248 cx,
249 |this, _cx| {
250 this.icon(ToastIcon::new(IconName::Hammer).color(Color::Muted))
251 .action("Dismiss", |_, _| {})
252 },
253 );
254
255 workspace.toggle_status_toast(status_toast, cx);
256 }
257 })
258 .log_err();
259
260 self.dismiss(cx);
261 }
262
263 fn dismiss(&self, cx: &mut Context<Self>) {
264 cx.emit(DismissEvent);
265 }
266}
267
268fn wait_for_context_server(
269 context_server_store: &Entity<ContextServerStore>,
270 context_server_id: ContextServerId,
271 cx: &mut App,
272) -> Task<Result<(), Arc<str>>> {
273 let (tx, rx) = futures::channel::oneshot::channel();
274 let tx = Arc::new(Mutex::new(Some(tx)));
275
276 let subscription = cx.subscribe(context_server_store, move |_, event, _cx| match event {
277 project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
278 match status {
279 ContextServerStatus::Running => {
280 if server_id == &context_server_id {
281 if let Some(tx) = tx.lock().unwrap().take() {
282 let _ = tx.send(Ok(()));
283 }
284 }
285 }
286 ContextServerStatus::Stopped => {
287 if server_id == &context_server_id {
288 if let Some(tx) = tx.lock().unwrap().take() {
289 let _ = tx.send(Err("Context server stopped running".into()));
290 }
291 }
292 }
293 ContextServerStatus::Error(error) => {
294 if server_id == &context_server_id {
295 if let Some(tx) = tx.lock().unwrap().take() {
296 let _ = tx.send(Err(error.clone()));
297 }
298 }
299 }
300 _ => {}
301 }
302 }
303 });
304
305 cx.spawn(async move |_cx| {
306 let result = rx.await.unwrap();
307 drop(subscription);
308 result
309 })
310}
311
312impl Render for ConfigureContextServerModal {
313 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
314 let Some(setup) = self.context_servers_to_setup.first() else {
315 return div().into_any_element();
316 };
317
318 let focus_handle = self.focus_handle(cx);
319
320 div()
321 .elevation_3(cx)
322 .w(rems(42.))
323 .key_context("ConfigureContextServerModal")
324 .track_focus(&focus_handle)
325 .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| this.confirm(cx)))
326 .on_action(cx.listener(|this, _: &menu::Cancel, _window, cx| this.dismiss(cx)))
327 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
328 this.focus_handle(cx).focus(window);
329 }))
330 .child(
331 Modal::new("configure-context-server", None)
332 .header(ModalHeader::new().headline(format!("Configure {}", setup.id)))
333 .section(match &setup.configuration {
334 Configuration::NotAvailable => Section::new().child(
335 Label::new(
336 "No configuration options available for this context server. Visit the Repository for any further instructions.",
337 )
338 .color(Color::Muted),
339 ),
340 Configuration::Required(configuration) => Section::new()
341 .child(div().pb_2().text_sm().child(MarkdownElement::new(
342 configuration.installation_instructions.clone(),
343 default_markdown_style(window, cx),
344 )))
345 .child(
346 div()
347 .p_2()
348 .rounded_md()
349 .border_1()
350 .border_color(cx.theme().colors().border_variant)
351 .bg(cx.theme().colors().editor_background)
352 .gap_1()
353 .child({
354 let settings = ThemeSettings::get_global(cx);
355 let text_style = TextStyle {
356 color: cx.theme().colors().text,
357 font_family: settings.buffer_font.family.clone(),
358 font_fallbacks: settings.buffer_font.fallbacks.clone(),
359 font_size: settings.buffer_font_size(cx).into(),
360 font_weight: settings.buffer_font.weight,
361 line_height: relative(
362 settings.buffer_line_height.value(),
363 ),
364 ..Default::default()
365 };
366 EditorElement::new(
367 &configuration.settings_editor,
368 EditorStyle {
369 background: cx.theme().colors().editor_background,
370 local_player: cx.theme().players().local(),
371 text: text_style,
372 syntax: cx.theme().syntax().clone(),
373 ..Default::default()
374 },
375 )
376 })
377 .when_some(configuration.last_error.clone(), |this, error| {
378 this.child(
379 h_flex()
380 .gap_2()
381 .px_2()
382 .py_1()
383 .child(
384 Icon::new(IconName::Warning)
385 .size(IconSize::XSmall)
386 .color(Color::Warning),
387 )
388 .child(
389 div().w_full().child(
390 Label::new(error)
391 .size(LabelSize::Small)
392 .color(Color::Muted),
393 ),
394 ),
395 )
396 }),
397 )
398 .when(configuration.waiting_for_context_server, |this| {
399 this.child(
400 h_flex()
401 .gap_1p5()
402 .child(
403 Icon::new(IconName::ArrowCircle)
404 .size(IconSize::XSmall)
405 .color(Color::Info)
406 .with_animation(
407 "arrow-circle",
408 Animation::new(Duration::from_secs(2)).repeat(),
409 |icon, delta| {
410 icon.transform(Transformation::rotate(
411 percentage(delta),
412 ))
413 },
414 )
415 .into_any_element(),
416 )
417 .child(
418 Label::new("Waiting for Context Server")
419 .size(LabelSize::Small)
420 .color(Color::Muted),
421 ),
422 )
423 }),
424 })
425 .footer(
426 ModalFooter::new()
427 .when_some(setup.repository_url.clone(), |this, repository_url| {
428 this.start_slot(
429 h_flex().w_full().child(
430 Button::new("open-repository", "Open Repository")
431 .icon(IconName::ArrowUpRight)
432 .icon_color(Color::Muted)
433 .icon_size(IconSize::XSmall)
434 .tooltip({
435 let repository_url = repository_url.clone();
436 move |window, cx| {
437 Tooltip::with_meta(
438 "Open Repository",
439 None,
440 repository_url.clone(),
441 window,
442 cx,
443 )
444 }
445 })
446 .on_click(move |_, _, cx| cx.open_url(&repository_url)),
447 ),
448 )
449 })
450 .end_slot(match &setup.configuration {
451 Configuration::NotAvailable => Button::new("dismiss", "Dismiss")
452 .key_binding(
453 KeyBinding::for_action_in(
454 &menu::Cancel,
455 &focus_handle,
456 window,
457 cx,
458 )
459 .map(|kb| kb.size(rems_from_px(12.))),
460 )
461 .on_click(
462 cx.listener(|this, _event, _window, cx| this.dismiss(cx)),
463 )
464 .into_any_element(),
465 Configuration::Required(state) => h_flex()
466 .gap_2()
467 .child(
468 Button::new("cancel", "Cancel")
469 .key_binding(
470 KeyBinding::for_action_in(
471 &menu::Cancel,
472 &focus_handle,
473 window,
474 cx,
475 )
476 .map(|kb| kb.size(rems_from_px(12.))),
477 )
478 .on_click(cx.listener(|this, _event, _window, cx| {
479 this.dismiss(cx)
480 })),
481 )
482 .child(
483 Button::new("configure-server", "Configure MCP")
484 .disabled(state.waiting_for_context_server)
485 .key_binding(
486 KeyBinding::for_action_in(
487 &menu::Confirm,
488 &focus_handle,
489 window,
490 cx,
491 )
492 .map(|kb| kb.size(rems_from_px(12.))),
493 )
494 .on_click(cx.listener(|this, _event, _window, cx| {
495 this.confirm(cx)
496 })),
497 )
498 .into_any_element(),
499 }),
500 ),
501 ).into_any_element()
502 }
503}
504
505pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
506 let theme_settings = ThemeSettings::get_global(cx);
507 let colors = cx.theme().colors();
508 let mut text_style = window.text_style();
509 text_style.refine(&TextStyleRefinement {
510 font_family: Some(theme_settings.ui_font.family.clone()),
511 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
512 font_features: Some(theme_settings.ui_font.features.clone()),
513 font_size: Some(TextSize::XSmall.rems(cx).into()),
514 color: Some(colors.text_muted),
515 ..Default::default()
516 });
517
518 MarkdownStyle {
519 base_text_style: text_style.clone(),
520 selection_background_color: cx.theme().players().local().selection,
521 link: TextStyleRefinement {
522 background_color: Some(colors.editor_foreground.opacity(0.025)),
523 underline: Some(UnderlineStyle {
524 color: Some(colors.text_accent.opacity(0.5)),
525 thickness: px(1.),
526 ..Default::default()
527 }),
528 ..Default::default()
529 },
530 ..Default::default()
531 }
532}
533
534impl ModalView for ConfigureContextServerModal {}
535impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
536impl Focusable for ConfigureContextServerModal {
537 fn focus_handle(&self, cx: &App) -> FocusHandle {
538 if let Some(current) = self.context_servers_to_setup.first() {
539 match ¤t.configuration {
540 Configuration::NotAvailable => self.focus_handle.clone(),
541 Configuration::Required(configuration) => {
542 configuration.settings_editor.read(cx).focus_handle(cx)
543 }
544 }
545 } else {
546 self.focus_handle.clone()
547 }
548 }
549}