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}