1use std::rc::Rc;
2
3use collections::HashSet;
4use editor::Editor;
5use file_icons::FileIcons;
6use gpui::{
7 AppContext, Bounds, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
8 Subscription, View, WeakModel, WeakView,
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: Model<ContextStore>,
28 pub context_picker: View<ContextPicker>,
29 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
30 focus_handle: FocusHandle,
31 suggest_context_kind: SuggestContextKind,
32 workspace: WeakView<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: Model<ContextStore>,
41 workspace: WeakView<Workspace>,
42 editor: WeakView<Editor>,
43 thread_store: Option<WeakModel<ThreadStore>>,
44 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
45 suggest_context_kind: SuggestContextKind,
46 cx: &mut ViewContext<Self>,
47 ) -> Self {
48 let context_picker = cx.new_view(|cx| {
49 ContextPicker::new(
50 workspace.clone(),
51 thread_store.clone(),
52 context_store.downgrade(),
53 editor.clone(),
54 ConfirmBehavior::KeepOpen,
55 cx,
56 )
57 });
58
59 let focus_handle = cx.focus_handle();
60
61 let subscriptions = vec![
62 cx.subscribe(&context_picker, Self::handle_context_picker_event),
63 cx.on_focus(&focus_handle, Self::handle_focus),
64 cx.on_blur(&focus_handle, 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: &ViewContext<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: &ViewContext<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_model = editor.buffer().read(cx).as_singleton()?;
93 let active_buffer = active_buffer_model.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_model.downgrade(),
116 icon_path,
117 })
118 }
119
120 fn suggested_thread(&self, cx: &ViewContext<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: View<ContextPicker>,
153 _event: &DismissEvent,
154 cx: &mut ViewContext<Self>,
155 ) {
156 cx.emit(ContextStripEvent::PickerDismissed);
157 }
158
159 fn handle_focus(&mut self, cx: &mut ViewContext<Self>) {
160 self.focused_index = self.last_pill_index();
161 cx.notify();
162 }
163
164 fn handle_blur(&mut self, cx: &mut ViewContext<Self>) {
165 self.focused_index = None;
166 cx.notify();
167 }
168
169 fn focus_left(&mut self, _: &FocusLeft, cx: &mut ViewContext<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, cx: &mut ViewContext<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, cx: &mut ViewContext<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, cx: &mut ViewContext<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() {
242 None
243 } else {
244 Some(pills)
245 }
246 }
247
248 fn last_pill_index(&self) -> Option<usize> {
249 Some(self.pill_bounds()?.len() - 1)
250 }
251
252 fn find_best_horizontal_match<'a>(
253 focused: &'a Bounds<Pixels>,
254 iter: impl Iterator<Item = (usize, &'a Bounds<Pixels>)>,
255 ) -> Option<usize> {
256 let mut best = None;
257
258 let focused_left = focused.left();
259 let focused_right = focused.right();
260
261 for (index, probe) in iter {
262 if probe.origin.y == focused.origin.y {
263 continue;
264 }
265
266 let overlap = probe.right().min(focused_right) - probe.left().max(focused_left);
267
268 best = match best {
269 Some((_, prev_overlap, y)) if probe.origin.y != y || prev_overlap > overlap => {
270 break;
271 }
272 Some(_) | None => Some((index, overlap, probe.origin.y)),
273 };
274 }
275
276 best.map(|(index, _, _)| index)
277 }
278
279 fn remove_focused_context(&mut self, _: &RemoveFocusedContext, cx: &mut ViewContext<Self>) {
280 if let Some(index) = self.focused_index {
281 let mut is_empty = false;
282
283 self.context_store.update(cx, |this, _cx| {
284 if let Some(item) = this.context().get(index) {
285 this.remove_context(item.id());
286 }
287
288 is_empty = this.context().is_empty();
289 });
290
291 if is_empty {
292 cx.emit(ContextStripEvent::BlurredEmpty);
293 } else {
294 self.focused_index = Some(index.saturating_sub(1));
295 cx.notify();
296 }
297 }
298 }
299
300 fn is_suggested_focused<T>(&self, context: &Vec<T>) -> bool {
301 // We only suggest one item after the actual context
302 self.focused_index == Some(context.len())
303 }
304
305 fn accept_suggested_context(&mut self, _: &AcceptSuggestedContext, cx: &mut ViewContext<Self>) {
306 if let Some(suggested) = self.suggested_context(cx) {
307 let context_store = self.context_store.read(cx);
308
309 if self.is_suggested_focused(context_store.context()) {
310 self.add_suggested_context(&suggested, cx);
311 }
312 }
313 }
314
315 fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut ViewContext<Self>) {
316 let task = self.context_store.update(cx, |context_store, cx| {
317 context_store.accept_suggested_context(&suggested, cx)
318 });
319
320 cx.spawn(|this, mut cx| async move {
321 match task.await.notify_async_err(&mut cx) {
322 None => {}
323 Some(()) => {
324 if let Some(this) = this.upgrade() {
325 this.update(&mut cx, |_, cx| cx.notify())?;
326 }
327 }
328 }
329 anyhow::Ok(())
330 })
331 .detach_and_log_err(cx);
332
333 cx.notify();
334 }
335}
336
337impl FocusableView for ContextStrip {
338 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
339 self.focus_handle.clone()
340 }
341}
342
343impl Render for ContextStrip {
344 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
345 let context_store = self.context_store.read(cx);
346 let context = context_store
347 .context()
348 .iter()
349 .flat_map(|context| context.snapshot(cx))
350 .collect::<Vec<_>>();
351 let context_picker = self.context_picker.clone();
352 let focus_handle = self.focus_handle.clone();
353
354 let suggested_context = self.suggested_context(cx);
355
356 let dupe_names = context
357 .iter()
358 .map(|context| context.name.clone())
359 .sorted()
360 .tuple_windows()
361 .filter(|(a, b)| a == b)
362 .map(|(a, _)| a)
363 .collect::<HashSet<SharedString>>();
364
365 h_flex()
366 .flex_wrap()
367 .gap_1()
368 .track_focus(&focus_handle)
369 .key_context("ContextStrip")
370 .on_action(cx.listener(Self::focus_up))
371 .on_action(cx.listener(Self::focus_right))
372 .on_action(cx.listener(Self::focus_down))
373 .on_action(cx.listener(Self::focus_left))
374 .on_action(cx.listener(Self::remove_focused_context))
375 .on_action(cx.listener(Self::accept_suggested_context))
376 .on_children_prepainted({
377 let view = cx.view().downgrade();
378 move |children_bounds, cx| {
379 view.update(cx, |this, _| {
380 this.children_bounds = Some(children_bounds);
381 })
382 .ok();
383 }
384 })
385 .child(
386 PopoverMenu::new("context-picker")
387 .menu(move |cx| {
388 context_picker.update(cx, |this, cx| {
389 this.init(cx);
390 });
391
392 Some(context_picker.clone())
393 })
394 .trigger(
395 IconButton::new("add-context", IconName::Plus)
396 .icon_size(IconSize::Small)
397 .style(ui::ButtonStyle::Filled)
398 .tooltip({
399 let focus_handle = focus_handle.clone();
400
401 move |cx| {
402 Tooltip::for_action_in(
403 "Add Context",
404 &ToggleContextPicker,
405 &focus_handle,
406 cx,
407 )
408 }
409 }),
410 )
411 .attach(gpui::Corner::TopLeft)
412 .anchor(gpui::Corner::BottomLeft)
413 .offset(gpui::Point {
414 x: px(0.0),
415 y: px(-2.0),
416 })
417 .with_handle(self.context_picker_menu_handle.clone()),
418 )
419 .when(context.is_empty() && suggested_context.is_none(), {
420 |parent| {
421 parent.child(
422 h_flex()
423 .ml_1p5()
424 .gap_2()
425 .child(
426 Label::new("Add Context")
427 .size(LabelSize::Small)
428 .color(Color::Muted),
429 )
430 .opacity(0.5)
431 .children(
432 KeyBinding::for_action_in(&ToggleContextPicker, &focus_handle, cx)
433 .map(|binding| binding.into_any_element()),
434 ),
435 )
436 }
437 })
438 .children(context.iter().enumerate().map(|(i, context)| {
439 ContextPill::added(
440 context.clone(),
441 dupe_names.contains(&context.name),
442 self.focused_index == Some(i),
443 Some({
444 let id = context.id;
445 let context_store = self.context_store.clone();
446 Rc::new(cx.listener(move |_this, _event, cx| {
447 context_store.update(cx, |this, _cx| {
448 this.remove_context(id);
449 });
450 cx.notify();
451 }))
452 }),
453 )
454 .on_click(Rc::new(cx.listener(move |this, _, cx| {
455 this.focused_index = Some(i);
456 cx.notify();
457 })))
458 }))
459 .when_some(suggested_context, |el, suggested| {
460 el.child(
461 ContextPill::suggested(
462 suggested.name().clone(),
463 suggested.icon_path(),
464 suggested.kind(),
465 self.is_suggested_focused(&context),
466 )
467 .on_click(Rc::new(cx.listener(move |this, _event, cx| {
468 this.add_suggested_context(&suggested, cx);
469 }))),
470 )
471 })
472 .when(!context.is_empty(), {
473 move |parent| {
474 parent.child(
475 IconButton::new("remove-all-context", IconName::Eraser)
476 .icon_size(IconSize::Small)
477 .tooltip({
478 let focus_handle = focus_handle.clone();
479 move |cx| {
480 Tooltip::for_action_in(
481 "Remove All Context",
482 &RemoveAllContext,
483 &focus_handle,
484 cx,
485 )
486 }
487 })
488 .on_click(cx.listener({
489 let focus_handle = focus_handle.clone();
490 move |_this, _event, cx| {
491 focus_handle.dispatch_action(&RemoveAllContext, cx);
492 }
493 })),
494 )
495 }
496 })
497 }
498}
499
500pub enum ContextStripEvent {
501 PickerDismissed,
502 BlurredEmpty,
503 BlurredDown,
504 BlurredUp,
505}
506
507impl EventEmitter<ContextStripEvent> for ContextStrip {}
508
509pub enum SuggestContextKind {
510 File,
511 Thread,
512}
513
514#[derive(Clone)]
515pub enum SuggestedContext {
516 File {
517 name: SharedString,
518 icon_path: Option<SharedString>,
519 buffer: WeakModel<Buffer>,
520 },
521 Thread {
522 name: SharedString,
523 thread: WeakModel<Thread>,
524 },
525}
526
527impl SuggestedContext {
528 pub fn name(&self) -> &SharedString {
529 match self {
530 Self::File { name, .. } => name,
531 Self::Thread { name, .. } => name,
532 }
533 }
534
535 pub fn icon_path(&self) -> Option<SharedString> {
536 match self {
537 Self::File { icon_path, .. } => icon_path.clone(),
538 Self::Thread { .. } => None,
539 }
540 }
541
542 pub fn kind(&self) -> ContextKind {
543 match self {
544 Self::File { .. } => ContextKind::File,
545 Self::Thread { .. } => ContextKind::Thread,
546 }
547 }
548}