fetch_context_picker.rs

  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}