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}