1use std::path::Path;
2use std::rc::Rc;
3
4use collections::HashSet;
5use editor::Editor;
6use file_icons::FileIcons;
7use gpui::{
8 App, Bounds, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
9 Subscription, WeakEntity,
10};
11use itertools::Itertools;
12use language::Buffer;
13use project::ProjectItem;
14use ui::{KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
15use workspace::{Workspace, notifications::NotifyResultExt};
16
17use crate::context::{ContextId, ContextKind};
18use crate::context_picker::{ConfirmBehavior, ContextPicker};
19use crate::context_store::ContextStore;
20use crate::thread::Thread;
21use crate::thread_store::ThreadStore;
22use crate::ui::{AddedContext, ContextPill};
23use crate::{
24 AcceptSuggestedContext, AssistantPanel, FocusDown, FocusLeft, FocusRight, FocusUp,
25 RemoveAllContext, RemoveFocusedContext, ToggleContextPicker,
26};
27
28pub struct ContextStrip {
29 context_store: Entity<ContextStore>,
30 context_picker: Entity<ContextPicker>,
31 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
32 focus_handle: FocusHandle,
33 suggest_context_kind: SuggestContextKind,
34 workspace: WeakEntity<Workspace>,
35 _subscriptions: Vec<Subscription>,
36 focused_index: Option<usize>,
37 children_bounds: Option<Vec<Bounds<Pixels>>>,
38}
39
40impl ContextStrip {
41 pub fn new(
42 context_store: Entity<ContextStore>,
43 workspace: WeakEntity<Workspace>,
44 thread_store: Option<WeakEntity<ThreadStore>>,
45 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
46 suggest_context_kind: SuggestContextKind,
47 window: &mut Window,
48 cx: &mut Context<Self>,
49 ) -> Self {
50 let context_picker = cx.new(|cx| {
51 ContextPicker::new(
52 workspace.clone(),
53 thread_store.clone(),
54 context_store.downgrade(),
55 ConfirmBehavior::KeepOpen,
56 window,
57 cx,
58 )
59 });
60
61 let focus_handle = cx.focus_handle();
62
63 let subscriptions = vec![
64 cx.observe(&context_store, |_, _, cx| cx.notify()),
65 cx.subscribe_in(&context_picker, window, Self::handle_context_picker_event),
66 cx.on_focus(&focus_handle, window, Self::handle_focus),
67 cx.on_blur(&focus_handle, window, Self::handle_blur),
68 ];
69
70 Self {
71 context_store: context_store.clone(),
72 context_picker,
73 context_picker_menu_handle,
74 focus_handle,
75 suggest_context_kind,
76 workspace,
77 _subscriptions: subscriptions,
78 focused_index: None,
79 children_bounds: None,
80 }
81 }
82
83 fn suggested_context(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
84 match self.suggest_context_kind {
85 SuggestContextKind::File => self.suggested_file(cx),
86 SuggestContextKind::Thread => self.suggested_thread(cx),
87 }
88 }
89
90 fn suggested_file(&self, cx: &Context<Self>) -> Option<SuggestedContext> {
91 let workspace = self.workspace.upgrade()?;
92 let active_item = workspace.read(cx).active_item(cx)?;
93
94 let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
95 let active_buffer_entity = editor.buffer().read(cx).as_singleton()?;
96 let active_buffer = active_buffer_entity.read(cx);
97
98 let project_path = active_buffer.project_path(cx)?;
99
100 if self
101 .context_store
102 .read(cx)
103 .will_include_buffer(active_buffer.remote_id(), &project_path)
104 .is_some()
105 {
106 return None;
107 }
108
109 let file_name = active_buffer.file()?.file_name(cx);
110
111 let icon_path = FileIcons::get_icon(&Path::new(&file_name), cx);
112
113 Some(SuggestedContext::File {
114 name: file_name.to_string_lossy().into_owned().into(),
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(), cx);
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, cx);
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}