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