From fd2c4f7d4e762a2a4deecc7f39af6413569f4dc7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:04:42 +0800 Subject: [PATCH] feat(panel): Search and GitHub tabs in the right panel (#978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(panel): add Search and GitHub tabs to the right panel The right panel grows from three tabs to five. Five word labels do not fit the panel's 280px resting width, so the tab row now draws a glyph per tab and names it in a tooltip. Both new panes are placeholders here; the content search and the GitHub issues/PR browser land on top of this. * feat(panel): find in files in the right panel's Search tab The Search tab replaces its placeholder with a content search over the active tab's project -- the same roots the Files tab shows -- on the host that project lives on. Hits arrive as you type (debounced, with a generation counter so a stale answer never lands), grouped by file with a count, each line excerpted with its matches highlighted. Clicking a hit opens the built-in editor at that line and column; Enter searches again. Match-case, whole-word and regex toggles sit at the end of the field, and the tab focuses its field whenever it is brought forward. Host::search_content is new on the Host trait, implemented once in host::content_search (ignore walk + regex) and run by LocalHost directly and by tty7-server over a new SearchContent control request. The walk honours .gitignore with or without a repository, skips dot-entries, binary files and files over 1 MB, and reports a capped search as truncated. The request is gated on a new `content-search` hello feature, so a server that predates it is never sent it; the panel says the server needs updating instead of showing no results. Conformance cases cover local and the stdio server alike. * feat(panel): browse GitHub issues and pull requests in the right panel The GitHub tab follows the focused pane's repository: its root is resolved the way the Source Control tab does, its remotes are read through the Host (the tree may be on another machine), and the github.com remote is bound, upstream over origin in a fork, with a menu to pick another. The list switches between issues and pull requests, open and closed, 50 rows a page with Load more; rows carry a state glyph distinct by shape, labels (click one to filter by it) and relative times. A row opens the detail in place: title, state, author, labels, description and comments as Markdown, and for a pull request its branches, size and changed files. A file opens in the diff overlay through a new supplied-patch DiffSource, so GitHub's patch renders exactly like a local one without a git probe. Read-only, and sign-in reuses the GitHub CLI: GH_TOKEN, GITHUB_TOKEN, then `gh auth token`, found on PATH or at the Homebrew locations a Finder launch cannot see. Signed out, public repositories still work; 401, 403, 404 and rate limits are told apart and explained. Requests go out from this machine over the installer's ureq stack and proxy settings, on threads of their own, cached per repository with background revalidation. Remote images in issue text become links instead of loading, and non-web link targets are disarmed. The Info tab gains a GitHub row that opens the branch on the remote it tracks, or the repository for a branch never pushed. * docs: list ShowRightPanelGitHub with the other panel actions * feat(panel): one-line GitHub rows, a pill for the current tab - GitHub list rows are one line: state glyph, #number, title. Labels and the age of the last update appear on hover, from state rather than a group_hover display switch, which gpui cannot paint. - The current right panel tab sits on the sidebar's selected fill; ink alone could not tell five same-weight glyphs apart. - The GitHub glyph is a 1.8px outline like the other tab icons, not the filled mark. - The detail byline names both times (opened / updated) so it no longer reads as disagreeing with the list's update age. * feat(github): show screenshots pasted into issues Images GitHub hosts itself (github.com/user-attachments, a repo's /assets, *.githubusercontent.com) now render in issue and PR text, each in a paragraph of its own so the text view draws it at its size rather than at line height. Images from any other host stay links, so opening an issue still tells no third party that you read it. gpui held a null HTTP client, so no remote image could load; the app now installs the update check's reqwest client (same user agent and proxy) at launch. * fix(github): load private-repo screenshots, give inline code a neutral fill - Pasted attachments (github.com/user-attachments/assets/) want a browser session on a private repository, which an API token is not. The detail and comment requests now ask for the full media type, and each attachment is swapped for the signed private-user-images URL the rendered body_html carries for the same uuid. - Inline code in rendered Markdown (the GitHub tab and the editor's preview) sits on a faint neutral fill instead of the theme accent, which is also the selection colour. Needs gpui-component 6af19d91 for TextViewStyle::inline_code_background. * style(panel): tidy the GitHub and Search tabs' top rows - GitHub drops its heading row on macOS. It existed only to hold the refresh tile, and no other tab has one; refresh now sits with the repository's other actions, in the repo row and a detail's header. - Search's Aa / ab / .* toggles are muted while off instead of body ink. - Search's idle note puts the folder on its own line, spelled ~/…, so the narrow column no longer breaks the path at a slash. * feat(panel): order the right panel's tabs Info, Files, Search, Changes, GitHub Info stays first as the default and the pane's overview; after it come two pairs, the project's files (Files, Search) and its version control from local to remote (Changes, GitHub), where Changes and GitHub were split by the file tabs before. The palette, the Keybindings list and the docs follow the same order. * style(panel): drop the change count from the Changes tab Beside one glyph of five, the number read as a badge on that tab alone, and the Changes tab already leads with the same count under its own heading. right_panel_tabs no longer needs the row's width, which it only measured to decide whether the count fit. * style(icons): fit the GitHub glyph to the other tab icons' size The Lucide mark filled its whole 24px box, edge to edge, where tty7's own icons keep about 3.5px clear, so at 15px it drew a size larger than the four tabs beside it. Scale it to 0.9 about the centre, and raise the stroke to 2.0 so it still renders at the others' 1.8. * style(icons): a simpler GitHub glyph Drop the Lucide mark's tail and redraw the head and legs on tty7's own grid: the same ~15px live area and 1.8 stroke as the other tab icons, no scale transform. The legs keep it reading as the Octocat; a head alone read as any cat. * test(github): find gh on PATH in the blank-variable token test The test placed gh only at /opt/homebrew/bin/gh, which gh_candidates never offers on Windows, so the Windows CI job panicked at unwrap. Put gh on a PATH directory spelled with the platform's exe name instead. * fix(github): close image and link bypasses in the issue Markdown sanitiser Checked against markdown-rs (the parser TextView uses), several inputs got past the line-based rewrite: - is_github_hosted cut the host only at `/`, so `https://evil.io?.githubusercontent.com/x.png` (and `#`, `\`, `/`) counted as GitHub-hosted and was fetched from evil.io. The host now ends at the first of `/?#\` and may hold only DNS characters. - `` values were written into `![..](..)` unescaped, so a `)` in the value closed the image and opened a second one from any host. Written destinations are now percent-encoded. - `` (which the HTML parser reads as ``) passed as an ordinary tag and loaded its src. - A kept link target was copied without scanning; when the parser ended the link elsewhere (open title, unbalanced paren) a `![..](..)` inside it came alive. Markup characters in it are now encoded. - `file:///...` and similar character references passed is_safe_target and decoded to a `file:` link. References are decoded before judging. The rewrite still cannot see every construct the way the parser does (code spans inside tag attributes, fences the parser rejects, multi-line link definitions), so the detail view now also checks the parsed tree: a block containing a non-GitHub image, an unsafe link or definition, or raw `` is drawn as its plain source instead. * fix(github): hide gh's console, bound Retry-After, and reject URL authorities with ?#\ - run gh through proc::output_within with hide_console, so a Windows GUI launch does not flash a console window and stdout is drained while gh runs. - saturating_add a hostile Retry-After instead of overflowing i64. - parse_github_url no longer accepts `https://evil.io#@github.com/o/r`. * fix(search): no panic on an unbounded time budget, and read files through the size cap ContentLimits arrive off the wire on a server; Instant + u64::MAX ms panicked. A file that grew between the size check and the read was read whole; it is now read through a take() at the cap. * fix(panel): keep Load more on an empty filtered page, and drop another host's hits - /issues pages filtered to one kind can come back empty while later pages hold matches; the GitHub list said "No issues" and hid Load more. It now reads on through up to five such pages and keeps Load more offered. - While a new search runs, the previous hits stay on screen; if they came from another host, a click opened their path on the active host. They are now kept only when the host is the same. --- CHANGELOG.md | 29 + Cargo.lock | 7 +- Cargo.toml | 5 +- assets/icons/github.svg | 1 + assets/icons/github/issue-closed.svg | 1 + assets/icons/github/issue-not-planned.svg | 1 + assets/icons/github/issue-open.svg | 1 + assets/icons/github/pr-closed.svg | 1 + assets/icons/github/pr-draft.svg | 1 + assets/icons/github/pr-merged.svg | 1 + assets/icons/github/pr-open.svg | 1 + crates/tty7-core/Cargo.toml | 12 + crates/tty7-core/src/core/agent_hooks.rs | 8 + crates/tty7-core/src/core/config.rs | 13 +- crates/tty7-core/src/core/git/diff.rs | 18 + crates/tty7-core/src/core/git/log.rs | 2 +- crates/tty7-core/src/core/github/api.rs | 639 ++++++++++++ crates/tty7-core/src/core/github/http.rs | 97 ++ crates/tty7-core/src/core/github/markdown.rs | 848 ++++++++++++++++ crates/tty7-core/src/core/github/mod.rs | 20 + crates/tty7-core/src/core/github/model.rs | 534 ++++++++++ crates/tty7-core/src/core/github/remote.rs | 401 ++++++++ crates/tty7-core/src/core/github/token.rs | 283 ++++++ crates/tty7-core/src/core/mod.rs | 1 + crates/tty7-core/src/daemon/control.rs | 49 +- crates/tty7-core/src/host/conformance.rs | 208 +++- crates/tty7-core/src/host/content_search.rs | 503 ++++++++++ crates/tty7-core/src/host/local.rs | 14 +- crates/tty7-core/src/host/mod.rs | 107 ++ crates/tty7-core/src/host/remote.rs | 121 ++- crates/tty7-core/src/host/server.rs | 37 + crates/tty7-server/tests/stdio_conformance.rs | 2 +- docs/reference/keyboard-shortcuts.mdx | 2 +- docs/reference/privacy.mdx | 11 +- docs/window/search-everywhere.mdx | 2 +- docs/window/side-panel.mdx | 105 +- src/core/actions.rs | 2 + src/core/update.rs | 14 + src/main.rs | 1 + src/ui/app.rs | 14 + src/ui/assets.rs | 13 + src/ui/code_editor.rs | 11 +- src/ui/diff_overlay.rs | 73 +- src/ui/file_tree.rs | 55 +- src/ui/github/detail.rs | 609 ++++++++++++ src/ui/github/mod.rs | 526 ++++++++++ src/ui/i18n/en.rs | 86 ++ src/ui/i18n/ja.rs | 90 ++ src/ui/i18n/mod.rs | 64 ++ src/ui/i18n/zh.rs | 74 ++ src/ui/keymap.rs | 16 +- src/ui/mod.rs | 3 + src/ui/panel_github.rs | 930 ++++++++++++++++++ src/ui/panel_search.rs | 877 +++++++++++++++++ src/ui/panel_search/model.rs | 405 ++++++++ src/ui/right_panel.rs | 80 +- src/ui/scm/panel.rs | 6 +- src/ui/search/command.rs | 18 +- src/ui/sftp_host.rs | 9 + src/ui/tab_strip.rs | 166 ++-- src/ui/theme.rs | 10 + 61 files changed, 8049 insertions(+), 189 deletions(-) create mode 100644 assets/icons/github.svg create mode 100644 assets/icons/github/issue-closed.svg create mode 100644 assets/icons/github/issue-not-planned.svg create mode 100644 assets/icons/github/issue-open.svg create mode 100644 assets/icons/github/pr-closed.svg create mode 100644 assets/icons/github/pr-draft.svg create mode 100644 assets/icons/github/pr-merged.svg create mode 100644 assets/icons/github/pr-open.svg create mode 100644 crates/tty7-core/src/core/github/api.rs create mode 100644 crates/tty7-core/src/core/github/http.rs create mode 100644 crates/tty7-core/src/core/github/markdown.rs create mode 100644 crates/tty7-core/src/core/github/mod.rs create mode 100644 crates/tty7-core/src/core/github/model.rs create mode 100644 crates/tty7-core/src/core/github/remote.rs create mode 100644 crates/tty7-core/src/core/github/token.rs create mode 100644 crates/tty7-core/src/host/content_search.rs create mode 100644 src/ui/github/detail.rs create mode 100644 src/ui/github/mod.rs create mode 100644 src/ui/panel_github.rs create mode 100644 src/ui/panel_search.rs create mode 100644 src/ui/panel_search/model.rs 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