1use std::rc::Rc;
2
3use collections::HashSet;
4use editor::Editor;
5use gpui::{
6 AppContext, DismissEvent, EventEmitter, FocusHandle, Model, Subscription, View, WeakModel,
7 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 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 Some(SuggestedContext::File {
98 name,
99 buffer: active_buffer_model.downgrade(),
100 })
101 }
102
103 fn suggested_thread(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
104 let workspace = self.workspace.upgrade()?;
105 let active_thread = workspace
106 .read(cx)
107 .panel::<AssistantPanel>(cx)?
108 .read(cx)
109 .active_thread(cx);
110 let weak_active_thread = active_thread.downgrade();
111
112 let active_thread = active_thread.read(cx);
113
114 if self
115 .context_store
116 .read(cx)
117 .includes_thread(active_thread.id())
118 .is_some()
119 {
120 return None;
121 }
122
123 Some(SuggestedContext::Thread {
124 name: active_thread.summary().unwrap_or("New Thread".into()),
125 thread: weak_active_thread,
126 })
127 }
128
129 fn handle_context_picker_event(
130 &mut self,
131 _picker: View<ContextPicker>,
132 _event: &DismissEvent,
133 cx: &mut ViewContext<Self>,
134 ) {
135 cx.emit(ContextStripEvent::PickerDismissed);
136 }
137}
138
139impl Render for ContextStrip {
140 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
141 let context_store = self.context_store.read(cx);
142 let context = context_store
143 .context()
144 .iter()
145 .flat_map(|context| context.snapshot(cx))
146 .collect::<Vec<_>>();
147 let context_picker = self.context_picker.clone();
148 let focus_handle = self.focus_handle.clone();
149
150 let suggested_context = self.suggested_context(cx);
151
152 let dupe_names = context
153 .iter()
154 .map(|context| context.name.clone())
155 .sorted()
156 .tuple_windows()
157 .filter(|(a, b)| a == b)
158 .map(|(a, _)| a)
159 .collect::<HashSet<SharedString>>();
160
161 h_flex()
162 .flex_wrap()
163 .gap_1()
164 .child(
165 PopoverMenu::new("context-picker")
166 .menu(move |_cx| Some(context_picker.clone()))
167 .trigger(
168 IconButton::new("add-context", IconName::Plus)
169 .icon_size(IconSize::Small)
170 .style(ui::ButtonStyle::Filled)
171 .tooltip({
172 let focus_handle = focus_handle.clone();
173
174 move |cx| {
175 Tooltip::for_action_in(
176 "Add Context",
177 &ToggleContextPicker,
178 &focus_handle,
179 cx,
180 )
181 }
182 }),
183 )
184 .attach(gpui::Corner::TopLeft)
185 .anchor(gpui::Corner::BottomLeft)
186 .offset(gpui::Point {
187 x: px(0.0),
188 y: px(-16.0),
189 })
190 .with_handle(self.context_picker_menu_handle.clone()),
191 )
192 .when(context.is_empty() && suggested_context.is_none(), {
193 |parent| {
194 parent.child(
195 h_flex()
196 .ml_1p5()
197 .gap_2()
198 .child(
199 Label::new("Add Context")
200 .size(LabelSize::Small)
201 .color(Color::Muted),
202 )
203 .opacity(0.5)
204 .children(
205 KeyBinding::for_action_in(&ToggleContextPicker, &focus_handle, cx)
206 .map(|binding| binding.into_any_element()),
207 ),
208 )
209 }
210 })
211 .children(context.iter().map(|context| {
212 ContextPill::new_added(
213 context.clone(),
214 dupe_names.contains(&context.name),
215 Some({
216 let id = context.id;
217 let context_store = self.context_store.clone();
218 Rc::new(cx.listener(move |_this, _event, cx| {
219 context_store.update(cx, |this, _cx| {
220 this.remove_context(id);
221 });
222 cx.notify();
223 }))
224 }),
225 )
226 }))
227 .when_some(suggested_context, |el, suggested| {
228 el.child(ContextPill::new_suggested(
229 suggested.name().clone(),
230 suggested.kind(),
231 {
232 let context_store = self.context_store.clone();
233 Rc::new(cx.listener(move |_this, _event, cx| {
234 context_store.update(cx, |context_store, cx| {
235 suggested.accept(context_store, cx);
236 });
237
238 cx.notify();
239 }))
240 },
241 ))
242 })
243 .when(!context.is_empty(), {
244 move |parent| {
245 parent.child(
246 IconButton::new("remove-all-context", IconName::Eraser)
247 .icon_size(IconSize::Small)
248 .tooltip({
249 let focus_handle = focus_handle.clone();
250 move |cx| {
251 Tooltip::for_action_in(
252 "Remove All Context",
253 &RemoveAllContext,
254 &focus_handle,
255 cx,
256 )
257 }
258 })
259 .on_click(cx.listener({
260 let focus_handle = focus_handle.clone();
261 move |_this, _event, cx| {
262 focus_handle.dispatch_action(&RemoveAllContext, cx);
263 }
264 })),
265 )
266 }
267 })
268 }
269}
270
271pub enum ContextStripEvent {
272 PickerDismissed,
273}
274
275impl EventEmitter<ContextStripEvent> for ContextStrip {}
276
277pub enum SuggestContextKind {
278 File,
279 Thread,
280}
281
282#[derive(Clone)]
283pub enum SuggestedContext {
284 File {
285 name: SharedString,
286 buffer: WeakModel<Buffer>,
287 },
288 Thread {
289 name: SharedString,
290 thread: WeakModel<Thread>,
291 },
292}
293
294impl SuggestedContext {
295 pub fn name(&self) -> &SharedString {
296 match self {
297 Self::File { name, .. } => name,
298 Self::Thread { name, .. } => name,
299 }
300 }
301
302 pub fn accept(&self, context_store: &mut ContextStore, cx: &mut AppContext) {
303 match self {
304 Self::File { buffer, name: _ } => {
305 if let Some(buffer) = buffer.upgrade() {
306 context_store.insert_file(buffer, cx);
307 };
308 }
309 Self::Thread { thread, name: _ } => {
310 if let Some(thread) = thread.upgrade() {
311 context_store.insert_thread(thread, cx);
312 };
313 }
314 }
315 }
316
317 pub fn kind(&self) -> ContextKind {
318 match self {
319 Self::File { .. } => ContextKind::File,
320 Self::Thread { .. } => ContextKind::Thread,
321 }
322 }
323}