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