fetch_command.rs

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