diff --git a/Cargo.lock b/Cargo.lock index 557e8ba8..f143e68d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3353,7 +3353,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#51a6925955adda598c9363915a06eae6de5dd8df" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#6af19d91285bde86151287addcbb9d81266bbf30" dependencies = [ "aho-corasick", "anyhow", @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#51a6925955adda598c9363915a06eae6de5dd8df" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#6af19d91285bde86151287addcbb9d81266bbf30" dependencies = [ "anyhow", "gpui", @@ -3450,7 +3450,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#51a6925955adda598c9363915a06eae6de5dd8df" +source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#6af19d91285bde86151287addcbb9d81266bbf30" dependencies = [ "proc-macro2", "quote", diff --git a/crates/tty7-core/src/core/github/api.rs b/crates/tty7-core/src/core/github/api.rs index f328e590..d5e6fe1b 100644 --- a/crates/tty7-core/src/core/github/api.rs +++ b/crates/tty7-core/src/core/github/api.rs @@ -70,6 +70,14 @@ pub struct Reply { pub trait Transport: Send + Sync { /// `path` starts with `/` and includes the query string. fn get(&self, path: &str) -> Result; + /// The same GET, asking for the `full` media type: text fields come with + /// their rendered `*_html` too, which is where GitHub puts the signed + /// URLs a pasted attachment can actually be downloaded from (see + /// [`markdown::sign_attachments`](super::markdown::sign_attachments)). + /// Only the detail asks for it; a list does not need 50 bodies twice. + fn get_full(&self, path: &str) -> Result { + self.get(path) + } /// Whether requests carry a token. Decides how a 404 or a rate limit is /// explained: to a signed-out user, both usually mean "sign in". fn authenticated(&self) -> bool; @@ -241,12 +249,15 @@ pub fn list(t: &dyn Transport, q: &ListQuery, page: u32) -> Result Result { let base = repo_path(slug); - let issue: RawIssue = decode(&t.get(&format!("{base}/issues/{number}"))?)?; + let issue: RawIssue = decode(&t.get_full(&format!("{base}/issues/{number}"))?)?; let is_pr = issue.is_pr(); - let body = issue.body.clone().unwrap_or_default(); + let body = super::markdown::sign_attachments( + issue.body.as_deref().unwrap_or_default(), + issue.body_html.as_deref().unwrap_or_default(), + ); let mut item = issue.into_item(); - let reply = t.get(&format!( + let reply = t.get_full(&format!( "{base}/issues/{number}/comments?per_page={DETAIL_PAGE}" ))?; let comments: Vec = decode::>(&reply)? diff --git a/crates/tty7-core/src/core/github/http.rs b/crates/tty7-core/src/core/github/http.rs index 6466a06a..f68ad1a6 100644 --- a/crates/tty7-core/src/core/github/http.rs +++ b/crates/tty7-core/src/core/github/http.rs @@ -47,10 +47,24 @@ impl HttpTransport { impl Transport for HttpTransport { fn get(&self, path: &str) -> Result { + self.request(path, "application/vnd.github+json") + } + + fn get_full(&self, path: &str) -> Result { + self.request(path, "application/vnd.github.full+json") + } + + fn authenticated(&self) -> bool { + self.token.is_some() + } +} + +impl HttpTransport { + fn request(&self, path: &str, accept: &str) -> Result { let mut request = self .agent .get(format!("{API}{path}")) - .header("Accept", "application/vnd.github+json") + .header("Accept", accept) .header("X-GitHub-Api-Version", "2022-11-28"); if let Some(token) = &self.token { request = request.header("Authorization", format!("Bearer {}", token.expose())); @@ -80,8 +94,4 @@ impl Transport for HttpTransport { body, }) } - - fn authenticated(&self) -> bool { - self.token.is_some() - } } diff --git a/crates/tty7-core/src/core/github/markdown.rs b/crates/tty7-core/src/core/github/markdown.rs index 740f5793..64fd363f 100644 --- a/crates/tty7-core/src/core/github/markdown.rs +++ b/crates/tty7-core/src/core/github/markdown.rs @@ -324,6 +324,48 @@ fn scheme(url: &str) -> Option<&str> { Some(head) } +/// Point each pasted attachment at a URL it can be downloaded from. +/// +/// An image pasted into an issue is written into the Markdown as +/// `https://github.com/user-attachments/assets/`. On a public repository +/// that URL answers anyone; on a private one it wants a browser session, and +/// an API token does not count. The rendered HTML GitHub returns alongside +/// (`body_html`, from the `full` media type) carries the same image as a +/// `private-user-images.githubusercontent.com/……?jwt=…` URL that +/// downloads without credentials for a few minutes. Swap each attachment for +/// its signed twin by the uuid both carry; one with no twin is left alone. +pub fn sign_attachments(markdown: &str, html: &str) -> String { + const PREFIX: &str = "https://github.com/user-attachments/assets/"; + if html.is_empty() || !markdown.contains(PREFIX) { + return markdown.to_string(); + } + let signed: Vec = html + .split(['"', '\'']) + .filter(|v| v.starts_with("https://private-user-images.githubusercontent.com/")) + .map(|v| v.replace("&", "&")) + .collect(); + let mut out = String::with_capacity(markdown.len()); + let mut rest = markdown; + while let Some(at) = rest.find(PREFIX) { + out.push_str(&rest[..at]); + let tail = &rest[at + PREFIX.len()..]; + let id_len = tail + .find(|c: char| !(c.is_ascii_hexdigit() || c == '-')) + .unwrap_or(tail.len()); + let id = &tail[..id_len]; + match signed + .iter() + .find(|url| id.len() >= 32 && url.split('?').next().is_some_and(|p| p.contains(id))) + { + Some(url) => out.push_str(url), + None => out.push_str(&rest[at..at + PREFIX.len() + id_len]), + } + rest = &tail[id_len..]; + } + out.push_str(rest); + out +} + /// `![alt](url)` or `![alt](url "title")` at the start of `s`: the alt text, /// the URL, and how many bytes the whole form takes. `None` for anything /// else, including the reference form `![alt][id]`. @@ -526,4 +568,31 @@ mod tests { assert!(is_safe_target("./a:b")); assert!(is_safe_target("/abs/path")); } + + #[test] + fn attachments_are_swapped_for_their_signed_twins() { + let id = "352d14e0-d11c-42db-b8b3-042b31e34ce7"; + let md = format!( + "see \"x\" and \ + https://github.com/user-attachments/assets/00000000-0000-0000-0000-000000000000" + ); + let signed = format!( + "https://private-user-images.githubusercontent.com/1/2-{id}.png?jwt=a.b&x=1" + ); + let html = format!(r#"x"#); + let out = sign_attachments(&md, &html); + assert!( + out.contains(&format!( + "https://private-user-images.githubusercontent.com/1/2-{id}.png?jwt=a.b&x=1\"" + )), + "{out}" + ); + // No twin in the HTML: left as written. + assert!( + out.ends_with("assets/00000000-0000-0000-0000-000000000000"), + "{out}" + ); + // No HTML at all (a fixture, an old reply): nothing changes. + assert_eq!(sign_attachments(&md, ""), md); + } } diff --git a/crates/tty7-core/src/core/github/model.rs b/crates/tty7-core/src/core/github/model.rs index 10f00ac9..43bd0b4a 100644 --- a/crates/tty7-core/src/core/github/model.rs +++ b/crates/tty7-core/src/core/github/model.rs @@ -204,6 +204,9 @@ pub(crate) struct RawIssue { updated_at: String, #[serde(default)] pub(crate) body: Option, + /// Only with the `full` media type. + #[serde(default)] + pub(crate) body_html: Option, #[serde(default)] html_url: String, #[serde(default)] @@ -264,6 +267,9 @@ pub(crate) struct RawComment { user: Option, #[serde(default)] body: Option, + /// Only with the `full` media type. + #[serde(default)] + body_html: Option, #[serde(default)] created_at: String, #[serde(default)] @@ -378,7 +384,10 @@ impl RawComment { Comment { id: self.id, author: login(self.user), - body: self.body.unwrap_or_default(), + body: super::markdown::sign_attachments( + self.body.as_deref().unwrap_or_default(), + self.body_html.as_deref().unwrap_or_default(), + ), created_at: timestamp(&self.created_at), html_url: self.html_url, } diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 4396492a..4cc4586c 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -1255,10 +1255,13 @@ impl Tty7App { .track_scroll(&scroll) .px_4() .py_3() - .child(gpui_component::text::TextView::markdown( - "editor-md-preview-body", - markdown, - )), + .child( + gpui_component::text::TextView::markdown( + "editor-md-preview-body", + markdown, + ) + .style(crate::ui::theme::markdown_style(cx)), + ), &scroll, )) .into_any_element() diff --git a/src/ui/github/detail.rs b/src/ui/github/detail.rs index eaf5a01f..8259e5d6 100644 --- a/src/ui/github/detail.rs +++ b/src/ui/github/detail.rs @@ -461,14 +461,14 @@ impl Tty7App { /// Headings a step or two over the body rather than the document-sized ramp /// the text view defaults to: an issue's `## What happened?` is a label in a /// 280px column, not a page title. -fn panel_markdown_style() -> gpui_component::text::TextViewStyle { +fn panel_markdown_style(cx: &gpui::App) -> gpui_component::text::TextViewStyle { gpui_component::text::TextViewStyle { heading_font_size: Some(Arc::new(|level, base| match level { 1 => base * 1.25, 2 => base * 1.15, _ => base * 1.05, })), - ..Default::default() + ..crate::ui::theme::markdown_style(cx) } } @@ -504,7 +504,7 @@ fn markdown_block(id: String, source: &str, empty: &str, cx: &gpui::App) -> AnyE .text_size(rems(TEXT)) .child( gpui_component::text::TextView::markdown(SharedString::from(id), safe) - .style(panel_markdown_style()) + .style(panel_markdown_style(cx)) .selectable(true), ) .into_any_element() diff --git a/src/ui/theme.rs b/src/ui/theme.rs index d418c02f..6dff84ae 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1270,3 +1270,13 @@ mod tests { ); } } + +/// The text view's style for rendered Markdown, wherever tty7 shows it. +/// +/// Inline `code` gets a faint neutral fill. The text view's own default is the +/// theme's `accent`, which tty7 also paints selection with, so every code span +/// read as selected text. +pub(crate) fn markdown_style(cx: &gpui::App) -> gpui_component::text::TextViewStyle { + gpui_component::text::TextViewStyle::default() + .inline_code_background(cx.theme().foreground.opacity(0.08)) +}