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