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