fetch_command.rs

  1use std::cell::RefCell;
  2use std::rc::Rc;
  3use std::sync::atomic::AtomicBool;
  4use std::sync::Arc;
  5
  6use anyhow::{anyhow, bail, Context, Result};
  7use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
  8use futures::AsyncReadExt;
  9use gpui::{AppContext, Task, WeakView};
 10use html_to_markdown::{convert_html_to_markdown, markdown, TagHandler};
 11use http::{AsyncBody, HttpClient, HttpClientWithUrl};
 12use language::LspAdapterDelegate;
 13use ui::{prelude::*, ButtonLike, ElevationIndex};
 14use workspace::Workspace;
 15
 16#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 17enum ContentType {
 18    Html,
 19    Plaintext,
 20    Json,
 21}
 22
 23pub(crate) struct FetchSlashCommand;
 24
 25impl FetchSlashCommand {
 26    async fn build_message(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<String> {
 27        let mut url = url.to_owned();
 28        if !url.starts_with("https://") {
 29            url = format!("https://{url}");
 30        }
 31
 32        let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
 33
 34        let mut body = Vec::new();
 35        response
 36            .body_mut()
 37            .read_to_end(&mut body)
 38            .await
 39            .context("error reading response body")?;
 40
 41        if response.status().is_client_error() {
 42            let text = String::from_utf8_lossy(body.as_slice());
 43            bail!(
 44                "status error {}, response: {text:?}",
 45                response.status().as_u16()
 46            );
 47        }
 48
 49        let Some(content_type) = response.headers().get("content-type") else {
 50            bail!("missing Content-Type header");
 51        };
 52        let content_type = content_type
 53            .to_str()
 54            .context("invalid Content-Type header")?;
 55        let content_type = match content_type {
 56            "text/html" => ContentType::Html,
 57            "text/plain" => ContentType::Plaintext,
 58            "application/json" => ContentType::Json,
 59            _ => ContentType::Html,
 60        };
 61
 62        match content_type {
 63            ContentType::Html => {
 64                let mut handlers: Vec<TagHandler> = vec![
 65                    Rc::new(RefCell::new(markdown::ParagraphHandler)),
 66                    Rc::new(RefCell::new(markdown::HeadingHandler)),
 67                    Rc::new(RefCell::new(markdown::ListHandler)),
 68                    Rc::new(RefCell::new(markdown::TableHandler::new())),
 69                    Rc::new(RefCell::new(markdown::StyledTextHandler)),
 70                ];
 71                if url.contains("wikipedia.org") {
 72                    use html_to_markdown::structure::wikipedia;
 73
 74                    handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
 75                    handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
 76                    handlers.push(Rc::new(
 77                        RefCell::new(wikipedia::WikipediaCodeHandler::new()),
 78                    ));
 79                } else {
 80                    handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
 81                }
 82
 83                convert_html_to_markdown(&body[..], &mut handlers)
 84            }
 85            ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
 86            ContentType::Json => {
 87                let json: serde_json::Value = serde_json::from_slice(&body)?;
 88
 89                Ok(format!(
 90                    "```json\n{}\n```",
 91                    serde_json::to_string_pretty(&json)?
 92                ))
 93            }
 94        }
 95    }
 96}
 97
 98impl SlashCommand for FetchSlashCommand {
 99    fn name(&self) -> String {
100        "fetch".into()
101    }
102
103    fn description(&self) -> String {
104        "insert URL contents".into()
105    }
106
107    fn menu_text(&self) -> String {
108        "Insert fetched URL contents".into()
109    }
110
111    fn requires_argument(&self) -> bool {
112        true
113    }
114
115    fn complete_argument(
116        &self,
117        _query: String,
118        _cancel: Arc<AtomicBool>,
119        _workspace: Option<WeakView<Workspace>>,
120        _cx: &mut AppContext,
121    ) -> Task<Result<Vec<String>>> {
122        Task::ready(Ok(Vec::new()))
123    }
124
125    fn run(
126        self: Arc<Self>,
127        argument: Option<&str>,
128        workspace: WeakView<Workspace>,
129        _delegate: Arc<dyn LspAdapterDelegate>,
130        cx: &mut WindowContext,
131    ) -> Task<Result<SlashCommandOutput>> {
132        let Some(argument) = argument else {
133            return Task::ready(Err(anyhow!("missing URL")));
134        };
135        let Some(workspace) = workspace.upgrade() else {
136            return Task::ready(Err(anyhow!("workspace was dropped")));
137        };
138
139        let http_client = workspace.read(cx).client().http_client();
140        let url = argument.to_string();
141
142        let text = cx.background_executor().spawn({
143            let url = url.clone();
144            async move { Self::build_message(http_client, &url).await }
145        });
146
147        let url = SharedString::from(url);
148        cx.foreground_executor().spawn(async move {
149            let text = text.await?;
150            let range = 0..text.len();
151            Ok(SlashCommandOutput {
152                text,
153                sections: vec![SlashCommandOutputSection {
154                    range,
155                    render_placeholder: Arc::new(move |id, unfold, _cx| {
156                        FetchPlaceholder {
157                            id,
158                            unfold,
159                            url: url.clone(),
160                        }
161                        .into_any_element()
162                    }),
163                }],
164                run_commands_in_text: false,
165            })
166        })
167    }
168}
169
170#[derive(IntoElement)]
171struct FetchPlaceholder {
172    pub id: ElementId,
173    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
174    pub url: SharedString,
175}
176
177impl RenderOnce for FetchPlaceholder {
178    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
179        let unfold = self.unfold;
180
181        ButtonLike::new(self.id)
182            .style(ButtonStyle::Filled)
183            .layer(ElevationIndex::ElevatedSurface)
184            .child(Icon::new(IconName::AtSign))
185            .child(Label::new(format!("fetch {url}", url = self.url)))
186            .on_click(move |_, cx| unfold(cx))
187    }
188}