1use std::rc::Rc;
2
3use collections::HashSet;
4use editor::Editor;
5use file_icons::FileIcons;
6use gpui::{
7 App, Bounds, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
8 WeakEntity,
9};
10use itertools::Itertools;
11use language::Buffer;
12use ui::{prelude::*, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip};
13use workspace::{notifications::NotifyResultExt, Workspace};
14
15use crate::context::ContextKind;
16use crate::context_picker::{ConfirmBehavior, ContextPicker};
17use crate::context_store::ContextStore;
18use crate::thread::Thread;
19use crate::thread_store::ThreadStore;
20use crate::ui::ContextPill;
21use crate::{
22 AcceptSuggestedContext, AssistantPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
23 RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
24};
25
26pub struct ContextStrip {
27 context_store: Entity<ContextStore>,
28 pub context_picker: Entity<ContextPicker>,
29 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
30 focus_handle: FocusHandle,
31 suggest_context_kind: SuggestContextKind,
32 workspace: WeakEntity<Workspace>,
33 _subscriptions: Vec<Subscription>,
34 focused_index: Option<usize>,
35 children_bounds: Option<Vec<Bounds<Pixels>>>,
36}
37
38impl ContextStrip {
39 #[allow(clippy::too_many_arguments)]
40 pub fn new(
41 context_store: Entity<ContextStore>,
42 workspace: WeakEntity<Workspace>,
43 editor: WeakEntity<Editor>,
44 thread_store: Option<WeakEntity<ThreadStore>>,
45 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
46 suggest_context_kind: SuggestContextKind,
47 window: &mut Window,
48 cx: &mut Context<Self>,
49 ) -> Self {
50 let context_picker = cx.new(|cx| {
51 ContextPicker::new(
52 workspace.clone(),
53 thread_store.clone(),
54 context_store.downgrade(),
55 editor.clone(),
56 ConfirmBehavior::KeepOpen,
57 window,
58 cx,
59 )
60 });
61
62 let focus_handle = cx.focus_handle();
63
64 let subscriptions = vec![
65 cx.subscribe_in(&context_picker, window, Self::handle_context_picker_event),
66 cx.on_focus(&focus_handle, window, Self::handle_focus),
67 cx.on_blur(&focus_handle, window, Self::handle_blur),
68 ];
69
70 Self {
71 context_store: context_store.clone(),
72 context_picker,
73 context_picker_menu_handle,
74 focus_handle,
75 suggest_context_kind,
76 workspace,
77 _subscriptions: subscriptions,
78 focused_index: None,
79 children_bounds: None,
80 }
81 }
82
83 fn suggested_context(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
84 match self.suggest_context_kind {
85 SuggestContextKind::File => self.suggested_file(cx),
86 SuggestContextKind::Thread => self.suggested_thread(cx),
87 }
88 }
89
90 fn suggested_file(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
91 let workspace = self.workspace.upgrade()?;
92 let active_item = workspace.read(cx).active_item(cx)?;
93
94 let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
95 let active_buffer_entity = editor.buffer().read(cx).as_singleton()?;
96 let active_buffer = active_buffer_entity.read(cx);
97
98 let path = active_buffer.file()?.path();
99
100 if self
101 .context_store
102 .read(cx)
103 .will_include_buffer(active_buffer.remote_id(), path)
104 .is_some()
105 {
106 return None;
107 }
108
109 let name = match path.file_name() {
110 Some(name) => name.to_string_lossy().into_owned().into(),
111 None => path.to_string_lossy().into_owned().into(),
112 };
113
114 let icon_path = FileIcons::get_icon(path, cx);
115
116 Some(SuggestedContext::File {
117 name,
118 buffer: active_buffer_entity.downgrade(),
119 icon_path,
120 })
121 }
122
123 fn suggested_thread(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
124 if !self.context_picker.read(cx).allow_threads() {
125 return None;
126 }
127
128 let workspace = self.workspace.upgrade()?;
129 let active_thread = workspace
130 .read(cx)
131 .panel::<AssistantPanel>(cx)?
132 .read(cx)
133 .active_thread(cx);
134 let weak_active_thread = active_thread.downgrade();
135
136 let active_thread = active_thread.read(cx);
137
138 if self
139 .context_store
140 .read(cx)
141 .includes_thread(active_thread.id())
142 .is_some()
143 {
144 return None;
145 }
146
147 Some(SuggestedContext::Thread {
148 name: active_thread.summary_or_default(),
149 thread: weak_active_thread,
150 })
151 }
152
153 fn handle_context_picker_event(
154 &mut self,
155 _picker: &Entity<ContextPicker>,
156 _event: &DismissEvent,
157 _window: &mut Window,
158 cx: &mut Context<Self>,
159 ) {
160 cx.emit(ContextStripEvent::PickerDismissed);
161 }
162
163 fn handle_focus(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
164 self.focused_index = self.last_pill_index();
165 cx.notify();
166 }
167
168 fn handle_blur(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
169 self.focused_index = None;
170 cx.notify();
171 }
172
173 fn focus_left(&mut self, _: &FocusLeft, _window: &mut Window, cx: &mut Context<Self>) {
174 self.focused_index = match self.focused_index {
175 Some(index) if index > 0 => Some(index - 1),
176 _ => self.last_pill_index(),
177 };
178
179 cx.notify();
180 }
181
182 fn focus_right(&mut self, _: &FocusRight, _window: &mut Window, cx: &mut Context<Self>) {
183 let Some(last_index) = self.last_pill_index() else {
184 return;
185 };
186
187 self.focused_index = match self.focused_index {
188 Some(index) if index < last_index => Some(index + 1),
189 _ => Some(0),
190 };
191
192 cx.notify();
193 }
194
195 fn focus_up(&mut self, _: &FocusUp, _window: &mut Window, cx: &mut Context<Self>) {
196 let Some(focused_index) = self.focused_index else {
197 return;
198 };
199
200 if focused_index == 0 {
201 return cx.emit(ContextStripEvent::BlurredUp);
202 }
203
204 let Some((focused, pills)) = self.focused_bounds(focused_index) else {
205 return;
206 };
207
208 let iter = pills[..focused_index].iter().enumerate().rev();
209 self.focused_index = Self::find_best_horizontal_match(focused, iter).or(Some(0));
210 cx.notify();
211 }
212
213 fn focus_down(&mut self, _: &FocusDown, _window: &mut Window, cx: &mut Context<Self>) {
214 let Some(focused_index) = self.focused_index else {
215 return;
216 };
217
218 let last_index = self.last_pill_index();
219
220 if self.focused_index == last_index {
221 return cx.emit(ContextStripEvent::BlurredDown);
222 }
223
224 let Some((focused, pills)) = self.focused_bounds(focused_index) else {
225 return;
226 };
227
228 let iter = pills.iter().enumerate().skip(focused_index + 1);
229 self.focused_index = Self::find_best_horizontal_match(focused, iter).or(last_index);
230 cx.notify();
231 }
232
233 fn focused_bounds(&self, focused: usize) -> Option<(&Bounds<Pixels>, &[Bounds<Pixels>])> {
234 let pill_bounds = self.pill_bounds()?;
235 let focused = pill_bounds.get(focused)?;
236
237 Some((focused, pill_bounds))
238 }
239
240 fn pill_bounds(&self) -> Option<&[Bounds<Pixels>]> {
241 let bounds = self.children_bounds.as_ref()?;
242 let eraser = if bounds.len() < 3 { 0 } else { 1 };
243 let pills = &bounds[1..bounds.len() - eraser];
244
245 if pills.is_empty() {
246 None
247 } else {
248 Some(pills)
249 }
250 }
251
252 fn last_pill_index(&self) -> Option<usize> {
253 Some(self.pill_bounds()?.len() - 1)
254 }
255
256 fn find_best_horizontal_match<'a>(
257 focused: &'a Bounds<Pixels>,
258 iter: impl Iterator<Item = (usize, &'a Bounds<Pixels>)>,
259 ) -> Option<usize> {
260 let mut best = None;
261
262 let focused_left = focused.left();
263 let focused_right = focused.right();
264
265 for (index, probe) in iter {
266 if probe.origin.y == focused.origin.y {
267 continue;
268 }
269
270 let overlap = probe.right().min(focused_right) - probe.left().max(focused_left);
271
272 best = match best {
273 Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => {
274 break;
275 }
276 Some(_) | None => Some((index, overlap, probe.origin.y)),
277 };
278 }
279
280 best.map(|(index, _, _)| index)
281 }
282
283 fn remove_focused_context(
284 &mut self,
285 _: &RemoveFocusedContext,
286 _window: &mut Window,
287 cx: &mut Context<Self>,
288 ) {
289 if let Some(index) = self.focused_index {
290 let mut is_empty = false;
291
292 self.context_store.update(cx, |this, _cx| {
293 if let Some(item) = this.context().get(index) {
294 this.remove_context(item.id());
295 }
296
297 is_empty = this.context().is_empty();
298 });
299
300 if is_empty {
301 cx.emit(ContextStripEvent::BlurredEmpty);
302 } else {
303 self.focused_index = Some(index.saturating_sub(1));
304 cx.notify();
305 }
306 }
307 }
308
309 fn is_suggested_focused<T>(&self, context: &Vec<T>) -> bool {
310 // We only suggest one item after the actual context
311 self.focused_index == Some(context.len())
312 }
313
314 fn accept_suggested_context(
315 &mut self,
316 _: &AcceptSuggestedContext,
317 window: &mut Window,
318 cx: &mut Context<Self>,
319 ) {
320 if let Some(suggested) = self.suggested_context(cx) {
321 let context_store = self.context_store.read(cx);
322
323 if self.is_suggested_focused(context_store.context()) {
324 self.add_suggested_context(&suggested, window, cx);
325 }
326 }
327 }
328
329 fn add_suggested_context(
330 &mut self,
331 suggested: &SuggestedContext,
332 window: &mut Window,
333 cx: &mut Context<Self>,
334 ) {
335 let task = self.context_store.update(cx, |context_store, cx| {
336 context_store.accept_suggested_context(&suggested, cx)
337 });
338
339 cx.spawn_in(window, |this, mut cx| async move {
340 match task.await.notify_async_err(&mut cx) {
341 None => {}
342 Some(()) => {
343 if let Some(this) = this.upgrade() {
344 this.update(&mut cx, |_, cx| cx.notify())?;
345 }
346 }
347 }
348 anyhow::Ok(())
349 })
350 .detach_and_log_err(cx);
351
352 cx.notify();
353 }
354}
355
356impl Focusable for ContextStrip {
357 fn focus_handle(&self, _cx: &App) -> FocusHandle {
358 self.focus_handle.clone()
359 }
360}
361
362impl Render for ContextStrip {
363 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
364 let context_store = self.context_store.read(cx);
365 let context = context_store
366 .context()
367 .iter()
368 .flat_map(|context| context.snapshot(cx))
369 .collect::<Vec<_>>();
370 let context_picker = self.context_picker.clone();
371 let focus_handle = self.focus_handle.clone();
372
373 let suggested_context = self.suggested_context(cx);
374
375 let dupe_names = context
376 .iter()
377 .map(|context| context.name.clone())
378 .sorted()
379 .tuple_windows()
380 .filter(|(a, b)| a == b)
381 .map(|(a, _)| a)
382 .collect::<HashSet<SharedString>>();
383
384 h_flex()
385 .flex_wrap()
386 .gap_1()
387 .track_focus(&focus_handle)
388 .key_context("ContextStrip")
389 .on_action(cx.listener(Self::focus_up))
390 .on_action(cx.listener(Self::focus_right))
391 .on_action(cx.listener(Self::focus_down))
392 .on_action(cx.listener(Self::focus_left))
393 .on_action(cx.listener(Self::remove_focused_context))
394 .on_action(cx.listener(Self::accept_suggested_context))
395 .on_children_prepainted({
396 let entity = cx.entity().downgrade();
397 move |children_bounds, _window, cx| {
398 entity
399 .update(cx, |this, _| {
400 this.children_bounds = Some(children_bounds);
401 })
402 .ok();
403 }
404 })
405 .child(
406 PopoverMenu::new("context-picker")
407 .menu(move |window, cx| {
408 context_picker.update(cx, |this, cx| {
409 this.init(window, cx);
410 });
411
412 Some(context_picker.clone())
413 })
414 .trigger_with_tooltip(
415 IconButton::new("add-context", IconName::Plus)
416 .icon_size(IconSize::Small)
417 .style(ui::ButtonStyle::Filled),
418 {
419 let focus_handle = focus_handle.clone();
420 move |window, cx| {
421 Tooltip::for_action_in(
422 "Add Context",
423 &ToggleContextPicker,
424 &focus_handle,
425 window,
426 cx,
427 )
428 }
429 },
430 )
431 .attach(gpui::Corner::TopLeft)
432 .anchor(gpui::Corner::BottomLeft)
433 .offset(gpui::Point {
434 x: px(0.0),
435 y: px(-2.0),
436 })
437 .with_handle(self.context_picker_menu_handle.clone()),
438 )
439 .when(context.is_empty() && suggested_context.is_none(), {
440 |parent| {
441 parent.child(
442 h_flex()
443 .ml_1p5()
444 .gap_2()
445 .child(
446 Label::new("Add Context")
447 .size(LabelSize::Small)
448 .color(Color::Muted),
449 )
450 .opacity(0.5)
451 .children(
452 KeyBinding::for_action_in(
453 &ToggleContextPicker,
454 &focus_handle,
455 window,
456 cx,
457 )
458 .map(|binding| binding.into_any_element()),
459 ),
460 )
461 }
462 })
463 .children(context.iter().enumerate().map(|(i, context)| {
464 ContextPill::added(
465 context.clone(),
466 dupe_names.contains(&context.name),
467 self.focused_index == Some(i),
468 Some({
469 let id = context.id;
470 let context_store = self.context_store.clone();
471 Rc::new(cx.listener(move |_this, _event, _window, cx| {
472 context_store.update(cx, |this, _cx| {
473 this.remove_context(id);
474 });
475 cx.notify();
476 }))
477 }),
478 )
479 .on_click(Rc::new(cx.listener(move |this, _, _window, cx| {
480 this.focused_index = Some(i);
481 cx.notify();
482 })))
483 }))
484 .when_some(suggested_context, |el, suggested| {
485 el.child(
486 ContextPill::suggested(
487 suggested.name().clone(),
488 suggested.icon_path(),
489 suggested.kind(),
490 self.is_suggested_focused(&context),
491 )
492 .on_click(Rc::new(cx.listener(
493 move |this, _event, window, cx| {
494 this.add_suggested_context(&suggested, window, cx);
495 },
496 ))),
497 )
498 })
499 .when(!context.is_empty(), {
500 move |parent| {
501 parent.child(
502 IconButton::new("remove-all-context", IconName::Eraser)
503 .icon_size(IconSize::Small)
504 .tooltip({
505 let focus_handle = focus_handle.clone();
506 move |window, cx| {
507 Tooltip::for_action_in(
508 "Remove All Context",
509 &RemoveAllContext,
510 &focus_handle,
511 window,
512 cx,
513 )
514 }
515 })
516 .on_click(cx.listener({
517 let focus_handle = focus_handle.clone();
518 move |_this, _event, window, cx| {
519 focus_handle.dispatch_action(&RemoveAllContext, window, cx);
520 }
521 })),
522 )
523 }
524 })
525 }
526}
527
528pub enum ContextStripEvent {
529 PickerDismissed,
530 BlurredEmpty,
531 BlurredDown,
532 BlurredUp,
533}
534
535impl EventEmitter<ContextStripEvent> for ContextStrip {}
536
537pub enum SuggestContextKind {
538 File,
539 Thread,
540}
541
542#[derive(Clone)]
543pub enum SuggestedContext {
544 File {
545 name: SharedString,
546 icon_path: Option<SharedString>,
547 buffer: WeakEntity<Buffer>,
548 },
549 Thread {
550 name: SharedString,
551 thread: WeakEntity<Thread>,
552 },
553}
554
555impl SuggestedContext {
556 pub fn name(&self) -> &SharedString {
557 match self {
558 Self::File { name, .. } => name,
559 Self::Thread { name, .. } => name,
560 }
561 }
562
563 pub fn icon_path(&self) -> Option<SharedString> {
564 match self {
565 Self::File { icon_path, .. } => icon_path.clone(),
566 Self::Thread { .. } => None,
567 }
568 }
569
570 pub fn kind(&self) -> ContextKind {
571 match self {
572 Self::File { .. } => ContextKind::File,
573 Self::Thread { .. } => ContextKind::Thread,
574 }
575 }
576}