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 `` 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\n\n"),
+ false => format!(""),
+ };
+ 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 `` stays an image.
+ if rest.starts_with("` / `![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
+}
+
+/// `` or `` 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!("")),
+ format!("\n\n\n\n")
+ );
+ assert_eq!(
+ s(&format!("a  b")),
+ format!("a \n\n\n\n b")
+ );
+ assert_eq!(
+ s(
+ r#""#
+ ),
+ "\n\n\n\n"
+ );
+ assert_eq!(
+ s(&format!(r#""#)),
+ format!("\n\n\n\n")
+ );
+ // In a table row the image stays in its cell.
+ assert_eq!(
+ s(&format!("|  |")),
+ format!("|  |")
+ );
+ }
+
+ #[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  here"),
+ "see [shot](https://x.io/a.png) here"
+ );
+ assert_eq!(s(""), "[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"\"), r"\");
+ }
+
+ #[test]
+ fn html_images_become_links_too() {
+ assert_eq!(
+ s(r#""#),
+ "[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\n\n```\n\n";
+ assert_eq!(
+ s(fenced),
+ "```md\n\n\n```\n[y](http://c/d.png)\n"
+ );
+ assert_eq!(
+ s("use `` for images, "),
+ "use `` 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(", "{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/ \"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 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#""#);
+ 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