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 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 remove_focused_context(
281 &mut self,
282 _: &RemoveFocusedContext,
283 _window: &mut Window,
284 cx: &mut Context<Self>,
285 ) {
286 if let Some(index) = self.focused_index {
287 let mut is_empty = false;
288
289 self.context_store.update(cx, |this, _cx| {
290 if let Some(item) = this.context().get(index) {
291 this.remove_context(item.id());
292 }
293
294 is_empty = this.context().is_empty();
295 });
296
297 if is_empty {
298 cx.emit(ContextStripEvent::BlurredEmpty);
299 } else {
300 self.focused_index = Some(index.saturating_sub(1));
301 cx.notify();
302 }
303 }
304 }
305
306 fn is_suggested_focused<T>(&self, context: &Vec<T>) -> bool {
307 // We only suggest one item after the actual context
308 self.focused_index == Some(context.len())
309 }
310
311 fn accept_suggested_context(
312 &mut self,
313 _: &AcceptSuggestedContext,
314 window: &mut Window,
315 cx: &mut Context<Self>,
316 ) {
317 if let Some(suggested) = self.suggested_context(cx) {
318 let context_store = self.context_store.read(cx);
319
320 if self.is_suggested_focused(context_store.context()) {
321 self.add_suggested_context(&suggested, window, cx);
322 }
323 }
324 }
325
326 fn add_suggested_context(
327 &mut self,
328 suggested: &SuggestedContext,
329 window: &mut Window,
330 cx: &mut Context<Self>,
331 ) {
332 let task = self.context_store.update(cx, |context_store, cx| {
333 context_store.accept_suggested_context(&suggested, cx)
334 });
335
336 cx.spawn_in(window, async move |this, cx| {
337 match task.await.notify_async_err(cx) {
338 None => {}
339 Some(()) => {
340 if let Some(this) = this.upgrade() {
341 this.update(cx, |_, cx| cx.notify())?;
342 }
343 }
344 }
345 anyhow::Ok(())
346 })
347 .detach_and_log_err(cx);
348
349 cx.notify();
350 }
351}
352
353impl Focusable for ContextStrip {
354 fn focus_handle(&self, _cx: &App) -> FocusHandle {
355 self.focus_handle.clone()
356 }
357}
358
359impl Render for ContextStrip {
360 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
361 let context_store = self.context_store.read(cx);
362 let context = context_store
363 .context()
364 .iter()
365 .flat_map(|context| context.snapshot(cx))
366 .collect::<Vec<_>>();
367 let context_picker = self.context_picker.clone();
368 let focus_handle = self.focus_handle.clone();
369
370 let suggested_context = self.suggested_context(cx);
371
372 let dupe_names = context
373 .iter()
374 .map(|context| context.name.clone())
375 .sorted()
376 .tuple_windows()
377 .filter(|(a, b)| a == b)
378 .map(|(a, _)| a)
379 .collect::<HashSet<SharedString>>();
380
381 h_flex()
382 .flex_wrap()
383 .gap_1()
384 .track_focus(&focus_handle)
385 .key_context("ContextStrip")
386 .on_action(cx.listener(Self::focus_up))
387 .on_action(cx.listener(Self::focus_right))
388 .on_action(cx.listener(Self::focus_down))
389 .on_action(cx.listener(Self::focus_left))
390 .on_action(cx.listener(Self::remove_focused_context))
391 .on_action(cx.listener(Self::accept_suggested_context))
392 .on_children_prepainted({
393 let entity = cx.entity().downgrade();
394 move |children_bounds, _window, cx| {
395 entity
396 .update(cx, |this, _| {
397 this.children_bounds = Some(children_bounds);
398 })
399 .ok();
400 }
401 })
402 .child(
403 PopoverMenu::new("context-picker")
404 .menu(move |window, cx| {
405 context_picker.update(cx, |this, cx| {
406 this.init(window, cx);
407 });
408
409 Some(context_picker.clone())
410 })
411 .trigger_with_tooltip(
412 IconButton::new("add-context", IconName::Plus)
413 .icon_size(IconSize::Small)
414 .style(ui::ButtonStyle::Filled),
415 {
416 let focus_handle = focus_handle.clone();
417 move |window, cx| {
418 Tooltip::for_action_in(
419 "Add Context",
420 &ToggleContextPicker,
421 &focus_handle,
422 window,
423 cx,
424 )
425 }
426 },
427 )
428 .attach(gpui::Corner::TopLeft)
429 .anchor(gpui::Corner::BottomLeft)
430 .offset(gpui::Point {
431 x: px(0.0),
432 y: px(-2.0),
433 })
434 .with_handle(self.context_picker_menu_handle.clone()),
435 )
436 .when(context.is_empty() && suggested_context.is_none(), {
437 |parent| {
438 parent.child(
439 h_flex()
440 .ml_1p5()
441 .gap_2()
442 .child(
443 Label::new("Add Context")
444 .size(LabelSize::Small)
445 .color(Color::Muted),
446 )
447 .opacity(0.5)
448 .children(
449 KeyBinding::for_action_in(
450 &ToggleContextPicker,
451 &focus_handle,
452 window,
453 cx,
454 )
455 .map(|binding| binding.into_any_element()),
456 ),
457 )
458 }
459 })
460 .children(context.iter().enumerate().map(|(i, context)| {
461 ContextPill::added(
462 context.clone(),
463 dupe_names.contains(&context.name),
464 self.focused_index == Some(i),
465 Some({
466 let id = context.id;
467 let context_store = self.context_store.clone();
468 Rc::new(cx.listener(move |_this, _event, _window, cx| {
469 context_store.update(cx, |this, _cx| {
470 this.remove_context(id);
471 });
472 cx.notify();
473 }))
474 }),
475 )
476 .on_click(Rc::new(cx.listener(move |this, _, _window, cx| {
477 this.focused_index = Some(i);
478 cx.notify();
479 })))
480 }))
481 .when_some(suggested_context, |el, suggested| {
482 el.child(
483 ContextPill::suggested(
484 suggested.name().clone(),
485 suggested.icon_path(),
486 suggested.kind(),
487 self.is_suggested_focused(&context),
488 )
489 .on_click(Rc::new(cx.listener(
490 move |this, _event, window, cx| {
491 this.add_suggested_context(&suggested, window, cx);
492 },
493 ))),
494 )
495 })
496 .when(!context.is_empty(), {
497 move |parent| {
498 parent.child(
499 IconButton::new("remove-all-context", IconName::Eraser)
500 .icon_size(IconSize::Small)
501 .tooltip({
502 let focus_handle = focus_handle.clone();
503 move |window, cx| {
504 Tooltip::for_action_in(
505 "Remove All Context",
506 &RemoveAllContext,
507 &focus_handle,
508 window,
509 cx,
510 )
511 }
512 })
513 .on_click(cx.listener({
514 let focus_handle = focus_handle.clone();
515 move |_this, _event, window, cx| {
516 focus_handle.dispatch_action(&RemoveAllContext, window, cx);
517 }
518 })),
519 )
520 }
521 })
522 }
523}
524
525pub enum ContextStripEvent {
526 PickerDismissed,
527 BlurredEmpty,
528 BlurredDown,
529 BlurredUp,
530}
531
532impl EventEmitter<ContextStripEvent> for ContextStrip {}
533
534pub enum SuggestContextKind {
535 File,
536 Thread,
537}
538
539#[derive(Clone)]
540pub enum SuggestedContext {
541 File {
542 name: SharedString,
543 icon_path: Option<SharedString>,
544 buffer: WeakEntity<Buffer>,
545 },
546 Thread {
547 name: SharedString,
548 thread: WeakEntity<Thread>,
549 },
550}
551
552impl SuggestedContext {
553 pub fn name(&self) -> &SharedString {
554 match self {
555 Self::File { name, .. } => name,
556 Self::Thread { name, .. } => name,
557 }
558 }
559
560 pub fn icon_path(&self) -> Option<SharedString> {
561 match self {
562 Self::File { icon_path, .. } => icon_path.clone(),
563 Self::Thread { .. } => None,
564 }
565 }
566
567 pub fn kind(&self) -> ContextKind {
568 match self {
569 Self::File { .. } => ContextKind::File,
570 Self::Thread { .. } => ContextKind::Thread,
571 }
572 }
573}