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