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