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 const 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}