1use std::cell::RefCell;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use anyhow::{bail, Context as _, Result};
6use futures::AsyncReadExt as _;
7use gpui::{App, DismissEvent, Entity, FocusHandle, Focusable, Task, WeakEntity};
8use html_to_markdown::{convert_html_to_markdown, markdown, TagHandler};
9use http_client::{AsyncBody, HttpClientWithUrl};
10use picker::{Picker, PickerDelegate};
11use ui::{prelude::*, Context, ListItem, Window};
12use workspace::Workspace;
13
14use crate::context_picker::{ConfirmBehavior, ContextPicker};
15use crate::context_store::ContextStore;
16
17pub struct FetchContextPicker {
18 picker: Entity<Picker<FetchContextPickerDelegate>>,
19}
20
21impl FetchContextPicker {
22 pub fn new(
23 context_picker: WeakEntity<ContextPicker>,
24 workspace: WeakEntity<Workspace>,
25 context_store: WeakEntity<ContextStore>,
26 confirm_behavior: ConfirmBehavior,
27 window: &mut Window,
28 cx: &mut Context<Self>,
29 ) -> Self {
30 let delegate = FetchContextPickerDelegate::new(
31 context_picker,
32 workspace,
33 context_store,
34 confirm_behavior,
35 );
36 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
37
38 Self { picker }
39 }
40}
41
42impl Focusable for FetchContextPicker {
43 fn focus_handle(&self, cx: &App) -> FocusHandle {
44 self.picker.focus_handle(cx)
45 }
46}
47
48impl Render for FetchContextPicker {
49 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
50 self.picker.clone()
51 }
52}
53
54#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
55enum ContentType {
56 Html,
57 Plaintext,
58 Json,
59}
60
61pub struct FetchContextPickerDelegate {
62 context_picker: WeakEntity<ContextPicker>,
63 workspace: WeakEntity<Workspace>,
64 context_store: WeakEntity<ContextStore>,
65 confirm_behavior: ConfirmBehavior,
66 url: String,
67}
68
69impl FetchContextPickerDelegate {
70 pub fn new(
71 context_picker: WeakEntity<ContextPicker>,
72 workspace: WeakEntity<Workspace>,
73 context_store: WeakEntity<ContextStore>,
74 confirm_behavior: ConfirmBehavior,
75 ) -> Self {
76 FetchContextPickerDelegate {
77 context_picker,
78 workspace,
79 context_store,
80 confirm_behavior,
81 url: String::new(),
82 }
83 }
84
85 async fn build_message(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
86 let url = if !url.starts_with("https://") && !url.starts_with("http://") {
87 format!("https://{url}")
88 } else {
89 url
90 };
91
92 let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
93
94 let mut body = Vec::new();
95 response
96 .body_mut()
97 .read_to_end(&mut body)
98 .await
99 .context("error reading response body")?;
100
101 if response.status().is_client_error() {
102 let text = String::from_utf8_lossy(body.as_slice());
103 bail!(
104 "status error {}, response: {text:?}",
105 response.status().as_u16()
106 );
107 }
108
109 let Some(content_type) = response.headers().get("content-type") else {
110 bail!("missing Content-Type header");
111 };
112 let content_type = content_type
113 .to_str()
114 .context("invalid Content-Type header")?;
115 let content_type = match content_type {
116 "text/html" => ContentType::Html,
117 "text/plain" => ContentType::Plaintext,
118 "application/json" => ContentType::Json,
119 _ => ContentType::Html,
120 };
121
122 match content_type {
123 ContentType::Html => {
124 let mut handlers: Vec<TagHandler> = vec![
125 Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
126 Rc::new(RefCell::new(markdown::ParagraphHandler)),
127 Rc::new(RefCell::new(markdown::HeadingHandler)),
128 Rc::new(RefCell::new(markdown::ListHandler)),
129 Rc::new(RefCell::new(markdown::TableHandler::new())),
130 Rc::new(RefCell::new(markdown::StyledTextHandler)),
131 ];
132 if url.contains("wikipedia.org") {
133 use html_to_markdown::structure::wikipedia;
134
135 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
136 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
137 handlers.push(Rc::new(
138 RefCell::new(wikipedia::WikipediaCodeHandler::new()),
139 ));
140 } else {
141 handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
142 }
143
144 convert_html_to_markdown(&body[..], &mut handlers)
145 }
146 ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
147 ContentType::Json => {
148 let json: serde_json::Value = serde_json::from_slice(&body)?;
149
150 Ok(format!(
151 "```json\n{}\n```",
152 serde_json::to_string_pretty(&json)?
153 ))
154 }
155 }
156 }
157}
158
159impl PickerDelegate for FetchContextPickerDelegate {
160 type ListItem = ListItem;
161
162 fn match_count(&self) -> usize {
163 if self.url.is_empty() {
164 0
165 } else {
166 1
167 }
168 }
169
170 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> SharedString {
171 "Enter the URL that you would like to fetch".into()
172 }
173
174 fn selected_index(&self) -> usize {
175 0
176 }
177
178 fn set_selected_index(
179 &mut self,
180 _ix: usize,
181 _window: &mut Window,
182 _cx: &mut Context<Picker<Self>>,
183 ) {
184 }
185
186 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
187 "Enter a URL…".into()
188 }
189
190 fn update_matches(
191 &mut self,
192 query: String,
193 _window: &mut Window,
194 _cx: &mut Context<Picker<Self>>,
195 ) -> Task<()> {
196 self.url = query;
197
198 Task::ready(())
199 }
200
201 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
202 let Some(workspace) = self.workspace.upgrade() else {
203 return;
204 };
205
206 let http_client = workspace.read(cx).client().http_client().clone();
207 let url = self.url.clone();
208 let confirm_behavior = self.confirm_behavior;
209 cx.spawn_in(window, |this, mut cx| async move {
210 let text = cx
211 .background_executor()
212 .spawn(Self::build_message(http_client, url.clone()))
213 .await?;
214
215 this.update_in(&mut cx, |this, window, cx| {
216 this.delegate
217 .context_store
218 .update(cx, |context_store, _cx| {
219 context_store.add_fetched_url(url, text);
220 })?;
221
222 match confirm_behavior {
223 ConfirmBehavior::KeepOpen => {}
224 ConfirmBehavior::Close => this.delegate.dismissed(window, cx),
225 }
226
227 anyhow::Ok(())
228 })??;
229
230 anyhow::Ok(())
231 })
232 .detach_and_log_err(cx);
233 }
234
235 fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
236 self.context_picker
237 .update(cx, |_, cx| {
238 cx.emit(DismissEvent);
239 })
240 .ok();
241 }
242
243 fn render_match(
244 &self,
245 ix: usize,
246 selected: bool,
247 _window: &mut Window,
248 cx: &mut Context<Picker<Self>>,
249 ) -> Option<Self::ListItem> {
250 let added = self.context_store.upgrade().map_or(false, |context_store| {
251 context_store.read(cx).includes_url(&self.url).is_some()
252 });
253
254 Some(
255 ListItem::new(ix)
256 .inset(true)
257 .toggle_state(selected)
258 .child(Label::new(self.url.clone()))
259 .when(added, |child| {
260 child.disabled(true).end_slot(
261 h_flex()
262 .gap_1()
263 .child(
264 Icon::new(IconName::Check)
265 .size(IconSize::Small)
266 .color(Color::Success),
267 )
268 .child(Label::new("Added").size(LabelSize::Small)),
269 )
270 }),
271 )
272 }
273}