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