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