fetch_context_picker.rs

  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}