diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ae2725..53145f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Find in files, in the right panel's new Search tab.** Type and the active + tab's project — the same roots the Files tab shows — is searched as you go, + with hits grouped by file, a count per file, and each match highlighted in + its line. Click a hit to open the file in the built-in editor at that line + and column; Enter searches again. Toggles for match case, whole word and + regular expressions sit at the end of the field. The walk honours + `.gitignore` with or without a repository, skips dot-directories, binary + files and files over 1 MB, and says so when a cap (2,000 lines, 100 per + file, 20,000 files, ten seconds) cut it short. In a remote workspace the + search runs on the remote machine through `tty7-server`; a server that + predates it is never sent the request — the tab asks for the server to be + updated instead of showing an empty result. + +- **A GitHub tab in the right panel: issues and pull requests, read-only.** It + follows the focused pane's repository, binds to its `github.com` remote + (`upstream` over `origin` in a fork, with a menu to pick another), and lists + open or closed issues or pull requests with state glyphs, labels and + relative times; a label click filters by it. A row opens the detail — + description and comments as Markdown, and for a pull request its branches, + size and changed files, each of which opens its patch in the diff overlay. + Sign-in reuses the GitHub CLI (`GH_TOKEN`, `GITHUB_TOKEN`, then + `gh auth token`, found even from a Finder launch); public repositories work + signed out, and a private repository or a spent rate limit says to run + `gh auth login`. Screenshots pasted into an issue are shown; images from + any other host are shown as links rather than loaded, and non-web link + targets are disabled. The Info tab gains a GitHub + row that opens the branch you are on. The tab talks to `api.github.com` only + while you use it. + - **Reorder the active tab from the keyboard** (`MoveTabLeft` / `MoveTabRight`). The tab moves one slot past its neighbour — the keyboard form of dragging it in the tab strip or the sidebar — and wraps past either end, so one held key diff --git a/Cargo.lock b/Cargo.lock index 72f3a199..a9c62a65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3365,7 +3365,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", @@ -3448,7 +3448,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", @@ -3462,7 +3462,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", @@ -9974,6 +9974,7 @@ dependencies = [ "miniz_oxide", "notify 8.2.0", "portable-pty", + "regex", "rusqlite", "russh", "russh-sftp", diff --git a/Cargo.toml b/Cargo.toml index 5bdd028f..764a826b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,10 @@ required-features = ["updater"] # client used only to *download* a `tty7-server` onto a remote machine # (decision D5). The server binary is the thing being downloaded, so it can # never take that path; the GUI, which is the client that pushes it, can. -tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] } +# +# `github` is the right panel's GitHub tab: the same HTTPS client, pointed at +# api.github.com. +tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install", "github"] } gpui = { workspace = true } gpui_platform = { workspace = true } diff --git a/assets/icons/github.svg b/assets/icons/github.svg new file mode 100644 index 00000000..11c044d4 --- /dev/null +++ b/assets/icons/github.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/issue-closed.svg b/assets/icons/github/issue-closed.svg new file mode 100644 index 00000000..8c5c93d0 --- /dev/null +++ b/assets/icons/github/issue-closed.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/issue-not-planned.svg b/assets/icons/github/issue-not-planned.svg new file mode 100644 index 00000000..ed1922b6 --- /dev/null +++ b/assets/icons/github/issue-not-planned.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/issue-open.svg b/assets/icons/github/issue-open.svg new file mode 100644 index 00000000..e1eeed09 --- /dev/null +++ b/assets/icons/github/issue-open.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-closed.svg b/assets/icons/github/pr-closed.svg new file mode 100644 index 00000000..be486a3a --- /dev/null +++ b/assets/icons/github/pr-closed.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-draft.svg b/assets/icons/github/pr-draft.svg new file mode 100644 index 00000000..0f9675f9 --- /dev/null +++ b/assets/icons/github/pr-draft.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-merged.svg b/assets/icons/github/pr-merged.svg new file mode 100644 index 00000000..b36e133f --- /dev/null +++ b/assets/icons/github/pr-merged.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/github/pr-open.svg b/assets/icons/github/pr-open.svg new file mode 100644 index 00000000..1fcf81d1 --- /dev/null +++ b/assets/icons/github/pr-open.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index 4a7eb55e..738c7939 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -48,6 +48,14 @@ sha2 = "0.11" # implementation. ignore = "0.4" +# The matcher behind `Host::search_content` (the right panel's Search tab): +# a literal, whole-word or regular-expression query compiled once and run over +# each file the `ignore` walk above hands it. Lives here for the same reason +# `ignore` does — a remote `tty7-server` answers the search with the identical +# implementation. Already in the tree via the GUI and `ignore`, so this pins no +# new code. +regex = "1" + # Format-preserving TOML editing for `core::agent_hooks`: Kimi Code takes its # hooks as `[[hooks]]` entries in the same `config.toml` that holds the user's # providers, models and comments, so installing must edit that file in place @@ -231,6 +239,10 @@ remote-install = [ "dep:system-configuration", "dep:system-configuration-sys", ] +# The GitHub tab's REST client (`core::github::http`). It rides the same HTTPS +# stack and system-proxy resolution as the installer, so it is that feature +# plus a name of its own: the server, which never reads GitHub, builds neither. +github = ["remote-install"] [lints] workspace = true diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 6136f61e..e83f558e 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -2641,6 +2641,14 @@ mod tests { ) -> io::Result> { self.0.search(roots, query, limit, max_dirs, show_hidden) } + fn search_content( + &self, + roots: &[PathBuf], + query: &crate::host::ContentQuery, + limits: &crate::host::ContentLimits, + ) -> io::Result { + self.0.search_content(roots, query, limits) + } fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result { self.0.write_file(p, bytes) } diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 3dc79c32..06e38bb7 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -1517,13 +1517,18 @@ pub enum RightPanelTab { /// The source control panel. Renamed from `Changes` in place rather than /// added alongside it: `rename` works in both directions, so a config /// written by this version still says `"changes"` and an older build reads - /// it back unchanged. A fourth variant could not do that — the old build - /// would fall through `de_lenient` to `Info` and kick anyone who rolled - /// back off the panel they were sitting on. 260px has no room for a fourth - /// tab tile either. + /// it back unchanged. #[serde(rename = "changes", alias = "scm", alias = "git")] Scm, Files, + /// Project-wide content search. Added as a tab of its own, which costs a + /// rolled-back build one thing: it does not know `"search"`, falls through + /// `de_lenient` to `Info`, and opens there. Nothing else is lost. + Search, + /// The bound GitHub repository's issues and pull requests. Same rollback + /// cost as `Search`. + #[serde(rename = "github")] + GitHub, } /// What opens when a file link in the grid is clicked. diff --git a/crates/tty7-core/src/core/git/diff.rs b/crates/tty7-core/src/core/git/diff.rs index 552cfa90..b36c4dda 100644 --- a/crates/tty7-core/src/core/git/diff.rs +++ b/crates/tty7-core/src/core/git/diff.rs @@ -66,6 +66,14 @@ pub enum DiffSource { }, /// `base...head`: what `head` added since the two diverged. Range { base: String, head: String }, + /// A patch handed over whole rather than read from git — a GitHub pull + /// request's files. `id` is its identity (`owner/repo#12`); `label` rides + /// along the way a commit's does. Never probed: whoever opens one installs + /// the snapshot with it, and nothing can make it stale. + Patch { + id: String, + label: Option, + }, } impl PartialEq for DiffSource { @@ -109,6 +117,7 @@ impl DiffSource { // US, which can occur in neither a refname nor an object id. DiffSource::Commit { rev, .. } => format!("commit\u{1f}{rev}"), DiffSource::Range { base, head } => format!("range\u{1f}{base}\u{1f}{head}"), + DiffSource::Patch { id, .. } => format!("patch\u{1f}{id}"), } } @@ -143,6 +152,9 @@ impl DiffSource { argv.push("diff".to_string()); argv.push(format!("{base}...{head}")); } + // Not git's to produce. `probe_diff` refuses the source before an + // argv is built; this arm only keeps the match total. + DiffSource::Patch { .. } => argv.push("diff".to_string()), } argv.extend(strings(&[ "--no-color", @@ -157,6 +169,11 @@ impl DiffSource { argv } + /// Whether this patch is supplied from outside rather than read from git. + pub fn is_supplied(&self) -> bool { + matches!(self, DiffSource::Patch { .. }) + } + /// Whether untracked files belong in the snapshot. They are a property of /// the working tree, so a commit or a range has none, and a staged diff /// does not either — an untracked file is by definition not in the index. @@ -178,6 +195,7 @@ impl DiffSource { DiffSource::Worktree | DiffSource::Staged | DiffSource::Head => true, DiffSource::Commit { rev, .. } => ok(rev), DiffSource::Range { base, head } => ok(base) && ok(head), + DiffSource::Patch { .. } => false, } } } diff --git a/crates/tty7-core/src/core/git/log.rs b/crates/tty7-core/src/core/git/log.rs index ad3251f9..9d0d1b42 100644 --- a/crates/tty7-core/src/core/git/log.rs +++ b/crates/tty7-core/src/core/git/log.rs @@ -1030,7 +1030,7 @@ fn rev(host: &dyn Host, root: &Path, spec: &str) -> Option { /// Hand-rolled because the workspace carries neither `chrono` nor `time`, and /// thirty lines of arithmetic is a poor reason to add a dependency tree to a /// crate the headless server also builds. -fn parse_iso8601(text: &str) -> Option { +pub(crate) fn parse_iso8601(text: &str) -> Option { let b = text.as_bytes(); if !text.is_ascii() || b.len() < 19 { return None; diff --git a/crates/tty7-core/src/core/github/api.rs b/crates/tty7-core/src/core/github/api.rs new file mode 100644 index 00000000..66e4f67d --- /dev/null +++ b/crates/tty7-core/src/core/github/api.rs @@ -0,0 +1,639 @@ +//! The REST calls the panel makes, over an injected [`Transport`]. +//! +//! Everything here is request shaping and response decoding; the bytes move +//! through a `Transport`, which the GUI backs with HTTPS +//! ([`super::http::HttpTransport`]) and tests back with fixtures. No test in +//! this crate touches the network. + +use serde::de::DeserializeOwned; + +use super::model::{ + Comment, Detail, Item, Kind, PrFile, RawComment, RawFile, RawIssue, RawPull, StateFilter, +}; +use super::remote::RepoSlug; + +/// Rows per page. GitHub's maximum is 100; 50 keeps the first answer quick on +/// a slow link while still filling a tall panel. +pub const PAGE_SIZE: u32 = 50; + +/// Comments and files fetched with a detail view. One page each: past that the +/// view says there is more and links to GitHub. +pub const DETAIL_PAGE: u32 = 100; + +/// Why a call failed, in the terms the panel explains to the user. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApiError { + /// 401: a token was sent and GitHub refused it (revoked, expired). + Unauthorized, + /// 404: no such repository — or a private one this request cannot see, + /// which GitHub deliberately does not distinguish. + NotFound, + /// The rate limit is spent. `reset` is when it refills, unix seconds. + RateLimited { reset: Option }, + /// 403 for another reason (SSO enforcement, a blocked token), with + /// GitHub's own message. + Forbidden(String), + /// The request never got an HTTP answer. + Network(String), + /// Any other non-success status. + Http(u16), + /// A 2xx whose body did not decode. + Decode(String), +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiError::Unauthorized => f.write_str("GitHub rejected the token (401)"), + ApiError::NotFound => f.write_str("not found (404)"), + ApiError::RateLimited { reset } => match reset { + Some(at) => write!(f, "rate limited until {at}"), + None => f.write_str("rate limited"), + }, + ApiError::Forbidden(msg) => write!(f, "forbidden (403): {msg}"), + ApiError::Network(msg) => write!(f, "network error: {msg}"), + ApiError::Http(code) => write!(f, "HTTP {code}"), + ApiError::Decode(msg) => write!(f, "unexpected response: {msg}"), + } + } +} + +/// A successful answer: the body, and whether the `Link` header offered a +/// next page. +#[derive(Clone, Debug, Default)] +pub struct Reply { + pub body: Vec, + pub has_next: bool, +} + +/// One authenticated-or-not GET against `https://api.github.com`. +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; +} + +/// Map a response's status and headers to success or an [`ApiError`]. +/// +/// `header` looks a response header up by lower-case name. `body` is only read +/// for GitHub's `message`, which tells a secondary rate limit apart from any +/// other 403. +pub fn classify( + status: u16, + header: &dyn Fn(&str) -> Option, + body: &[u8], +) -> Result<(), ApiError> { + if (200..300).contains(&status) { + return Ok(()); + } + let message = || { + serde_json::from_slice::(body) + .ok() + .and_then(|v| v.get("message")?.as_str().map(str::to_string)) + .unwrap_or_default() + }; + let reset = || { + header("x-ratelimit-reset") + .and_then(|v| v.trim().parse::().ok()) + .or_else(|| { + let after = header("retry-after")?.trim().parse::().ok()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs() as i64; + Some(now.saturating_add(after)) + }) + }; + match status { + 401 => Err(ApiError::Unauthorized), + 404 => Err(ApiError::NotFound), + 429 => Err(ApiError::RateLimited { reset: reset() }), + 403 => { + let spent = header("x-ratelimit-remaining").is_some_and(|v| v.trim() == "0"); + let msg = message(); + if spent + || header("retry-after").is_some() + || msg.to_ascii_lowercase().contains("rate limit") + { + Err(ApiError::RateLimited { reset: reset() }) + } else { + Err(ApiError::Forbidden(msg)) + } + } + code => Err(ApiError::Http(code)), + } +} + +/// Whether a `Link` header names a `rel="next"` page. +pub fn link_has_next(link: &str) -> bool { + link.split(',').any(|part| { + part.split(';') + .skip(1) + .any(|p| p.trim().eq_ignore_ascii_case("rel=\"next\"")) + }) +} + +/// What a list shows: which repository, which kind, which state, which label. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ListQuery { + pub slug: RepoSlug, + pub kind: Kind, + pub state: StateFilter, + pub label: Option, +} + +/// One page of a list, and the page after it when there is one. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ListPage { + pub items: Vec, + pub next_page: Option, +} + +/// Which endpoint answers a list query. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Endpoint { + /// `/issues`, keeping only the entries whose kind matches. + Issues { want_prs: bool }, + /// `/pulls`, which can say draft and merged but cannot filter by label. + Pulls, +} + +fn repo_path(slug: &RepoSlug) -> String { + format!( + "/repos/{}/{}", + super::remote::escape_path(&slug.owner), + super::remote::escape_path(&slug.name) + ) +} + +fn list_request(q: &ListQuery, page: u32) -> (String, Endpoint) { + let base = repo_path(&q.slug); + let common = format!( + "state={}&sort=updated&direction=desc&per_page={PAGE_SIZE}&page={page}", + q.state.as_query() + ); + let label = q.label.as_deref().filter(|l| !l.is_empty()); + match (q.kind, label) { + (Kind::Pulls, None) => (format!("{base}/pulls?{common}"), Endpoint::Pulls), + (kind, label) => { + let mut path = format!("{base}/issues?{common}"); + if let Some(label) = label { + path.push_str("&labels="); + path.push_str(&escape_query(label)); + } + ( + path, + Endpoint::Issues { + want_prs: kind == Kind::Pulls, + }, + ) + } + } +} + +fn escape_query(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn decode(reply: &Reply) -> Result { + serde_json::from_slice(&reply.body).map_err(|e| ApiError::Decode(e.to_string())) +} + +/// Page `page` (1-based) of a list. +/// +/// `/issues` interleaves pull requests with issues, so a page of it filtered +/// down to one kind can be short — even empty — while later pages still hold +/// matches. `next_page` follows GitHub's `Link` header, not the row count, so +/// "load more" stays offered for exactly as long as there is more to load. +pub fn list(t: &dyn Transport, q: &ListQuery, page: u32) -> Result { + let page = page.max(1); + let (path, endpoint) = list_request(q, page); + let reply = t.get(&path)?; + let items = match endpoint { + Endpoint::Pulls => decode::>(&reply)? + .into_iter() + .map(|p| p.into_item().0) + .collect(), + Endpoint::Issues { want_prs } => decode::>(&reply)? + .into_iter() + .filter(|i| i.is_pr() == want_prs) + .map(RawIssue::into_item) + .collect(), + }; + Ok(ListPage { + items, + next_page: reply.has_next.then_some(page + 1), + }) +} + +/// One issue or pull request with its body and conversation — and, for a pull +/// request, its branches, size and changed files. +pub fn detail(t: &dyn Transport, slug: &RepoSlug, number: u64) -> Result { + let base = repo_path(slug); + let issue: RawIssue = decode(&t.get_full(&format!("{base}/issues/{number}"))?)?; + let is_pr = issue.is_pr(); + 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_full(&format!( + "{base}/issues/{number}/comments?per_page={DETAIL_PAGE}" + ))?; + let comments: Vec = decode::>(&reply)? + .into_iter() + .map(RawComment::into_comment) + .collect(); + let comments_truncated = reply.has_next; + + let (mut pull, mut files, mut files_truncated) = (None, None, false); + if is_pr { + let (pr_item, info) = + decode::(&t.get(&format!("{base}/pulls/{number}"))?)?.into_item(); + // `/pulls/{n}` is the one that knows about drafts; the issue's + // labels and comment count are kept, which `/pulls` omits. + item.state = pr_item.state; + pull = Some(info); + let reply = t.get(&format!( + "{base}/pulls/{number}/files?per_page={DETAIL_PAGE}" + ))?; + files = Some( + decode::>(&reply)? + .into_iter() + .map(RawFile::into_file) + .collect::>(), + ); + files_truncated = reply.has_next; + } + + Ok(Detail { + item, + body, + comments, + comments_truncated, + pull, + files, + files_truncated, + }) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::core::git::diff::FileStatus; + use crate::core::github::model::ItemState; + use std::collections::HashMap; + use std::sync::Mutex; + + /// A transport that answers from a path → reply table and records every + /// path asked for. Paths it has no answer for are a 404. + pub(crate) struct Fixture { + pub(crate) replies: HashMap>, + pub(crate) asked: Mutex>, + pub(crate) authenticated: bool, + } + + impl Fixture { + pub(crate) fn new() -> Fixture { + Fixture { + replies: HashMap::new(), + asked: Mutex::new(Vec::new()), + authenticated: true, + } + } + + pub(crate) fn on(&mut self, path: &str, body: &str, has_next: bool) { + self.replies.insert( + path.to_string(), + Ok(Reply { + body: body.as_bytes().to_vec(), + has_next, + }), + ); + } + } + + impl Transport for Fixture { + fn get(&self, path: &str) -> Result { + self.asked.lock().unwrap().push(path.to_string()); + self.replies + .get(path) + .cloned() + .unwrap_or(Err(ApiError::NotFound)) + } + + fn authenticated(&self) -> bool { + self.authenticated + } + } + + pub(crate) fn slug() -> RepoSlug { + RepoSlug { + owner: "l0ng-ai".into(), + name: "tty7".into(), + } + } + + const ISSUE_LIST: &str = r#"[ + {"number": 12, "title": "Crash on resize", "state": "open", "state_reason": null, + "user": {"login": "ada"}, "labels": [{"name": "bug", "color": "d73a4a"}], + "comments": 3, "created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-09-20T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/issues/12", "body": "It crashes."}, + {"number": 13, "title": "Add panel", "state": "open", "user": {"login": "bob"}, + "labels": [], "comments": 0, "created_at": "2026-09-02T10:00:00Z", + "updated_at": "2026-09-21T08:30:00Z", "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "draft": true, "pull_request": {"url": "x", "merged_at": null}}, + {"number": 9, "title": "Old idea", "state": "closed", "state_reason": "not_planned", + "user": null, "labels": [], "comments": 1, "created_at": "2026-08-01T10:00:00Z", + "updated_at": "2026-08-02T10:00:00Z", "html_url": "https://github.com/l0ng-ai/tty7/issues/9"} + ]"#; + + const PULL_LIST: &str = r#"[ + {"number": 13, "title": "Add panel", "state": "open", "draft": true, "merged_at": null, + "user": {"login": "bob"}, "labels": [{"name": "ui", "color": "0e8a16"}], + "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-21T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "head": {"ref": "feat/panel", "label": "bob:feat/panel"}, "base": {"ref": "main"}}, + {"number": 8, "title": "Fix typo", "state": "closed", "draft": false, + "merged_at": "2026-09-03T10:00:00Z", "user": {"login": "cy"}, "labels": [], + "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-03T10:00:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/pull/8", + "head": {"ref": "typo"}, "base": {"ref": "main"}} + ]"#; + + fn query(kind: Kind, label: Option<&str>) -> ListQuery { + ListQuery { + slug: slug(), + kind, + state: StateFilter::Open, + label: label.map(str::to_string), + } + } + + #[test] + fn issues_come_from_issues_with_the_pull_requests_filtered_out() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues?state=open&sort=updated&direction=desc&per_page=50&page=1", + ISSUE_LIST, + true, + ); + let page = list(&t, &query(Kind::Issues, None), 1).unwrap(); + let numbers: Vec = page.items.iter().map(|i| i.number).collect(); + assert_eq!(numbers, vec![12, 9]); + assert_eq!(page.next_page, Some(2)); + let first = &page.items[0]; + assert_eq!(first.author, "ada"); + assert_eq!(first.state, ItemState::Open); + assert_eq!(first.labels[0].name, "bug"); + assert_eq!(first.comments, 3); + assert!(first.updated_at > first.created_at); + assert_eq!(page.items[1].state, ItemState::NotPlanned); + assert_eq!( + page.items[1].author, "", + "a deleted user is blank, not a failure" + ); + } + + #[test] + fn pull_requests_come_from_pulls_with_draft_and_merged() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/pulls?state=open&sort=updated&direction=desc&per_page=50&page=2", + PULL_LIST, + false, + ); + let page = list(&t, &query(Kind::Pulls, None), 2).unwrap(); + assert_eq!(page.next_page, None); + let states: Vec = page.items.iter().map(|i| i.state).collect(); + assert_eq!(states, vec![ItemState::Draft, ItemState::Merged]); + assert!(page.items.iter().all(|i| i.is_pr)); + } + + #[test] + fn a_label_filter_on_pull_requests_goes_through_issues() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues?state=open&sort=updated&direction=desc&per_page=50&page=1&labels=good%20first%20issue", + ISSUE_LIST, + false, + ); + let page = list(&t, &query(Kind::Pulls, Some("good first issue")), 1).unwrap(); + let numbers: Vec = page.items.iter().map(|i| i.number).collect(); + assert_eq!(numbers, vec![13]); + assert_eq!(page.items[0].state, ItemState::Draft); + } + + #[test] + fn page_zero_is_page_one() { + let t = Fixture::new(); + let _ = list(&t, &query(Kind::Issues, None), 0); + assert!(t.asked.lock().unwrap()[0].ends_with("&page=1")); + } + + #[test] + fn an_issue_detail_reads_the_issue_and_its_comments_only() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues/12", + r#"{"number": 12, "title": "Crash on resize", "state": "open", + "user": {"login": "ada"}, "labels": [], "comments": 1, + "created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-09-20T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/issues/12", "body": "It **crashes**."}"#, + false, + ); + t.on( + "/repos/l0ng-ai/tty7/issues/12/comments?per_page=100", + r#"[{"id": 1, "user": {"login": "bob"}, "body": "Same here", + "created_at": "2026-09-02T10:00:00Z", "html_url": "https://github.com/c/1"}]"#, + true, + ); + let d = detail(&t, &slug(), 12).unwrap(); + assert_eq!(d.body, "It **crashes**."); + assert_eq!(d.comments.len(), 1); + assert_eq!(d.comments[0].author, "bob"); + assert!(d.comments_truncated); + assert!(d.pull.is_none() && d.files.is_none()); + assert_eq!(t.asked.lock().unwrap().len(), 2); + } + + #[test] + fn a_pull_request_detail_adds_branches_and_files() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues/13", + r#"{"number": 13, "title": "Add panel", "state": "open", "user": {"login": "bob"}, + "labels": [{"name": "ui"}], "comments": 0, "created_at": "2026-09-02T10:00:00Z", + "updated_at": "2026-09-21T08:30:00Z", "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "body": null, "pull_request": {"merged_at": null}}"#, + false, + ); + t.on( + "/repos/l0ng-ai/tty7/issues/13/comments?per_page=100", + "[]", + false, + ); + t.on( + "/repos/l0ng-ai/tty7/pulls/13", + r#"{"number": 13, "title": "Add panel", "state": "open", "draft": true, + "merged_at": null, "user": {"login": "bob"}, "labels": [], + "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-21T08:30:00Z", + "html_url": "https://github.com/l0ng-ai/tty7/pull/13", + "head": {"ref": "feat/panel"}, "base": {"ref": "main"}, + "additions": 120, "deletions": 4, "changed_files": 2, "commits": 3}"#, + false, + ); + t.on( + "/repos/l0ng-ai/tty7/pulls/13/files?per_page=100", + r#"[{"filename": "src/new.rs", "status": "added", "additions": 118, "deletions": 0, + "patch": "@@ -0,0 +1,2 @@\n+fn a() {}\n+fn b() {}"}, + {"filename": "assets/logo.png", "status": "renamed", + "previous_filename": "logo.png", "additions": 2, "deletions": 4}]"#, + true, + ); + let d = detail(&t, &slug(), 13).unwrap(); + assert_eq!(d.body, "", "a null body is an empty one"); + assert_eq!(d.item.state, ItemState::Draft); + assert_eq!(d.item.labels[0].name, "ui", "labels come from the issue"); + let pull = d.pull.unwrap(); + assert_eq!( + (pull.head_ref.as_str(), pull.base_ref.as_str()), + ("feat/panel", "main") + ); + assert_eq!((pull.additions, pull.deletions, pull.commits), (120, 4, 3)); + let files = d.files.unwrap(); + assert_eq!(files[0].status, FileStatus::Added); + assert!(files[0].patch.is_some()); + assert_eq!(files[1].status, FileStatus::Renamed); + assert_eq!(files[1].old_path.as_deref(), Some("logo.png")); + assert!(files[1].patch.is_none()); + assert!(d.files_truncated); + } + + #[test] + fn a_failed_sub_request_fails_the_detail() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues/12", + r#"{"number": 12, "title": "x", "state": "open"}"#, + false, + ); + t.replies.insert( + "/repos/l0ng-ai/tty7/issues/12/comments?per_page=100".into(), + Err(ApiError::RateLimited { reset: Some(5) }), + ); + assert_eq!( + detail(&t, &slug(), 12), + Err(ApiError::RateLimited { reset: Some(5) }) + ); + } + + #[test] + fn a_body_that_is_not_the_expected_shape_is_a_decode_error() { + let mut t = Fixture::new(); + t.on( + "/repos/l0ng-ai/tty7/issues?state=open&sort=updated&direction=desc&per_page=50&page=1", + r#"{"message": "surprise"}"#, + false, + ); + assert!(matches!( + list(&t, &query(Kind::Issues, None), 1), + Err(ApiError::Decode(_)) + )); + } + + fn headers(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |name| { + pairs + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_string()) + } + } + + #[test] + fn statuses_map_to_distinct_errors() { + let none = headers(&[]); + assert_eq!(classify(200, &none, b""), Ok(())); + assert_eq!(classify(204, &none, b""), Ok(())); + assert_eq!(classify(401, &none, b""), Err(ApiError::Unauthorized)); + assert_eq!(classify(404, &none, b""), Err(ApiError::NotFound)); + assert_eq!(classify(500, &none, b""), Err(ApiError::Http(500))); + assert_eq!( + classify( + 403, + &none, + br#"{"message": "Resource protected by organization SAML enforcement."}"# + ), + Err(ApiError::Forbidden( + "Resource protected by organization SAML enforcement.".into() + )) + ); + } + + #[test] + fn a_spent_rate_limit_is_told_apart_from_other_403s() { + let spent = headers(&[ + ("x-ratelimit-remaining", "0"), + ("x-ratelimit-reset", "1790000000"), + ]); + assert_eq!( + classify(403, &spent, b"{}"), + Err(ApiError::RateLimited { + reset: Some(1_790_000_000) + }) + ); + assert_eq!( + classify(429, &spent, b"{}"), + Err(ApiError::RateLimited { + reset: Some(1_790_000_000) + }) + ); + // A secondary limit keeps quota but says so in its message. + let left = headers(&[("x-ratelimit-remaining", "12")]); + assert_eq!( + classify( + 403, + &left, + br#"{"message": "You have exceeded a secondary rate limit."}"# + ), + Err(ApiError::RateLimited { reset: None }) + ); + let retry = headers(&[("retry-after", "60")]); + assert!(matches!( + classify(403, &retry, b"{}"), + Err(ApiError::RateLimited { reset: Some(_) }) + )); + } + + #[test] + fn the_link_header_decides_whether_there_is_more() { + assert!(link_has_next( + r#"; rel="next", ; rel="last""# + )); + assert!(!link_has_next( + r#"; rel="prev", ; rel="first""# + )); + assert!(!link_has_next("")); + } +} diff --git a/crates/tty7-core/src/core/github/http.rs b/crates/tty7-core/src/core/github/http.rs new file mode 100644 index 00000000..f68ad1a6 --- /dev/null +++ b/crates/tty7-core/src/core/github/http.rs @@ -0,0 +1,97 @@ +//! [`Transport`] over HTTPS, with the same stack and proxy resolution the +//! installer and the update check use. +//! +//! Blocking — every call runs on a worker, never on the UI thread. + +use std::time::Duration; + +use super::api::{ApiError, Reply, Transport, classify, link_has_next}; +use super::token::Token; + +const API: &str = "https://api.github.com"; + +/// Interactive reads: long enough for a slow link, short enough that a dead +/// one is reported rather than sat on. +const TIMEOUT: Duration = Duration::from_secs(30); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// A pull request's file list with every patch inlined is the largest answer +/// the panel asks for; GitHub itself caps a patch well under this. +const MAX_BODY: u64 = 32 * 1024 * 1024; + +pub struct HttpTransport { + agent: ureq::Agent, + token: Option, +} + +impl HttpTransport { + /// `manual_proxy` is the `http_proxy` setting, as for tty7's other + /// downloads. + pub fn new(token: Option, manual_proxy: Option<&str>) -> HttpTransport { + let mut builder = ureq::Agent::config_builder() + .timeout_global(Some(TIMEOUT)) + .timeout_connect(Some(CONNECT_TIMEOUT)) + // 4xx/5xx come back as responses, so their headers (the rate + // limit's reset time) can be read. + .http_status_as_error(false) + .user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION"))); + if let Some(proxy) = crate::daemon::install::proxy::resolve(API, manual_proxy) { + builder = builder.proxy(Some(proxy)); + } + HttpTransport { + agent: builder.build().into(), + token, + } + } +} + +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", accept) + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = &self.token { + request = request.header("Authorization", format!("Bearer {}", token.expose())); + } + // ureq's error text names the URL and the transport failure, never + // the request headers, so it is safe to show. + let mut response = request + .call() + .map_err(|e| ApiError::Network(e.to_string()))?; + let status = response.status().as_u16(); + let headers = response.headers().clone(); + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + }; + let body = response + .body_mut() + .with_config() + .limit(MAX_BODY) + .read_to_vec() + .map_err(|e| ApiError::Network(e.to_string()))?; + classify(status, &header, &body)?; + Ok(Reply { + has_next: header("link").is_some_and(|l| link_has_next(&l)), + body, + }) + } +} diff --git a/crates/tty7-core/src/core/github/markdown.rs b/crates/tty7-core/src/core/github/markdown.rs new file mode 100644 index 00000000..9801044b --- /dev/null +++ b/crates/tty7-core/src/core/github/markdown.rs @@ -0,0 +1,848 @@ +//! Making an issue's Markdown safe to hand to the text view. +//! +//! Issue bodies and comments are written by anyone on the internet. The text +//! view renders Markdown natively (no browser, no script), so there is nothing +//! to execute — but two things in it act on the reader's machine: +//! +//! - **Images load themselves.** An `![](…)` or `` is fetched the +//! moment it is drawn, which tells whoever controls that URL that this +//! issue was opened, from which IP, and when. Only images GitHub itself +//! hosts (a screenshot pasted into an issue, see [`is_github_hosted`]) are +//! drawn — reading the issue already told GitHub as much. Any other image +//! becomes a plain link: the reader can still open it, deliberately, in +//! the browser. (github.com proxies third-party images through its own +//! servers for the same reason; tty7 has no proxy, so it does not load them.) +//! - **Links open whatever they name.** A click hands the URL to the OS, and +//! `file:///…` or an app's custom scheme can launch programs. Only `http`, +//! `https` and `mailto` targets survive; anything else is pointed at `#`. +//! +//! This is a rewrite of the *source text*, not a Markdown parser. It knows +//! enough structure to leave code alone (fenced blocks and inline code spans +//! are copied verbatim, since `![x](y)` inside backticks is text) and to find +//! the link and image forms Markdown and the HTML subset actually have. + +/// Rewrite `src` as described in the module docs. `image_label` names an image +/// whose alt text is empty ("image"), in the reader's language. +pub fn sanitize(src: &str, image_label: &str) -> String { + let mut out = String::with_capacity(src.len() + 16); + let mut fence: Option<(char, usize)> = None; + for line in src.split_inclusive('\n') { + let trimmed = line.trim_start_matches(' '); + let indent = line.len() - trimmed.len(); + let marker = fence_marker(trimmed).filter(|_| indent <= 3); + match (fence, marker) { + (None, Some(m)) => { + fence = Some(m); + out.push_str(line); + continue; + } + (Some((ch, n)), Some((mch, mn))) if ch == mch && mn >= n && closes_fence(trimmed) => { + fence = None; + out.push_str(line); + continue; + } + (Some(_), _) => { + out.push_str(line); + continue; + } + (None, None) => {} + } + out.push_str(&sanitize_line(line, image_label)); + } + out +} + +/// A fence opener/closer: three or more of one of `` ` `` / `~`. +fn fence_marker(line: &str) -> Option<(char, usize)> { + let ch = line.chars().next().filter(|c| *c == '`' || *c == '~')?; + let n = line.chars().take_while(|c| *c == ch).count(); + (n >= 3).then_some((ch, n)) +} + +/// A closing fence carries nothing after its marker but whitespace. +fn closes_fence(line: &str) -> bool { + let ch = line.chars().next().unwrap_or(' '); + line.trim_start_matches(ch).trim().is_empty() +} + +/// One line outside a fenced block: code spans kept, the rest rewritten. +fn sanitize_line(line: &str, image_label: &str) -> String { + // A reference definition, `[id]: url`, is a link target of its own. + if let Some(rewritten) = reference_definition(line) { + return rewritten; + } + // A table row cannot be split across paragraphs without ending the table. + let own_block = !line.trim_start().starts_with('|'); + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while !rest.is_empty() { + match rest.find('`') { + Some(start) => { + out.push_str(&sanitize_text(&rest[..start], image_label, own_block)); + let after = &rest[start..]; + let ticks = after.chars().take_while(|c| *c == '`').count(); + let closer = "`".repeat(ticks); + match after[ticks..].find(&closer) { + Some(end) => { + let span = ticks + end + ticks; + out.push_str(&after[..span]); + rest = &after[span..]; + } + // An unclosed run of backticks is literal text. + None => { + out.push_str(&after[..ticks]); + rest = &after[ticks..]; + } + } + } + None => { + out.push_str(&sanitize_text(rest, image_label, own_block)); + break; + } + } + } + out +} + +fn reference_definition(line: &str) -> Option { + let trimmed = line.trim_start_matches(' '); + if line.len() - trimmed.len() > 3 || !trimmed.starts_with('[') { + return None; + } + let close = trimmed.find("]:")?; + if trimmed[1..close].contains(']') { + return None; + } + let head_len = line.len() - trimmed.len() + close + 2; + let tail = &line[head_len..]; + let target_start = tail.len() - tail.trim_start().len(); + let target = tail[target_start..].split_whitespace().next()?; + let bare = target.trim_start_matches('<').trim_end_matches('>'); + if is_safe_target(bare) { + return None; + } + let from = head_len + target_start; + Some(format!( + "{}#{}", + &line[..from], + &line[from + target.len()..] + )) +} + +/// Plain text between code spans. `own_block` puts each image kept as an +/// image in a paragraph of its own: the text view draws an image that shares +/// a paragraph with text at line height, a screenshot shrunk to an icon. +fn sanitize_text(text: &str, image_label: &str, own_block: bool) -> String { + let image = |alt: &str, url: &str| match own_block { + true => format!("\n\n![{alt}]({url})\n\n"), + false => format!("![{alt}]({url})"), + }; + let mut out = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + while i < text.len() { + let rest = &text[i..]; + // A GitHub-hosted `![alt](url)` stays an image. + if rest.starts_with("![") + && !escaped(bytes, i) + && let Some((alt, url, len)) = inline_image(rest) + && is_github_hosted(url) + { + out.push_str(&image(alt, &escape_destination(url))); + i += len; + continue; + } + // Any other `![alt](…)` / `![alt][ref]` → a link with the same target. + if rest.starts_with("![") && !escaped(bytes, i) { + out.push('['); + if rest[2..].starts_with(']') { + out.push_str(image_label); + } + i += 2; + continue; + } + // `](target` — the target half of an inline link or image. + if rest.starts_with("](") { + out.push_str("]("); + i += 2; + let target = &text[i..]; + let lead = target.len() - target.trim_start().len(); + let body = &target[lead..]; + let (url, len) = if let Some(inner) = body.strip_prefix('<') { + let end = inner.find('>').unwrap_or(inner.len()); + (&inner[..end], end + 2) + } else { + let end = body + .find(|c: char| c.is_whitespace() || c == ')') + .unwrap_or(body.len()); + (&body[..end], end) + }; + out.push_str(&target[..lead]); + if is_safe_target(url) { + // Copied, not scanned, so nothing in it may read as markup + // of its own: were the parser to end the link elsewhere + // (an unbalanced `(`, a title left open), a `![…](…)` or a + // tag inside the URL would come alive unsanitized. + let pointy = body.starts_with('<'); + if pointy { + out.push('<'); + } + out.push_str(&neutralize_markup(url)); + if pointy && len <= body.len() && body[..len].ends_with('>') { + out.push('>'); + } + } else { + out.push('#'); + } + i += lead + len.min(body.len()); + continue; + } + if rest.starts_with('<') { + // `` → a link to what it would have loaded. + if starts_with_tag(rest, "img") || starts_with_tag(rest, "image") { + let end = rest.find('>').map_or(rest.len(), |e| e + 1); + let tag = &rest[..end]; + let src = attr(tag, "src").unwrap_or_default(); + let alt = attr(tag, "alt").filter(|a| !a.trim().is_empty()); + let label = alt.as_deref().unwrap_or(image_label); + let label = label.replace(['[', ']'], ""); + if is_github_hosted(&src) { + out.push_str(&image(&label, &escape_destination(&src))); + } else if is_safe_target(&src) && !src.is_empty() { + out.push_str(&format!("[{label}]({})", escape_destination(&src))); + } else { + out.push_str(&format!("[{label}](#)")); + } + i += end; + continue; + } + // `` autolink with a scheme we do not open. + if let Some(end) = rest.find('>') { + let inner = &rest[1..end]; + if !inner.contains(char::is_whitespace) + && scheme(inner).is_some() + && !is_safe_target(inner) + { + out.push_str("\\<"); + i += 1; + continue; + } + } + // Any other tag: neutralise `href`/`src` values it carries. + if let Some(end) = tag_end(rest) { + out.push_str(&rewrite_tag_urls(&rest[..end])); + i += end; + continue; + } + } + let ch = rest.chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +fn escaped(bytes: &[u8], i: usize) -> bool { + let mut n = 0; + let mut j = i; + while j > 0 && bytes[j - 1] == b'\\' { + n += 1; + j -= 1; + } + n % 2 == 1 +} + +fn starts_with_tag(s: &str, name: &str) -> bool { + let Some(rest) = s.strip_prefix('<') else { + return false; + }; + rest.len() > name.len() + && rest[..name.len()].eq_ignore_ascii_case(name) + && rest[name.len()..] + .chars() + .next() + .is_some_and(|c| c.is_whitespace() || c == '>' || c == '/') +} + +/// The length of an HTML tag starting at `s[0] == '<'`, when it is one. +fn tag_end(s: &str) -> Option { + let rest = s.strip_prefix('<')?; + let rest = rest.strip_prefix('/').unwrap_or(rest); + if !rest.chars().next()?.is_ascii_alphabetic() { + return None; + } + // Stop at the next `<`: a stray `<` in prose is not a tag. + let end = s.find('>')?; + (!s[1..end].contains('<')).then_some(end + 1) +} + +fn rewrite_tag_urls(tag: &str) -> String { + let mut tag = tag.to_string(); + for name in ["href", "src", "srcset", "poster", "background"] { + if let Some(value) = attr(&tag, name) + && !is_safe_target(&value) + { + tag = tag.replacen(&value, "#", 1); + } + } + tag +} + +/// An attribute's value, quoted or bare. Case-insensitive on the name. +fn attr(tag: &str, name: &str) -> Option { + let lower = tag.to_ascii_lowercase(); + let mut from = 0; + while let Some(pos) = lower[from..].find(name) { + let at = from + pos; + from = at + name.len(); + let before_ok = lower[..at] + .chars() + .last() + .is_some_and(|c| c.is_whitespace() || c == '/'); + let after = lower[from..].trim_start(); + if !before_ok || !after.starts_with('=') { + continue; + } + let offset = tag.len() - tag[from..].trim_start().len() + 1; + let value = tag[offset..].trim_start(); + return Some(match value.chars().next() { + Some(q @ ('"' | '\'')) => value[1..].split(q).next().unwrap_or("").to_string(), + _ => value + .split(|c: char| c.is_whitespace() || c == '>') + .next() + .unwrap_or("") + .trim_end_matches('/') + .to_string(), + }); + } + None +} + +/// The scheme of `url`, when it has one: letters first, then letters, digits, +/// `+`, `-`, `.`, up to a `:` that comes before any `/`, `?` or `#`. +fn scheme(url: &str) -> Option<&str> { + let colon = url.find(':')?; + let head = &url[..colon]; + if head.is_empty() + || url[..colon].contains(['/', '?', '#']) + || !head.chars().next()?.is_ascii_alphabetic() + || !head + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + 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]`. +fn inline_image(s: &str) -> Option<(&str, &str, usize)> { + let rest = s.strip_prefix("![")?; + let alt_end = rest.find(']')?; + let alt = &rest[..alt_end]; + if alt.contains('[') { + return None; + } + let target = rest[alt_end + 1..].strip_prefix('(')?; + let close = target.find(')')?; + let inside = target[..close].trim(); + let url = inside.split_whitespace().next()?; + let url = url + .strip_prefix('<') + .and_then(|u| u.strip_suffix('>')) + .unwrap_or(url); + Some((alt, url, 2 + alt_end + 1 + 1 + close + 1)) +} + +/// Whether an image URL is one GitHub serves itself: attachments pasted into +/// an issue (`github.com/user-attachments/…`, a repository's `/assets/…`) and +/// anything under `githubusercontent.com` (older attachments, raw files, +/// avatars, GitHub's own image proxy). Only `https`. +pub fn is_github_hosted(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://") else { + return false; + }; + // The authority ends at the first of these for a URL parser (`\\` counts + // as `/` in an `https` URL), so `https://evil.io?.githubusercontent.com` + // is evil.io. + let (host, path) = rest.split_at(rest.find(['/', '?', '#', '\\']).unwrap_or(rest.len())); + // Only plain DNS characters: userinfo, a port, an entity or a percent + // escape would each make the "host" above something else. + if host.is_empty() + || !host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + { + return false; + } + let host = host.to_ascii_lowercase(); + if host == "github.com" { + let mut parts = path.trim_start_matches('/').split('/'); + return match (parts.next(), parts.next(), parts.next()) { + (Some("user-attachments"), Some(_), _) => true, + (Some(_), Some(_), Some("assets")) => true, + _ => false, + }; + } + host.ends_with(".githubusercontent.com") +} + +/// Whether a link target may be handed to the OS opener. +/// +/// Relative targets and fragments carry no scheme and so cannot name a program; +/// they are kept. Whitespace and control characters are stripped first, the +/// way a browser does, so ` jav\tascript:` is judged as `javascript:`. +/// +/// Character references are decoded before judging too — both the Markdown +/// and the HTML parser decode them in a URL, so `file:///x` is `file:///x` +/// by the time it is opened. An `&` still standing before the path, after +/// that, is a reference this decoder does not know, and is not guessed at. +pub fn is_safe_target(url: &str) -> bool { + let judge = |url: &str| { + let cleaned: String = url + .chars() + .filter(|c| !c.is_whitespace() && !c.is_control()) + .collect(); + let head = &cleaned[..cleaned.find(['/', '?', '#']).unwrap_or(cleaned.len())]; + if head.contains('&') { + return false; + } + match scheme(&cleaned) { + None => !cleaned.contains(':') || cleaned.starts_with(['/', '#', '?', '.']), + Some(s) => matches!(s.to_ascii_lowercase().as_str(), "http" | "https" | "mailto"), + } + }; + judge(&decode_char_refs(url)) +} + +/// Decode the character references a parser would decode in a URL: numeric +/// ones (with or without the `;`, as HTML allows) and the few named ones that +/// spell URL syntax or whitespace. Anything else is left as written. +fn decode_char_refs(s: &str) -> String { + const NAMED: [(&str, char); 10] = [ + ("colon", ':'), + ("Tab", '\t'), + ("NewLine", '\n'), + ("sol", '/'), + ("quest", '?'), + ("num", '#'), + ("period", '.'), + ("amp", '&'), + ("lpar", '('), + ("rpar", ')'), + ]; + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find('&') { + out.push_str(&rest[..at]); + let tail = &rest[at + 1..]; + if let Some(num) = tail.strip_prefix('#') { + let (hex, digits) = match num.strip_prefix(['x', 'X']) { + Some(h) => (true, h), + None => (false, num), + }; + let n = digits + .find(|c: char| { + !(if hex { + c.is_ascii_hexdigit() + } else { + c.is_ascii_digit() + }) + }) + .unwrap_or(digits.len()); + if n > 0 { + let ch = u32::from_str_radix(&digits[..n], if hex { 16 } else { 10 }) + .ok() + .and_then(char::from_u32) + .unwrap_or('\u{FFFD}'); + out.push(ch); + let mut used = 1 + usize::from(hex) + n; + if tail[used..].starts_with(';') { + used += 1; + } + rest = &tail[used..]; + continue; + } + } else { + let n = tail + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(tail.len()); + if tail[n..].starts_with(';') + && let Some((_, ch)) = NAMED.iter().find(|(name, _)| *name == &tail[..n]) + { + out.push(*ch); + rest = &tail[n + 1..]; + continue; + } + } + out.push('&'); + rest = tail; + } + out.push_str(rest); + out +} + +/// `url` percent-encoded where Markdown would read it as syntax — the link's +/// own end, a nested image or link, a tag, an escape, a code span — for +/// writing as an inline link's destination. +fn escape_destination(url: &str) -> String { + let mut out = String::with_capacity(url.len()); + for c in url.chars() { + match c { + ' ' | '(' | ')' | '<' | '>' | '[' | ']' | '\\' | '`' | '"' | '\'' => { + out.push_str(&format!("%{:02X}", c as u32)); + } + c if c.is_whitespace() || c.is_control() => { + let mut buf = [0u8; 4]; + for b in c.encode_utf8(&mut buf).bytes() { + out.push_str(&format!("%{b:02X}")); + } + } + c => out.push(c), + } + } + out +} + +/// [`escape_destination`]'s lighter cousin for a destination copied from the +/// source as written: only what could open markup of its own is encoded, so +/// a URL with balanced parentheses (`…/Foo_(bar)`) still reads the same. +fn neutralize_markup(url: &str) -> String { + let mut out = String::with_capacity(url.len()); + for c in url.chars() { + match c { + '<' | '>' | '[' | ']' => out.push_str(&format!("%{:02X}", c as u32)), + c => out.push(c), + } + } + out +} + +/// Whether a fragment of raw HTML, as the Markdown parser hands it over, is +/// one [`sanitize`] could have produced: no `` (every one is rewritten +/// into Markdown, and the HTML parser reads `` as ``), and every +/// `href`/`src` a target [`is_safe_target`] allows. +/// +/// For checking what the parser actually saw, after the fact — the rewrite is +/// line-based, and a construct it reads differently from the parser (a code +/// span that is really inside a tag, a fence the parser does not accept) +/// would otherwise slip through. +pub fn html_is_safe(html: &str) -> bool { + let lower = html.to_ascii_lowercase(); + if lower.contains(" value[1..].split(q).next().unwrap_or(""), + _ => value + .split(|c: char| c.is_whitespace() || c == '>') + .next() + .unwrap_or(""), + }; + if !is_safe_target(value) { + return false; + } + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(src: &str) -> String { + sanitize(src, "image") + } + + #[test] + fn github_hosted_images_stay_images() { + let pasted = "https://github.com/user-attachments/assets/352d14e0-d11c"; + // Each in a paragraph of its own, so it is drawn at its size. + assert_eq!( + s(&format!("![shot]({pasted})")), + format!("\n\n![shot]({pasted})\n\n") + ); + assert_eq!( + s(&format!("a ![](<{pasted}> \"t\") b")), + format!("a \n\n![]({pasted})\n\n b") + ); + assert_eq!( + s( + r#"Screenshot"# + ), + "\n\n![Screenshot](https://user-images.githubusercontent.com/1/a.png)\n\n" + ); + assert_eq!( + s(&format!(r#""#)), + format!("\n\n![image]({pasted})\n\n") + ); + // In a table row the image stays in its cell. + assert_eq!( + s(&format!("| ![x]({pasted}) |")), + format!("| ![x]({pasted}) |") + ); + } + + #[test] + fn only_github_itself_counts_as_github_hosted() { + for yes in [ + "https://github.com/user-attachments/assets/x", + "https://github.com/owner/repo/assets/1/x", + "https://private-user-images.githubusercontent.com/1/x.png?jwt=y", + "https://camo.githubusercontent.com/abc", + "https://RAW.githubusercontent.com/o/r/main/a.png", + ] { + assert!(is_github_hosted(yes), "{yes}"); + } + for no in [ + "http://github.com/user-attachments/assets/x", + "https://github.com/owner/repo", + "https://github.com.evil.io/user-attachments/assets/x", + "https://evil.io/x.githubusercontent.com/a.png", + "https://githubusercontent.com.evil.io/a.png", + "https://github.com@evil.io/user-attachments/assets/x", + "https://x.io/a.png", + "", + ] { + assert!(!is_github_hosted(no), "{no}"); + } + } + + #[test] + fn images_become_links_to_the_same_target() { + assert_eq!( + s("see ![shot](https://x.io/a.png) here"), + "see [shot](https://x.io/a.png) here" + ); + assert_eq!(s("![](https://x.io/a.png)"), "[image](https://x.io/a.png)"); + assert_eq!(s("![ref style][1]"), "[ref style][1]"); + // An escaped bang is text, and stays text. + assert_eq!(s(r"\![not](x)"), r"\![not](x)"); + } + + #[test] + fn html_images_become_links_too() { + assert_eq!( + s(r#"Screenshot"#), + "[Screenshot](https://x.io/u/a.png)" + ); + assert_eq!( + s(""), + "[image](https://x.io/b.png)" + ); + assert_eq!(s(r#""#), "[image](#)"); + } + + #[test] + fn links_to_anything_but_the_web_are_disarmed() { + assert_eq!(s("[ok](https://github.com)"), "[ok](https://github.com)"); + assert_eq!(s("[mail](mailto:a@b.c)"), "[mail](mailto:a@b.c)"); + assert_eq!(s("[rel](docs/a.md)"), "[rel](docs/a.md)"); + assert_eq!(s("[frag](#heading)"), "[frag](#heading)"); + assert_eq!(s("[x](file:///Applications/Calc.app)"), "[x](#)"); + assert_eq!(s("[x](javascript:alert(1))"), "[x](#))"); + assert_eq!(s("[x]( \"t\")"), "[x](# \"t\")"); + assert_eq!(s("[x]( ssh://host )"), "[x]( # )"); + assert_eq!(s("x"), "x"); + assert_eq!( + s("x"), + "x" + ); + assert_eq!(s(""), "\\"); + assert_eq!(s(""), ""); + assert_eq!(s("[r]: file:///x \"t\"\n"), "[r]: # \"t\"\n"); + assert_eq!(s("[r]: https://ok.io\n"), "[r]: https://ok.io\n"); + } + + #[test] + fn code_is_left_exactly_as_written() { + let fenced = "```md\n![x](http://a/b.png)\n\n```\n![y](http://c/d.png)\n"; + assert_eq!( + s(fenced), + "```md\n![x](http://a/b.png)\n\n```\n[y](http://c/d.png)\n" + ); + assert_eq!( + s("use `![a](b)` for images, ![c](http://d)"), + "use `![a](b)` for images, [c](http://d)" + ); + assert_eq!( + s("~~~~\n[x](file:///y)\n~~~~\n"), + "~~~~\n[x](file:///y)\n~~~~\n" + ); + } + + #[test] + fn prose_with_angle_brackets_is_untouched() { + assert_eq!(s("a < b and c > d"), "a < b and c > d"); + assert_eq!(s("Vec is fine"), "Vec is fine"); + assert_eq!(s("unicode ✓ → ok"), "unicode ✓ → ok"); + } + + #[test] + fn safe_targets_are_judged_after_stripping_whitespace() { + assert!(!is_safe_target(" jav\tascript:alert(1)")); + assert!(!is_safe_target("FILE:///x")); + assert!(is_safe_target("HTTPS://x.io")); + assert!(is_safe_target("./a:b")); + assert!(is_safe_target("/abs/path")); + } + + #[test] + fn a_host_is_what_a_url_parser_would_call_the_host() { + for no in [ + "https://evil.io?.githubusercontent.com/a.png", + "https://evil.io#.githubusercontent.com/a.png", + "https://evil.io\\.githubusercontent.com/a.png", + "https://evil.io/.githubusercontent.com/a.png", + "https://evil.io%2F.githubusercontent.com/a.png", + "https://x.githubusercontent.com:8443/a.png", + ] { + assert!(!is_github_hosted(no), "{no}"); + } + assert!(is_github_hosted( + "https://camo.githubusercontent.com/abc?x=1#y" + )); + } + + #[test] + fn an_image_url_cannot_smuggle_a_second_image() { + // `)` in an attribute value would close the Markdown image early and + // open a new one from the rest of the value. + let out = s(r#""#); + assert!(!out.contains("![](https://evil"), "{out}"); + assert!(out.contains("/a%29!%5B%5D%28https://evil.io"), "{out}"); + // A kept link target cannot hide an image in itself either. + let out = s("[x](https://ok.io/![b](https://evil.io/p.png) \"open title"); + assert!(!out.contains("![b]"), "{out}"); + } + + #[test] + fn the_html_parsers_image_alias_is_an_image_too() { + assert_eq!( + s(r#""#), + "[image](https://x.io/a.png)" + ); + assert_eq!( + s(""), + "[image](https://x.io/a.png)" + ); + } + + #[test] + fn character_references_do_not_disguise_a_scheme() { + for no in [ + "file:///System/Applications/Calculator.app", + "file:///x", + "file:///x", + "file:///x", + "jav ascript:alert(1)", + "file:///x", + "&unknown;:x", + ] { + assert!(!is_safe_target(no), "{no}"); + } + assert!(is_safe_target("https://x.io/?a=1&b=2")); + assert!(is_safe_target("https://x.io/a&b")); + assert_eq!( + s("[x](file:///System/Applications/Calculator.app)"), + "[x](#)" + ); + assert_eq!( + s(r#"x"#), + r##"x"## + ); + } + + #[test] + fn html_left_standing_is_checked_as_the_parser_sees_it() { + assert!(html_is_safe(r#""#)); + assert!(html_is_safe("
x")); + assert!(!html_is_safe(r#""#)); + assert!(!html_is_safe(r#""#)); + assert!(!html_is_safe(r#""#)); + assert!(!html_is_safe(r#""#)); + assert!(!html_is_safe(r#""#)); + } + + #[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/mod.rs b/crates/tty7-core/src/core/github/mod.rs new file mode 100644 index 00000000..9062eeb6 --- /dev/null +++ b/crates/tty7-core/src/core/github/mod.rs @@ -0,0 +1,20 @@ +//! Read-only GitHub: which repository a working tree belongs to, whose token +//! to read it with, and the handful of REST calls the right panel's GitHub tab +//! makes. +//! +//! Framework-free like the rest of this crate. The HTTPS half ([`http`]) is +//! behind the `github` feature, which only the GUI turns on — the headless +//! server never talks to GitHub. + +pub mod api; +#[cfg(feature = "github")] +pub mod http; +pub mod markdown; +pub mod model; +pub mod remote; +pub mod token; + +pub use api::{ApiError, ListPage, ListQuery, Reply, Transport}; +pub use model::{Comment, Detail, Item, ItemState, Kind, Label, PrFile, PullInfo, StateFilter}; +pub use remote::{GitHubRemote, RepoSlug}; +pub use token::{Token, TokenSource}; diff --git a/crates/tty7-core/src/core/github/model.rs b/crates/tty7-core/src/core/github/model.rs new file mode 100644 index 00000000..43bd0b4a --- /dev/null +++ b/crates/tty7-core/src/core/github/model.rs @@ -0,0 +1,534 @@ +//! What the panel shows, decoded out of GitHub's REST JSON. +//! +//! The wire structs (`Raw*`) mirror only the fields read; everything else in +//! GitHub's (large) payloads is ignored, so a field GitHub adds or drops later +//! cannot break decoding. The public types are what the UI draws from, already +//! reduced to the decisions it needs — one [`ItemState`] rather than the three +//! fields (`state`, `state_reason`, `merged_at`/`draft`) GitHub spreads it over. + +use serde::Deserialize; + +use crate::core::git::diff::{DiffBudget, DiffParser, FileDiff, FileStatus}; + +/// Issues or pull requests. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum Kind { + #[default] + Issues, + Pulls, +} + +/// The list's open/closed switch. "Closed" includes merged pull requests, as +/// it does on GitHub. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum StateFilter { + #[default] + Open, + Closed, +} + +impl StateFilter { + pub fn as_query(self) -> &'static str { + match self { + StateFilter::Open => "open", + StateFilter::Closed => "closed", + } + } +} + +/// Where an issue or a pull request stands. Each one gets its own glyph +/// *shape*, not only its own colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ItemState { + Open, + /// A pull request marked as a draft (and still open). + Draft, + /// An issue closed as completed, or a pull request closed unmerged. + Closed, + /// An issue closed as "not planned" / duplicate. + NotPlanned, + Merged, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Label { + pub name: String, + /// `0xRRGGBB`, when GitHub sent a usable colour. + pub color: Option, +} + +/// One row of the list. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub number: u64, + pub title: String, + pub state: ItemState, + pub is_pr: bool, + pub author: String, + pub labels: Vec