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