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) -> Option<SharedString> {
171        Some("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, async move |this, cx| {
210            let text = cx
211                .background_spawn(Self::build_message(http_client, url.clone()))
212                .await?;
213
214            this.update_in(cx, |this, window, cx| {
215                this.delegate
216                    .context_store
217                    .update(cx, |context_store, _cx| {
218                        context_store.add_fetched_url(url, text);
219                    })?;
220
221                match confirm_behavior {
222                    ConfirmBehavior::KeepOpen => {}
223                    ConfirmBehavior::Close => this.delegate.dismissed(window, cx),
224                }
225
226                anyhow::Ok(())
227            })??;
228
229            anyhow::Ok(())
230        })
231        .detach_and_log_err(cx);
232    }
233
234    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
235        self.context_picker
236            .update(cx, |_, cx| {
237                cx.emit(DismissEvent);
238            })
239            .ok();
240    }
241
242    fn render_match(
243        &self,
244        ix: usize,
245        selected: bool,
246        _window: &mut Window,
247        cx: &mut Context<Picker<Self>>,
248    ) -> Option<Self::ListItem> {
249        let added = self.context_store.upgrade().map_or(false, |context_store| {
250            context_store.read(cx).includes_url(&self.url).is_some()
251        });
252
253        Some(
254            ListItem::new(ix)
255                .inset(true)
256                .toggle_state(selected)
257                .child(Label::new(self.url.clone()))
258                .when(added, |child| {
259                    child.disabled(true).end_slot(
260                        h_flex()
261                            .gap_1()
262                            .child(
263                                Icon::new(IconName::Check)
264                                    .size(IconSize::Small)
265                                    .color(Color::Success),
266                            )
267                            .child(Label::new("Added").size(LabelSize::Small)),
268                    )
269                }),
270        )
271    }
272}