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}