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: &str) -> Result<String> {
 85        let mut url = url.to_owned();
 86        if !url.starts_with("https://") && !url.starts_with("http://") {
 87            url = format!("https://{url}");
 88        }
 89
 90        let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
 91
 92        let mut body = Vec::new();
 93        response
 94            .body_mut()
 95            .read_to_end(&mut body)
 96            .await
 97            .context("error reading response body")?;
 98
 99        if response.status().is_client_error() {
100            let text = String::from_utf8_lossy(body.as_slice());
101            bail!(
102                "status error {}, response: {text:?}",
103                response.status().as_u16()
104            );
105        }
106
107        let Some(content_type) = response.headers().get("content-type") else {
108            bail!("missing Content-Type header");
109        };
110        let content_type = content_type
111            .to_str()
112            .context("invalid Content-Type header")?;
113        let content_type = match content_type {
114            "text/html" => ContentType::Html,
115            "text/plain" => ContentType::Plaintext,
116            "application/json" => ContentType::Json,
117            _ => ContentType::Html,
118        };
119
120        match content_type {
121            ContentType::Html => {
122                let mut handlers: Vec<TagHandler> = vec![
123                    Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
124                    Rc::new(RefCell::new(markdown::ParagraphHandler)),
125                    Rc::new(RefCell::new(markdown::HeadingHandler)),
126                    Rc::new(RefCell::new(markdown::ListHandler)),
127                    Rc::new(RefCell::new(markdown::TableHandler::new())),
128                    Rc::new(RefCell::new(markdown::StyledTextHandler)),
129                ];
130                if url.contains("wikipedia.org") {
131                    use html_to_markdown::structure::wikipedia;
132
133                    handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
134                    handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
135                    handlers.push(Rc::new(
136                        RefCell::new(wikipedia::WikipediaCodeHandler::new()),
137                    ));
138                } else {
139                    handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
140                }
141
142                convert_html_to_markdown(&body[..], &mut handlers)
143            }
144            ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
145            ContentType::Json => {
146                let json: serde_json::Value = serde_json::from_slice(&body)?;
147
148                Ok(format!(
149                    "```json\n{}\n```",
150                    serde_json::to_string_pretty(&json)?
151                ))
152            }
153        }
154    }
155}
156
157impl PickerDelegate for FetchContextPickerDelegate {
158    type ListItem = ListItem;
159
160    fn match_count(&self) -> usize {
161        if self.url.is_empty() {
162            0
163        } else {
164            1
165        }
166    }
167
168    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
169        "Enter the URL that you would like to fetch".into()
170    }
171
172    fn selected_index(&self) -> usize {
173        0
174    }
175
176    fn set_selected_index(&mut self, _ix: usize, _cx: &mut ViewContext<Picker<Self>>) {}
177
178    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
179        "Enter a URL…".into()
180    }
181
182    fn update_matches(&mut self, query: String, _cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
183        self.url = query;
184
185        Task::ready(())
186    }
187
188    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
189        let Some(workspace) = self.workspace.upgrade() else {
190            return;
191        };
192
193        let http_client = workspace.read(cx).client().http_client().clone();
194        let url = self.url.clone();
195        let confirm_behavior = self.confirm_behavior;
196        cx.spawn(|this, mut cx| async move {
197            let text = Self::build_message(http_client, &url).await?;
198
199            this.update(&mut cx, |this, cx| {
200                this.delegate
201                    .context_store
202                    .update(cx, |context_store, _cx| {
203                        if context_store.included_url(&url).is_none() {
204                            context_store.insert_fetched_url(url, text);
205                        }
206                    })?;
207
208                match confirm_behavior {
209                    ConfirmBehavior::KeepOpen => {}
210                    ConfirmBehavior::Close => this.delegate.dismissed(cx),
211                }
212
213                anyhow::Ok(())
214            })??;
215
216            anyhow::Ok(())
217        })
218        .detach_and_log_err(cx);
219    }
220
221    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
222        self.context_picker
223            .update(cx, |this, cx| {
224                this.reset_mode();
225                cx.emit(DismissEvent);
226            })
227            .ok();
228    }
229
230    fn render_match(
231        &self,
232        ix: usize,
233        selected: bool,
234        cx: &mut ViewContext<Picker<Self>>,
235    ) -> Option<Self::ListItem> {
236        let added = self.context_store.upgrade().map_or(false, |context_store| {
237            context_store.read(cx).included_url(&self.url).is_some()
238        });
239
240        Some(
241            ListItem::new(ix)
242                .inset(true)
243                .toggle_state(selected)
244                .child(Label::new(self.url.clone()))
245                .when(added, |child| {
246                    child.disabled(true).end_slot(
247                        h_flex()
248                            .gap_1()
249                            .child(
250                                Icon::new(IconName::Check)
251                                    .size(IconSize::Small)
252                                    .color(Color::Success),
253                            )
254                            .child(Label::new("Added").size(LabelSize::Small)),
255                    )
256                }),
257        )
258    }
259}