fix(github): load private-repo screenshots, give inline code a neutral fill

- Pasted attachments (github.com/user-attachments/assets/<uuid>) want a
  browser session on a private repository, which an API token is not.
  The detail and comment requests now ask for the full media type, and
  each attachment is swapped for the signed private-user-images URL the
  rendered body_html carries for the same uuid.
- Inline code in rendered Markdown (the GitHub tab and the editor's
  preview) sits on a faint neutral fill instead of the theme accent,
  which is also the selection colour. Needs gpui-component 6af19d91 for
  TextViewStyle::inline_code_background.
This commit is contained in:
l0ng-ai
2026-09-27 08:13:37 +02:00
parent fdfa19cfea
commit 7221e90169
8 changed files with 131 additions and 19 deletions
Generated
+3 -3
View File
@@ -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",
+14 -3
View File
@@ -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<Reply, ApiError>;
/// 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<Reply, ApiError> {
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<ListPage, Api
/// request, its branches, size and changed files.
pub fn detail(t: &dyn Transport, slug: &RepoSlug, number: u64) -> Result<Detail, ApiError> {
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<Comment> = decode::<Vec<RawComment>>(&reply)?
+15 -5
View File
@@ -47,10 +47,24 @@ impl HttpTransport {
impl Transport for HttpTransport {
fn get(&self, path: &str) -> Result<Reply, ApiError> {
self.request(path, "application/vnd.github+json")
}
fn get_full(&self, path: &str) -> Result<Reply, ApiError> {
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<Reply, ApiError> {
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()
}
}
@@ -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/<uuid>`. 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/…<uuid>…?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<String> = html
.split(['"', '\''])
.filter(|v| v.starts_with("https://private-user-images.githubusercontent.com/"))
.map(|v| v.replace("&amp;", "&"))
.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 <img alt=\"x\" src=\"https://github.com/user-attachments/assets/{id}\" /> 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&amp;x=1"
);
let html = format!(r#"<a href="{signed}"><img src="{signed}" alt="x"></a>"#);
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);
}
}
+10 -1
View File
@@ -204,6 +204,9 @@ pub(crate) struct RawIssue {
updated_at: String,
#[serde(default)]
pub(crate) body: Option<String>,
/// Only with the `full` media type.
#[serde(default)]
pub(crate) body_html: Option<String>,
#[serde(default)]
html_url: String,
#[serde(default)]
@@ -264,6 +267,9 @@ pub(crate) struct RawComment {
user: Option<RawUser>,
#[serde(default)]
body: Option<String>,
/// Only with the `full` media type.
#[serde(default)]
body_html: Option<String>,
#[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,
}
+7 -4
View File
@@ -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()
+3 -3
View File
@@ -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()
+10
View File
@@ -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))
}