1use std::rc::Rc;
9
10use gpui::{
11 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
12 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
13};
14use gpui_kit_assets::{Icon, icon};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{
17 ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, TypeScale,
18};
19
20use crate::agent::server_list::{Offering, OfferingKind};
21use crate::display::badge::{Badge, Tone};
22use crate::display::empty::{EmptyKind, EmptyState};
23use crate::display::status::StatusDot;
24use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
25use crate::strings::{ActiveStrings, StringKey};
26
27type ActivateHandler = Rc<dyn Fn(OfferingIdentity, &mut Window, &mut App)>;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct OfferingIdentity {
32 pub server_id: SharedString,
33 pub offering_id: SharedString,
34}
35
36impl OfferingIdentity {
37 pub fn new(server_id: impl Into<SharedString>, offering_id: impl Into<SharedString>) -> Self {
38 Self {
39 server_id: server_id.into(),
40 offering_id: offering_id.into(),
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SearchableOffering {
48 offering: Offering,
49 searchable_text: SharedString,
50}
51
52impl SearchableOffering {
53 pub fn new(offering: Offering, searchable_text: impl Into<SharedString>) -> Self {
54 Self {
55 offering,
56 searchable_text: searchable_text.into(),
57 }
58 }
59
60 pub fn offering(&self) -> &Offering {
61 &self.offering
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum OfferingSourceState {
68 Loading,
69 Empty,
70 Unavailable(SharedString),
71 Error(SharedString),
72 Ready(Vec<SearchableOffering>),
73 Stale {
75 offerings: Vec<SearchableOffering>,
76 reason: SharedString,
77 },
78}
79
80impl OfferingSourceState {
81 pub fn name(&self) -> &'static str {
82 match self {
83 Self::Loading => "loading",
84 Self::Empty => "empty",
85 Self::Unavailable(_) => "unavailable",
86 Self::Error(_) => "error",
87 Self::Ready(offerings) if offerings.is_empty() => "empty",
88 Self::Ready(_) => "ready",
89 Self::Stale { .. } => "stale",
90 }
91 }
92
93 fn offerings(&self) -> &[SearchableOffering] {
94 match self {
95 Self::Ready(offerings) | Self::Stale { offerings, .. } => offerings,
96 _ => &[],
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct OfferingSource {
104 id: SharedString,
105 name: SharedString,
106 state: OfferingSourceState,
107}
108
109impl OfferingSource {
110 pub fn new(
111 id: impl Into<SharedString>,
112 name: impl Into<SharedString>,
113 state: OfferingSourceState,
114 ) -> Self {
115 Self {
116 id: id.into(),
117 name: name.into(),
118 state,
119 }
120 }
121
122 pub fn id(&self) -> &SharedString {
123 &self.id
124 }
125
126 pub fn state(&self) -> &OfferingSourceState {
127 &self.state
128 }
129}
130
131#[derive(IntoElement)]
133pub struct OfferingCatalog {
134 ident: Ident,
135 sources: Vec<OfferingSource>,
136 query: SharedString,
137 kinds: Vec<OfferingKind>,
138 selected: Option<OfferingIdentity>,
139 size: ControlSize,
140 disabled: bool,
141 on_activate: Option<ActivateHandler>,
142}
143
144impl std::fmt::Debug for OfferingCatalog {
145 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 formatter
147 .debug_struct("OfferingCatalog")
148 .field("ident", &self.ident)
149 .field("sources", &self.sources)
150 .field("query", &self.query)
151 .field("kinds", &self.kinds)
152 .field("selected", &self.selected)
153 .field("disabled", &self.disabled)
154 .finish()
155 }
156}
157
158impl OfferingCatalog {
159 pub fn new(ident: impl Into<Ident>) -> Self {
160 Self {
161 ident: ident.into(),
162 sources: Vec::new(),
163 query: SharedString::default(),
164 kinds: Vec::new(),
165 selected: None,
166 size: ControlSize::Md,
167 disabled: false,
168 on_activate: None,
169 }
170 }
171
172 pub fn source(mut self, source: OfferingSource) -> Self {
173 self.sources.push(source);
174 self
175 }
176
177 pub fn sources(mut self, sources: impl IntoIterator<Item = OfferingSource>) -> Self {
178 self.sources.extend(sources);
179 self
180 }
181
182 pub fn query(mut self, query: impl Into<SharedString>) -> Self {
184 self.query = query.into();
185 self
186 }
187
188 pub fn kinds(mut self, kinds: impl IntoIterator<Item = OfferingKind>) -> Self {
190 self.kinds = kinds.into_iter().collect();
191 self
192 }
193
194 pub fn selected(mut self, identity: OfferingIdentity) -> Self {
195 self.selected = Some(identity);
196 self
197 }
198
199 pub fn on_activate(
201 mut self,
202 handler: impl Fn(OfferingIdentity, &mut Window, &mut App) + 'static,
203 ) -> Self {
204 self.on_activate = Some(Rc::new(handler));
205 self
206 }
207}
208
209impl Disableable for OfferingCatalog {
210 fn disabled(mut self, disabled: bool) -> Self {
211 self.disabled = disabled;
212 self
213 }
214}
215
216impl Sizable for OfferingCatalog {
217 fn control_size(mut self, size: ControlSize) -> Self {
218 self.size = size;
219 self
220 }
221}
222
223impl RenderOnce for OfferingCatalog {
224 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
225 let theme = cx.theme().clone();
226 let state_name = self.aggregate_state();
227 let busy = self
228 .sources
229 .iter()
230 .any(|source| matches!(source.state, OfferingSourceState::Loading));
231 let invalid = self
232 .sources
233 .iter()
234 .any(|source| matches!(source.state, OfferingSourceState::Error(_)));
235 let statuses: Vec<AnyElement> = self
236 .sources
237 .iter()
238 .filter(|source| source.state.name() != "ready")
239 .map(|source| self.source_status(source, &theme, cx))
240 .collect();
241 let body = self.results(&theme, cx);
242
243 div()
244 .id(self.ident.element_id())
245 .column()
246 .w_full()
247 .gap_token(&theme, Space::Sm)
248 .p_token(&theme, Space::Sm)
249 .radius(&theme, Radius::Card)
250 .frame(&theme, Surface::Panel, Elevation::Raised)
251 .children(statuses)
252 .child(body)
253 .semantic_in(
254 cx,
255 NodeSpec::new(self.ident.semantic_id(), Role::Region)
256 .value(state_name)
257 .busy(busy)
258 .invalid(invalid),
259 )
260 }
261}
262
263impl OfferingCatalog {
264 fn aggregate_state(&self) -> &'static str {
265 let Some(first) = self.sources.first().map(|source| source.state.name()) else {
266 return "empty";
267 };
268 if self
269 .sources
270 .iter()
271 .all(|source| source.state.name() == first)
272 {
273 first
274 } else {
275 "mixed"
276 }
277 }
278
279 fn source_status(
280 &self,
281 source: &OfferingSource,
282 theme: &gpui_kit_theme::Theme,
283 cx: &mut App,
284 ) -> AnyElement {
285 let ident = self
286 .ident
287 .child("source")
288 .child(encoded_segment(source.id.as_ref()));
289 let (label_key, reason, tone) = match &source.state {
290 OfferingSourceState::Loading => (StringKey::OfferingSourceLoading, None, Tone::Info),
291 OfferingSourceState::Empty | OfferingSourceState::Ready(_) => {
292 (StringKey::OfferingSourceEmpty, None, Tone::Neutral)
293 }
294 OfferingSourceState::Unavailable(reason) => (
295 StringKey::OfferingSourceUnavailable,
296 Some(reason.clone()),
297 Tone::Neutral,
298 ),
299 OfferingSourceState::Error(reason) => (
300 StringKey::OfferingSourceError,
301 Some(reason.clone()),
302 Tone::Danger,
303 ),
304 OfferingSourceState::Stale { reason, .. } => (
305 StringKey::OfferingSourceStale,
306 Some(reason.clone()),
307 Tone::Warning,
308 ),
309 };
310 let label = cx.strings().format(label_key, &[source.name.as_ref()]);
311 div()
312 .row()
313 .w_full()
314 .items_start()
315 .gap_token(theme, Space::Sm)
316 .p_token(theme, Space::Sm)
317 .radius(theme, Radius::Control)
318 .bg(tone.color(theme).opacity(0.12))
319 .child(div().mt(px(4.0)).child(StatusDot::new(tone)))
320 .child(
321 div()
322 .column()
323 .min_w_0()
324 .child(
325 text(theme, TypeScale::Caption, label.clone())
326 .text_color(tone.color(theme)),
327 )
328 .children(reason.clone().map(|reason| {
329 text(theme, TypeScale::Body, reason).text_color(tone.color(theme))
330 })),
331 )
332 .semantic_in(
333 cx,
334 NodeSpec::new(ident.semantic_id(), Role::Status)
335 .parent(self.ident.semantic_id())
336 .text(label)
337 .value(source.state.name())
338 .description(reason.unwrap_or_default())
339 .busy(matches!(source.state, OfferingSourceState::Loading))
340 .invalid(matches!(source.state, OfferingSourceState::Error(_))),
341 )
342 .into_any_element()
343 }
344
345 fn results(&self, theme: &gpui_kit_theme::Theme, cx: &mut App) -> AnyElement {
346 let query = self.query.to_lowercase();
347 let filtered: Vec<(&OfferingSource, &SearchableOffering)> = self
348 .sources
349 .iter()
350 .flat_map(|source| {
351 source
352 .state
353 .offerings()
354 .iter()
355 .map(move |offering| (source, offering))
356 })
357 .filter(|(_, result)| {
358 self.kinds.is_empty() || self.kinds.contains(&result.offering.kind())
359 })
360 .filter(|(_, result)| {
361 query.is_empty() || result.searchable_text.to_lowercase().contains(&query)
362 })
363 .collect();
364 if filtered.is_empty() {
365 let key = if self
366 .sources
367 .iter()
368 .any(|source| !source.state.offerings().is_empty())
369 {
370 StringKey::OfferingCatalogNoMatch
371 } else {
372 StringKey::OfferingCatalogEmpty
373 };
374 return EmptyState::new(self.ident.child("empty"), cx.strings().text(key))
375 .kind(EmptyKind::Empty)
376 .into_any_element();
377 }
378
379 let list_ident = self.ident.child("results");
380 div()
381 .column()
382 .w_full()
383 .gap_token(theme, Space::Xs)
384 .children(
385 filtered
386 .iter()
387 .map(|(source, result)| self.result(source, result, &list_ident, theme, cx)),
388 )
389 .semantic_in(
390 cx,
391 NodeSpec::new(list_ident.semantic_id(), Role::List)
392 .parent(self.ident.semantic_id())
393 .value(filtered.len().to_string()),
394 )
395 .into_any_element()
396 }
397
398 fn result(
399 &self,
400 source: &OfferingSource,
401 result: &SearchableOffering,
402 list_ident: &Ident,
403 theme: &gpui_kit_theme::Theme,
404 cx: &mut App,
405 ) -> AnyElement {
406 let identity = OfferingIdentity::new(source.id.clone(), result.offering.id().clone());
407 let ident = list_ident
408 .child(encoded_segment(identity.server_id.as_ref()))
409 .child(encoded_segment(identity.offering_id.as_ref()));
410 let selected = self.selected.as_ref() == Some(&identity);
411 let actionable = !self.disabled && self.on_activate.is_some();
412 let metrics = theme.control.get(self.size);
413 let kind = result.offering.kind();
414 let glyph = match kind {
415 OfferingKind::Tool => Icon::Tuning,
416 OfferingKind::Skill => Icon::Command,
417 OfferingKind::Resource => Icon::Document,
418 };
419 let mut row = div()
420 .id(ident.element_id())
421 .row()
422 .w_full()
423 .items_start()
424 .gap_token(theme, Space::Sm)
425 .p_token(theme, Space::Sm)
426 .radius(theme, Radius::Control)
427 .when(selected, |element| element.bg(theme.colors.selected))
428 .when(actionable, |element| {
429 element
430 .cursor_pointer()
431 .tab_index(0)
432 .pressable(cx)
433 .when(!selected, |element| {
434 element.hover(|style| style.bg(theme.colors.hover))
435 })
436 .focus_ring(theme)
437 })
438 .child(
439 icon(glyph)
440 .size(px(metrics.icon_size))
441 .text_color(theme.colors.text_faint),
442 )
443 .child(
444 div()
445 .column()
446 .flex_1()
447 .min_w_0()
448 .child(text(
449 theme,
450 TypeScale::Label,
451 result.offering.name().clone(),
452 ))
453 .when_some(
454 result.offering.summary_text().cloned(),
455 |element, summary| {
456 element.child(
457 text(theme, TypeScale::Body, summary)
458 .text_tone(theme, TextTone::Muted),
459 )
460 },
461 )
462 .when_some(
463 result.offering.qualifier_text().cloned(),
464 |element, qualifier| {
465 element.child(
466 text(theme, TypeScale::Code, qualifier)
467 .text_tone(theme, TextTone::Faint)
468 .font_family(theme.typography.mono.clone()),
469 )
470 },
471 ),
472 )
473 .child(
474 div()
475 .column()
476 .items_end()
477 .gap_token(theme, Space::Xs)
478 .child(Badge::new(kind.name()).tone(Tone::Neutral))
479 .child(
480 text(theme, TypeScale::Caption, source.name.clone())
481 .text_tone(theme, TextTone::Muted)
482 .semantic_in(
483 cx,
484 NodeSpec::new(ident.child("server").semantic_id(), Role::Status)
485 .parent(ident.semantic_id())
486 .text(source.name.clone())
487 .value(source.id.clone()),
488 ),
489 ),
490 );
491 if let (true, Some(handler)) = (actionable, self.on_activate.clone()) {
492 row = row.on_click(move |_, window, cx| handler(identity.clone(), window, cx));
493 }
494 row.semantic_in(
495 cx,
496 NodeSpec::new(ident.semantic_id(), Role::Row)
497 .parent(list_ident.semantic_id())
498 .text(result.offering.name().clone())
499 .value(kind.name())
500 .selected(selected)
501 .disabled(self.disabled),
502 )
503 .into_any_element()
504 }
505}
506
507fn encoded_segment(value: &str) -> String {
508 value.replace('%', "%25").replace('.', "%2E")
509}