1use anyhow::{Context as _, Result};
2use collections::HashMap;
3use context_server::{ContextServerCommand, ContextServerId};
4use editor::{Editor, EditorElement, EditorStyle};
5
6use gpui::{
7 AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle,
8 Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity,
9 prelude::*,
10};
11use language::{Language, LanguageRegistry};
12use markdown::{Markdown, MarkdownElement, MarkdownStyle};
13use notifications::status_toast::{StatusToast, ToastIcon};
14use parking_lot::Mutex;
15use project::{
16 context_server_store::{
17 ContextServerStatus, ContextServerStore, ServerStatusChangedEvent,
18 registry::ContextServerDescriptorRegistry,
19 },
20 project_settings::{ContextServerSettings, ProjectSettings},
21 worktree_store::WorktreeStore,
22};
23use serde::Deserialize;
24use settings::{Settings as _, update_settings_file};
25use std::sync::Arc;
26use theme_settings::ThemeSettings;
27use ui::{
28 CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip,
29 WithScrollbar, prelude::*,
30};
31use util::ResultExt as _;
32use workspace::{ModalView, Workspace};
33
34use crate::AddContextServer;
35
36enum ConfigurationTarget {
37 New,
38 Existing {
39 id: ContextServerId,
40 command: ContextServerCommand,
41 },
42 ExistingHttp {
43 id: ContextServerId,
44 url: String,
45 headers: HashMap<String, String>,
46 },
47 Extension {
48 id: ContextServerId,
49 repository_url: Option<SharedString>,
50 installation: Option<extension::ContextServerConfiguration>,
51 },
52}
53
54enum ConfigurationSource {
55 New {
56 editor: Entity<Editor>,
57 is_http: bool,
58 },
59 Existing {
60 editor: Entity<Editor>,
61 is_http: bool,
62 },
63 Extension {
64 id: ContextServerId,
65 editor: Option<Entity<Editor>>,
66 repository_url: Option<SharedString>,
67 installation_instructions: Option<Entity<markdown::Markdown>>,
68 settings_validator: Option<jsonschema::Validator>,
69 },
70}
71
72impl ConfigurationSource {
73 fn has_configuration_options(&self) -> bool {
74 !matches!(self, ConfigurationSource::Extension { editor: None, .. })
75 }
76
77 fn is_new(&self) -> bool {
78 matches!(self, ConfigurationSource::New { .. })
79 }
80
81 fn from_target(
82 target: ConfigurationTarget,
83 language_registry: Arc<LanguageRegistry>,
84 jsonc_language: Option<Arc<Language>>,
85 window: &mut Window,
86 cx: &mut App,
87 ) -> Self {
88 fn create_editor(
89 json: String,
90 jsonc_language: Option<Arc<Language>>,
91 window: &mut Window,
92 cx: &mut App,
93 ) -> Entity<Editor> {
94 cx.new(|cx| {
95 let mut editor = Editor::auto_height(4, 16, window, cx);
96 editor.set_text(json, window, cx);
97 editor.set_show_gutter(false, cx);
98 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
99 if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
100 buffer.update(cx, |buffer, cx| buffer.set_language(jsonc_language, cx))
101 }
102 editor
103 })
104 }
105
106 match target {
107 ConfigurationTarget::New => ConfigurationSource::New {
108 editor: create_editor(context_server_input(None), jsonc_language, window, cx),
109 is_http: false,
110 },
111 ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing {
112 editor: create_editor(
113 context_server_input(Some((id, command))),
114 jsonc_language,
115 window,
116 cx,
117 ),
118 is_http: false,
119 },
120 ConfigurationTarget::ExistingHttp {
121 id,
122 url,
123 headers: auth,
124 } => ConfigurationSource::Existing {
125 editor: create_editor(
126 context_server_http_input(Some((id, url, auth))),
127 jsonc_language,
128 window,
129 cx,
130 ),
131 is_http: true,
132 },
133 ConfigurationTarget::Extension {
134 id,
135 repository_url,
136 installation,
137 } => {
138 let settings_validator = installation.as_ref().and_then(|installation| {
139 jsonschema::validator_for(&installation.settings_schema)
140 .context("Failed to load JSON schema for context server settings")
141 .log_err()
142 });
143 let installation_instructions = installation.as_ref().map(|installation| {
144 cx.new(|cx| {
145 Markdown::new(
146 installation.installation_instructions.clone().into(),
147 Some(language_registry.clone()),
148 None,
149 cx,
150 )
151 })
152 });
153 ConfigurationSource::Extension {
154 id,
155 repository_url,
156 installation_instructions,
157 settings_validator,
158 editor: installation.map(|installation| {
159 create_editor(installation.default_settings, jsonc_language, window, cx)
160 }),
161 }
162 }
163 }
164 }
165
166 fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> {
167 match self {
168 ConfigurationSource::New { editor, is_http }
169 | ConfigurationSource::Existing { editor, is_http } => {
170 if *is_http {
171 parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth)| {
172 (
173 id,
174 ContextServerSettings::Http {
175 enabled: true,
176 url,
177 headers: auth,
178 timeout: None,
179 },
180 )
181 })
182 } else {
183 parse_input(&editor.read(cx).text(cx)).map(|(id, command)| {
184 (
185 id,
186 ContextServerSettings::Stdio {
187 enabled: true,
188 remote: false,
189 command,
190 },
191 )
192 })
193 }
194 }
195 ConfigurationSource::Extension {
196 id,
197 editor,
198 settings_validator,
199 ..
200 } => {
201 let text = editor
202 .as_ref()
203 .context("No output available")?
204 .read(cx)
205 .text(cx);
206 let settings = serde_json_lenient::from_str::<serde_json::Value>(&text)?;
207 if let Some(settings_validator) = settings_validator
208 && let Err(error) = settings_validator.validate(&settings)
209 {
210 return Err(anyhow::anyhow!(error.to_string()));
211 }
212 Ok((
213 id.clone(),
214 ContextServerSettings::Extension {
215 enabled: true,
216 remote: false,
217 settings,
218 },
219 ))
220 }
221 }
222 }
223}
224
225fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)>) -> String {
226 let (name, command, args, env) = match existing {
227 Some((id, cmd)) => {
228 let args = serde_json::to_string(&cmd.args).unwrap();
229 let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap();
230 let cmd_path = serde_json::to_string(&cmd.path).unwrap();
231 (id.0.to_string(), cmd_path, args, env)
232 }
233 None => (
234 "some-mcp-server".to_string(),
235 "".to_string(),
236 "[]".to_string(),
237 "{}".to_string(),
238 ),
239 };
240
241 format!(
242 r#"{{
243 /// Configure an MCP server that runs locally via stdin/stdout
244 ///
245 /// The name of your MCP server
246 "{name}": {{
247 /// The command which runs the MCP server
248 "command": {command},
249 /// The arguments to pass to the MCP server
250 "args": {args},
251 /// The environment variables to set
252 "env": {env}
253 }}
254}}"#
255 )
256}
257
258fn context_server_http_input(
259 existing: Option<(ContextServerId, String, HashMap<String, String>)>,
260) -> String {
261 let (name, url, headers) = match existing {
262 Some((id, url, headers)) => {
263 let header = if headers.is_empty() {
264 r#"// "Authorization": "Bearer <token>"#.to_string()
265 } else {
266 let json = serde_json::to_string_pretty(&headers).unwrap();
267 let mut lines = json.split("\n").collect::<Vec<_>>();
268 if lines.len() > 1 {
269 lines.remove(0);
270 lines.pop();
271 }
272 lines
273 .into_iter()
274 .map(|line| format!(" {}", line))
275 .collect::<String>()
276 };
277 (id.0.to_string(), url, header)
278 }
279 None => (
280 "some-remote-server".to_string(),
281 "https://example.com/mcp".to_string(),
282 r#"// "Authorization": "Bearer <token>"#.to_string(),
283 ),
284 };
285
286 format!(
287 r#"{{
288 /// Configure an MCP server that you connect to over HTTP
289 ///
290 /// The name of your remote MCP server
291 "{name}": {{
292 /// The URL of the remote MCP server
293 "url": "{url}",
294 "headers": {{
295 /// Any headers to send along
296 {headers}
297 }}
298 }}
299}}"#
300 )
301}
302
303fn parse_http_input(text: &str) -> Result<(ContextServerId, String, HashMap<String, String>)> {
304 #[derive(Deserialize)]
305 struct Temp {
306 url: String,
307 #[serde(default)]
308 headers: HashMap<String, String>,
309 }
310 let value: HashMap<String, Temp> = serde_json_lenient::from_str(text)?;
311 if value.len() != 1 {
312 anyhow::bail!("Expected exactly one context server configuration");
313 }
314
315 let (key, value) = value.into_iter().next().unwrap();
316
317 Ok((ContextServerId(key.into()), value.url, value.headers))
318}
319
320fn resolve_context_server_extension(
321 id: ContextServerId,
322 worktree_store: Entity<WorktreeStore>,
323 cx: &mut App,
324) -> Task<Option<ConfigurationTarget>> {
325 let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx);
326
327 let Some(descriptor) = registry.context_server_descriptor(&id.0) else {
328 return Task::ready(None);
329 };
330
331 let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx);
332 cx.spawn(async move |cx| {
333 let installation = descriptor
334 .configuration(worktree_store, cx)
335 .await
336 .context("Failed to resolve context server configuration")
337 .log_err()
338 .flatten();
339
340 Some(ConfigurationTarget::Extension {
341 id,
342 repository_url: extension
343 .and_then(|(_, manifest)| manifest.repository.clone().map(SharedString::from)),
344 installation,
345 })
346 })
347}
348
349enum State {
350 Idle,
351 Waiting,
352 AuthRequired { server_id: ContextServerId },
353 Authenticating { _server_id: ContextServerId },
354 Error(SharedString),
355}
356
357pub struct ConfigureContextServerModal {
358 context_server_store: Entity<ContextServerStore>,
359 workspace: WeakEntity<Workspace>,
360 source: ConfigurationSource,
361 state: State,
362 original_server_id: Option<ContextServerId>,
363 scroll_handle: ScrollHandle,
364 _auth_subscription: Option<Subscription>,
365}
366
367impl ConfigureContextServerModal {
368 pub fn register(
369 workspace: &mut Workspace,
370 language_registry: Arc<LanguageRegistry>,
371 _window: Option<&mut Window>,
372 _cx: &mut Context<Workspace>,
373 ) {
374 workspace.register_action({
375 move |_workspace, _: &AddContextServer, window, cx| {
376 let workspace_handle = cx.weak_entity();
377 let language_registry = language_registry.clone();
378 window
379 .spawn(cx, async move |cx| {
380 Self::show_modal(
381 ConfigurationTarget::New,
382 language_registry,
383 workspace_handle,
384 cx,
385 )
386 .await
387 })
388 .detach_and_log_err(cx);
389 }
390 });
391 }
392
393 pub fn show_modal_for_existing_server(
394 server_id: ContextServerId,
395 language_registry: Arc<LanguageRegistry>,
396 workspace: WeakEntity<Workspace>,
397 window: &mut Window,
398 cx: &mut App,
399 ) -> Task<Result<()>> {
400 let Some(settings) = ProjectSettings::get_global(cx)
401 .context_servers
402 .get(&server_id.0)
403 .cloned()
404 .or_else(|| {
405 ContextServerDescriptorRegistry::default_global(cx)
406 .read(cx)
407 .context_server_descriptor(&server_id.0)
408 .map(|_| ContextServerSettings::default_extension())
409 })
410 else {
411 return Task::ready(Err(anyhow::anyhow!("Context server not found")));
412 };
413
414 window.spawn(cx, async move |cx| {
415 let target = match settings {
416 ContextServerSettings::Stdio {
417 enabled: _,
418 command,
419 ..
420 } => Some(ConfigurationTarget::Existing {
421 id: server_id,
422 command,
423 }),
424 ContextServerSettings::Http {
425 enabled: _,
426 url,
427 headers,
428 timeout: _,
429 ..
430 } => Some(ConfigurationTarget::ExistingHttp {
431 id: server_id,
432 url,
433 headers,
434 }),
435 ContextServerSettings::Extension { .. } => {
436 match workspace
437 .update(cx, |workspace, cx| {
438 resolve_context_server_extension(
439 server_id,
440 workspace.project().read(cx).worktree_store(),
441 cx,
442 )
443 })
444 .ok()
445 {
446 Some(task) => task.await,
447 None => None,
448 }
449 }
450 };
451
452 match target {
453 Some(target) => Self::show_modal(target, language_registry, workspace, cx).await,
454 None => Err(anyhow::anyhow!("Failed to resolve context server")),
455 }
456 })
457 }
458
459 fn show_modal(
460 target: ConfigurationTarget,
461 language_registry: Arc<LanguageRegistry>,
462 workspace: WeakEntity<Workspace>,
463 cx: &mut AsyncWindowContext,
464 ) -> Task<Result<()>> {
465 cx.spawn(async move |cx| {
466 let jsonc_language = language_registry.language_for_name("jsonc").await.ok();
467 workspace.update_in(cx, |workspace, window, cx| {
468 let workspace_handle = cx.weak_entity();
469 let context_server_store = workspace.project().read(cx).context_server_store();
470 workspace.toggle_modal(window, cx, |window, cx| Self {
471 context_server_store,
472 workspace: workspace_handle,
473 state: State::Idle,
474 original_server_id: match &target {
475 ConfigurationTarget::Existing { id, .. } => Some(id.clone()),
476 ConfigurationTarget::ExistingHttp { id, .. } => Some(id.clone()),
477 ConfigurationTarget::Extension { id, .. } => Some(id.clone()),
478 ConfigurationTarget::New => None,
479 },
480 source: ConfigurationSource::from_target(
481 target,
482 language_registry,
483 jsonc_language,
484 window,
485 cx,
486 ),
487 scroll_handle: ScrollHandle::new(),
488 _auth_subscription: None,
489 })
490 })
491 })
492 }
493
494 fn set_error(&mut self, err: impl Into<SharedString>, cx: &mut Context<Self>) {
495 self.state = State::Error(err.into());
496 cx.notify();
497 }
498
499 fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
500 if matches!(
501 self.state,
502 State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
503 ) {
504 return;
505 }
506
507 self.state = State::Idle;
508 let Some(workspace) = self.workspace.upgrade() else {
509 return;
510 };
511
512 let (id, settings) = match self.source.output(cx) {
513 Ok(val) => val,
514 Err(error) => {
515 self.set_error(error.to_string(), cx);
516 return;
517 }
518 };
519
520 self.state = State::Waiting;
521
522 let existing_server = self.context_server_store.read(cx).get_running_server(&id);
523 if existing_server.is_some() {
524 self.context_server_store.update(cx, |store, cx| {
525 store.stop_server(&id, cx).log_err();
526 });
527 }
528
529 let wait_for_context_server_task =
530 wait_for_context_server(&self.context_server_store, id.clone(), cx);
531 cx.spawn({
532 let id = id.clone();
533 async move |this, cx| {
534 let result = wait_for_context_server_task.await;
535 this.update(cx, |this, cx| match result {
536 Ok(ContextServerStatus::Running) => {
537 this.state = State::Idle;
538 this.show_configured_context_server_toast(id, cx);
539 cx.emit(DismissEvent);
540 }
541 Ok(ContextServerStatus::AuthRequired) => {
542 this.state = State::AuthRequired { server_id: id };
543 cx.notify();
544 }
545 Err(err) => {
546 this.set_error(err, cx);
547 }
548 Ok(_) => {}
549 })
550 }
551 })
552 .detach();
553
554 let settings_changed =
555 ProjectSettings::get_global(cx).context_servers.get(&id.0) != Some(&settings);
556
557 if settings_changed {
558 // When we write the settings to the file, the context server will be restarted.
559 workspace.update(cx, |workspace, cx| {
560 let fs = workspace.app_state().fs.clone();
561 let original_server_id = self.original_server_id.clone();
562 update_settings_file(fs.clone(), cx, move |current, _| {
563 if let Some(original_id) = original_server_id {
564 if original_id != id {
565 current.project.context_servers.remove(&original_id.0);
566 }
567 }
568 current
569 .project
570 .context_servers
571 .insert(id.0, settings.into());
572 });
573 });
574 } else if let Some(existing_server) = existing_server {
575 self.context_server_store
576 .update(cx, |store, cx| store.start_server(existing_server, cx));
577 }
578 }
579
580 fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
581 cx.emit(DismissEvent);
582 }
583
584 fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
585 self.context_server_store.update(cx, |store, cx| {
586 store.authenticate_server(&server_id, cx).log_err();
587 });
588
589 self.state = State::Authenticating {
590 _server_id: server_id.clone(),
591 };
592
593 self._auth_subscription = Some(cx.subscribe(
594 &self.context_server_store,
595 move |this, _, event: &ServerStatusChangedEvent, cx| {
596 if event.server_id != server_id {
597 return;
598 }
599 match &event.status {
600 ContextServerStatus::Running => {
601 this._auth_subscription = None;
602 this.state = State::Idle;
603 this.show_configured_context_server_toast(event.server_id.clone(), cx);
604 cx.emit(DismissEvent);
605 }
606 ContextServerStatus::AuthRequired => {
607 this._auth_subscription = None;
608 this.state = State::AuthRequired {
609 server_id: event.server_id.clone(),
610 };
611 cx.notify();
612 }
613 ContextServerStatus::Error(error) => {
614 this._auth_subscription = None;
615 this.set_error(error.clone(), cx);
616 }
617 ContextServerStatus::Authenticating
618 | ContextServerStatus::Starting
619 | ContextServerStatus::Stopped => {}
620 }
621 },
622 ));
623
624 cx.notify();
625 }
626
627 fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) {
628 self.workspace
629 .update(cx, {
630 |workspace, cx| {
631 let status_toast = StatusToast::new(
632 format!("{} configured successfully.", id.0),
633 cx,
634 |this, _cx| {
635 this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted))
636 .action("Dismiss", |_, _| {})
637 },
638 );
639
640 workspace.toggle_status_toast(status_toast, cx);
641 }
642 })
643 .log_err();
644 }
645}
646
647fn parse_input(text: &str) -> Result<(ContextServerId, ContextServerCommand)> {
648 let value: serde_json::Value = serde_json_lenient::from_str(text)?;
649 let object = value.as_object().context("Expected object")?;
650 anyhow::ensure!(object.len() == 1, "Expected exactly one key-value pair");
651 let (context_server_name, value) = object.into_iter().next().unwrap();
652 let command: ContextServerCommand = serde_json::from_value(value.clone())?;
653 Ok((ContextServerId(context_server_name.clone().into()), command))
654}
655
656impl ModalView for ConfigureContextServerModal {}
657
658impl Focusable for ConfigureContextServerModal {
659 fn focus_handle(&self, cx: &App) -> FocusHandle {
660 match &self.source {
661 ConfigurationSource::New { editor, .. } => editor.focus_handle(cx),
662 ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx),
663 ConfigurationSource::Extension { editor, .. } => editor
664 .as_ref()
665 .map(|editor| editor.focus_handle(cx))
666 .unwrap_or_else(|| cx.focus_handle()),
667 }
668 }
669}
670
671impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
672
673impl ConfigureContextServerModal {
674 fn render_modal_header(&self) -> ModalHeader {
675 let text: SharedString = match &self.source {
676 ConfigurationSource::New { .. } => "Add MCP Server".into(),
677 ConfigurationSource::Existing { .. } => "Configure MCP Server".into(),
678 ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(),
679 };
680 ModalHeader::new().headline(text)
681 }
682
683 fn render_modal_description(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
684 const MODAL_DESCRIPTION: &str =
685 "Check the server docs for required arguments and environment variables.";
686
687 if let ConfigurationSource::Extension {
688 installation_instructions: Some(installation_instructions),
689 ..
690 } = &self.source
691 {
692 div()
693 .pb_2()
694 .text_sm()
695 .child(MarkdownElement::new(
696 installation_instructions.clone(),
697 default_markdown_style(window, cx),
698 ))
699 .into_any_element()
700 } else {
701 Label::new(MODAL_DESCRIPTION)
702 .color(Color::Muted)
703 .into_any_element()
704 }
705 }
706
707 fn render_tab_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
708 let is_http = match &self.source {
709 ConfigurationSource::New { is_http, .. } => *is_http,
710 _ => return None,
711 };
712
713 let tab = |label: &'static str, active: bool| {
714 div()
715 .id(label)
716 .cursor_pointer()
717 .p_1()
718 .text_sm()
719 .border_b_1()
720 .when(active, |this| {
721 this.border_color(cx.theme().colors().border_focused)
722 })
723 .when(!active, |this| {
724 this.border_color(gpui::transparent_black())
725 .text_color(cx.theme().colors().text_muted)
726 .hover(|s| s.text_color(cx.theme().colors().text))
727 })
728 .child(label)
729 };
730
731 Some(
732 h_flex()
733 .pt_1()
734 .mb_2p5()
735 .gap_1()
736 .border_b_1()
737 .border_color(cx.theme().colors().border.opacity(0.5))
738 .child(
739 tab("Local", !is_http).on_click(cx.listener(|this, _, window, cx| {
740 if let ConfigurationSource::New { editor, is_http } = &mut this.source {
741 if *is_http {
742 *is_http = false;
743 let new_text = context_server_input(None);
744 editor.update(cx, |editor, cx| {
745 editor.set_text(new_text, window, cx);
746 });
747 }
748 }
749 })),
750 )
751 .child(
752 tab("Remote", is_http).on_click(cx.listener(|this, _, window, cx| {
753 if let ConfigurationSource::New { editor, is_http } = &mut this.source {
754 if !*is_http {
755 *is_http = true;
756 let new_text = context_server_http_input(None);
757 editor.update(cx, |editor, cx| {
758 editor.set_text(new_text, window, cx);
759 });
760 }
761 }
762 })),
763 )
764 .into_any_element(),
765 )
766 }
767
768 fn render_modal_content(&self, cx: &App) -> AnyElement {
769 let editor = match &self.source {
770 ConfigurationSource::New { editor, .. } => editor,
771 ConfigurationSource::Existing { editor, .. } => editor,
772 ConfigurationSource::Extension { editor, .. } => {
773 let Some(editor) = editor else {
774 return div().into_any_element();
775 };
776 editor
777 }
778 };
779
780 div()
781 .p_2()
782 .rounded_md()
783 .border_1()
784 .border_color(cx.theme().colors().border_variant)
785 .bg(cx.theme().colors().editor_background)
786 .child({
787 let settings = ThemeSettings::get_global(cx);
788 let text_style = TextStyle {
789 color: cx.theme().colors().text,
790 font_family: settings.buffer_font.family.clone(),
791 font_fallbacks: settings.buffer_font.fallbacks.clone(),
792 font_size: settings.buffer_font_size(cx).into(),
793 font_weight: settings.buffer_font.weight,
794 line_height: relative(settings.buffer_line_height.value()),
795 ..Default::default()
796 };
797 EditorElement::new(
798 editor,
799 EditorStyle {
800 background: cx.theme().colors().editor_background,
801 local_player: cx.theme().players().local(),
802 text: text_style,
803 syntax: cx.theme().syntax().clone(),
804 ..Default::default()
805 },
806 )
807 })
808 .into_any_element()
809 }
810
811 fn render_modal_footer(&self, cx: &mut Context<Self>) -> ModalFooter {
812 let focus_handle = self.focus_handle(cx);
813 let is_busy = matches!(
814 self.state,
815 State::Waiting | State::AuthRequired { .. } | State::Authenticating { .. }
816 );
817
818 ModalFooter::new()
819 .start_slot::<Button>(
820 if let ConfigurationSource::Extension {
821 repository_url: Some(repository_url),
822 ..
823 } = &self.source
824 {
825 Some(
826 Button::new("open-repository", "Open Repository")
827 .end_icon(
828 Icon::new(IconName::ArrowUpRight)
829 .size(IconSize::Small)
830 .color(Color::Muted),
831 )
832 .tooltip({
833 let repository_url = repository_url.clone();
834 move |_window, cx| {
835 Tooltip::with_meta(
836 "Open Repository",
837 None,
838 repository_url.clone(),
839 cx,
840 )
841 }
842 })
843 .on_click({
844 let repository_url = repository_url.clone();
845 move |_, _, cx| cx.open_url(&repository_url)
846 }),
847 )
848 } else {
849 None
850 },
851 )
852 .end_slot(
853 h_flex()
854 .gap_2()
855 .child(
856 Button::new(
857 "cancel",
858 if self.source.has_configuration_options() {
859 "Cancel"
860 } else {
861 "Dismiss"
862 },
863 )
864 .key_binding(
865 KeyBinding::for_action_in(&menu::Cancel, &focus_handle, cx)
866 .map(|kb| kb.size(rems_from_px(12.))),
867 )
868 .on_click(
869 cx.listener(|this, _event, _window, cx| this.cancel(&menu::Cancel, cx)),
870 ),
871 )
872 .children(self.source.has_configuration_options().then(|| {
873 Button::new(
874 "add-server",
875 if self.source.is_new() {
876 "Add Server"
877 } else {
878 "Configure Server"
879 },
880 )
881 .disabled(is_busy)
882 .key_binding(
883 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
884 .map(|kb| kb.size(rems_from_px(12.))),
885 )
886 .on_click(
887 cx.listener(|this, _event, _window, cx| {
888 this.confirm(&menu::Confirm, cx)
889 }),
890 )
891 })),
892 )
893 }
894
895 fn render_loading(&self, label: impl Into<SharedString>) -> Div {
896 h_flex()
897 .h_8()
898 .gap_1p5()
899 .justify_center()
900 .child(
901 Icon::new(IconName::LoadCircle)
902 .size(IconSize::XSmall)
903 .color(Color::Muted)
904 .with_rotate_animation(3),
905 )
906 .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
907 }
908
909 fn render_auth_required(&self, server_id: &ContextServerId, cx: &mut Context<Self>) -> Div {
910 h_flex()
911 .h_8()
912 .min_w_0()
913 .w_full()
914 .gap_2()
915 .justify_center()
916 .child(
917 h_flex()
918 .gap_1p5()
919 .child(
920 Icon::new(IconName::Info)
921 .size(IconSize::Small)
922 .color(Color::Muted),
923 )
924 .child(
925 Label::new("Authenticate to connect this server")
926 .size(LabelSize::Small)
927 .color(Color::Muted),
928 ),
929 )
930 .child(
931 Button::new("authenticate-server", "Authenticate")
932 .style(ButtonStyle::Outlined)
933 .label_size(LabelSize::Small)
934 .on_click({
935 let server_id = server_id.clone();
936 cx.listener(move |this, _event, _window, cx| {
937 this.authenticate(server_id.clone(), cx);
938 })
939 }),
940 )
941 }
942
943 fn render_modal_error(error: SharedString) -> Div {
944 h_flex()
945 .h_8()
946 .gap_1p5()
947 .justify_center()
948 .child(
949 Icon::new(IconName::Warning)
950 .size(IconSize::Small)
951 .color(Color::Warning),
952 )
953 .child(
954 div()
955 .w_full()
956 .child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
957 )
958 }
959}
960
961impl Render for ConfigureContextServerModal {
962 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
963 div()
964 .elevation_3(cx)
965 .w(rems(40.))
966 .key_context("ConfigureContextServerModal")
967 .on_action(
968 cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
969 )
970 .on_action(
971 cx.listener(|this, _: &menu::Confirm, _window, cx| {
972 this.confirm(&menu::Confirm, cx)
973 }),
974 )
975 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
976 this.focus_handle(cx).focus(window, cx);
977 }))
978 .child(
979 Modal::new("configure-context-server", None)
980 .header(self.render_modal_header())
981 .section(
982 Section::new().child(
983 div()
984 .size_full()
985 .child(
986 div()
987 .id("modal-content")
988 .max_h(vh(0.7, window))
989 .overflow_y_scroll()
990 .track_scroll(&self.scroll_handle)
991 .child(self.render_modal_description(window, cx))
992 .children(self.render_tab_bar(cx))
993 .child(self.render_modal_content(cx))
994 .child(match &self.state {
995 State::Idle => div(),
996 State::Waiting => {
997 self.render_loading("Connecting Server…")
998 }
999 State::AuthRequired { server_id } => {
1000 self.render_auth_required(&server_id.clone(), cx)
1001 }
1002 State::Authenticating { .. } => {
1003 self.render_loading("Authenticating…")
1004 }
1005 State::Error(error) => {
1006 Self::render_modal_error(error.clone())
1007 }
1008 }),
1009 )
1010 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
1011 ),
1012 )
1013 .footer(self.render_modal_footer(cx)),
1014 )
1015 }
1016}
1017
1018fn wait_for_context_server(
1019 context_server_store: &Entity<ContextServerStore>,
1020 context_server_id: ContextServerId,
1021 cx: &mut App,
1022) -> Task<Result<ContextServerStatus, Arc<str>>> {
1023 use std::time::Duration;
1024
1025 const WAIT_TIMEOUT: Duration = Duration::from_secs(120);
1026
1027 let (tx, rx) = futures::channel::oneshot::channel();
1028 let tx = Arc::new(Mutex::new(Some(tx)));
1029
1030 let context_server_id_for_timeout = context_server_id.clone();
1031 let subscription = cx.subscribe(context_server_store, move |_, event, _cx| {
1032 let ServerStatusChangedEvent { server_id, status } = event;
1033
1034 if server_id != &context_server_id {
1035 return;
1036 }
1037
1038 match status {
1039 ContextServerStatus::Running | ContextServerStatus::AuthRequired => {
1040 if let Some(tx) = tx.lock().take() {
1041 let _ = tx.send(Ok(status.clone()));
1042 }
1043 }
1044 ContextServerStatus::Stopped => {
1045 if let Some(tx) = tx.lock().take() {
1046 let _ = tx.send(Err("Context server stopped running".into()));
1047 }
1048 }
1049 ContextServerStatus::Error(error) => {
1050 if let Some(tx) = tx.lock().take() {
1051 let _ = tx.send(Err(error.clone()));
1052 }
1053 }
1054 ContextServerStatus::Starting | ContextServerStatus::Authenticating => {}
1055 }
1056 });
1057
1058 cx.spawn(async move |cx| {
1059 let timeout = cx.background_executor().timer(WAIT_TIMEOUT);
1060 let result = futures::future::select(rx, timeout).await;
1061 drop(subscription);
1062 match result {
1063 futures::future::Either::Left((Ok(inner), _)) => inner,
1064 futures::future::Either::Left((Err(_), _)) => {
1065 Err(Arc::from("Context server store was dropped"))
1066 }
1067 futures::future::Either::Right(_) => Err(Arc::from(format!(
1068 "Timed out waiting for context server `{}` to start. Check the Zed log for details.",
1069 context_server_id_for_timeout
1070 ))),
1071 }
1072 })
1073}
1074
1075pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1076 let theme_settings = ThemeSettings::get_global(cx);
1077 let colors = cx.theme().colors();
1078 let mut text_style = window.text_style();
1079 text_style.refine(&TextStyleRefinement {
1080 font_family: Some(theme_settings.ui_font.family.clone()),
1081 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1082 font_features: Some(theme_settings.ui_font.features.clone()),
1083 font_size: Some(TextSize::XSmall.rems(cx).into()),
1084 color: Some(colors.text_muted),
1085 ..Default::default()
1086 });
1087
1088 MarkdownStyle {
1089 base_text_style: text_style.clone(),
1090 selection_background_color: colors.element_selection_background,
1091 link: TextStyleRefinement {
1092 background_color: Some(colors.editor_foreground.opacity(0.025)),
1093 underline: Some(UnderlineStyle {
1094 color: Some(colors.text_accent.opacity(0.5)),
1095 thickness: px(1.),
1096 ..Default::default()
1097 }),
1098 ..Default::default()
1099 },
1100 ..Default::default()
1101 }
1102}