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