1use std::rc::Rc;
2
3use collections::HashSet;
4use editor::Editor;
5use file_icons::FileIcons;
6use gpui::{
7 DismissEvent, EventEmitter, FocusHandle, Model, Subscription, View, WeakModel, WeakView,
8};
9use itertools::Itertools;
10use language::Buffer;
11use ui::{prelude::*, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip};
12use workspace::Workspace;
13
14use crate::context::ContextKind;
15use crate::context_picker::{ConfirmBehavior, ContextPicker};
16use crate::context_store::ContextStore;
17use crate::thread::Thread;
18use crate::thread_store::ThreadStore;
19use crate::ui::ContextPill;
20use crate::{AssistantPanel, RemoveAllContext, ToggleContextPicker};
21
22pub struct ContextStrip {
23 context_store: Model<ContextStore>,
24 pub context_picker: View<ContextPicker>,
25 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
26 focus_handle: FocusHandle,
27 suggest_context_kind: SuggestContextKind,
28 workspace: WeakView<Workspace>,
29 _context_picker_subscription: Subscription,
30}
31
32impl ContextStrip {
33 pub fn new(
34 context_store: Model<ContextStore>,
35 workspace: WeakView<Workspace>,
36 thread_store: Option<WeakModel<ThreadStore>>,
37 focus_handle: FocusHandle,
38 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
39 suggest_context_kind: SuggestContextKind,
40 cx: &mut ViewContext<Self>,
41 ) -> Self {
42 let context_picker = cx.new_view(|cx| {
43 ContextPicker::new(
44 workspace.clone(),
45 thread_store.clone(),
46 context_store.downgrade(),
47 ConfirmBehavior::KeepOpen,
48 cx,
49 )
50 });
51
52 let context_picker_subscription =
53 cx.subscribe(&context_picker, Self::handle_context_picker_event);
54
55 Self {
56 context_store: context_store.clone(),
57 context_picker,
58 context_picker_menu_handle,
59 focus_handle,
60 suggest_context_kind,
61 workspace,
62 _context_picker_subscription: context_picker_subscription,
63 }
64 }
65
66 fn suggested_context(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
67 match self.suggest_context_kind {
68 SuggestContextKind::File => self.suggested_file(cx),
69 SuggestContextKind::Thread => self.suggested_thread(cx),
70 }
71 }
72
73 fn suggested_file(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
74 let workspace = self.workspace.upgrade()?;
75 let active_item = workspace.read(cx).active_item(cx)?;
76
77 let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
78 let active_buffer_model = editor.buffer().read(cx).as_singleton()?;
79 let active_buffer = active_buffer_model.read(cx);
80
81 let path = active_buffer.file()?.path();
82
83 if self
84 .context_store
85 .read(cx)
86 .will_include_buffer(active_buffer.remote_id(), path)
87 .is_some()
88 {
89 return None;
90 }
91
92 let name = match path.file_name() {
93 Some(name) => name.to_string_lossy().into_owned().into(),
94 None => path.to_string_lossy().into_owned().into(),
95 };
96
97 let icon_path = FileIcons::get_icon(path, cx);
98
99 Some(SuggestedContext::File {
100 name,
101 buffer: active_buffer_model.downgrade(),
102 icon_path,
103 })
104 }
105
106 fn suggested_thread(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
107 let workspace = self.workspace.upgrade()?;
108 let active_thread = workspace
109 .read(cx)
110 .panel::<AssistantPanel>(cx)?
111 .read(cx)
112 .active_thread(cx);
113 let weak_active_thread = active_thread.downgrade();
114
115 let active_thread = active_thread.read(cx);
116
117 if self
118 .context_store
119 .read(cx)
120 .includes_thread(active_thread.id())
121 .is_some()
122 {
123 return None;
124 }
125
126 Some(SuggestedContext::Thread {
127 name: active_thread.summary_or_default(),
128 thread: weak_active_thread,
129 })
130 }
131
132 fn handle_context_picker_event(
133 &mut self,
134 _picker: View<ContextPicker>,
135 _event: &DismissEvent,
136 cx: &mut ViewContext<Self>,
137 ) {
138 cx.emit(ContextStripEvent::PickerDismissed);
139 }
140}
141
142impl Render for ContextStrip {
143 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
144 let context_store = self.context_store.read(cx);
145 let context = context_store
146 .context()
147 .iter()
148 .flat_map(|context| context.snapshot(cx))
149 .collect::<Vec<_>>();
150 let context_picker = self.context_picker.clone();
151 let focus_handle = self.focus_handle.clone();
152
153 let suggested_context = self.suggested_context(cx);
154
155 let dupe_names = context
156 .iter()
157 .map(|context| context.name.clone())
158 .sorted()
159 .tuple_windows()
160 .filter(|(a, b)| a == b)
161 .map(|(a, _)| a)
162 .collect::<HashSet<SharedString>>();
163
164 h_flex()
165 .flex_wrap()
166 .gap_1()
167 .child(
168 PopoverMenu::new("context-picker")
169 .menu(move |cx| {
170 context_picker.update(cx, |this, cx| {
171 this.reset_mode(cx);
172 });
173
174 Some(context_picker.clone())
175 })
176 .trigger(
177 IconButton::new("add-context", IconName::Plus)
178 .icon_size(IconSize::Small)
179 .style(ui::ButtonStyle::Filled)
180 .tooltip({
181 let focus_handle = focus_handle.clone();
182
183 move |cx| {
184 Tooltip::for_action_in(
185 "Add Context",
186 &ToggleContextPicker,
187 &focus_handle,
188 cx,
189 )
190 }
191 }),
192 )
193 .attach(gpui::Corner::TopLeft)
194 .anchor(gpui::Corner::BottomLeft)
195 .offset(gpui::Point {
196 x: px(0.0),
197 y: px(-2.0),
198 })
199 .with_handle(self.context_picker_menu_handle.clone()),
200 )
201 .when(context.is_empty() && suggested_context.is_none(), {
202 |parent| {
203 parent.child(
204 h_flex()
205 .ml_1p5()
206 .gap_2()
207 .child(
208 Label::new("Add Context")
209 .size(LabelSize::Small)
210 .color(Color::Muted),
211 )
212 .opacity(0.5)
213 .children(
214 KeyBinding::for_action_in(&ToggleContextPicker, &focus_handle, cx)
215 .map(|binding| binding.into_any_element()),
216 ),
217 )
218 }
219 })
220 .children(context.iter().map(|context| {
221 ContextPill::new_added(
222 context.clone(),
223 dupe_names.contains(&context.name),
224 Some({
225 let id = context.id;
226 let context_store = self.context_store.clone();
227 Rc::new(cx.listener(move |_this, _event, cx| {
228 context_store.update(cx, |this, _cx| {
229 this.remove_context(id);
230 });
231 cx.notify();
232 }))
233 }),
234 )
235 }))
236 .when_some(suggested_context, |el, suggested| {
237 el.child(ContextPill::new_suggested(
238 suggested.name().clone(),
239 suggested.icon_path(),
240 suggested.kind(),
241 {
242 let context_store = self.context_store.clone();
243 Rc::new(cx.listener(move |this, _event, cx| {
244 let task = context_store.update(cx, |context_store, cx| {
245 context_store.accept_suggested_context(&suggested, cx)
246 });
247
248 let workspace = this.workspace.clone();
249 cx.spawn(|this, mut cx| async move {
250 match task.await {
251 Ok(()) => {
252 if let Some(this) = this.upgrade() {
253 this.update(&mut cx, |_, cx| cx.notify())?;
254 }
255 }
256 Err(err) => {
257 let Some(workspace) = workspace.upgrade() else {
258 return anyhow::Ok(());
259 };
260
261 workspace.update(&mut cx, |workspace, cx| {
262 workspace.show_error(&err, cx);
263 })?;
264 }
265 }
266 anyhow::Ok(())
267 })
268 .detach_and_log_err(cx);
269 }))
270 },
271 ))
272 })
273 .when(!context.is_empty(), {
274 move |parent| {
275 parent.child(
276 IconButton::new("remove-all-context", IconName::Eraser)
277 .icon_size(IconSize::Small)
278 .tooltip({
279 let focus_handle = focus_handle.clone();
280 move |cx| {
281 Tooltip::for_action_in(
282 "Remove All Context",
283 &RemoveAllContext,
284 &focus_handle,
285 cx,
286 )
287 }
288 })
289 .on_click(cx.listener({
290 let focus_handle = focus_handle.clone();
291 move |_this, _event, cx| {
292 focus_handle.dispatch_action(&RemoveAllContext, cx);
293 }
294 })),
295 )
296 }
297 })
298 }
299}
300
301pub enum ContextStripEvent {
302 PickerDismissed,
303}
304
305impl EventEmitter<ContextStripEvent> for ContextStrip {}
306
307pub enum SuggestContextKind {
308 File,
309 Thread,
310}
311
312#[derive(Clone)]
313pub enum SuggestedContext {
314 File {
315 name: SharedString,
316 icon_path: Option<SharedString>,
317 buffer: WeakModel<Buffer>,
318 },
319 Thread {
320 name: SharedString,
321 thread: WeakModel<Thread>,
322 },
323}
324
325impl SuggestedContext {
326 pub fn name(&self) -> &SharedString {
327 match self {
328 Self::File { name, .. } => name,
329 Self::Thread { name, .. } => name,
330 }
331 }
332
333 pub fn icon_path(&self) -> Option<SharedString> {
334 match self {
335 Self::File { icon_path, .. } => icon_path.clone(),
336 Self::Thread { .. } => None,
337 }
338 }
339
340 pub fn kind(&self) -> ContextKind {
341 match self {
342 Self::File { .. } => ContextKind::File,
343 Self::Thread { .. } => ContextKind::Thread,
344 }
345 }
346}