1use std::rc::Rc;
2
3use editor::Editor;
4use gpui::{AppContext, FocusHandle, Model, View, WeakModel, WeakView};
5use language::Buffer;
6use project::ProjectEntryId;
7use ui::{prelude::*, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip};
8use workspace::Workspace;
9
10use crate::context::ContextKind;
11use crate::context_picker::{ConfirmBehavior, ContextPicker};
12use crate::context_store::ContextStore;
13use crate::thread::{Thread, ThreadId};
14use crate::thread_store::ThreadStore;
15use crate::ui::ContextPill;
16use crate::{AssistantPanel, ToggleContextPicker};
17
18pub struct ContextStrip {
19 context_store: Model<ContextStore>,
20 context_picker: View<ContextPicker>,
21 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
22 focus_handle: FocusHandle,
23 suggest_context_kind: SuggestContextKind,
24 workspace: WeakView<Workspace>,
25}
26
27impl ContextStrip {
28 pub fn new(
29 context_store: Model<ContextStore>,
30 workspace: WeakView<Workspace>,
31 thread_store: Option<WeakModel<ThreadStore>>,
32 focus_handle: FocusHandle,
33 context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
34 suggest_context_kind: SuggestContextKind,
35 cx: &mut ViewContext<Self>,
36 ) -> Self {
37 Self {
38 context_store: context_store.clone(),
39 context_picker: cx.new_view(|cx| {
40 ContextPicker::new(
41 workspace.clone(),
42 thread_store.clone(),
43 context_store.downgrade(),
44 ConfirmBehavior::KeepOpen,
45 cx,
46 )
47 }),
48 context_picker_menu_handle,
49 focus_handle,
50 suggest_context_kind,
51 workspace,
52 }
53 }
54
55 fn suggested_context(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
56 match self.suggest_context_kind {
57 SuggestContextKind::File => self.suggested_file(cx),
58 SuggestContextKind::Thread => self.suggested_thread(cx),
59 }
60 }
61
62 fn suggested_file(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
63 let workspace = self.workspace.upgrade()?;
64 let active_item = workspace.read(cx).active_item(cx)?;
65 let entry_id = *active_item.project_entry_ids(cx).first()?;
66
67 if self.context_store.read(cx).contains_project_entry(entry_id) {
68 return None;
69 }
70
71 let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
72 let active_buffer = editor.buffer().read(cx).as_singleton()?;
73
74 let file = active_buffer.read(cx).file()?;
75 let title = file.path().to_string_lossy().into_owned().into();
76
77 Some(SuggestedContext::File {
78 entry_id,
79 title,
80 buffer: active_buffer.downgrade(),
81 })
82 }
83
84 fn suggested_thread(&self, cx: &ViewContext<Self>) -> Option<SuggestedContext> {
85 let workspace = self.workspace.upgrade()?;
86 let active_thread = workspace
87 .read(cx)
88 .panel::<AssistantPanel>(cx)?
89 .read(cx)
90 .active_thread(cx);
91 let weak_active_thread = active_thread.downgrade();
92
93 let active_thread = active_thread.read(cx);
94
95 if self
96 .context_store
97 .read(cx)
98 .contains_thread(active_thread.id())
99 {
100 return None;
101 }
102
103 Some(SuggestedContext::Thread {
104 id: active_thread.id().clone(),
105 title: active_thread.summary().unwrap_or("Active Thread".into()),
106 thread: weak_active_thread,
107 })
108 }
109}
110
111impl Render for ContextStrip {
112 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
113 let context_store = self.context_store.read(cx);
114 let context = context_store.context().clone();
115 let context_picker = self.context_picker.clone();
116 let focus_handle = self.focus_handle.clone();
117
118 let suggested_context = self.suggested_context(cx);
119
120 h_flex()
121 .flex_wrap()
122 .gap_1()
123 .child(
124 PopoverMenu::new("context-picker")
125 .menu(move |_cx| Some(context_picker.clone()))
126 .trigger(
127 IconButton::new("add-context", IconName::Plus)
128 .icon_size(IconSize::Small)
129 .style(ui::ButtonStyle::Filled)
130 .tooltip({
131 let focus_handle = focus_handle.clone();
132
133 move |cx| {
134 Tooltip::for_action_in(
135 "Add Context",
136 &ToggleContextPicker,
137 &focus_handle,
138 cx,
139 )
140 }
141 }),
142 )
143 .attach(gpui::Corner::TopLeft)
144 .anchor(gpui::Corner::BottomLeft)
145 .offset(gpui::Point {
146 x: px(0.0),
147 y: px(-16.0),
148 })
149 .with_handle(self.context_picker_menu_handle.clone()),
150 )
151 .when(context.is_empty() && suggested_context.is_none(), {
152 |parent| {
153 parent.child(
154 h_flex()
155 .ml_1p5()
156 .gap_2()
157 .child(
158 Label::new("Add Context")
159 .size(LabelSize::Small)
160 .color(Color::Muted),
161 )
162 .opacity(0.5)
163 .children(
164 KeyBinding::for_action_in(&ToggleContextPicker, &focus_handle, cx)
165 .map(|binding| binding.into_any_element()),
166 ),
167 )
168 }
169 })
170 .children(context.iter().map(|context| {
171 ContextPill::new(context.clone()).on_remove({
172 let context = context.clone();
173 let context_store = self.context_store.clone();
174 Rc::new(cx.listener(move |_this, _event, cx| {
175 context_store.update(cx, |this, _cx| {
176 this.remove_context(&context.id);
177 });
178 cx.notify();
179 }))
180 })
181 }))
182 .when_some(suggested_context, |el, suggested| {
183 el.child(
184 Button::new("add-suggested-context", suggested.title().clone())
185 .on_click({
186 let context_store = self.context_store.clone();
187
188 cx.listener(move |_this, _event, cx| {
189 context_store.update(cx, |context_store, cx| {
190 suggested.accept(context_store, cx);
191 });
192 cx.notify();
193 })
194 })
195 .icon(IconName::Plus)
196 .icon_position(IconPosition::Start)
197 .icon_size(IconSize::XSmall)
198 .icon_color(Color::Muted)
199 .label_size(LabelSize::Small)
200 .style(ButtonStyle::Filled)
201 .tooltip(|cx| {
202 Tooltip::with_meta("Suggested Context", None, "Click to add it", cx)
203 }),
204 )
205 })
206 .when(!context.is_empty(), {
207 move |parent| {
208 parent.child(
209 IconButton::new("remove-all-context", IconName::Eraser)
210 .icon_size(IconSize::Small)
211 .tooltip(move |cx| Tooltip::text("Remove All Context", cx))
212 .on_click({
213 let context_store = self.context_store.clone();
214 cx.listener(move |_this, _event, cx| {
215 context_store.update(cx, |this, _cx| this.clear());
216 cx.notify();
217 })
218 }),
219 )
220 }
221 })
222 }
223}
224
225pub enum SuggestContextKind {
226 File,
227 Thread,
228}
229
230#[derive(Clone)]
231pub enum SuggestedContext {
232 File {
233 entry_id: ProjectEntryId,
234 title: SharedString,
235 buffer: WeakModel<Buffer>,
236 },
237 Thread {
238 id: ThreadId,
239 title: SharedString,
240 thread: WeakModel<Thread>,
241 },
242}
243
244impl SuggestedContext {
245 pub fn title(&self) -> &SharedString {
246 match self {
247 Self::File { title, .. } => title,
248 Self::Thread { title, .. } => title,
249 }
250 }
251
252 pub fn accept(&self, context_store: &mut ContextStore, cx: &mut AppContext) {
253 match self {
254 Self::File {
255 entry_id,
256 title,
257 buffer,
258 } => {
259 let Some(buffer) = buffer.upgrade() else {
260 return;
261 };
262 let text = buffer.read(cx).text();
263
264 context_store.insert_context(
265 ContextKind::File(*entry_id),
266 title.clone(),
267 text.clone(),
268 );
269 }
270 Self::Thread { id, title, thread } => {
271 let Some(thread) = thread.upgrade() else {
272 return;
273 };
274
275 context_store.insert_context(
276 ContextKind::Thread(id.clone()),
277 title.clone(),
278 thread.read(cx).text(),
279 );
280 }
281 }
282 }
283}