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