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 let workspace = self.workspace.upgrade()?;
122 let active_thread = workspace
123 .read(cx)
124 .panel::<AssistantPanel>(cx)?
125 .read(cx)
126 .active_thread(cx);
127 let weak_active_thread = active_thread.downgrade();
128
129 let active_thread = active_thread.read(cx);
130
131 if self
132 .context_store
133 .read(cx)
134 .includes_thread(active_thread.id())
135 .is_some()
136 {
137 return None;
138 }
139
140 Some(SuggestedContext::Thread {
141 name: active_thread.summary_or_default(),
142 thread: weak_active_thread,
143 })
144 }
145
146 fn handle_context_picker_event(
147 &mut self,
148 _picker: View<ContextPicker>,
149 _event: &DismissEvent,
150 cx: &mut ViewContext<Self>,
151 ) {
152 cx.emit(ContextStripEvent::PickerDismissed);
153 }
154
155 fn handle_focus(&mut self, cx: &mut ViewContext<Self>) {
156 self.focused_index = self.last_pill_index();
157 cx.notify();
158 }
159
160 fn handle_blur(&mut self, cx: &mut ViewContext<Self>) {
161 self.focused_index = None;
162 cx.notify();
163 }
164
165 fn focus_left(&mut self, _: &FocusLeft, cx: &mut ViewContext<Self>) {
166 self.focused_index = match self.focused_index {
167 Some(index) if index > 0 => Some(index - 1),
168 _ => self.last_pill_index(),
169 };
170
171 cx.notify();
172 }
173
174 fn focus_right(&mut self, _: &FocusRight, cx: &mut ViewContext<Self>) {
175 let Some(last_index) = self.last_pill_index() else {
176 return;
177 };
178
179 self.focused_index = match self.focused_index {
180 Some(index) if index < last_index => Some(index + 1),
181 _ => Some(0),
182 };
183
184 cx.notify();
185 }
186
187 fn focus_up(&mut self, _: &FocusUp, cx: &mut ViewContext<Self>) {
188 let Some(focused_index) = self.focused_index else {
189 return;
190 };
191
192 if focused_index == 0 {
193 return cx.emit(ContextStripEvent::BlurredUp);
194 }
195
196 let Some((focused, pills)) = self.focused_bounds(focused_index) else {
197 return;
198 };
199
200 let iter = pills[..focused_index].iter().enumerate().rev();
201 self.focused_index = Self::find_best_horizontal_match(focused, iter).or(Some(0));
202 cx.notify();
203 }
204
205 fn focus_down(&mut self, _: &FocusDown, cx: &mut ViewContext<Self>) {
206 let Some(focused_index) = self.focused_index else {
207 return;
208 };
209
210 let last_index = self.last_pill_index();
211
212 if self.focused_index == last_index {
213 return cx.emit(ContextStripEvent::BlurredDown);
214 }
215
216 let Some((focused, pills)) = self.focused_bounds(focused_index) else {
217 return;
218 };
219
220 let iter = pills.iter().enumerate().skip(focused_index + 1);
221 self.focused_index = Self::find_best_horizontal_match(focused, iter).or(last_index);
222 cx.notify();
223 }
224
225 fn focused_bounds(&self, focused: usize) -> Option<(&Bounds<Pixels>, &[Bounds<Pixels>])> {
226 let pill_bounds = self.pill_bounds()?;
227 let focused = pill_bounds.get(focused)?;
228
229 Some((focused, pill_bounds))
230 }
231
232 fn pill_bounds(&self) -> Option<&[Bounds<Pixels>]> {
233 let bounds = self.children_bounds.as_ref()?;
234 let eraser = if bounds.len() < 3 { 0 } else { 1 };
235 let pills = &bounds[1..bounds.len() - eraser];
236
237 if pills.is_empty() {
238 None
239 } else {
240 Some(pills)
241 }
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 remove_focused_context(&mut self, _: &RemoveFocusedContext, cx: &mut ViewContext<Self>) {
276 if let Some(index) = self.focused_index {
277 let mut is_empty = false;
278
279 self.context_store.update(cx, |this, _cx| {
280 if let Some(item) = this.context().get(index) {
281 this.remove_context(item.id());
282 }
283
284 is_empty = this.context().is_empty();
285 });
286
287 if is_empty {
288 cx.emit(ContextStripEvent::BlurredEmpty);
289 } else {
290 self.focused_index = Some(index.saturating_sub(1));
291 cx.notify();
292 }
293 }
294 }
295
296 fn is_suggested_focused<T>(&self, context: &Vec<T>) -> bool {
297 // We only suggest one item after the actual context
298 self.focused_index == Some(context.len())
299 }
300
301 fn accept_suggested_context(&mut self, _: &AcceptSuggestedContext, cx: &mut ViewContext<Self>) {
302 if let Some(suggested) = self.suggested_context(cx) {
303 let context_store = self.context_store.read(cx);
304
305 if self.is_suggested_focused(context_store.context()) {
306 self.add_suggested_context(&suggested, cx);
307 }
308 }
309 }
310
311 fn add_suggested_context(&mut self, suggested: &SuggestedContext, cx: &mut ViewContext<Self>) {
312 let task = self.context_store.update(cx, |context_store, cx| {
313 context_store.accept_suggested_context(&suggested, cx)
314 });
315
316 cx.spawn(|this, mut cx| async move {
317 match task.await.notify_async_err(&mut cx) {
318 None => {}
319 Some(()) => {
320 if let Some(this) = this.upgrade() {
321 this.update(&mut cx, |_, cx| cx.notify())?;
322 }
323 }
324 }
325 anyhow::Ok(())
326 })
327 .detach_and_log_err(cx);
328
329 cx.notify();
330 }
331}
332
333impl FocusableView for ContextStrip {
334 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
335 self.focus_handle.clone()
336 }
337}
338
339impl Render for ContextStrip {
340 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
341 let context_store = self.context_store.read(cx);
342 let context = context_store
343 .context()
344 .iter()
345 .flat_map(|context| context.snapshot(cx))
346 .collect::<Vec<_>>();
347 let context_picker = self.context_picker.clone();
348 let focus_handle = self.focus_handle.clone();
349
350 let suggested_context = self.suggested_context(cx);
351
352 let dupe_names = context
353 .iter()
354 .map(|context| context.name.clone())
355 .sorted()
356 .tuple_windows()
357 .filter(|(a, b)| a == b)
358 .map(|(a, _)| a)
359 .collect::<HashSet<SharedString>>();
360
361 h_flex()
362 .flex_wrap()
363 .gap_1()
364 .track_focus(&focus_handle)
365 .key_context("ContextStrip")
366 .on_action(cx.listener(Self::focus_up))
367 .on_action(cx.listener(Self::focus_right))
368 .on_action(cx.listener(Self::focus_down))
369 .on_action(cx.listener(Self::focus_left))
370 .on_action(cx.listener(Self::remove_focused_context))
371 .on_action(cx.listener(Self::accept_suggested_context))
372 .on_children_prepainted({
373 let view = cx.view().downgrade();
374 move |children_bounds, cx| {
375 view.update(cx, |this, _| {
376 this.children_bounds = Some(children_bounds);
377 })
378 .ok();
379 }
380 })
381 .child(
382 PopoverMenu::new("context-picker")
383 .menu(move |cx| {
384 context_picker.update(cx, |this, cx| {
385 this.init(cx);
386 });
387
388 Some(context_picker.clone())
389 })
390 .trigger(
391 IconButton::new("add-context", IconName::Plus)
392 .icon_size(IconSize::Small)
393 .style(ui::ButtonStyle::Filled)
394 .tooltip({
395 let focus_handle = focus_handle.clone();
396
397 move |cx| {
398 Tooltip::for_action_in(
399 "Add Context",
400 &ToggleContextPicker,
401 &focus_handle,
402 cx,
403 )
404 }
405 }),
406 )
407 .attach(gpui::Corner::TopLeft)
408 .anchor(gpui::Corner::BottomLeft)
409 .offset(gpui::Point {
410 x: px(0.0),
411 y: px(-2.0),
412 })
413 .with_handle(self.context_picker_menu_handle.clone()),
414 )
415 .when(context.is_empty() && suggested_context.is_none(), {
416 |parent| {
417 parent.child(
418 h_flex()
419 .ml_1p5()
420 .gap_2()
421 .child(
422 Label::new("Add Context")
423 .size(LabelSize::Small)
424 .color(Color::Muted),
425 )
426 .opacity(0.5)
427 .children(
428 KeyBinding::for_action_in(&ToggleContextPicker, &focus_handle, cx)
429 .map(|binding| binding.into_any_element()),
430 ),
431 )
432 }
433 })
434 .children(context.iter().enumerate().map(|(i, context)| {
435 ContextPill::new_added(
436 context.clone(),
437 dupe_names.contains(&context.name),
438 self.focused_index == Some(i),
439 Some({
440 let id = context.id;
441 let context_store = self.context_store.clone();
442 Rc::new(cx.listener(move |_this, _event, cx| {
443 context_store.update(cx, |this, _cx| {
444 this.remove_context(id);
445 });
446 cx.notify();
447 }))
448 }),
449 )
450 .on_click(Rc::new(cx.listener(move |this, _, cx| {
451 this.focused_index = Some(i);
452 cx.notify();
453 })))
454 }))
455 .when_some(suggested_context, |el, suggested| {
456 el.child(
457 ContextPill::new_suggested(
458 suggested.name().clone(),
459 suggested.icon_path(),
460 suggested.kind(),
461 self.is_suggested_focused(&context),
462 )
463 .on_click(Rc::new(cx.listener(move |this, _event, cx| {
464 this.add_suggested_context(&suggested, cx);
465 }))),
466 )
467 })
468 .when(!context.is_empty(), {
469 move |parent| {
470 parent.child(
471 IconButton::new("remove-all-context", IconName::Eraser)
472 .icon_size(IconSize::Small)
473 .tooltip({
474 let focus_handle = focus_handle.clone();
475 move |cx| {
476 Tooltip::for_action_in(
477 "Remove All Context",
478 &RemoveAllContext,
479 &focus_handle,
480 cx,
481 )
482 }
483 })
484 .on_click(cx.listener({
485 let focus_handle = focus_handle.clone();
486 move |_this, _event, cx| {
487 focus_handle.dispatch_action(&RemoveAllContext, cx);
488 }
489 })),
490 )
491 }
492 })
493 }
494}
495
496pub enum ContextStripEvent {
497 PickerDismissed,
498 BlurredEmpty,
499 BlurredDown,
500 BlurredUp,
501}
502
503impl EventEmitter<ContextStripEvent> for ContextStrip {}
504
505pub enum SuggestContextKind {
506 File,
507 Thread,
508}
509
510#[derive(Clone)]
511pub enum SuggestedContext {
512 File {
513 name: SharedString,
514 icon_path: Option<SharedString>,
515 buffer: WeakModel<Buffer>,
516 },
517 Thread {
518 name: SharedString,
519 thread: WeakModel<Thread>,
520 },
521}
522
523impl SuggestedContext {
524 pub fn name(&self) -> &SharedString {
525 match self {
526 Self::File { name, .. } => name,
527 Self::Thread { name, .. } => name,
528 }
529 }
530
531 pub fn icon_path(&self) -> Option<SharedString> {
532 match self {
533 Self::File { icon_path, .. } => icon_path.clone(),
534 Self::Thread { .. } => None,
535 }
536 }
537
538 pub fn kind(&self) -> ContextKind {
539 match self {
540 Self::File { .. } => ContextKind::File,
541 Self::Thread { .. } => ContextKind::Thread,
542 }
543 }
544}