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.
@@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **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`. Remote images in issue text 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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8.2 12.4l2.6 2.6 5-5.2"/></svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M6.6 17.4L17.4 6.6"/></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5.5" r="2.3"/><circle cx="6" cy="18.5" r="2.3"/><circle cx="18" cy="18.5" r="2.3"/><path d="M6 7.8v8.4"/><path d="M18 16.2v-5"/><path d="M15.6 3.6l4.8 4.8M20.4 3.6l-4.8 4.8"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5.5" r="2.3"/><circle cx="6" cy="18.5" r="2.3"/><circle cx="18" cy="18.5" r="2.3"/><path d="M6 7.8v8.4"/><path d="M18 5.5v.01M18 10.5v.01" stroke-width="2.6"/></svg>
|
||||
|
After Width: | Height: | Size: 368 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5.5" r="2.3"/><circle cx="6" cy="18.5" r="2.3"/><circle cx="18" cy="13" r="2.3"/><path d="M6 7.8v8.4"/><path d="M6 7.8a5.2 5.2 0 0 0 5.2 5.2h4.5"/></svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5.5" r="2.3"/><circle cx="6" cy="18.5" r="2.3"/><circle cx="18" cy="18.5" r="2.3"/><path d="M6 7.8v8.4"/><path d="M18 16.2V9.5a3 3 0 0 0-3-3h-4.5"/><path d="M12.8 4l-2.5 2.5 2.5 2.5"/></svg>
|
||||
|
After Width: | Height: | Size: 393 B |
@@ -222,6 +222,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
|
||||
|
||||
@@ -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<CommitLabel>,
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,7 +1030,7 @@ fn rev(host: &dyn Host, root: &Path, spec: &str) -> Option<String> {
|
||||
/// 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<OffsetTs> {
|
||||
pub(crate) fn parse_iso8601(text: &str) -> Option<OffsetTs> {
|
||||
let b = text.as_bytes();
|
||||
if !text.is_ascii() || b.len() < 19 {
|
||||
return None;
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
//! 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<i64> },
|
||||
/// 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<u8>,
|
||||
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<Reply, ApiError>;
|
||||
/// 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<String>,
|
||||
body: &[u8],
|
||||
) -> Result<(), ApiError> {
|
||||
if (200..300).contains(&status) {
|
||||
return Ok(());
|
||||
}
|
||||
let message = || {
|
||||
serde_json::from_slice::<serde_json::Value>(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::<i64>().ok())
|
||||
.or_else(|| {
|
||||
let after = header("retry-after")?.trim().parse::<i64>().ok()?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs() as i64;
|
||||
Some(now + 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Item>,
|
||||
pub next_page: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<T: DeserializeOwned>(reply: &Reply) -> Result<T, ApiError> {
|
||||
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<ListPage, ApiError> {
|
||||
let page = page.max(1);
|
||||
let (path, endpoint) = list_request(q, page);
|
||||
let reply = t.get(&path)?;
|
||||
let items = match endpoint {
|
||||
Endpoint::Pulls => decode::<Vec<RawPull>>(&reply)?
|
||||
.into_iter()
|
||||
.map(|p| p.into_item().0)
|
||||
.collect(),
|
||||
Endpoint::Issues { want_prs } => decode::<Vec<RawIssue>>(&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<Detail, ApiError> {
|
||||
let base = repo_path(slug);
|
||||
let issue: RawIssue = decode(&t.get(&format!("{base}/issues/{number}"))?)?;
|
||||
let is_pr = issue.is_pr();
|
||||
let body = issue.body.clone().unwrap_or_default();
|
||||
let mut item = issue.into_item();
|
||||
|
||||
let reply = t.get(&format!(
|
||||
"{base}/issues/{number}/comments?per_page={DETAIL_PAGE}"
|
||||
))?;
|
||||
let comments: Vec<Comment> = decode::<Vec<RawComment>>(&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::<RawPull>(&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::<Vec<RawFile>>(&reply)?
|
||||
.into_iter()
|
||||
.map(RawFile::into_file)
|
||||
.collect::<Vec<PrFile>>(),
|
||||
);
|
||||
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<String, Result<Reply, ApiError>>,
|
||||
pub(crate) asked: Mutex<Vec<String>>,
|
||||
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<Reply, ApiError> {
|
||||
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<u64> = 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<ItemState> = 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<u64> = 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<String> {
|
||||
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#"<https://api.github.com/x?page=2>; rel="next", <https://api.github.com/x?page=9>; rel="last""#
|
||||
));
|
||||
assert!(!link_has_next(
|
||||
r#"<https://api.github.com/x?page=1>; rel="prev", <https://api.github.com/x?page=1>; rel="first""#
|
||||
));
|
||||
assert!(!link_has_next(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! [`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<Token>,
|
||||
}
|
||||
|
||||
impl HttpTransport {
|
||||
/// `manual_proxy` is the `http_proxy` setting, as for tty7's other
|
||||
/// downloads.
|
||||
pub fn new(token: Option<Token>, 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<Reply, ApiError> {
|
||||
let mut request = self
|
||||
.agent
|
||||
.get(format!("{API}{path}"))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.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,
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticated(&self) -> bool {
|
||||
self.token.is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! 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 `<img src>` is fetched the
|
||||
//! moment it is drawn, which tells whoever controls that URL that this
|
||||
//! issue was opened, from which IP, and when. Every image becomes a plain
|
||||
//! link instead: the reader can still open it, deliberately, in the browser.
|
||||
//! - **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;
|
||||
}
|
||||
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));
|
||||
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));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reference_definition(line: &str) -> Option<String> {
|
||||
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.
|
||||
fn sanitize_text(text: &str, image_label: &str) -> String {
|
||||
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..];
|
||||
// `` / `![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) {
|
||||
out.push_str(&body[..len.min(body.len())]);
|
||||
} else {
|
||||
out.push('#');
|
||||
}
|
||||
i += lead + len.min(body.len());
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with('<') {
|
||||
// `<img …>` → a link to what it would have loaded.
|
||||
if starts_with_tag(rest, "img") {
|
||||
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_safe_target(&src) && !src.is_empty() {
|
||||
out.push_str(&format!("[{label}]({})", src.replace(' ', "%20")));
|
||||
} else {
|
||||
out.push_str(&format!("[{label}](#)"));
|
||||
}
|
||||
i += end;
|
||||
continue;
|
||||
}
|
||||
// `<scheme:…>` 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<usize> {
|
||||
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<String> {
|
||||
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());
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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:`.
|
||||
pub fn is_safe_target(url: &str) -> bool {
|
||||
let cleaned: String = url
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && !c.is_control())
|
||||
.collect();
|
||||
match scheme(&cleaned) {
|
||||
None => !cleaned.contains(':') || cleaned.starts_with(['/', '#', '?', '.']),
|
||||
Some(s) => matches!(s.to_ascii_lowercase().as_str(), "http" | "https" | "mailto"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn s(src: &str) -> String {
|
||||
sanitize(src, "image")
|
||||
}
|
||||
|
||||
#[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#"<img width="300" alt="Screenshot" src="https://github.com/u/a.png">"#),
|
||||
"[Screenshot](https://github.com/u/a.png)"
|
||||
);
|
||||
assert_eq!(
|
||||
s("<IMG SRC='https://x.io/b.png' />"),
|
||||
"[image](https://x.io/b.png)"
|
||||
);
|
||||
assert_eq!(s(r#"<img src="file:///etc/passwd">"#), "[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](<vscode://open?x=1> \"t\")"), "[x](# \"t\")");
|
||||
assert_eq!(s("[x]( ssh://host )"), "[x]( # )");
|
||||
assert_eq!(s("<a href=\"file:///x\">x</a>"), "<a href=\"#\">x</a>");
|
||||
assert_eq!(
|
||||
s("<a href=\"https://ok.io\">x</a>"),
|
||||
"<a href=\"https://ok.io\">x</a>"
|
||||
);
|
||||
assert_eq!(s("<smb://share/x>"), "\\<smb://share/x>");
|
||||
assert_eq!(s("<https://ok.io>"), "<https://ok.io>");
|
||||
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<img src=\"x\">\n```\n\n";
|
||||
assert_eq!(
|
||||
s(fenced),
|
||||
"```md\n\n<img src=\"x\">\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<String> is fine"), "Vec<String> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -0,0 +1,525 @@
|
||||
//! 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<u32>,
|
||||
}
|
||||
|
||||
/// 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<Label>,
|
||||
pub comments: u32,
|
||||
/// Unix seconds; zero when GitHub sent something unparseable.
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Comment {
|
||||
pub id: u64,
|
||||
pub author: String,
|
||||
pub body: String,
|
||||
pub created_at: i64,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
/// The pull-request-only half of a detail view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct PullInfo {
|
||||
pub head_ref: String,
|
||||
pub base_ref: String,
|
||||
pub additions: u32,
|
||||
pub deletions: u32,
|
||||
pub changed_files: u32,
|
||||
pub commits: u32,
|
||||
}
|
||||
|
||||
/// One file a pull request touches, from `/pulls/{n}/files`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PrFile {
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
pub status: FileStatus,
|
||||
pub additions: u32,
|
||||
pub deletions: u32,
|
||||
/// The unified hunks, without a `diff --git` header. GitHub leaves it out
|
||||
/// for binary files and for patches too large to inline.
|
||||
pub patch: Option<String>,
|
||||
}
|
||||
|
||||
impl PrFile {
|
||||
/// This file as the diff overlay's own model, so the overlay can show a
|
||||
/// pull request's patch exactly the way it shows a local one.
|
||||
///
|
||||
/// The counts come from GitHub rather than from the parsed hunks: a file
|
||||
/// GitHub did not inline a patch for still has real `+N −M`.
|
||||
pub fn to_file_diff(&self, budget: DiffBudget) -> FileDiff {
|
||||
let mut parser = DiffParser::with_budget(budget);
|
||||
// A placeholder header opens the file; the real paths are set below,
|
||||
// so a path with spaces never has to survive header splitting.
|
||||
parser.push_line("diff --git a/f b/f");
|
||||
if let Some(patch) = &self.patch {
|
||||
for line in patch.lines() {
|
||||
parser.push_line(line);
|
||||
}
|
||||
}
|
||||
let mut file = parser.finish().into_iter().next().unwrap_or(FileDiff {
|
||||
path: String::new(),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
binary: false,
|
||||
truncated: None,
|
||||
hunks: Vec::new(),
|
||||
});
|
||||
file.path = self.path.clone();
|
||||
file.old_path = self.old_path.clone();
|
||||
file.status = self.status;
|
||||
file.added = self.additions;
|
||||
file.removed = self.deletions;
|
||||
// No patch but real changes is GitHub declining to inline one — a
|
||||
// binary file or an oversized text one. The overlay's binary card
|
||||
// ("no preview") is the honest thing to show for both.
|
||||
file.binary = self.patch.is_none() && (self.additions > 0 || self.deletions > 0);
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the detail view needs about one issue or pull request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Detail {
|
||||
pub item: Item,
|
||||
pub body: String,
|
||||
pub comments: Vec<Comment>,
|
||||
/// More comments exist than were fetched.
|
||||
pub comments_truncated: bool,
|
||||
pub pull: Option<PullInfo>,
|
||||
/// `Some` for a pull request.
|
||||
pub files: Option<Vec<PrFile>>,
|
||||
pub files_truncated: bool,
|
||||
}
|
||||
|
||||
// ---- wire ------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawUser {
|
||||
#[serde(default)]
|
||||
login: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawLabel {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawPrRef {
|
||||
#[serde(default)]
|
||||
merged_at: Option<String>,
|
||||
}
|
||||
|
||||
/// An entry of `/issues` — which also lists pull requests, marked by the
|
||||
/// presence of `pull_request`.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawIssue {
|
||||
pub(crate) number: u64,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
state_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
user: Option<RawUser>,
|
||||
#[serde(default)]
|
||||
labels: Vec<RawLabel>,
|
||||
#[serde(default)]
|
||||
comments: u32,
|
||||
#[serde(default)]
|
||||
created_at: String,
|
||||
#[serde(default)]
|
||||
updated_at: String,
|
||||
#[serde(default)]
|
||||
pub(crate) body: Option<String>,
|
||||
#[serde(default)]
|
||||
html_url: String,
|
||||
#[serde(default)]
|
||||
pub(crate) pull_request: Option<RawPrRef>,
|
||||
#[serde(default)]
|
||||
draft: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawBranchRef {
|
||||
#[serde(default, rename = "ref")]
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// An entry of `/pulls`, or `/pulls/{n}`.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawPull {
|
||||
number: u64,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
draft: Option<bool>,
|
||||
#[serde(default)]
|
||||
merged_at: Option<String>,
|
||||
#[serde(default)]
|
||||
user: Option<RawUser>,
|
||||
#[serde(default)]
|
||||
labels: Vec<RawLabel>,
|
||||
#[serde(default)]
|
||||
comments: Option<u32>,
|
||||
#[serde(default)]
|
||||
created_at: String,
|
||||
#[serde(default)]
|
||||
updated_at: String,
|
||||
#[serde(default)]
|
||||
html_url: String,
|
||||
#[serde(default)]
|
||||
head: Option<RawBranchRef>,
|
||||
#[serde(default)]
|
||||
base: Option<RawBranchRef>,
|
||||
#[serde(default)]
|
||||
additions: Option<u32>,
|
||||
#[serde(default)]
|
||||
deletions: Option<u32>,
|
||||
#[serde(default)]
|
||||
changed_files: Option<u32>,
|
||||
#[serde(default)]
|
||||
commits: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawComment {
|
||||
#[serde(default)]
|
||||
id: u64,
|
||||
#[serde(default)]
|
||||
user: Option<RawUser>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: String,
|
||||
#[serde(default)]
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct RawFile {
|
||||
#[serde(default)]
|
||||
filename: String,
|
||||
#[serde(default)]
|
||||
previous_filename: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
additions: u32,
|
||||
#[serde(default)]
|
||||
deletions: u32,
|
||||
#[serde(default)]
|
||||
patch: Option<String>,
|
||||
}
|
||||
|
||||
fn login(user: Option<RawUser>) -> String {
|
||||
user.map(|u| u.login).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn labels(raw: Vec<RawLabel>) -> Vec<Label> {
|
||||
raw.into_iter()
|
||||
.filter(|l| !l.name.is_empty())
|
||||
.map(|l| Label {
|
||||
color: l
|
||||
.color
|
||||
.as_deref()
|
||||
.filter(|c| c.len() == 6)
|
||||
.and_then(|c| u32::from_str_radix(c, 16).ok()),
|
||||
name: l.name,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// GitHub's `2024-05-01T12:34:56Z`, as unix seconds (zero if unparseable).
|
||||
pub fn timestamp(text: &str) -> i64 {
|
||||
crate::core::git::log::parse_iso8601(text).map_or(0, |ts| ts.unix)
|
||||
}
|
||||
|
||||
impl RawIssue {
|
||||
pub(crate) fn is_pr(&self) -> bool {
|
||||
self.pull_request.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn into_item(self) -> Item {
|
||||
let is_pr = self.is_pr();
|
||||
let merged = self
|
||||
.pull_request
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.merged_at.is_some());
|
||||
let state = item_state(
|
||||
&self.state,
|
||||
self.state_reason.as_deref(),
|
||||
merged,
|
||||
self.draft.unwrap_or(false),
|
||||
);
|
||||
Item {
|
||||
number: self.number,
|
||||
title: self.title,
|
||||
state,
|
||||
is_pr,
|
||||
author: login(self.user),
|
||||
labels: labels(self.labels),
|
||||
comments: self.comments,
|
||||
created_at: timestamp(&self.created_at),
|
||||
updated_at: timestamp(&self.updated_at),
|
||||
html_url: self.html_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RawPull {
|
||||
pub(crate) fn into_item(self) -> (Item, PullInfo) {
|
||||
let state = item_state(
|
||||
&self.state,
|
||||
None,
|
||||
self.merged_at.is_some(),
|
||||
self.draft.unwrap_or(false),
|
||||
);
|
||||
let info = PullInfo {
|
||||
head_ref: self.head.map(|h| h.name).unwrap_or_default(),
|
||||
base_ref: self.base.map(|b| b.name).unwrap_or_default(),
|
||||
additions: self.additions.unwrap_or(0),
|
||||
deletions: self.deletions.unwrap_or(0),
|
||||
changed_files: self.changed_files.unwrap_or(0),
|
||||
commits: self.commits.unwrap_or(0),
|
||||
};
|
||||
let item = Item {
|
||||
number: self.number,
|
||||
title: self.title,
|
||||
state,
|
||||
is_pr: true,
|
||||
author: login(self.user),
|
||||
labels: labels(self.labels),
|
||||
comments: self.comments.unwrap_or(0),
|
||||
created_at: timestamp(&self.created_at),
|
||||
updated_at: timestamp(&self.updated_at),
|
||||
html_url: self.html_url,
|
||||
};
|
||||
(item, info)
|
||||
}
|
||||
}
|
||||
|
||||
impl RawComment {
|
||||
pub(crate) fn into_comment(self) -> Comment {
|
||||
Comment {
|
||||
id: self.id,
|
||||
author: login(self.user),
|
||||
body: self.body.unwrap_or_default(),
|
||||
created_at: timestamp(&self.created_at),
|
||||
html_url: self.html_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RawFile {
|
||||
pub(crate) fn into_file(self) -> PrFile {
|
||||
let status = match self.status.as_str() {
|
||||
"added" => FileStatus::Added,
|
||||
"removed" => FileStatus::Deleted,
|
||||
"renamed" => FileStatus::Renamed,
|
||||
"copied" => FileStatus::Copied,
|
||||
// "modified", "changed" (mode only) and "unchanged" all draw as a
|
||||
// modification; none of them is a different kind of row.
|
||||
_ => FileStatus::Modified,
|
||||
};
|
||||
PrFile {
|
||||
path: self.filename,
|
||||
old_path: self.previous_filename,
|
||||
status,
|
||||
additions: self.additions,
|
||||
deletions: self.deletions,
|
||||
patch: self.patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one place GitHub's three state fields become one state.
|
||||
fn item_state(state: &str, reason: Option<&str>, merged: bool, draft: bool) -> ItemState {
|
||||
if merged {
|
||||
return ItemState::Merged;
|
||||
}
|
||||
match state {
|
||||
"closed" => match reason {
|
||||
Some("not_planned") | Some("duplicate") => ItemState::NotPlanned,
|
||||
_ => ItemState::Closed,
|
||||
},
|
||||
_ if draft => ItemState::Draft,
|
||||
_ => ItemState::Open,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn state_folds_three_fields_into_one() {
|
||||
assert_eq!(item_state("open", None, false, false), ItemState::Open);
|
||||
assert_eq!(item_state("open", None, false, true), ItemState::Draft);
|
||||
assert_eq!(item_state("closed", None, false, false), ItemState::Closed);
|
||||
assert_eq!(
|
||||
item_state("closed", Some("completed"), false, false),
|
||||
ItemState::Closed
|
||||
);
|
||||
assert_eq!(
|
||||
item_state("closed", Some("not_planned"), false, false),
|
||||
ItemState::NotPlanned
|
||||
);
|
||||
// Merged wins over "closed", and a closed draft is closed.
|
||||
assert_eq!(item_state("closed", None, true, false), ItemState::Merged);
|
||||
assert_eq!(item_state("closed", None, false, true), ItemState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_colours_parse_and_bad_ones_are_dropped() {
|
||||
let got = labels(vec![
|
||||
RawLabel {
|
||||
name: "bug".into(),
|
||||
color: Some("d73a4a".into()),
|
||||
},
|
||||
RawLabel {
|
||||
name: "weird".into(),
|
||||
color: Some("zzz".into()),
|
||||
},
|
||||
RawLabel {
|
||||
name: String::new(),
|
||||
color: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
Label {
|
||||
name: "bug".into(),
|
||||
color: Some(0xd73a4a)
|
||||
},
|
||||
Label {
|
||||
name: "weird".into(),
|
||||
color: None
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_timestamps_are_unix_seconds() {
|
||||
assert_eq!(timestamp("1970-01-01T00:00:00Z"), 0);
|
||||
assert_eq!(timestamp("2024-02-29T12:00:00Z"), 1_709_208_000);
|
||||
assert_eq!(timestamp("garbage"), 0);
|
||||
}
|
||||
|
||||
fn file(patch: Option<&str>, additions: u32, deletions: u32) -> PrFile {
|
||||
PrFile {
|
||||
path: "src/a file.rs".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
additions,
|
||||
deletions,
|
||||
patch: patch.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_github_patch_becomes_the_overlays_file_model() {
|
||||
let f = file(
|
||||
Some(
|
||||
"@@ -1,3 +1,3 @@\n fn a() {}\n-fn b() {}\n+fn c() {}\n fn d() {}\n\\ No newline at end of file",
|
||||
),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
let diff = f.to_file_diff(DiffBudget::default());
|
||||
assert_eq!(diff.path, "src/a file.rs", "a path with a space survives");
|
||||
assert_eq!((diff.added, diff.removed), (1, 1));
|
||||
assert!(!diff.binary);
|
||||
assert_eq!(diff.hunks.len(), 1);
|
||||
let lines = &diff.hunks[0].lines;
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert_eq!(lines[1].old_no, Some(2));
|
||||
assert_eq!(lines[2].new_no, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_github_would_not_inline_reads_as_binary() {
|
||||
let diff = file(None, 0, 0).to_file_diff(DiffBudget::default());
|
||||
assert!(!diff.binary, "an empty change is not a binary one");
|
||||
let diff = file(None, 120, 4).to_file_diff(DiffBudget::default());
|
||||
assert!(diff.binary);
|
||||
assert_eq!((diff.added, diff.removed), (120, 4));
|
||||
assert!(diff.hunks.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Which GitHub repository a working tree belongs to.
|
||||
//!
|
||||
//! Read off `git remote -v`, which the caller runs through the `Host` — the
|
||||
//! repository can live on another machine, and only its git knows the remotes
|
||||
//! (including any `url.<base>.insteadOf` rewrite, which `remote -v` applies and
|
||||
//! a raw `config --get` would not).
|
||||
//!
|
||||
//! Only `github.com` is recognised. GitHub Enterprise hosts are out of scope:
|
||||
//! they need their own API base and their own token, and guessing either from
|
||||
//! a hostname would send the user's github.com token to a server it was never
|
||||
//! issued for.
|
||||
|
||||
/// `owner/name` on github.com.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct RepoSlug {
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl RepoSlug {
|
||||
/// `owner/name`, the spelling GitHub itself uses in its UI.
|
||||
pub fn full(&self) -> String {
|
||||
format!("{}/{}", self.owner, self.name)
|
||||
}
|
||||
|
||||
/// The repository's page.
|
||||
pub fn web_url(&self) -> String {
|
||||
format!("https://github.com/{}/{}", self.owner, self.name)
|
||||
}
|
||||
|
||||
/// The page for one branch's tree, or the repository page when the branch
|
||||
/// is unknown. Slashes in a branch name stay slashes — GitHub resolves
|
||||
/// `tree/feat/x` to the branch `feat/x` — but everything that would end
|
||||
/// the path segment early (`#`, `?`, `%`, spaces) is escaped.
|
||||
pub fn tree_url(&self, branch: Option<&str>) -> String {
|
||||
match branch.map(str::trim).filter(|b| !b.is_empty()) {
|
||||
Some(branch) => format!("{}/tree/{}", self.web_url(), escape_path(branch)),
|
||||
None => self.web_url(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One remote that points at github.com.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GitHubRemote {
|
||||
/// The git remote's name — `origin`, `upstream`, …
|
||||
pub remote: String,
|
||||
pub slug: RepoSlug,
|
||||
}
|
||||
|
||||
/// `owner/name` out of a remote URL, when the URL is a github.com one.
|
||||
///
|
||||
/// Accepts every spelling git itself accepts for the host:
|
||||
///
|
||||
/// - `https://github.com/owner/name(.git)(/)`, with or without userinfo
|
||||
/// (`https://token@github.com/…`) and an explicit `:443`
|
||||
/// - `git@github.com:owner/name(.git)` (scp-like)
|
||||
/// - `ssh://git@github.com(:22)/owner/name(.git)`
|
||||
/// - `git://github.com/owner/name.git`
|
||||
///
|
||||
/// The host comparison ignores case, the way DNS does. Everything else — a
|
||||
/// GitHub Enterprise host, `gitlab.com`, a local path — is `None`.
|
||||
pub fn parse_github_url(url: &str) -> Option<RepoSlug> {
|
||||
let url = url.trim();
|
||||
let path = if let Some((scheme, rest)) = url.split_once("://") {
|
||||
if !matches!(
|
||||
scheme.to_ascii_lowercase().as_str(),
|
||||
"https" | "http" | "ssh" | "git" | "git+ssh" | "ssh+git"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let (authority, path) = rest.split_once('/')?;
|
||||
// Userinfo can itself hold a `@` only percent-encoded, so the last
|
||||
// one is the separator.
|
||||
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = host_port.split(':').next().unwrap_or("");
|
||||
if !host.eq_ignore_ascii_case("github.com") && !host.eq_ignore_ascii_case("www.github.com")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
path
|
||||
} else {
|
||||
// scp-like: `[user@]host:path`. A colon before any slash is what tells
|
||||
// it from a local path (`./a:b` has its slash first).
|
||||
let (head, path) = url.split_once(':')?;
|
||||
if head.contains('/') {
|
||||
return None;
|
||||
}
|
||||
let host = head.rsplit_once('@').map_or(head, |(_, h)| h);
|
||||
if !host.eq_ignore_ascii_case("github.com") {
|
||||
return None;
|
||||
}
|
||||
path
|
||||
};
|
||||
slug_from_path(path)
|
||||
}
|
||||
|
||||
fn slug_from_path(path: &str) -> Option<RepoSlug> {
|
||||
let path = path.trim_matches('/');
|
||||
let mut parts = path.split('/');
|
||||
let owner = parts.next()?.trim();
|
||||
let name = parts.next()?.trim();
|
||||
// `github.com/owner/name/tree/main` is a page, not a remote.
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let name = name.strip_suffix(".git").unwrap_or(name);
|
||||
if !valid_segment(owner) || !valid_segment(name) {
|
||||
return None;
|
||||
}
|
||||
Some(RepoSlug {
|
||||
owner: owner.to_string(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// What GitHub allows in an owner or repository name: ASCII letters, digits,
|
||||
/// `-`, `_`, `.` — and never `.`/`..`, which would walk the API path.
|
||||
fn valid_segment(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
/// The GitHub remotes in `git remote -v` output, one per remote name, in the
|
||||
/// order git listed them.
|
||||
///
|
||||
/// Each remote appears twice (fetch and push); the fetch URL is the one that
|
||||
/// says where the repository lives. A push-only override (`pushurl`) at a
|
||||
/// different host is exactly the case where the two disagree, and the fetch
|
||||
/// side is the one reads come from.
|
||||
pub fn github_remotes(remote_v: &str) -> Vec<GitHubRemote> {
|
||||
let mut out: Vec<GitHubRemote> = Vec::new();
|
||||
for line in remote_v.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(name), Some(url)) = (fields.next(), fields.next()) else {
|
||||
continue;
|
||||
};
|
||||
let kind = fields.next().unwrap_or("(fetch)");
|
||||
if kind != "(fetch)" || out.iter().any(|r| r.remote == name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slug) = parse_github_url(url) {
|
||||
out.push(GitHubRemote {
|
||||
remote: name.to_string(),
|
||||
slug,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Which remote the panel shows by default.
|
||||
///
|
||||
/// A fork is cloned with `origin` at the fork and `upstream` at the project,
|
||||
/// and the project is where the issues and pull requests are — so `upstream`
|
||||
/// wins, then `origin`, then whatever git listed first. `preferred` is the
|
||||
/// user's own pick from the panel, honoured while that remote still exists.
|
||||
pub fn default_remote<'a>(
|
||||
remotes: &'a [GitHubRemote],
|
||||
preferred: Option<&str>,
|
||||
) -> Option<&'a GitHubRemote> {
|
||||
let named = |name: &str| remotes.iter().find(|r| r.remote == name);
|
||||
preferred
|
||||
.and_then(named)
|
||||
.or_else(|| named("upstream"))
|
||||
.or_else(|| named("origin"))
|
||||
.or_else(|| remotes.first())
|
||||
}
|
||||
|
||||
/// Where "view this on GitHub" should land for a checkout: the branch's page
|
||||
/// on the remote it tracks, when that remote is on GitHub, and otherwise the
|
||||
/// chosen repository's front page.
|
||||
///
|
||||
/// `upstream` is `git rev-parse --abbrev-ref @{u}` — `origin/feat/x`. A branch
|
||||
/// with no upstream has never been pushed, and a link to its tree would be a
|
||||
/// 404, so it gets the repository instead. Returns the repository the link is
|
||||
/// in, with the URL.
|
||||
pub fn checkout_url<'a>(
|
||||
remotes: &'a [GitHubRemote],
|
||||
chosen: &'a GitHubRemote,
|
||||
upstream: Option<&str>,
|
||||
) -> (&'a RepoSlug, String) {
|
||||
let tracked = upstream.and_then(|up| {
|
||||
remotes.iter().find_map(|r| {
|
||||
let branch = up.strip_prefix(r.remote.as_str())?.strip_prefix('/')?;
|
||||
(!branch.is_empty()).then_some((r, branch))
|
||||
})
|
||||
});
|
||||
match tracked {
|
||||
Some((r, branch)) => (&r.slug, r.slug.tree_url(Some(branch))),
|
||||
None => (&chosen.slug, chosen.slug.web_url()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode what cannot appear raw in one URL path segment, keeping `/`.
|
||||
pub(crate) fn escape_path(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'~' | b'/' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn slug(owner: &str, name: &str) -> Option<RepoSlug> {
|
||||
Some(RepoSlug {
|
||||
owner: owner.into(),
|
||||
name: name.into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_spelling_git_accepts_for_github_parses() {
|
||||
for url in [
|
||||
"https://github.com/l0ng-ai/tty7",
|
||||
"https://github.com/l0ng-ai/tty7.git",
|
||||
"https://github.com/l0ng-ai/tty7/",
|
||||
"https://github.com/l0ng-ai/tty7.git/",
|
||||
"http://github.com/l0ng-ai/tty7",
|
||||
"https://GitHub.com/l0ng-ai/tty7",
|
||||
"https://www.github.com/l0ng-ai/tty7",
|
||||
"https://x-access-token:abc@github.com/l0ng-ai/tty7.git",
|
||||
"https://github.com:443/l0ng-ai/tty7.git",
|
||||
"git@github.com:l0ng-ai/tty7.git",
|
||||
"git@github.com:l0ng-ai/tty7",
|
||||
"github.com:l0ng-ai/tty7.git",
|
||||
"ssh://git@github.com/l0ng-ai/tty7.git",
|
||||
"ssh://git@github.com:22/l0ng-ai/tty7",
|
||||
"git+ssh://git@github.com/l0ng-ai/tty7.git",
|
||||
"git://github.com/l0ng-ai/tty7.git",
|
||||
" git@github.com:l0ng-ai/tty7.git ",
|
||||
] {
|
||||
assert_eq!(parse_github_url(url), slug("l0ng-ai", "tty7"), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repository_name_may_contain_dots() {
|
||||
assert_eq!(
|
||||
parse_github_url("https://github.com/owner/my.site.git"),
|
||||
slug("owner", "my.site")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_url("git@github.com:owner/name.github.io"),
|
||||
slug("owner", "name.github.io")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_hosts_and_non_remotes_are_ignored() {
|
||||
for url in [
|
||||
"https://gitlab.com/owner/name.git",
|
||||
"git@gitlab.com:owner/name.git",
|
||||
"https://github.example.com/owner/name.git",
|
||||
"git@github.example.com:owner/name.git",
|
||||
"https://notgithub.com/owner/name",
|
||||
"https://github.com.evil.io/owner/name",
|
||||
"/srv/git/name.git",
|
||||
"./relative:path",
|
||||
"file:///srv/git/name.git",
|
||||
"https://github.com/owner",
|
||||
"https://github.com/owner/name/tree/main",
|
||||
"https://github.com/../name",
|
||||
"https://github.com/owner/na%20me",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(parse_github_url(url), None, "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_v_keeps_one_fetch_url_per_github_remote() {
|
||||
let out = "\
|
||||
origin\tgit@github.com:me/tty7.git (fetch)
|
||||
origin\tgit@github.com:me/tty7.git (push)
|
||||
upstream\thttps://github.com/l0ng-ai/tty7.git (fetch)
|
||||
upstream\tno_push (push)
|
||||
mirror\thttps://gitlab.com/me/tty7.git (fetch)
|
||||
mirror\thttps://gitlab.com/me/tty7.git (push)
|
||||
";
|
||||
let remotes = github_remotes(out);
|
||||
assert_eq!(
|
||||
remotes,
|
||||
vec![
|
||||
GitHubRemote {
|
||||
remote: "origin".into(),
|
||||
slug: slug("me", "tty7").unwrap()
|
||||
},
|
||||
GitHubRemote {
|
||||
remote: "upstream".into(),
|
||||
slug: slug("l0ng-ai", "tty7").unwrap()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_push_only_github_url_does_not_make_a_github_remote() {
|
||||
let out = "\
|
||||
mirror\thttps://gitlab.com/me/tty7.git (fetch)
|
||||
mirror\tgit@github.com:me/tty7.git (push)
|
||||
";
|
||||
assert!(github_remotes(out).is_empty());
|
||||
}
|
||||
|
||||
fn remote(name: &str) -> GitHubRemote {
|
||||
GitHubRemote {
|
||||
remote: name.into(),
|
||||
slug: slug("o", name).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fork_defaults_to_upstream_then_origin_then_the_first() {
|
||||
let pick = |names: &[&str], preferred: Option<&str>| {
|
||||
let remotes: Vec<_> = names.iter().map(|n| remote(n)).collect();
|
||||
default_remote(&remotes, preferred).map(|r| r.remote.clone())
|
||||
};
|
||||
assert_eq!(
|
||||
pick(&["origin", "upstream"], None).as_deref(),
|
||||
Some("upstream")
|
||||
);
|
||||
assert_eq!(pick(&["fork", "origin"], None).as_deref(), Some("origin"));
|
||||
assert_eq!(pick(&["fork", "mine"], None).as_deref(), Some("fork"));
|
||||
assert_eq!(pick(&[], None), None);
|
||||
// The user's pick wins while it exists, and is forgotten once it does
|
||||
// not rather than leaving the panel on nothing.
|
||||
assert_eq!(
|
||||
pick(&["origin", "upstream"], Some("origin")).as_deref(),
|
||||
Some("origin")
|
||||
);
|
||||
assert_eq!(
|
||||
pick(&["origin", "upstream"], Some("gone")).as_deref(),
|
||||
Some("upstream")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_url_keeps_slashes_and_escapes_the_rest() {
|
||||
let s = slug("l0ng-ai", "tty7").unwrap();
|
||||
assert_eq!(s.full(), "l0ng-ai/tty7");
|
||||
assert_eq!(s.tree_url(None), "https://github.com/l0ng-ai/tty7");
|
||||
assert_eq!(s.tree_url(Some(" ")), "https://github.com/l0ng-ai/tty7");
|
||||
assert_eq!(
|
||||
s.tree_url(Some("feat/panel-github")),
|
||||
"https://github.com/l0ng-ai/tty7/tree/feat/panel-github"
|
||||
);
|
||||
assert_eq!(
|
||||
s.tree_url(Some("fix#12 ?x")),
|
||||
"https://github.com/l0ng-ai/tty7/tree/fix%2312%20%3Fx"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_checkout_links_to_its_pushed_branch_or_else_the_repository() {
|
||||
let remotes = vec![
|
||||
GitHubRemote {
|
||||
remote: "origin".into(),
|
||||
slug: slug("me", "tty7").unwrap(),
|
||||
},
|
||||
GitHubRemote {
|
||||
remote: "upstream".into(),
|
||||
slug: slug("l0ng-ai", "tty7").unwrap(),
|
||||
},
|
||||
];
|
||||
let chosen = &remotes[1];
|
||||
// Tracked on the fork: the fork's branch page, not upstream's.
|
||||
let (repo, url) = checkout_url(&remotes, chosen, Some("origin/feat/x"));
|
||||
assert_eq!(repo.full(), "me/tty7");
|
||||
assert_eq!(url, "https://github.com/me/tty7/tree/feat/x");
|
||||
// Never pushed: the repository, not a 404.
|
||||
let (repo, url) = checkout_url(&remotes, chosen, None);
|
||||
assert_eq!(repo.full(), "l0ng-ai/tty7");
|
||||
assert_eq!(url, "https://github.com/l0ng-ai/tty7");
|
||||
// Tracking a remote that is not on GitHub.
|
||||
let (_, url) = checkout_url(&remotes, chosen, Some("gitlab/main"));
|
||||
assert_eq!(url, "https://github.com/l0ng-ai/tty7");
|
||||
// `origin-old/x` is not `origin` + `-old/x`.
|
||||
let (_, url) = checkout_url(&remotes, chosen, Some("origin-old/x"));
|
||||
assert_eq!(url, "https://github.com/l0ng-ai/tty7");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Where the GitHub token comes from.
|
||||
//!
|
||||
//! tty7 has no OAuth app and stores no credential of its own: it borrows the
|
||||
//! one the user already gave the GitHub CLI. In order:
|
||||
//!
|
||||
//! 1. `GH_TOKEN`, then `GITHUB_TOKEN` — the variables `gh` itself honours, so
|
||||
//! a CI-style setup behaves the same here as in the shell;
|
||||
//! 2. `gh auth token --hostname github.com`.
|
||||
//!
|
||||
//! No token is not an error. Public repositories read fine without one, at
|
||||
//! GitHub's unauthenticated rate (60 requests an hour).
|
||||
//!
|
||||
//! `gh` is looked for on `PATH` *and* at the Homebrew and `/usr/local`
|
||||
//! locations, because an app launched from Finder or the Dock inherits
|
||||
//! launchd's minimal `PATH` rather than the login shell's, and `gh` is almost
|
||||
//! never on it there.
|
||||
//!
|
||||
//! The token never reaches a log line or the screen: [`Token`]'s `Debug`
|
||||
//! prints a placeholder, and nothing else in tty7 formats it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A GitHub token. Opaque on purpose — see the module docs.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Token(String);
|
||||
|
||||
impl Token {
|
||||
/// A token from wherever it was found, or `None` for an empty value
|
||||
/// (`GH_TOKEN=` set but blank is "no token", as it is for `gh`).
|
||||
pub fn new(raw: &str) -> Option<Token> {
|
||||
let raw = raw.trim();
|
||||
(!raw.is_empty() && !raw.contains(char::is_whitespace)).then(|| Token(raw.to_string()))
|
||||
}
|
||||
|
||||
/// For the `Authorization` header, and for nothing else.
|
||||
pub fn expose(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Token {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Token(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a token was found, for the panel to say so ("signed in via gh").
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TokenSource {
|
||||
Env(&'static str),
|
||||
GhCli,
|
||||
}
|
||||
|
||||
/// The environment variables consulted, in order.
|
||||
pub const TOKEN_VARS: [&str; 2] = ["GH_TOKEN", "GITHUB_TOKEN"];
|
||||
|
||||
/// Fixed places `gh` lands when installed outside the login `PATH`.
|
||||
pub const GH_FALLBACKS: [&str; 3] = [
|
||||
"/opt/homebrew/bin/gh",
|
||||
"/opt/homebrew/opt/gh/bin/gh",
|
||||
"/usr/local/bin/gh",
|
||||
];
|
||||
|
||||
/// The side effects token resolution needs, injected so tests never read the
|
||||
/// real environment or run the real `gh`.
|
||||
pub struct TokenEnv<'a> {
|
||||
pub var: &'a dyn Fn(&str) -> Option<String>,
|
||||
pub is_file: &'a dyn Fn(&Path) -> bool,
|
||||
/// Run `<gh> auth token …` and hand back its stdout on success.
|
||||
pub gh_token: &'a dyn Fn(&Path) -> Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve a token, or `None` to go unauthenticated.
|
||||
pub fn resolve_token(env: &TokenEnv<'_>) -> Option<(Token, TokenSource)> {
|
||||
for var in TOKEN_VARS {
|
||||
if let Some(token) = (env.var)(var).as_deref().and_then(Token::new) {
|
||||
return Some((token, TokenSource::Env(var)));
|
||||
}
|
||||
}
|
||||
// Only the first `gh` found is asked. A second install answering for a
|
||||
// different account than the one on PATH would be a surprise, not a
|
||||
// fallback.
|
||||
let gh = gh_candidates((env.var)("PATH").as_deref())
|
||||
.into_iter()
|
||||
.find(|p| (env.is_file)(p))?;
|
||||
let out = (env.gh_token)(&gh)?;
|
||||
// `gh auth token` prints the token and a newline; anything multi-line is
|
||||
// not a token.
|
||||
let line = out.lines().next()?;
|
||||
Token::new(line).map(|t| (t, TokenSource::GhCli))
|
||||
}
|
||||
|
||||
/// Every place `gh` might be, `PATH` first, without duplicates.
|
||||
pub fn gh_candidates(path_var: Option<&str>) -> Vec<PathBuf> {
|
||||
let exe = if cfg!(windows) { "gh.exe" } else { "gh" };
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
if let Some(path) = path_var {
|
||||
for dir in std::env::split_paths(path) {
|
||||
if dir.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(dir.join(exe));
|
||||
}
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
out.extend(GH_FALLBACKS.iter().map(PathBuf::from));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|p| seen.insert(p.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// How long `gh auth token` may take. It reads a keyring, which is local and
|
||||
/// fast — but a locked keyring can sit on a prompt, and the panel must not
|
||||
/// wait on that forever.
|
||||
const GH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// [`resolve_token`] against the real process environment. Blocking: call it
|
||||
/// off the UI thread.
|
||||
pub fn resolve_token_from_system() -> Option<(Token, TokenSource)> {
|
||||
resolve_token(&TokenEnv {
|
||||
var: &|name| std::env::var(name).ok(),
|
||||
is_file: &|p| p.is_file(),
|
||||
gh_token: &run_gh_auth_token,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_gh_auth_token(gh: &Path) -> Option<String> {
|
||||
use std::io::Read as _;
|
||||
use std::process::{Command, Stdio};
|
||||
let mut child = Command::new(gh)
|
||||
.args(["auth", "token", "--hostname", "github.com"])
|
||||
.env("GH_PROMPT_DISABLED", "1")
|
||||
.env("GH_NO_UPDATE_NOTIFIER", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let deadline = std::time::Instant::now() + GH_TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::new();
|
||||
child.stdout.take()?.read_to_string(&mut out).ok()?;
|
||||
return Some(out);
|
||||
}
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct Fake {
|
||||
vars: HashMap<&'static str, &'static str>,
|
||||
files: Vec<PathBuf>,
|
||||
gh_out: Option<&'static str>,
|
||||
asked: RefCell<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new() -> Fake {
|
||||
Fake {
|
||||
vars: HashMap::new(),
|
||||
files: Vec::new(),
|
||||
gh_out: None,
|
||||
asked: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(&self) -> Option<(Token, TokenSource)> {
|
||||
resolve_token(&TokenEnv {
|
||||
var: &|name| self.vars.get(name).map(|v| v.to_string()),
|
||||
is_file: &|p| self.files.iter().any(|f| f == p),
|
||||
gh_token: &|p| {
|
||||
self.asked.borrow_mut().push(p.to_path_buf());
|
||||
self.gh_out.map(str::to_string)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gh_token_outranks_github_token_and_both_outrank_the_cli() {
|
||||
let mut f = Fake::new();
|
||||
f.vars.insert("GH_TOKEN", "from-gh-token");
|
||||
f.vars.insert("GITHUB_TOKEN", "from-github-token");
|
||||
f.files.push(PathBuf::from("/usr/local/bin/gh"));
|
||||
f.gh_out = Some("from-cli\n");
|
||||
let (token, source) = f.resolve().unwrap();
|
||||
assert_eq!(token.expose(), "from-gh-token");
|
||||
assert_eq!(source, TokenSource::Env("GH_TOKEN"));
|
||||
assert!(
|
||||
f.asked.borrow().is_empty(),
|
||||
"gh is not run when env answers"
|
||||
);
|
||||
|
||||
f.vars.remove("GH_TOKEN");
|
||||
let (token, source) = f.resolve().unwrap();
|
||||
assert_eq!(token.expose(), "from-github-token");
|
||||
assert_eq!(source, TokenSource::Env("GITHUB_TOKEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_variable_is_no_token_and_falls_through() {
|
||||
let mut f = Fake::new();
|
||||
f.vars.insert("GH_TOKEN", " ");
|
||||
f.files.push(PathBuf::from("/opt/homebrew/bin/gh"));
|
||||
f.gh_out = Some("gho_cli\n");
|
||||
let (token, source) = f.resolve().unwrap();
|
||||
assert_eq!(token.expose(), "gho_cli");
|
||||
assert_eq!(source, TokenSource::GhCli);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gh_on_path_is_asked_before_the_homebrew_fallbacks() {
|
||||
let mut f = Fake::new();
|
||||
f.vars.insert("PATH", "/usr/bin:/home/me/bin");
|
||||
f.files.push(PathBuf::from("/home/me/bin/gh"));
|
||||
f.files.push(PathBuf::from("/opt/homebrew/bin/gh"));
|
||||
f.gh_out = Some("gho_x\n");
|
||||
f.resolve().unwrap();
|
||||
assert_eq!(*f.asked.borrow(), vec![PathBuf::from("/home/me/bin/gh")]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_finder_launch_still_finds_homebrew_gh() {
|
||||
let mut f = Fake::new();
|
||||
// launchd's PATH: none of it holds gh.
|
||||
f.vars.insert("PATH", "/usr/bin:/bin:/usr/sbin:/sbin");
|
||||
f.files.push(PathBuf::from("/opt/homebrew/opt/gh/bin/gh"));
|
||||
f.gh_out = Some("gho_brew\n");
|
||||
let (token, _) = f.resolve().unwrap();
|
||||
assert_eq!(token.expose(), "gho_brew");
|
||||
assert_eq!(
|
||||
*f.asked.borrow(),
|
||||
vec![PathBuf::from("/opt/homebrew/opt/gh/bin/gh")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_env_and_no_gh_is_unauthenticated_not_an_error() {
|
||||
let f = Fake::new();
|
||||
assert!(f.resolve().is_none());
|
||||
assert!(f.asked.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_signed_out_gh_is_unauthenticated() {
|
||||
let mut f = Fake::new();
|
||||
f.files.push(PathBuf::from("/usr/local/bin/gh"));
|
||||
f.gh_out = None;
|
||||
assert!(f.resolve().is_none());
|
||||
f.gh_out = Some("\n");
|
||||
assert!(f.resolve().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_skip_empty_path_entries_and_duplicates() {
|
||||
let c = gh_candidates(Some(""));
|
||||
let exe = if cfg!(windows) { "gh.exe" } else { "gh" };
|
||||
assert!(c.iter().all(|p| p.file_name().unwrap() == exe));
|
||||
let unique: std::collections::HashSet<_> = c.iter().collect();
|
||||
assert_eq!(unique.len(), c.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_never_prints_itself() {
|
||||
let token = Token::new("ghp_secretsecret").unwrap();
|
||||
let shown = format!("{token:?} {:?}", Some((token.clone(), TokenSource::GhCli)));
|
||||
assert!(!shown.contains("secret"), "{shown}");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod codename;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod gitignore;
|
||||
pub mod group_key;
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -46,9 +46,17 @@ manual grant in **System Settings → Privacy & Security → Full Disk Access**.
|
||||
|---|---|
|
||||
| **Update checks** | A request to the GitHub releases API every six hours, plus the download when you accept one. Turn it off with `check_for_updates: false`. |
|
||||
| **Remote server installs** | Downloading a `tty7-server` binary for a machine you connected to — or, for WSL, copying the one already bundled with your install. |
|
||||
| **The GitHub tab** | Only while you use it: requests to `api.github.com` for the issues, pull requests, comments and changed files of the repository the focused pane is in. They go out from this machine, even for a repository in a remote workspace, and carry the GitHub CLI's token when you are signed in with `gh`. |
|
||||
| **Everything else** | Nothing. There is no telemetry, no analytics, and no account. |
|
||||
|
||||
Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies)
|
||||
All of the above honour `http_proxy`. [Updates →](/reference/updates#proxies)
|
||||
|
||||
The GitHub tab stores no credential of its own. It asks `GH_TOKEN`,
|
||||
`GITHUB_TOKEN` or `gh auth token` each time it connects, keeps the answer in
|
||||
memory only, and never writes it to disk or to the log. Images in issue and pull
|
||||
request text are not fetched — they are shown as links — so opening an issue
|
||||
does not tell a third-party image host that you read it.
|
||||
[The GitHub tab →](/window/side-panel#github)
|
||||
|
||||
## What is stored, and where
|
||||
|
||||
|
||||
@@ -60,6 +60,50 @@ Both directions work over a [remote workspace](/remote/workspaces) too, reading
|
||||
on one machine and writing on the other, up to the size one control frame can
|
||||
carry — past that the panel tells you to use [SFTP](/remote/sftp).
|
||||
|
||||
## GitHub
|
||||
|
||||
Issues and pull requests for the repository the focused pane is in, read-only.
|
||||
The tab reads the repository's remotes and binds to the one on `github.com`; in a
|
||||
fork, where `origin` is yours and `upstream` is the project, it picks
|
||||
`upstream`, and the name at the top is a menu of every GitHub remote when there
|
||||
is more than one. GitHub Enterprise hosts are not supported.
|
||||
|
||||
- **Issues | Pull Requests** and **Open | Closed** switch the list, newest
|
||||
activity first, 50 at a time with **Load more** underneath.
|
||||
- Each row carries a state glyph whose *shape* says open, closed, not planned,
|
||||
merged or draft; the number, author and last update; and the labels.
|
||||
**Click a label** to show only that label, and the × beside it to clear it.
|
||||
- **Click a row** for the detail: title, state, author, labels, the description
|
||||
and the conversation as Markdown. A pull request adds its branches, size and
|
||||
changed files — click a file to open its patch in the
|
||||
[diff overlay](/git/diffs).
|
||||
- The ↗ button opens the repository, or the issue, on github.com; ↻ refreshes.
|
||||
|
||||
Lists are kept per repository, so switching tabs or panes does not fetch them
|
||||
again; anything older than two minutes is refreshed in the background while
|
||||
it stays on screen.
|
||||
|
||||
### Signing in
|
||||
|
||||
There is nothing to sign in to in tty7. It reuses the GitHub CLI's login: the
|
||||
token comes from `GH_TOKEN`, then `GITHUB_TOKEN`, then `gh auth token`. `gh` is
|
||||
looked for on your `PATH` and in the usual Homebrew and `/usr/local/bin`
|
||||
locations, so it is found even when tty7 was opened from Finder.
|
||||
|
||||
Without a token, public repositories still work, at GitHub's unauthenticated
|
||||
limit of 60 requests an hour. A private repository, or a spent limit, says so
|
||||
in the panel and suggests running `gh auth login`; press ↻ afterwards and the
|
||||
new login is picked up without a restart.
|
||||
|
||||
The Info tab gains a **GitHub** row for a repository with a GitHub remote. It
|
||||
opens the branch you are on, on the remote it tracks — or the repository's page
|
||||
for a branch you have not pushed.
|
||||
|
||||
Markdown from GitHub is written by strangers, so it is rendered with two
|
||||
changes: images are not loaded — each becomes a link you can open in the
|
||||
browser — and links that would open anything other than a web page or an
|
||||
e-mail are disabled. See [what leaves your machine](/reference/privacy#what-leaves-your-machine).
|
||||
|
||||
## The editor
|
||||
|
||||
<kbd>⌘ ⇧ E</kbd> toggles the code panel; clicking a file in the Files tab opens
|
||||
|
||||
@@ -904,6 +904,7 @@ pub struct Tty7App {
|
||||
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
|
||||
pub(crate) right_panel: crate::ui::right_panel::RightPanelState,
|
||||
pub(crate) scm: crate::ui::scm::ScmPanelState,
|
||||
pub(crate) github: crate::ui::github::GitHubPanelState,
|
||||
pub(crate) diff_probes_inflight:
|
||||
std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>,
|
||||
pub(crate) diff_probes_restale:
|
||||
@@ -1544,6 +1545,7 @@ impl Tty7App {
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
github: Default::default(),
|
||||
diff_probes_inflight: Default::default(),
|
||||
diff_probes_restale: Default::default(),
|
||||
file_tree,
|
||||
|
||||
@@ -101,6 +101,18 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
|
||||
"icons/agents/codebuddy.svg" => {
|
||||
include_bytes!("../../assets/icons/agents/codebuddy.svg")
|
||||
}
|
||||
// The GitHub tab's state glyphs: one shape per state, not one colour.
|
||||
"icons/github/issue-open.svg" => include_bytes!("../../assets/icons/github/issue-open.svg"),
|
||||
"icons/github/issue-closed.svg" => {
|
||||
include_bytes!("../../assets/icons/github/issue-closed.svg")
|
||||
}
|
||||
"icons/github/issue-not-planned.svg" => {
|
||||
include_bytes!("../../assets/icons/github/issue-not-planned.svg")
|
||||
}
|
||||
"icons/github/pr-open.svg" => include_bytes!("../../assets/icons/github/pr-open.svg"),
|
||||
"icons/github/pr-closed.svg" => include_bytes!("../../assets/icons/github/pr-closed.svg"),
|
||||
"icons/github/pr-merged.svg" => include_bytes!("../../assets/icons/github/pr-merged.svg"),
|
||||
"icons/github/pr-draft.svg" => include_bytes!("../../assets/icons/github/pr-draft.svg"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(bytes)
|
||||
|
||||
@@ -196,6 +196,35 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Open (or refocus, or close — the same toggle as any other source) the
|
||||
/// overlay on a patch that did not come from git, with its snapshot.
|
||||
///
|
||||
/// The snapshot names its own directory and source, which must be a
|
||||
/// [`DiffSource::Patch`]; the probe skips those, so the snapshot installed
|
||||
/// here is the only one the overlay will ever have.
|
||||
pub(crate) fn open_supplied_diff(
|
||||
&mut self,
|
||||
host: crate::ui::host_ops::HostId,
|
||||
snapshot: Arc<DiffSnapshot>,
|
||||
focus: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let (cwd, source) = (snapshot.root.clone(), snapshot.source.clone());
|
||||
debug_assert!(source.is_supplied());
|
||||
self.open_diff_overlay(host, cwd.clone(), source.clone(), focus, window, cx);
|
||||
if let Some(overlay) = self
|
||||
.tabs
|
||||
.get_mut(self.active)
|
||||
.and_then(|t| t.diff_overlay.as_mut())
|
||||
.filter(|o| o.cwd == cwd && o.host_id == host && o.source == source)
|
||||
&& !matches!(overlay.load, DiffLoad::Ready(_))
|
||||
{
|
||||
overlay.load = DiffLoad::Ready(snapshot);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Which file the open overlay is focused on — for the row that asked,
|
||||
/// which means the *source* has to match too: a file staged and edited
|
||||
/// again sits in two panel groups, and only the row whose patch is
|
||||
@@ -349,7 +378,8 @@ impl Tty7App {
|
||||
let Some(overlay) = self.tabs.get(active).and_then(|t| t.diff_overlay.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
if overlay.loading {
|
||||
// A supplied patch has nothing to probe: git never had it.
|
||||
if overlay.loading || overlay.source.is_supplied() {
|
||||
return;
|
||||
}
|
||||
let cwd = overlay.cwd.clone();
|
||||
@@ -492,7 +522,9 @@ impl Tty7App {
|
||||
let stale = match overlay.source {
|
||||
// A commit and a range are fixed patches. Nothing can make either
|
||||
// of them out of date, so nothing should reprobe them.
|
||||
DiffSource::Commit { .. } | DiffSource::Range { .. } => return,
|
||||
DiffSource::Commit { .. } | DiffSource::Range { .. } | DiffSource::Patch { .. } => {
|
||||
return;
|
||||
}
|
||||
// The cached counts come from `git diff --numstat HEAD`, so only a
|
||||
// HEAD snapshot is comparable to them.
|
||||
DiffSource::Head => {
|
||||
@@ -1973,6 +2005,16 @@ fn source_subject(source: &DiffSource, branch: String) -> SourceSubject {
|
||||
is_rev: true,
|
||||
label: None,
|
||||
},
|
||||
// A pull request: `#12` in mono, its title beside it.
|
||||
DiffSource::Patch { id, label } => SourceSubject {
|
||||
icon: "icons/github.svg",
|
||||
text: id
|
||||
.rsplit_once('#')
|
||||
.map_or(id.clone(), |(_, n)| format!("#{n}")),
|
||||
chip: None,
|
||||
is_rev: true,
|
||||
label: label.clone().filter(|l| !l.subject.is_empty()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2849,6 +2891,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supplied_patch_is_named_by_its_number() {
|
||||
let source = DiffSource::Patch {
|
||||
id: "l0ng-ai/tty7#974".into(),
|
||||
label: Some(CommitLabel {
|
||||
subject: "feat(tabs): reorder".into(),
|
||||
author: "migege".into(),
|
||||
at: 0,
|
||||
}),
|
||||
};
|
||||
let subject = source_subject(&source, "move-tab-keybind".into());
|
||||
assert_eq!(subject.text, "#974");
|
||||
assert!(subject.is_rev);
|
||||
assert_eq!(subject.icon, "icons/github.svg");
|
||||
assert_eq!(subject.label.unwrap().subject, "feat(tabs): reorder");
|
||||
assert!(source.is_supplied());
|
||||
assert!(!DiffSource::Head.is_supplied());
|
||||
// One pull request is one overlay, however it is labelled.
|
||||
assert_eq!(
|
||||
source,
|
||||
DiffSource::Patch {
|
||||
id: "l0ng-ai/tty7#974".into(),
|
||||
label: None
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
//! One issue or pull request, pushed into the GitHub tab over its list.
|
||||
//!
|
||||
//! Title, state, author, labels, the body and the conversation as Markdown —
|
||||
//! through gpui-component's `TextView`, after `core::github::markdown` has
|
||||
//! turned images into links and disarmed non-web link targets — and, for a
|
||||
//! pull request, its branches and changed files. A file opens in the diff
|
||||
//! overlay, the same surface a commit's files open in, fed the patch GitHub
|
||||
//! sent rather than one git read.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px, rems};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use tty7_core::core::git::diff::{CommitLabel, DiffBudget, DiffSnapshot, DiffSource};
|
||||
use tty7_core::core::github::{Comment, Detail, PrFile, RepoSlug};
|
||||
|
||||
use crate::ui::app::{CONTENT_INSET, Tty7App};
|
||||
use crate::ui::github::now_unix;
|
||||
use crate::ui::i18n::{L10nKey, t, t_plural};
|
||||
use crate::ui::panel_github::{describe_error, github_tile, label_chip, state_glyph, state_label};
|
||||
use crate::ui::right_panel::{
|
||||
HEADING, META, META_MONO, ROW_FILL_RADIUS, ROW_INSET, TEXT, TEXT_INSET, git_badge,
|
||||
};
|
||||
use crate::ui::scm::path::{relative_time, split_display_path};
|
||||
use crate::ui::scm::state::RepoKey;
|
||||
use crate::ui::scm::status::{status_color, status_glyph};
|
||||
|
||||
const ROW_H: f32 = 26.;
|
||||
const SECTION_GAP: f32 = 16.;
|
||||
|
||||
impl Tty7App {
|
||||
/// The rows pinned over the detail, and the detail itself.
|
||||
pub(crate) fn render_github_detail(
|
||||
&mut self,
|
||||
repo: &RepoKey,
|
||||
slug: &RepoSlug,
|
||||
number: u64,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> (Vec<AnyElement>, AnyElement) {
|
||||
self.github_ensure_detail(slug, number, cx);
|
||||
let key = (slug.clone(), number);
|
||||
let (detail, error) = match self.github.details.get(&key) {
|
||||
Some(c) => (c.detail.clone(), c.error.clone()),
|
||||
None => (None, None),
|
||||
};
|
||||
let is_pr = detail.as_ref().map_or(
|
||||
self.github.kind == tty7_core::core::github::Kind::Pulls,
|
||||
|d| d.item.is_pr,
|
||||
);
|
||||
let url = detail.as_ref().map_or_else(
|
||||
|| {
|
||||
format!(
|
||||
"{}/{}/{number}",
|
||||
slug.web_url(),
|
||||
if is_pr { "pull" } else { "issues" }
|
||||
)
|
||||
},
|
||||
|d| d.item.html_url.clone(),
|
||||
);
|
||||
let pinned = vec![self.github_back_row(number, is_pr, url, cx)];
|
||||
|
||||
let Some(detail) = detail else {
|
||||
let body = match error {
|
||||
Some(err) => {
|
||||
let (text, hint) = describe_error(&err, self.github_authenticated());
|
||||
self.panel_empty(&text, hint.as_deref(), cx)
|
||||
}
|
||||
None => self.panel_empty(t(L10nKey::PanelLoading), None, cx),
|
||||
};
|
||||
return (pinned, body);
|
||||
};
|
||||
|
||||
let mut body = v_flex()
|
||||
.pb(px(16.))
|
||||
.child(self.github_detail_head(&detail, cx))
|
||||
.child(markdown_block(
|
||||
format!("gh-body-{}-{number}", slug.full()),
|
||||
&detail.body,
|
||||
t(L10nKey::GitHubNoDescription),
|
||||
cx,
|
||||
));
|
||||
if let Some(err) = error {
|
||||
// A refresh that failed over a detail already on screen: keep the
|
||||
// detail, and say why it may be out of date.
|
||||
let (text, hint) = describe_error(&err, self.github_authenticated());
|
||||
body = body.child(self.panel_empty(&text, hint.as_deref(), cx));
|
||||
}
|
||||
if let Some(files) = &detail.files {
|
||||
body = body.child(self.github_detail_files(repo, slug, &detail, files, cx));
|
||||
}
|
||||
body = body.child(self.github_detail_comments(slug, number, &detail, cx));
|
||||
(pinned, body.into_any_element())
|
||||
}
|
||||
|
||||
fn github_back_row(
|
||||
&self,
|
||||
number: u64,
|
||||
is_pr: bool,
|
||||
url: String,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(4.))
|
||||
.h(px(28.))
|
||||
.px(px(CONTENT_INSET))
|
||||
.child(
|
||||
h_flex()
|
||||
.id("panel-github-back")
|
||||
.items_center()
|
||||
.gap(px(2.))
|
||||
.h(px(ROW_H - 4.))
|
||||
.pl(px(ROW_INSET - 2.))
|
||||
.pr(px(ROW_INSET))
|
||||
.rounded(px(6.))
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.on_click(cx.listener(|this, _, _window, cx| this.github_close_detail(cx)))
|
||||
.child(Icon::new(IconName::ChevronLeft).small().text_color(muted))
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(muted)
|
||||
.child(t(if is_pr {
|
||||
L10nKey::GitHubPulls
|
||||
} else {
|
||||
L10nKey::GitHubIssues
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().min_w_0())
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_color(muted)
|
||||
.child(format!("#{number}")),
|
||||
)
|
||||
.child(
|
||||
github_tile(
|
||||
"panel-github-open-item",
|
||||
Icon::new(IconName::ExternalLink),
|
||||
t(L10nKey::GitHubOpenOnGitHub),
|
||||
cx,
|
||||
)
|
||||
.on_click(move |_, _window, cx| cx.open_url(&url)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Title, state, byline, branches and size, labels.
|
||||
fn github_detail_head(&self, detail: &Detail, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let (muted, fg) = (theme.muted_foreground, theme.foreground);
|
||||
let (added_ink, removed_ink) = (theme.success, theme.danger);
|
||||
let mono = theme.mono_font_family.clone();
|
||||
let item = &detail.item;
|
||||
let now = now_unix();
|
||||
let byline = match (item.author.as_str(), item.created_at) {
|
||||
("", 0) => String::new(),
|
||||
("", at) => relative_time(now, at),
|
||||
(author, 0) => author.to_string(),
|
||||
(author, at) => format!("{author} · {}", relative_time(now, at)),
|
||||
};
|
||||
let mut head = v_flex()
|
||||
.px(px(TEXT_INSET))
|
||||
.pt(px(4.))
|
||||
.pb(px(10.))
|
||||
.gap(px(6.))
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(TEXT + 1. / 16.))
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(fg)
|
||||
.child(item.title.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap(px(6.))
|
||||
.flex_wrap()
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(4.))
|
||||
.px(px(6.))
|
||||
.py(px(1.))
|
||||
.rounded(px(10.))
|
||||
.bg(theme.foreground.opacity(0.06))
|
||||
.child(state_glyph(item.state, item.is_pr, cx))
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(META))
|
||||
.text_color(fg)
|
||||
.child(state_label(item.state)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.child(byline),
|
||||
),
|
||||
);
|
||||
if let Some(pull) = &detail.pull {
|
||||
head = head.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.flex_wrap()
|
||||
.gap(px(6.))
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family(mono.clone())
|
||||
.text_color(muted)
|
||||
.child(format!("{} → {}", pull.head_ref, pull.base_ref))
|
||||
.child(
|
||||
div()
|
||||
.text_color(added_ink)
|
||||
.child(format!("+{}", pull.additions)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(removed_ink)
|
||||
.child(format!("−{}", pull.deletions)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.font_family.clone())
|
||||
.text_size(rems(META))
|
||||
.child(t_plural(L10nKey::GitHubCommits, pull.commits as usize, &[])),
|
||||
),
|
||||
);
|
||||
}
|
||||
if !item.labels.is_empty() {
|
||||
head = head.child(
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.gap(px(4.))
|
||||
.children(item.labels.iter().map(|l| label_chip(l, cx))),
|
||||
);
|
||||
}
|
||||
head.into_any_element()
|
||||
}
|
||||
|
||||
fn github_detail_comments(
|
||||
&self,
|
||||
slug: &RepoSlug,
|
||||
number: u64,
|
||||
detail: &Detail,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let fg = cx.theme().foreground;
|
||||
let now = now_unix();
|
||||
let count = detail.comments.len().max(detail.item.comments as usize);
|
||||
let mut section = v_flex().mt(px(SECTION_GAP)).child(section_heading(
|
||||
t_plural(L10nKey::GitHubComments, count, &[]),
|
||||
cx,
|
||||
));
|
||||
for c in &detail.comments {
|
||||
section = section.child(comment_block(slug, number, c, now, fg, muted, cx));
|
||||
}
|
||||
if detail.comments_truncated {
|
||||
section = section.child(more_on_github(
|
||||
"panel-github-more-comments",
|
||||
detail.item.html_url.clone(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
section.into_any_element()
|
||||
}
|
||||
|
||||
fn github_detail_files(
|
||||
&self,
|
||||
repo: &RepoKey,
|
||||
slug: &RepoSlug,
|
||||
detail: &Detail,
|
||||
files: &[PrFile],
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let mono = theme.mono_font_family.clone();
|
||||
let (added_ink, removed_ink, muted) = (theme.success, theme.danger, theme.muted_foreground);
|
||||
let (added, removed) = files
|
||||
.iter()
|
||||
.fold((0, 0), |(a, r), f| (a + f.additions, r + f.deletions));
|
||||
let source = DiffSource::Patch {
|
||||
id: format!("{}#{}", slug.full(), detail.item.number),
|
||||
label: Some(CommitLabel {
|
||||
subject: detail.item.title.clone(),
|
||||
author: detail.item.author.clone(),
|
||||
at: detail.item.created_at,
|
||||
}),
|
||||
};
|
||||
let head_ref = detail
|
||||
.pull
|
||||
.as_ref()
|
||||
.map(|p| p.head_ref.clone())
|
||||
.unwrap_or_default();
|
||||
let files: Arc<Vec<PrFile>> = Arc::new(files.to_vec());
|
||||
let focused = self
|
||||
.diff_overlay_focus(repo.host, &repo.root, &source)
|
||||
.map(str::to_string);
|
||||
|
||||
let heading = h_flex()
|
||||
.items_center()
|
||||
.gap(px(6.))
|
||||
.min_h(px(22.))
|
||||
.px(px(TEXT_INSET))
|
||||
.child(
|
||||
div()
|
||||
.text_size(rems(HEADING))
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.text_color(muted)
|
||||
.child(t_plural(L10nKey::ScmFilesChanged, files.len(), &[])),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap(px(5.))
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family(mono.clone())
|
||||
.child(div().text_color(added_ink).child(format!("+{added}")))
|
||||
.child(div().text_color(removed_ink).child(format!("−{removed}"))),
|
||||
);
|
||||
let mut rows = v_flex().px(px(CONTENT_INSET));
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
let selected = focused.as_deref() == Some(file.path.as_str());
|
||||
rows = rows.child(
|
||||
self.github_file_row(i, file, selected, repo, &source, &head_ref, &files, cx),
|
||||
);
|
||||
}
|
||||
let mut section = v_flex().mt(px(SECTION_GAP)).child(heading).child(rows);
|
||||
if detail.files_truncated {
|
||||
section = section.child(more_on_github(
|
||||
"panel-github-more-files",
|
||||
format!("{}/files", detail.item.html_url),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
section.into_any_element()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn github_file_row(
|
||||
&self,
|
||||
i: usize,
|
||||
file: &PrFile,
|
||||
selected: bool,
|
||||
repo: &RepoKey,
|
||||
source: &DiffSource,
|
||||
head_ref: &str,
|
||||
files: &Arc<Vec<PrFile>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
let deco = crate::ui::diff_overlay::deco_status(file.status);
|
||||
let (name, dir) = split_display_path(&file.path);
|
||||
let (added_ink, removed_ink) = (cx.theme().success, cx.theme().danger);
|
||||
let repo = repo.clone();
|
||||
let source = source.clone();
|
||||
let path = file.path.clone();
|
||||
let branch = head_ref.to_string();
|
||||
let files = files.clone();
|
||||
h_flex()
|
||||
.id(("panel-github-file", i))
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.min_h(rems(ROW_H / 16.))
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.px(px(ROW_INSET))
|
||||
.py(px(3.))
|
||||
.rounded(ROW_FILL_RADIUS)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.when(selected, |s| s.bg(gpui::rgb(sf.selected)))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
// Built on click, not per frame: a hundred parsed patches is
|
||||
// real work, and only the one opened is looked at.
|
||||
let snapshot = Arc::new(DiffSnapshot {
|
||||
root: repo.root.clone(),
|
||||
source: source.clone(),
|
||||
branch: branch.clone(),
|
||||
files: files
|
||||
.iter()
|
||||
.map(|f| f.to_file_diff(DiffBudget::default()))
|
||||
.collect(),
|
||||
untracked: Vec::new(),
|
||||
untracked_total: 0,
|
||||
read_failed: false,
|
||||
});
|
||||
this.open_supplied_diff(repo.host, snapshot, Some(path.clone()), window, cx);
|
||||
}))
|
||||
.child(git_badge(status_glyph(deco), status_color(deco, cx), &mono))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink(1.)
|
||||
.min_w(px(40.))
|
||||
.truncate()
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(if selected {
|
||||
gpui::rgb(sf.text_selected)
|
||||
} else {
|
||||
gpui::rgb(sf.text_resting)
|
||||
})
|
||||
.child(name.to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis_start()
|
||||
.text_right()
|
||||
.text_size(rems(META))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(dir.to_string()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.gap(px(4.))
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family(mono.clone())
|
||||
.when(file.additions > 0, |d| {
|
||||
d.child(
|
||||
div()
|
||||
.text_color(added_ink)
|
||||
.child(format!("+{}", file.additions)),
|
||||
)
|
||||
})
|
||||
.when(file.deletions > 0, |d| {
|
||||
d.child(
|
||||
div()
|
||||
.text_color(removed_ink)
|
||||
.child(format!("−{}", file.deletions)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// Headings a step or two over the body rather than the document-sized ramp
|
||||
/// the text view defaults to: an issue's `## What happened?` is a label in a
|
||||
/// 280px column, not a page title.
|
||||
fn panel_markdown_style() -> gpui_component::text::TextViewStyle {
|
||||
gpui_component::text::TextViewStyle {
|
||||
heading_font_size: Some(Arc::new(|level, base| match level {
|
||||
1 => base * 1.25,
|
||||
2 => base * 1.15,
|
||||
_ => base * 1.05,
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn section_heading(text: String, cx: &gpui::App) -> AnyElement {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.min_h(px(22.))
|
||||
.px(px(TEXT_INSET))
|
||||
.text_size(rems(HEADING))
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Untrusted Markdown, made safe and rendered. An empty body says so.
|
||||
fn markdown_block(id: String, source: &str, empty: &str, cx: &gpui::App) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
if source.trim().is_empty() {
|
||||
return div()
|
||||
.px(px(TEXT_INSET))
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.italic()
|
||||
.child(empty.to_string())
|
||||
.into_any_element();
|
||||
}
|
||||
let safe = tty7_core::core::github::markdown::sanitize(source, t(L10nKey::GitHubImage));
|
||||
div()
|
||||
.px(px(TEXT_INSET))
|
||||
.min_w_0()
|
||||
.text_size(rems(TEXT))
|
||||
.child(
|
||||
gpui_component::text::TextView::markdown(SharedString::from(id), safe)
|
||||
.style(panel_markdown_style())
|
||||
.selectable(true),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn comment_block(
|
||||
slug: &RepoSlug,
|
||||
number: u64,
|
||||
c: &Comment,
|
||||
now: i64,
|
||||
fg: gpui::Hsla,
|
||||
muted: gpui::Hsla,
|
||||
cx: &gpui::App,
|
||||
) -> AnyElement {
|
||||
let when = (c.created_at > 0).then(|| relative_time(now, c.created_at));
|
||||
v_flex()
|
||||
.pt(px(14.))
|
||||
.gap(px(4.))
|
||||
.child(
|
||||
h_flex()
|
||||
.px(px(TEXT_INSET))
|
||||
.gap(px(6.))
|
||||
.items_baseline()
|
||||
.text_size(rems(META))
|
||||
.child(
|
||||
div()
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.text_color(fg)
|
||||
.child(c.author.clone()),
|
||||
)
|
||||
.children(when.map(|w| div().text_color(muted).child(w))),
|
||||
)
|
||||
.child(markdown_block(
|
||||
format!("gh-comment-{}-{number}-{}", slug.full(), c.id),
|
||||
&c.body,
|
||||
"",
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn more_on_github(id: &'static str, url: String, cx: &gpui::App) -> AnyElement {
|
||||
h_flex()
|
||||
.id(id)
|
||||
.mt(px(6.))
|
||||
.mx(px(TEXT_INSET))
|
||||
.gap(px(4.))
|
||||
.items_center()
|
||||
.cursor_pointer()
|
||||
.text_size(rems(META))
|
||||
.text_color(cx.theme().link)
|
||||
.hover(|s| s.underline())
|
||||
.on_click(move |_, _window, cx| cx.open_url(&url))
|
||||
.child(t(L10nKey::GitHubMoreOnGitHub))
|
||||
.child(Icon::new(IconName::ExternalLink).xsmall())
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
//! The right panel's GitHub tab: state, and the fetches behind it.
|
||||
//!
|
||||
//! Read-only issues and pull requests for the repository the active pane is
|
||||
//! in. The rendering lives in `ui::panel_github` (the list) and
|
||||
//! [`detail`] (one issue or pull request); this module owns what they draw
|
||||
//! from and how it gets there:
|
||||
//!
|
||||
//! - **Which repository.** The pane's working tree, resolved to its root the
|
||||
//! way the Source Control tab does (`scm_repo_root`), then its remotes read
|
||||
//! through the `Host` — the tree can be on another machine.
|
||||
//! - **Which token.** Resolved once, on a worker, from the environment or the
|
||||
//! GitHub CLI (`tty7_core::core::github::token`). Refresh resolves it again,
|
||||
//! so a `gh auth login` in a pane takes effect without a restart.
|
||||
//! - **The requests.** Made from *this* machine, never the remote host, on a
|
||||
//! dedicated thread each — a slow link must never hold a UI frame.
|
||||
//!
|
||||
//! Lists and details are cached per repository (not per pane), so flipping
|
||||
//! tabs or panes back and forth does not refetch; an entry older than
|
||||
//! [`STALE_AFTER`] is revalidated in the background while the old rows stay
|
||||
//! on screen.
|
||||
|
||||
pub(crate) mod detail;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{Context, Window};
|
||||
use tty7_core::core::github::api::{self, ListQuery};
|
||||
use tty7_core::core::github::{
|
||||
ApiError, Detail, GitHubRemote, Item, Kind, RepoSlug, StateFilter, Transport,
|
||||
};
|
||||
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::host_ops::SharedHost;
|
||||
use crate::ui::scm::panel::RepoLookup;
|
||||
use crate::ui::scm::state::RepoKey;
|
||||
|
||||
/// How old a cached list or detail may get before a render revalidates it.
|
||||
pub(crate) const STALE_AFTER: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A transport, signed in or not.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Connection {
|
||||
pub(crate) transport: Arc<dyn Transport>,
|
||||
}
|
||||
|
||||
/// Builds a [`Connection`]. Blocking — it may run `gh`. Tests install one that
|
||||
/// hands back a fixture transport, so no test ever reaches the network.
|
||||
pub(crate) type Connector = Arc<dyn Fn() -> Connection + Send + Sync>;
|
||||
|
||||
pub(crate) enum RemoteLookup {
|
||||
Loading,
|
||||
Ready(Arc<Vec<GitHubRemote>>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ListCache {
|
||||
pub(crate) items: Arc<Vec<Item>>,
|
||||
pub(crate) next_page: Option<u32>,
|
||||
pub(crate) loading: bool,
|
||||
/// Whether any answer has landed yet — tells "loading" from "empty".
|
||||
pub(crate) loaded: bool,
|
||||
pub(crate) error: Option<ApiError>,
|
||||
pub(crate) fetched: Option<Instant>,
|
||||
/// Bumped by every request; a reply carrying an older number is dropped,
|
||||
/// so a refresh's page 1 cannot be followed by a stale "load more".
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DetailCache {
|
||||
pub(crate) detail: Option<Arc<Detail>>,
|
||||
pub(crate) loading: bool,
|
||||
pub(crate) error: Option<ApiError>,
|
||||
pub(crate) fetched: Option<Instant>,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GitHubPanelState {
|
||||
pub(crate) connection: Option<Connection>,
|
||||
pub(crate) connecting: bool,
|
||||
/// `None` builds the real HTTPS transport.
|
||||
pub(crate) connector: Option<Connector>,
|
||||
pub(crate) remotes: HashMap<RepoKey, RemoteLookup>,
|
||||
/// The remote the user picked per repository, by remote name.
|
||||
pub(crate) remote_pick: HashMap<RepoKey, String>,
|
||||
/// Each checked-out branch's upstream (`origin/feat/x`, or `None` for a
|
||||
/// branch never pushed), for the Info tab's link. Keyed by branch, so a
|
||||
/// checkout of another branch asks again.
|
||||
pub(crate) upstreams: HashMap<(RepoKey, String), Option<String>>,
|
||||
pub(crate) upstream_lookups: std::collections::HashSet<(RepoKey, String)>,
|
||||
pub(crate) kind: Kind,
|
||||
pub(crate) state: StateFilter,
|
||||
pub(crate) label: Option<String>,
|
||||
pub(crate) lists: HashMap<ListQuery, ListCache>,
|
||||
pub(crate) details: HashMap<(RepoSlug, u64), DetailCache>,
|
||||
/// The issue or pull request whose detail replaces the list.
|
||||
pub(crate) open: Option<(RepoSlug, u64)>,
|
||||
pub(crate) list_scroll: gpui::ScrollHandle,
|
||||
pub(crate) detail_scroll: gpui::ScrollHandle,
|
||||
}
|
||||
|
||||
/// What the panel can say about the active pane's repository.
|
||||
pub(crate) enum GhTarget {
|
||||
NoPane,
|
||||
Pending,
|
||||
NotARepo,
|
||||
NoRemote,
|
||||
Ready {
|
||||
repo: RepoKey,
|
||||
remotes: Arc<Vec<GitHubRemote>>,
|
||||
chosen: GitHubRemote,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run blocking work on a thread of its own and await it from the UI.
|
||||
///
|
||||
/// Not gpui's background executor: a request can sit on a dead link for its
|
||||
/// whole timeout, and parking one of the executor's few workers for that long
|
||||
/// starves everything else scheduled on it.
|
||||
fn off_ui<T, F>(f: F) -> impl std::future::Future<Output = Option<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = smol::channel::bounded(1);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("tty7-github".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send_blocking(f());
|
||||
});
|
||||
async move {
|
||||
spawned.ok()?;
|
||||
rx.recv().await.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// The real connector: resolve a token (env, then `gh`), build the client.
|
||||
fn system_connector(proxy: Option<String>) -> Connector {
|
||||
Arc::new(move || {
|
||||
let token =
|
||||
tty7_core::core::github::token::resolve_token_from_system().map(|(t, source)| {
|
||||
log::info!("github: signed in via {source:?}");
|
||||
t
|
||||
});
|
||||
Connection {
|
||||
transport: Arc::new(tty7_core::core::github::http::HttpTransport::new(
|
||||
token,
|
||||
proxy.as_deref(),
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// The active pane's GitHub repository, resolving whatever is not known
|
||||
/// yet. Safe to call every frame: each lookup is dispatched once and
|
||||
/// cached.
|
||||
pub(crate) fn github_target(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> GhTarget {
|
||||
let Some((host, cwd)) = self.scm_pane_target(window, cx) else {
|
||||
return GhTarget::NoPane;
|
||||
};
|
||||
let root = match self.scm_repo_root(&host, &cwd, cx) {
|
||||
RepoLookup::Pending => return GhTarget::Pending,
|
||||
RepoLookup::NotARepo => return GhTarget::NotARepo,
|
||||
RepoLookup::Root(root) => root,
|
||||
};
|
||||
let repo = RepoKey {
|
||||
host: host.id(),
|
||||
root,
|
||||
};
|
||||
self.github_target_for(host, repo, cx)
|
||||
}
|
||||
|
||||
pub(crate) fn github_target_for(
|
||||
&mut self,
|
||||
host: SharedHost,
|
||||
repo: RepoKey,
|
||||
cx: &mut Context<Self>,
|
||||
) -> GhTarget {
|
||||
let remotes = match self.github.remotes.get(&repo) {
|
||||
Some(RemoteLookup::Ready(list)) => list.clone(),
|
||||
Some(RemoteLookup::Loading) => return GhTarget::Pending,
|
||||
None => {
|
||||
self.github_load_remotes(host, repo, cx);
|
||||
return GhTarget::Pending;
|
||||
}
|
||||
};
|
||||
let pick = self.github.remote_pick.get(&repo).map(String::as_str);
|
||||
match tty7_core::core::github::remote::default_remote(&remotes, pick) {
|
||||
Some(chosen) => {
|
||||
let chosen = chosen.clone();
|
||||
GhTarget::Ready {
|
||||
repo,
|
||||
remotes,
|
||||
chosen,
|
||||
}
|
||||
}
|
||||
None => GhTarget::NoRemote,
|
||||
}
|
||||
}
|
||||
|
||||
fn github_load_remotes(&mut self, host: SharedHost, repo: RepoKey, cx: &mut Context<Self>) {
|
||||
self.github
|
||||
.remotes
|
||||
.insert(repo.clone(), RemoteLookup::Loading);
|
||||
let root = repo.root.clone();
|
||||
crate::ui::host_ops::HostOps::run(
|
||||
host,
|
||||
cx,
|
||||
move |h| tty7_core::core::git::git(h, &root, &["remote", "-v"]),
|
||||
move |this, out, cx| {
|
||||
let remotes =
|
||||
tty7_core::core::github::remote::github_remotes(&out.unwrap_or_default());
|
||||
this.github
|
||||
.remotes
|
||||
.insert(repo, RemoteLookup::Ready(Arc::new(remotes)));
|
||||
cx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The connection, or `None` while one is being made.
|
||||
pub(crate) fn github_connection(&mut self, cx: &mut Context<Self>) -> Option<Connection> {
|
||||
if let Some(c) = &self.github.connection {
|
||||
return Some(c.clone());
|
||||
}
|
||||
if self.github.connecting {
|
||||
return None;
|
||||
}
|
||||
self.github.connecting = true;
|
||||
let connector = self.github.connector.clone().unwrap_or_else(|| {
|
||||
system_connector(
|
||||
cx.global::<crate::core::config::Config>()
|
||||
.http_proxy
|
||||
.clone(),
|
||||
)
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Some(connection) = off_ui(move || connector()).await else {
|
||||
return;
|
||||
};
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.github.connecting = false;
|
||||
this.github.connection = Some(connection);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
None
|
||||
}
|
||||
|
||||
/// Make sure `q`'s first page is loaded, or being revalidated when old.
|
||||
pub(crate) fn github_ensure_list(&mut self, q: &ListQuery, cx: &mut Context<Self>) {
|
||||
let due = match self.github.lists.get(q) {
|
||||
None => true,
|
||||
Some(c) => {
|
||||
!c.loading
|
||||
&& c.error.is_none()
|
||||
&& c.fetched.is_none_or(|t| t.elapsed() > STALE_AFTER)
|
||||
}
|
||||
};
|
||||
if due {
|
||||
self.github_fetch_list(q.clone(), 1, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch one page of `q` — page 1 replaces the list, later pages extend it.
|
||||
pub(crate) fn github_fetch_list(&mut self, q: ListQuery, page: u32, cx: &mut Context<Self>) {
|
||||
// Without a connection yet, nothing is recorded: the render the
|
||||
// connection's landing causes finds the query due and asks again.
|
||||
let Some(connection) = self.github_connection(cx) else {
|
||||
return;
|
||||
};
|
||||
let entry = self.github.lists.entry(q.clone()).or_default();
|
||||
entry.loading = true;
|
||||
entry.seq += 1;
|
||||
let seq = entry.seq;
|
||||
let query = q.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let transport = connection.transport.clone();
|
||||
let Some(result) = off_ui(move || api::list(&*transport, &query, page)).await else {
|
||||
return;
|
||||
};
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
let Some(entry) = this.github.lists.get_mut(&q).filter(|e| e.seq == seq) else {
|
||||
return;
|
||||
};
|
||||
entry.loading = false;
|
||||
entry.loaded = true;
|
||||
match result {
|
||||
Ok(got) => {
|
||||
let items = if page <= 1 {
|
||||
got.items
|
||||
} else {
|
||||
// A row that moved between pages while they were
|
||||
// read (it was updated, and the list sorts by
|
||||
// update time) is kept once, where it first was.
|
||||
let mut items = entry.items.as_ref().clone();
|
||||
for item in got.items {
|
||||
if !items.iter().any(|i| i.number == item.number) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
items
|
||||
};
|
||||
entry.items = Arc::new(items);
|
||||
entry.next_page = got.next_page;
|
||||
entry.error = None;
|
||||
entry.fetched = Some(Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("github: listing {}: {e}", q.slug.full());
|
||||
entry.error = Some(e);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Make sure one issue's or pull request's detail is loaded.
|
||||
pub(crate) fn github_ensure_detail(
|
||||
&mut self,
|
||||
slug: &RepoSlug,
|
||||
number: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let key = (slug.clone(), number);
|
||||
let due = match self.github.details.get(&key) {
|
||||
None => true,
|
||||
Some(c) => {
|
||||
!c.loading
|
||||
&& c.error.is_none()
|
||||
&& c.fetched.is_none_or(|t| t.elapsed() > STALE_AFTER)
|
||||
}
|
||||
};
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
let Some(connection) = self.github_connection(cx) else {
|
||||
return;
|
||||
};
|
||||
let entry = self.github.details.entry(key.clone()).or_default();
|
||||
entry.loading = true;
|
||||
entry.seq += 1;
|
||||
let seq = entry.seq;
|
||||
let slug = slug.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let transport = connection.transport.clone();
|
||||
let Some(result) = off_ui(move || api::detail(&*transport, &slug, number)).await else {
|
||||
return;
|
||||
};
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
let Some(entry) = this.github.details.get_mut(&key).filter(|e| e.seq == seq) else {
|
||||
return;
|
||||
};
|
||||
entry.loading = false;
|
||||
match result {
|
||||
Ok(d) => {
|
||||
entry.detail = Some(Arc::new(d));
|
||||
entry.error = None;
|
||||
entry.fetched = Some(Instant::now());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("github: reading {}#{}: {e}", key.0.full(), key.1);
|
||||
entry.error = Some(e);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Start over for the repository on screen: resolve the token again (a
|
||||
/// `gh auth login` since the last try counts), re-read the remotes, and
|
||||
/// mark the visible list and detail due.
|
||||
pub(crate) fn github_refresh(&mut self, repo: Option<RepoKey>, cx: &mut Context<Self>) {
|
||||
if !self.github.connecting {
|
||||
self.github.connection = None;
|
||||
}
|
||||
if let Some(repo) = repo {
|
||||
self.github.remotes.remove(&repo);
|
||||
}
|
||||
self.github.upstreams.clear();
|
||||
for entry in self.github.lists.values_mut() {
|
||||
entry.error = None;
|
||||
entry.fetched = None;
|
||||
entry.loading = false;
|
||||
// A new sequence number orphans whatever is still in flight.
|
||||
entry.seq += 1;
|
||||
}
|
||||
// Lists with nothing to show are dropped so they read as loading
|
||||
// rather than as an empty result.
|
||||
self.github.lists.retain(|_, e| e.loaded);
|
||||
for entry in self.github.details.values_mut() {
|
||||
entry.error = None;
|
||||
entry.fetched = None;
|
||||
entry.loading = false;
|
||||
entry.seq += 1;
|
||||
}
|
||||
self.github.details.retain(|_, e| e.detail.is_some());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The list query the panel shows for `slug`.
|
||||
pub(crate) fn github_query(&self, slug: &RepoSlug) -> ListQuery {
|
||||
ListQuery {
|
||||
slug: slug.clone(),
|
||||
kind: self.github.kind,
|
||||
state: self.github.state,
|
||||
label: self.github.label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn github_open_detail(
|
||||
&mut self,
|
||||
slug: RepoSlug,
|
||||
number: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.github.open = Some((slug, number));
|
||||
self.github.detail_scroll = gpui::ScrollHandle::new();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn github_close_detail(&mut self, cx: &mut Context<Self>) {
|
||||
self.github.open = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The GitHub page for the active pane's checkout, for the Info tab: the
|
||||
/// branch on the remote it tracks, or the repository when it was never
|
||||
/// pushed. `None` until the lookups land, and for a tree with no GitHub
|
||||
/// remote.
|
||||
pub(crate) fn github_info_link(
|
||||
&mut self,
|
||||
branch: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<(String, String)> {
|
||||
let GhTarget::Ready {
|
||||
repo,
|
||||
remotes,
|
||||
chosen,
|
||||
} = self.github_target(window, cx)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let key = (repo.clone(), branch.to_string());
|
||||
let upstream = match self.github.upstreams.get(&key) {
|
||||
Some(upstream) => upstream.clone(),
|
||||
None => {
|
||||
self.github_load_upstream(key, cx);
|
||||
None
|
||||
}
|
||||
};
|
||||
let (slug, url) =
|
||||
tty7_core::core::github::remote::checkout_url(&remotes, &chosen, upstream.as_deref());
|
||||
Some((slug.full(), url))
|
||||
}
|
||||
|
||||
fn github_load_upstream(&mut self, key: (RepoKey, String), cx: &mut Context<Self>) {
|
||||
if !self.github.upstream_lookups.insert(key.clone()) {
|
||||
return;
|
||||
}
|
||||
let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, key.0.host) else {
|
||||
return;
|
||||
};
|
||||
let root = key.0.root.clone();
|
||||
crate::ui::host_ops::HostOps::run(
|
||||
host,
|
||||
cx,
|
||||
move |h| {
|
||||
tty7_core::core::git::git(
|
||||
h,
|
||||
&root,
|
||||
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
)
|
||||
},
|
||||
move |this, out, cx| {
|
||||
this.github.upstream_lookups.remove(&key);
|
||||
let upstream = out
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
this.github.upstreams.insert(key, upstream);
|
||||
cx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the epoch, for relative times.
|
||||
pub(crate) fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
@@ -1995,6 +1995,46 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::TabContextWake => "Wake",
|
||||
L10nKey::TabTooltipAsleep => "Hibernated — select to wake",
|
||||
L10nKey::TabWakeFailed => "Could not wake the tab: none of its panes could be started",
|
||||
// ---- The right panel's GitHub tab ----
|
||||
L10nKey::GitHubIssues => "Issues",
|
||||
L10nKey::GitHubPulls => "Pull Requests",
|
||||
L10nKey::GitHubOpen => "Open",
|
||||
L10nKey::GitHubClosed => "Closed",
|
||||
L10nKey::GitHubMerged => "Merged",
|
||||
L10nKey::GitHubDraft => "Draft",
|
||||
L10nKey::GitHubNotPlanned => "Not planned",
|
||||
L10nKey::GitHubRefresh => "Refresh",
|
||||
L10nKey::GitHubOpenOnGitHub => "Open on GitHub",
|
||||
L10nKey::GitHubShowRemote => "Show issues from",
|
||||
L10nKey::GitHubLoadMore => "Load more",
|
||||
L10nKey::GitHubNoRemote => "No GitHub remote",
|
||||
L10nKey::GitHubNoRemoteHint => "None of this repository's remotes point at github.com.",
|
||||
L10nKey::GitHubNoIssues => "No issues match.",
|
||||
L10nKey::GitHubNoPulls => "No pull requests match.",
|
||||
L10nKey::GitHubSignInHint => "Sign in with `gh auth login` in a terminal, then refresh.",
|
||||
L10nKey::GitHubNotFoundSignedOut => {
|
||||
"GitHub did not find this repository. If it is private, sign in first."
|
||||
}
|
||||
L10nKey::GitHubNotFoundSignedIn => {
|
||||
"GitHub did not find this repository, or this account cannot see it."
|
||||
}
|
||||
L10nKey::GitHubUnauthorized => "GitHub rejected the saved sign-in.",
|
||||
L10nKey::GitHubRateLimited => "GitHub's rate limit is used up.",
|
||||
L10nKey::GitHubRateLimitResetIn => "It resets in {n} min.",
|
||||
L10nKey::GitHubRateLimitSignedOut => {
|
||||
"Signed out, GitHub allows 60 requests an hour. Sign in with `gh auth login` for more."
|
||||
}
|
||||
L10nKey::GitHubForbidden => "GitHub refused the request.",
|
||||
L10nKey::GitHubNetworkError => "Could not reach GitHub.",
|
||||
L10nKey::GitHubHttpError => "GitHub answered with an error ({code}).",
|
||||
L10nKey::GitHubDecodeError => "GitHub sent a response tty7 could not read.",
|
||||
L10nKey::GitHubMoreOnGitHub => "More on GitHub",
|
||||
L10nKey::GitHubNoDescription => "No description provided.",
|
||||
L10nKey::GitHubFilterByLabel => "Show only this label",
|
||||
L10nKey::GitHubClearLabel => "Clear label filter",
|
||||
L10nKey::GitHubImage => "image",
|
||||
L10nKey::GitHubComments => "{count} comments",
|
||||
L10nKey::GitHubCommits => "{count} commits",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2149,6 +2189,13 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::WindowDeleteShells, "other") => {
|
||||
"{count} running shells will be ended and the layout forgotten."
|
||||
}
|
||||
// ---- The right panel's GitHub tab ----
|
||||
(L10nKey::GitHubComments, "zero") => "No comments",
|
||||
(L10nKey::GitHubComments, "one") => "1 comment",
|
||||
(L10nKey::GitHubComments, "other") => "{count} comments",
|
||||
(L10nKey::GitHubCommits, "zero") => "No commits",
|
||||
(L10nKey::GitHubCommits, "one") => "1 commit",
|
||||
(L10nKey::GitHubCommits, "other") => "{count} commits",
|
||||
_ => return None,
|
||||
};
|
||||
Some(res)
|
||||
|
||||
@@ -2044,6 +2044,50 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::TabContextWake => "復帰",
|
||||
L10nKey::TabTooltipAsleep => "休止中 — 選択すると復帰",
|
||||
L10nKey::TabWakeFailed => "タブを復帰できませんでした:どのペインも起動できません",
|
||||
// ---- The right panel's GitHub tab ----
|
||||
L10nKey::GitHubIssues => "イシュー",
|
||||
L10nKey::GitHubPulls => "プルリクエスト",
|
||||
L10nKey::GitHubOpen => "オープン",
|
||||
L10nKey::GitHubClosed => "クローズ済み",
|
||||
L10nKey::GitHubMerged => "マージ済み",
|
||||
L10nKey::GitHubDraft => "下書き",
|
||||
L10nKey::GitHubNotPlanned => "対応予定なし",
|
||||
L10nKey::GitHubRefresh => "再読み込み",
|
||||
L10nKey::GitHubOpenOnGitHub => "GitHub で開く",
|
||||
L10nKey::GitHubShowRemote => "表示するリモート",
|
||||
L10nKey::GitHubLoadMore => "さらに読み込む",
|
||||
L10nKey::GitHubNoRemote => "GitHub リモートがありません",
|
||||
L10nKey::GitHubNoRemoteHint => {
|
||||
"このリポジトリのリモートはどれも github.com を指していません。"
|
||||
}
|
||||
L10nKey::GitHubNoIssues => "一致するイシューはありません。",
|
||||
L10nKey::GitHubNoPulls => "一致するプルリクエストはありません。",
|
||||
L10nKey::GitHubSignInHint => {
|
||||
"ターミナルで `gh auth login` を実行してサインインし、再読み込みしてください。"
|
||||
}
|
||||
L10nKey::GitHubNotFoundSignedOut => {
|
||||
"GitHub でこのリポジトリが見つかりません。非公開の場合は先にサインインしてください。"
|
||||
}
|
||||
L10nKey::GitHubNotFoundSignedIn => {
|
||||
"GitHub でこのリポジトリが見つからないか、このアカウントでは閲覧できません。"
|
||||
}
|
||||
L10nKey::GitHubUnauthorized => "GitHub が保存済みのサインイン情報を拒否しました。",
|
||||
L10nKey::GitHubRateLimited => "GitHub のレート制限に達しました。",
|
||||
L10nKey::GitHubRateLimitResetIn => "{n} 分後にリセットされます。",
|
||||
L10nKey::GitHubRateLimitSignedOut => {
|
||||
"サインインしていない場合、GitHub へのリクエストは 1 時間に 60 回までです。`gh auth login` でサインインすると上限が上がります。"
|
||||
}
|
||||
L10nKey::GitHubForbidden => "GitHub がリクエストを拒否しました。",
|
||||
L10nKey::GitHubNetworkError => "GitHub に接続できません。",
|
||||
L10nKey::GitHubHttpError => "GitHub がエラーを返しました({code})。",
|
||||
L10nKey::GitHubDecodeError => "GitHub から tty7 が読み取れない応答が返されました。",
|
||||
L10nKey::GitHubMoreOnGitHub => "続きは GitHub で",
|
||||
L10nKey::GitHubNoDescription => "説明はありません。",
|
||||
L10nKey::GitHubFilterByLabel => "このラベルのみ表示",
|
||||
L10nKey::GitHubClearLabel => "ラベルの絞り込みを解除",
|
||||
L10nKey::GitHubImage => "画像",
|
||||
L10nKey::GitHubComments => "{count} 件のコメント",
|
||||
L10nKey::GitHubCommits => "{count} 件のコミット",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2191,6 +2235,13 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::WindowDeleteShells, "other") => {
|
||||
"{count} 個の実行中シェルが終了し、レイアウトが消去されます"
|
||||
}
|
||||
// ---- The right panel's GitHub tab ----
|
||||
(L10nKey::GitHubComments, "zero") => "コメントはありません",
|
||||
(L10nKey::GitHubComments, "one") => "1 件のコメント",
|
||||
(L10nKey::GitHubComments, "other") => "{count} 件のコメント",
|
||||
(L10nKey::GitHubCommits, "zero") => "コミットはありません",
|
||||
(L10nKey::GitHubCommits, "one") => "1 件のコミット",
|
||||
(L10nKey::GitHubCommits, "other") => "{count} 件のコミット",
|
||||
_ => return None,
|
||||
};
|
||||
Some(res)
|
||||
|
||||
@@ -1567,6 +1567,40 @@ l10n_keys! {
|
||||
AppLocalServerAlreadyCurrent,
|
||||
RemoteUpdateBody,
|
||||
RemoteUpdateNeedsLocalServer,
|
||||
// ---- The right panel's GitHub tab ----
|
||||
GitHubIssues,
|
||||
GitHubPulls,
|
||||
GitHubOpen,
|
||||
GitHubClosed,
|
||||
GitHubMerged,
|
||||
GitHubDraft,
|
||||
GitHubNotPlanned,
|
||||
GitHubRefresh,
|
||||
GitHubOpenOnGitHub,
|
||||
GitHubShowRemote,
|
||||
GitHubLoadMore,
|
||||
GitHubNoRemote,
|
||||
GitHubNoRemoteHint,
|
||||
GitHubNoIssues,
|
||||
GitHubNoPulls,
|
||||
GitHubSignInHint,
|
||||
GitHubNotFoundSignedOut,
|
||||
GitHubNotFoundSignedIn,
|
||||
GitHubUnauthorized,
|
||||
GitHubRateLimited,
|
||||
GitHubRateLimitResetIn,
|
||||
GitHubRateLimitSignedOut,
|
||||
GitHubForbidden,
|
||||
GitHubNetworkError,
|
||||
GitHubHttpError,
|
||||
GitHubDecodeError,
|
||||
GitHubMoreOnGitHub,
|
||||
GitHubNoDescription,
|
||||
GitHubFilterByLabel,
|
||||
GitHubClearLabel,
|
||||
GitHubImage,
|
||||
GitHubComments,
|
||||
GitHubCommits,
|
||||
}
|
||||
|
||||
/// The source control strings that are translated but not yet displayed.
|
||||
@@ -1881,6 +1915,8 @@ mod tests {
|
||||
L10nKey::HomeTimeDaysAgo,
|
||||
L10nKey::HomeTimeWeeksAgo,
|
||||
L10nKey::HomeTimeMonthsAgo,
|
||||
L10nKey::GitHubComments,
|
||||
L10nKey::GitHubCommits,
|
||||
];
|
||||
for key in plural_keys {
|
||||
for branch in ["zero", "one", "other"] {
|
||||
|
||||
@@ -1876,6 +1876,42 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::TabContextWake => "唤醒",
|
||||
L10nKey::TabTooltipAsleep => "已休眠 — 选中即可唤醒",
|
||||
L10nKey::TabWakeFailed => "无法唤醒标签页:其中的窗格都无法启动",
|
||||
// ---- The right panel's GitHub tab ----
|
||||
L10nKey::GitHubIssues => "议题",
|
||||
L10nKey::GitHubPulls => "拉取请求",
|
||||
L10nKey::GitHubOpen => "开放",
|
||||
L10nKey::GitHubClosed => "已关闭",
|
||||
L10nKey::GitHubMerged => "已合并",
|
||||
L10nKey::GitHubDraft => "草稿",
|
||||
L10nKey::GitHubNotPlanned => "不予计划",
|
||||
L10nKey::GitHubRefresh => "刷新",
|
||||
L10nKey::GitHubOpenOnGitHub => "在 GitHub 上打开",
|
||||
L10nKey::GitHubShowRemote => "显示此远程的议题",
|
||||
L10nKey::GitHubLoadMore => "加载更多",
|
||||
L10nKey::GitHubNoRemote => "没有 GitHub 远程仓库",
|
||||
L10nKey::GitHubNoRemoteHint => "此仓库的远程都不指向 github.com。",
|
||||
L10nKey::GitHubNoIssues => "没有匹配的议题。",
|
||||
L10nKey::GitHubNoPulls => "没有匹配的拉取请求。",
|
||||
L10nKey::GitHubSignInHint => "在终端中运行 `gh auth login` 登录,然后刷新。",
|
||||
L10nKey::GitHubNotFoundSignedOut => "GitHub 找不到此仓库。如果它是私有仓库,请先登录。",
|
||||
L10nKey::GitHubNotFoundSignedIn => "GitHub 找不到此仓库,或当前账户无权查看。",
|
||||
L10nKey::GitHubUnauthorized => "GitHub 拒绝了已保存的登录凭据。",
|
||||
L10nKey::GitHubRateLimited => "GitHub 的请求额度已用完。",
|
||||
L10nKey::GitHubRateLimitResetIn => "{n} 分钟后重置。",
|
||||
L10nKey::GitHubRateLimitSignedOut => {
|
||||
"未登录时 GitHub 每小时只允许 60 次请求。运行 `gh auth login` 登录可提高额度。"
|
||||
}
|
||||
L10nKey::GitHubForbidden => "GitHub 拒绝了该请求。",
|
||||
L10nKey::GitHubNetworkError => "无法连接到 GitHub。",
|
||||
L10nKey::GitHubHttpError => "GitHub 返回了错误({code})。",
|
||||
L10nKey::GitHubDecodeError => "GitHub 返回了 tty7 无法解析的响应。",
|
||||
L10nKey::GitHubMoreOnGitHub => "在 GitHub 上查看更多",
|
||||
L10nKey::GitHubNoDescription => "未提供描述。",
|
||||
L10nKey::GitHubFilterByLabel => "只显示此标签",
|
||||
L10nKey::GitHubClearLabel => "清除标签筛选",
|
||||
L10nKey::GitHubImage => "图片",
|
||||
L10nKey::GitHubComments => "{count} 条评论",
|
||||
L10nKey::GitHubCommits => "{count} 个提交",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1999,6 +2035,13 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::WindowDeleteShells, "other") => {
|
||||
"{count} 个正在运行的 shell 将会被终止,布局也将被清除。"
|
||||
}
|
||||
// ---- The right panel's GitHub tab ----
|
||||
(L10nKey::GitHubComments, "zero") => "暂无评论",
|
||||
(L10nKey::GitHubComments, "one") => "1 条评论",
|
||||
(L10nKey::GitHubComments, "other") => "{count} 条评论",
|
||||
(L10nKey::GitHubCommits, "zero") => "没有提交",
|
||||
(L10nKey::GitHubCommits, "one") => "1 个提交",
|
||||
(L10nKey::GitHubCommits, "other") => "{count} 个提交",
|
||||
_ => return None,
|
||||
};
|
||||
Some(res)
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod document_column;
|
||||
pub mod file_copy;
|
||||
pub mod file_tree;
|
||||
pub mod forwards;
|
||||
pub mod github;
|
||||
pub mod hints;
|
||||
pub mod home;
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
//! The right panel's GitHub tab.
|
||||
//! The right panel's GitHub tab: the list of issues or pull requests.
|
||||
//!
|
||||
//! State and fetching live in [`crate::ui::github`]; the single-item view in
|
||||
//! [`crate::ui::github::detail`]. This file draws the chrome over the list —
|
||||
//! which repository, which kind, which state, which label — and the rows.
|
||||
|
||||
use gpui::{AnyElement, Context, Window};
|
||||
use gpui::{AnyElement, Context, SharedString, Window, div, prelude::*, px, relative, rems};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::i18n::{L10nKey, t};
|
||||
use tty7_core::core::github::{
|
||||
ApiError, GitHubRemote, Item, ItemState, Kind, Label, RepoSlug, StateFilter,
|
||||
};
|
||||
|
||||
use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App};
|
||||
use crate::ui::github::{GhTarget, now_unix};
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
use crate::ui::right_panel::{META, ROW_FILL_RADIUS, ROW_INSET, TAB_TEXT, TEXT, TEXT_INSET};
|
||||
use crate::ui::scm::path::relative_time;
|
||||
use crate::ui::scm::state::RepoKey;
|
||||
|
||||
/// A list row: the title line and the meta line under it.
|
||||
const ROW_MIN_H: f32 = 44.;
|
||||
/// The state glyph, and the column it sits in.
|
||||
const GLYPH: f32 = 14.;
|
||||
/// The pinned rows' height — the Info tab's row pitch.
|
||||
const PINNED_ROW_H: f32 = 28.;
|
||||
/// The segmented switches' cells.
|
||||
const SWITCH_H: f32 = 22.;
|
||||
|
||||
impl Tty7App {
|
||||
pub(crate) fn render_panel_github(
|
||||
@@ -11,8 +35,872 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let title = self.panel_title(t(L10nKey::PanelGitHubTitle), None, None, window, cx);
|
||||
let body = self.panel_empty(t(L10nKey::PanelGitHubTitle), None, cx);
|
||||
self.panel_scroll(body, title)
|
||||
let target = self.github_target(window, cx);
|
||||
let refresh_repo = match &target {
|
||||
GhTarget::Ready { repo, .. } => Some(repo.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let refresh = self.github_refresh_tile(refresh_repo, cx);
|
||||
let title = self.panel_title(
|
||||
t(L10nKey::PanelGitHubTitle),
|
||||
None,
|
||||
Some(refresh),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (repo, remotes, chosen) = match target {
|
||||
GhTarget::NoPane => {
|
||||
let body = self.panel_empty(
|
||||
t(L10nKey::PanelNoWorkingDirectory),
|
||||
Some(t(L10nKey::PanelNoWorkingDirectoryHint)),
|
||||
cx,
|
||||
);
|
||||
return self.github_shell(title, Vec::new(), body, false);
|
||||
}
|
||||
GhTarget::Pending => {
|
||||
let body = self.panel_empty(t(L10nKey::PanelLoading), None, cx);
|
||||
return self.github_shell(title, Vec::new(), body, false);
|
||||
}
|
||||
GhTarget::NotARepo => {
|
||||
let body = self.panel_empty(
|
||||
t(L10nKey::PanelNotAGitRepo),
|
||||
Some(t(L10nKey::PanelNotAGitRepoHint)),
|
||||
cx,
|
||||
);
|
||||
return self.github_shell(title, Vec::new(), body, false);
|
||||
}
|
||||
GhTarget::NoRemote => {
|
||||
let body = self.panel_empty(
|
||||
t(L10nKey::GitHubNoRemote),
|
||||
Some(t(L10nKey::GitHubNoRemoteHint)),
|
||||
cx,
|
||||
);
|
||||
return self.github_shell(title, Vec::new(), body, false);
|
||||
}
|
||||
GhTarget::Ready {
|
||||
repo,
|
||||
remotes,
|
||||
chosen,
|
||||
} => (repo, remotes, chosen),
|
||||
};
|
||||
|
||||
// A detail belongs to the repository it was opened from. If the pane
|
||||
// has since moved to another one, the list of that one is what the
|
||||
// panel is about now.
|
||||
if let Some((slug, number)) = self.github.open.clone() {
|
||||
if slug == chosen.slug {
|
||||
let (pinned, body) = self.render_github_detail(&repo, &slug, number, window, cx);
|
||||
return self.github_shell(title, pinned, body, true);
|
||||
}
|
||||
self.github.open = None;
|
||||
}
|
||||
|
||||
let query = self.github_query(&chosen.slug);
|
||||
self.github_ensure_list(&query, cx);
|
||||
|
||||
let mut pinned = vec![
|
||||
self.github_repo_row(&repo, &remotes, &chosen, cx),
|
||||
self.github_switch_row(cx),
|
||||
];
|
||||
if let Some(label) = self.github.label.clone() {
|
||||
pinned.push(self.github_label_filter_row(&label, cx));
|
||||
}
|
||||
let body = self.github_list_body(&chosen.slug, cx);
|
||||
self.github_shell(title, pinned, body, false)
|
||||
}
|
||||
|
||||
/// Title, the rows pinned under it, and a body that scrolls on its own.
|
||||
fn github_shell(
|
||||
&self,
|
||||
title: AnyElement,
|
||||
pinned: Vec<AnyElement>,
|
||||
body: AnyElement,
|
||||
detail: bool,
|
||||
) -> AnyElement {
|
||||
let scroll = if detail {
|
||||
&self.github.detail_scroll
|
||||
} else {
|
||||
&self.github.list_scroll
|
||||
};
|
||||
let scroller = div()
|
||||
.id(if detail {
|
||||
"panel-github-detail"
|
||||
} else {
|
||||
"panel-github-list"
|
||||
})
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(scroll)
|
||||
.child(body);
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(title)
|
||||
.children(pinned)
|
||||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"panel-github-scrollbar",
|
||||
scroller,
|
||||
scroll,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn github_refresh_tile(&self, repo: Option<RepoKey>, cx: &mut Context<Self>) -> AnyElement {
|
||||
let busy = self.github.connecting
|
||||
|| self.github.lists.values().any(|l| l.loading)
|
||||
|| self.github.details.values().any(|d| d.loading);
|
||||
github_tile(
|
||||
"panel-github-refresh",
|
||||
if busy {
|
||||
Icon::new(IconName::LoaderCircle)
|
||||
} else {
|
||||
Icon::empty().path("icons/refresh.svg")
|
||||
},
|
||||
t(L10nKey::GitHubRefresh),
|
||||
cx,
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.github_refresh(repo.clone(), cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// `owner/name` with the remote it came from, a picker when there are
|
||||
/// several, and a way out to the browser.
|
||||
fn github_repo_row(
|
||||
&self,
|
||||
repo: &RepoKey,
|
||||
remotes: &[GitHubRemote],
|
||||
chosen: &GitHubRemote,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let fg = cx.theme().foreground;
|
||||
let url = chosen.slug.web_url();
|
||||
let name = h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.items_baseline()
|
||||
.gap(px(6.))
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.text_color(fg)
|
||||
.child(chosen.slug.full()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.child(chosen.remote.clone()),
|
||||
);
|
||||
let picker: AnyElement = if remotes.len() > 1 {
|
||||
let app = cx.entity().downgrade();
|
||||
let repo = repo.clone();
|
||||
let remotes: Vec<GitHubRemote> = remotes.to_vec();
|
||||
let current = chosen.remote.clone();
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.ml(px(-6.))
|
||||
.child(
|
||||
Button::new("panel-github-remote")
|
||||
.ghost()
|
||||
.small()
|
||||
.dropdown_caret(true)
|
||||
.child(name.line_height(relative(1.)))
|
||||
.w_full()
|
||||
.h(rems(24. / 16.))
|
||||
.pl(px(6.))
|
||||
.rounded(px(5.))
|
||||
.dropdown_menu_with_anchor(
|
||||
gpui::Anchor::TopLeft,
|
||||
move |menu, _window, _cx| {
|
||||
let mut menu = menu
|
||||
.min_w(px(200.))
|
||||
.item(PopupMenuItem::label(t(L10nKey::GitHubShowRemote)));
|
||||
for r in &remotes {
|
||||
let label = format!("{} {}", r.remote, r.slug.full());
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(label)
|
||||
.checked(r.remote == current)
|
||||
.on_click({
|
||||
let app = app.clone();
|
||||
let repo = repo.clone();
|
||||
let name = r.remote.clone();
|
||||
move |_, _window, cx| {
|
||||
let _ = app.update(cx, |this, cx| {
|
||||
this.github
|
||||
.remote_pick
|
||||
.insert(repo.clone(), name.clone());
|
||||
this.github.open = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
menu
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
name.into_any_element()
|
||||
};
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(6.))
|
||||
.min_h(px(PINNED_ROW_H))
|
||||
.pl(px(TEXT_INSET))
|
||||
.pr(px(CONTENT_INSET))
|
||||
.text_size(rems(TEXT))
|
||||
.child(
|
||||
Icon::empty()
|
||||
.path("icons/github.svg")
|
||||
.xsmall()
|
||||
.text_color(muted),
|
||||
)
|
||||
.child(picker)
|
||||
.child(
|
||||
github_tile(
|
||||
"panel-github-open-repo",
|
||||
Icon::new(IconName::ExternalLink),
|
||||
t(L10nKey::GitHubOpenOnGitHub),
|
||||
cx,
|
||||
)
|
||||
.on_click(move |_, _window, cx| cx.open_url(&url)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Issues | Pull Requests on the left, Open | Closed on the right.
|
||||
fn github_switch_row(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let kind = self.github.kind;
|
||||
let state = self.github.state;
|
||||
let kinds = [
|
||||
(Kind::Issues, t(L10nKey::GitHubIssues)),
|
||||
(Kind::Pulls, t(L10nKey::GitHubPulls)),
|
||||
];
|
||||
let states = [
|
||||
(StateFilter::Open, t(L10nKey::GitHubOpen)),
|
||||
(StateFilter::Closed, t(L10nKey::GitHubClosed)),
|
||||
];
|
||||
let kind_cells = kinds.into_iter().enumerate().map(|(i, (k, label))| {
|
||||
switch_cell(("panel-github-kind", i), label, k == kind, cx)
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.github.kind = k;
|
||||
this.github.list_scroll = gpui::ScrollHandle::new();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
});
|
||||
let state_cells = states.into_iter().enumerate().map(|(i, (s, label))| {
|
||||
switch_cell(("panel-github-state", i), label, s == state, cx)
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.github.state = s;
|
||||
this.github.list_scroll = gpui::ScrollHandle::new();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
});
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.flex_wrap()
|
||||
.gap(px(4.))
|
||||
.min_h(px(PINNED_ROW_H))
|
||||
.px(px(CONTENT_INSET))
|
||||
.pb(px(4.))
|
||||
.child(h_flex().gap(px(2.)).children(kind_cells))
|
||||
.child(h_flex().gap(px(2.)).children(state_cells))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn github_label_filter_row(&self, label: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
let known = self
|
||||
.github
|
||||
.lists
|
||||
.values()
|
||||
.flat_map(|l| l.items.iter())
|
||||
.flat_map(|i| i.labels.iter())
|
||||
.find(|l| l.name == label)
|
||||
.cloned()
|
||||
.unwrap_or(Label {
|
||||
name: label.to_string(),
|
||||
color: None,
|
||||
});
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(4.))
|
||||
.min_h(px(PINNED_ROW_H - 4.))
|
||||
.px(px(TEXT_INSET))
|
||||
.pb(px(4.))
|
||||
.child(label_chip(&known, cx))
|
||||
.child(
|
||||
github_tile(
|
||||
"panel-github-clear-label",
|
||||
Icon::new(IconName::Close),
|
||||
t(L10nKey::GitHubClearLabel),
|
||||
cx,
|
||||
)
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
this.github.label = None;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn github_list_body(&mut self, slug: &RepoSlug, cx: &mut Context<Self>) -> AnyElement {
|
||||
let query = self.github_query(slug);
|
||||
let authenticated = self.github_authenticated();
|
||||
let Some(cache) = self.github.lists.get(&query) else {
|
||||
return self.panel_empty(t(L10nKey::PanelLoading), None, cx);
|
||||
};
|
||||
let items = cache.items.clone();
|
||||
let error = cache.error.clone();
|
||||
let next_page = cache.next_page;
|
||||
let loading = cache.loading;
|
||||
let loaded = cache.loaded;
|
||||
|
||||
let mut body = v_flex().pb(px(12.));
|
||||
if let Some(err) = &error {
|
||||
let (text, hint) = describe_error(err, authenticated);
|
||||
body = body.child(self.panel_empty(&text, hint.as_deref(), cx));
|
||||
}
|
||||
if items.is_empty() {
|
||||
if error.is_none() {
|
||||
let text = if loaded && !loading {
|
||||
match query.kind {
|
||||
Kind::Issues => t(L10nKey::GitHubNoIssues),
|
||||
Kind::Pulls => t(L10nKey::GitHubNoPulls),
|
||||
}
|
||||
} else {
|
||||
t(L10nKey::PanelLoading)
|
||||
};
|
||||
body = body.child(self.panel_empty(text, None, cx));
|
||||
}
|
||||
return body.into_any_element();
|
||||
}
|
||||
let now = now_unix();
|
||||
let mut rows = v_flex().px(px(CONTENT_INSET));
|
||||
for item in items.iter() {
|
||||
rows = rows.child(self.github_item_row(slug, item, now, cx));
|
||||
}
|
||||
body = body.child(rows);
|
||||
if let Some(page) = next_page {
|
||||
let q = query.clone();
|
||||
body = body.child(
|
||||
h_flex().px(px(CONTENT_INSET)).pt(px(4.)).child(
|
||||
Button::new("panel-github-more")
|
||||
.ghost()
|
||||
.small()
|
||||
.w_full()
|
||||
.loading(loading)
|
||||
.label(t(L10nKey::GitHubLoadMore))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.github_fetch_list(q.clone(), page, cx);
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
body.into_any_element()
|
||||
}
|
||||
|
||||
fn github_item_row(
|
||||
&self,
|
||||
slug: &RepoSlug,
|
||||
item: &Item,
|
||||
now: i64,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
let number = item.number;
|
||||
let slug = slug.clone();
|
||||
let mut meta = format!("#{number}");
|
||||
if !item.author.is_empty() {
|
||||
meta.push_str(" · ");
|
||||
meta.push_str(&item.author);
|
||||
}
|
||||
if item.updated_at > 0 {
|
||||
meta.push_str(" · ");
|
||||
meta.push_str(&relative_time(now, item.updated_at));
|
||||
}
|
||||
let labels = item.labels.iter().enumerate().map(|(i, label)| {
|
||||
let name = label.name.clone();
|
||||
div()
|
||||
.id(SharedString::from(format!(
|
||||
"panel-github-label-{number}-{i}"
|
||||
)))
|
||||
.flex_none()
|
||||
.cursor_pointer()
|
||||
.tooltip(|window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(t(L10nKey::GitHubFilterByLabel))
|
||||
.build(window, cx)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.github.label = Some(name.clone());
|
||||
this.github.list_scroll = gpui::ScrollHandle::new();
|
||||
cx.notify();
|
||||
}))
|
||||
.child(label_chip(label, cx))
|
||||
});
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("panel-github-item-{number}")))
|
||||
.items_start()
|
||||
.gap(px(8.))
|
||||
.min_h(px(ROW_MIN_H))
|
||||
.w_full()
|
||||
.px(px(ROW_INSET))
|
||||
.py(px(5.))
|
||||
.rounded(ROW_FILL_RADIUS)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.github_open_detail(slug.clone(), number, cx);
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.pt(px(2.))
|
||||
.child(state_glyph(item.state, item.is_pr, cx)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap(px(2.))
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(gpui::rgb(sf.text_resting))
|
||||
.child(item.title.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.min_w_0()
|
||||
.gap(px(4.))
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(rems(META))
|
||||
.font_family(mono)
|
||||
.text_color(muted)
|
||||
.child(meta),
|
||||
)
|
||||
.children(labels),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Whether requests go out signed in. Unknown (still connecting) reads as
|
||||
/// signed in, so no sign-in advice flashes up while the token resolves.
|
||||
pub(crate) fn github_authenticated(&self) -> bool {
|
||||
self.github
|
||||
.connection
|
||||
.as_ref()
|
||||
.is_none_or(|c| c.transport.authenticated())
|
||||
}
|
||||
}
|
||||
|
||||
/// A 18px chrome tile, the Info rows' size.
|
||||
pub(crate) fn github_tile(
|
||||
id: &'static str,
|
||||
icon: Icon,
|
||||
tooltip: &'static str,
|
||||
cx: &gpui::App,
|
||||
) -> Button {
|
||||
crate::ui::tab_strip::chrome_tile_sized(
|
||||
Button::new(id).icon(icon),
|
||||
TILE_SIZE_XS,
|
||||
TILE_GLYPH_XS,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.rounded(px(4.))
|
||||
.tooltip(tooltip)
|
||||
}
|
||||
|
||||
fn switch_cell(
|
||||
id: (&'static str, usize),
|
||||
label: &'static str,
|
||||
live: bool,
|
||||
cx: &gpui::App,
|
||||
) -> gpui::Stateful<gpui::Div> {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
h_flex()
|
||||
.id(id)
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.h(px(SWITCH_H))
|
||||
.px(px(8.))
|
||||
.rounded(px(6.))
|
||||
.text_size(rems(TAB_TEXT))
|
||||
.whitespace_nowrap()
|
||||
.cursor_pointer()
|
||||
.when(live, |cell| {
|
||||
cell.bg(gpui::rgb(sf.selected))
|
||||
.text_color(gpui::rgb(sf.text_selected))
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
})
|
||||
.when(!live, |cell| {
|
||||
cell.text_color(cx.theme().muted_foreground)
|
||||
.hover(|h| h.bg(gpui::rgb(sf.hover)))
|
||||
})
|
||||
.active(|cell| cell.bg(gpui::rgb(sf.pressed)))
|
||||
.child(label)
|
||||
}
|
||||
|
||||
/// The glyph for a state: a distinct *shape* for each (open, closed, not
|
||||
/// planned, merged, draft), then GitHub's own colour family on top — green
|
||||
/// open, purple done, red closed-unmerged, grey not planned or draft.
|
||||
pub(crate) fn state_glyph(state: ItemState, is_pr: bool, cx: &gpui::App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let purple = gpui::hsla(270. / 360., 0.55, 0.6, 1.);
|
||||
let (path, color) = match (state, is_pr) {
|
||||
(ItemState::Open, false) => ("icons/github/issue-open.svg", theme.success),
|
||||
(ItemState::Closed, false) => ("icons/github/issue-closed.svg", purple),
|
||||
(ItemState::NotPlanned, _) => {
|
||||
("icons/github/issue-not-planned.svg", theme.muted_foreground)
|
||||
}
|
||||
(ItemState::Draft, _) => ("icons/github/pr-draft.svg", theme.muted_foreground),
|
||||
(ItemState::Open, true) => ("icons/github/pr-open.svg", theme.success),
|
||||
(ItemState::Closed, true) => ("icons/github/pr-closed.svg", theme.danger),
|
||||
(ItemState::Merged, _) => ("icons/github/pr-merged.svg", purple),
|
||||
};
|
||||
gpui::svg()
|
||||
.path(path)
|
||||
.flex_none()
|
||||
.size(px(GLYPH))
|
||||
.text_color(color)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn state_label(state: ItemState) -> &'static str {
|
||||
t(match state {
|
||||
ItemState::Open => L10nKey::GitHubOpen,
|
||||
ItemState::Draft => L10nKey::GitHubDraft,
|
||||
ItemState::Closed => L10nKey::GitHubClosed,
|
||||
ItemState::NotPlanned => L10nKey::GitHubNotPlanned,
|
||||
ItemState::Merged => L10nKey::GitHubMerged,
|
||||
})
|
||||
}
|
||||
|
||||
/// A label in GitHub's colour: a faint fill of it, and the ink of it pulled
|
||||
/// toward the theme's foreground so a pale yellow label stays readable.
|
||||
pub(crate) fn label_chip(label: &Label, cx: &gpui::App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let (fill, ink) = match label.color {
|
||||
Some(rgb) => {
|
||||
let c: gpui::Hsla = gpui::rgb(rgb).into();
|
||||
let l = if theme.mode.is_dark() {
|
||||
c.l.max(0.65)
|
||||
} else {
|
||||
c.l.min(0.38)
|
||||
};
|
||||
(c.opacity(0.18), gpui::hsla(c.h, c.s.min(0.8), l, 1.))
|
||||
}
|
||||
None => (theme.foreground.opacity(0.08), theme.muted_foreground),
|
||||
};
|
||||
div()
|
||||
.flex_none()
|
||||
.px(px(6.))
|
||||
.py(px(0.5))
|
||||
.rounded(px(8.))
|
||||
.bg(fill)
|
||||
.text_size(rems(META))
|
||||
.text_color(ink)
|
||||
.whitespace_nowrap()
|
||||
.child(label.name.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// What to tell the user about a failed call, and what they can do about it.
|
||||
pub(crate) fn describe_error(err: &ApiError, authenticated: bool) -> (String, Option<String>) {
|
||||
let sign_in = || Some(t(L10nKey::GitHubSignInHint).to_string());
|
||||
match err {
|
||||
ApiError::NotFound if authenticated => (t(L10nKey::GitHubNotFoundSignedIn).into(), None),
|
||||
ApiError::NotFound => (t(L10nKey::GitHubNotFoundSignedOut).into(), sign_in()),
|
||||
ApiError::Unauthorized => (t(L10nKey::GitHubUnauthorized).into(), sign_in()),
|
||||
ApiError::RateLimited { reset } => {
|
||||
let mut hint: Vec<String> = Vec::new();
|
||||
if let Some(at) = reset {
|
||||
let minutes = ((at - now_unix()).max(0) + 59) / 60;
|
||||
hint.push(t_fmt(
|
||||
L10nKey::GitHubRateLimitResetIn,
|
||||
&[("n", &minutes.max(1).to_string())],
|
||||
));
|
||||
}
|
||||
if !authenticated {
|
||||
hint.push(t(L10nKey::GitHubRateLimitSignedOut).to_string());
|
||||
}
|
||||
(
|
||||
t(L10nKey::GitHubRateLimited).into(),
|
||||
(!hint.is_empty()).then(|| hint.join(" ")),
|
||||
)
|
||||
}
|
||||
ApiError::Forbidden(msg) => (
|
||||
t(L10nKey::GitHubForbidden).into(),
|
||||
(!msg.is_empty()).then(|| msg.clone()),
|
||||
),
|
||||
ApiError::Network(msg) => (t(L10nKey::GitHubNetworkError).into(), Some(msg.clone())),
|
||||
ApiError::Http(code) => (
|
||||
t_fmt(L10nKey::GitHubHttpError, &[("code", &code.to_string())]),
|
||||
None,
|
||||
),
|
||||
ApiError::Decode(_) => (t(L10nKey::GitHubDecodeError).into(), None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_signed_out_rate_limit_says_when_and_how_to_raise_it() {
|
||||
crate::ui::i18n::set_locale("en");
|
||||
let reset = now_unix() + 10 * 60;
|
||||
let (text, hint) = describe_error(&ApiError::RateLimited { reset: Some(reset) }, false);
|
||||
assert_eq!(text, t(L10nKey::GitHubRateLimited));
|
||||
let hint = hint.expect("a rate limit explains itself");
|
||||
assert!(hint.contains("10 min"), "{hint}");
|
||||
assert!(hint.contains("gh auth login"), "{hint}");
|
||||
|
||||
let (_, hint) = describe_error(&ApiError::RateLimited { reset: None }, true);
|
||||
assert!(
|
||||
hint.is_none(),
|
||||
"signed in, with no reset time, there is nothing to add"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_404_is_explained_by_whether_the_request_was_signed_in() {
|
||||
crate::ui::i18n::set_locale("en");
|
||||
let (out, hint) = describe_error(&ApiError::NotFound, false);
|
||||
assert_eq!(out, t(L10nKey::GitHubNotFoundSignedOut));
|
||||
assert!(hint.unwrap().contains("gh auth login"));
|
||||
let (inn, hint) = describe_error(&ApiError::NotFound, true);
|
||||
assert_eq!(inn, t(L10nKey::GitHubNotFoundSignedIn));
|
||||
assert!(hint.is_none());
|
||||
let (_, hint) = describe_error(&ApiError::Unauthorized, true);
|
||||
assert!(hint.unwrap().contains("gh auth login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_error_has_something_to_say() {
|
||||
crate::ui::i18n::set_locale("en");
|
||||
for err in [
|
||||
ApiError::Unauthorized,
|
||||
ApiError::NotFound,
|
||||
ApiError::RateLimited { reset: None },
|
||||
ApiError::Forbidden(String::new()),
|
||||
ApiError::Network("dns".into()),
|
||||
ApiError::Http(502),
|
||||
ApiError::Decode("eof".into()),
|
||||
] {
|
||||
let (text, _) = describe_error(&err, true);
|
||||
assert!(!text.is_empty(), "{err:?}");
|
||||
}
|
||||
assert!(describe_error(&ApiError::Http(502), true).0.contains("502"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab end to end against a real repository and a fixture transport: the
|
||||
/// remote is read through the host, the list and a detail are fetched, and
|
||||
/// nothing reaches the network.
|
||||
#[cfg(test)]
|
||||
mod gpui_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||||
use tty7_core::core::config::RightPanelTab;
|
||||
use tty7_core::core::github::{ApiError, Kind, Reply, Transport};
|
||||
|
||||
use crate::daemon::protocol::DaemonMsg;
|
||||
use crate::ui::app::{Tty7App, test_window};
|
||||
use crate::ui::github::Connection;
|
||||
|
||||
struct Fake {
|
||||
asked: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Transport for Fake {
|
||||
fn get(&self, path: &str) -> Result<Reply, ApiError> {
|
||||
self.asked.lock().unwrap().push(path.to_string());
|
||||
let body = if path.starts_with("/repos/acme/widgets/issues?") {
|
||||
r#"[{"number": 7, "title": "Widgets wobble", "state": "open",
|
||||
"user": {"login": "ada"}, "labels": [{"name": "bug", "color": "d73a4a"}],
|
||||
"comments": 1, "created_at": "2026-09-01T10:00:00Z",
|
||||
"updated_at": "2026-09-02T10:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/issues/7"},
|
||||
{"number": 8, "title": "A pull request", "state": "open",
|
||||
"pull_request": {"merged_at": null},
|
||||
"created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-09-02T10:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/8"}]"#
|
||||
} else if path == "/repos/acme/widgets/issues/7" {
|
||||
r#"{"number": 7, "title": "Widgets wobble", "state": "open",
|
||||
"user": {"login": "ada"}, "labels": [], "comments": 1,
|
||||
"created_at": "2026-09-01T10:00:00Z", "updated_at": "2026-09-02T10:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/issues/7",
|
||||
"body": "They **wobble**. "}"#
|
||||
} else if path.starts_with("/repos/acme/widgets/issues/7/comments") {
|
||||
r#"[{"id": 1, "user": {"login": "bob"}, "body": "Seen it too.",
|
||||
"created_at": "2026-09-02T10:00:00Z", "html_url": "https://x/1"}]"#
|
||||
} else {
|
||||
return Err(ApiError::NotFound);
|
||||
};
|
||||
Ok(Reply {
|
||||
body: body.as_bytes().to_vec(),
|
||||
has_next: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticated(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-gh-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::canonicalize(&dir).unwrap()
|
||||
}
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
assert!(out.status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn settle(
|
||||
app: &Entity<Tty7App>,
|
||||
vcx: &mut VisualTestContext,
|
||||
what: &str,
|
||||
until: impl Fn(&Tty7App) -> bool,
|
||||
) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
app.update_in(vcx, |_, _, cx| cx.notify());
|
||||
vcx.background_executor.run_until_parked();
|
||||
if app.update_in(vcx, |app, _, _| until(app)) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"never settled: {what}"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_tab_lists_and_opens_an_issue_without_touching_the_network(cx: &mut TestAppContext) {
|
||||
let root = scratch("list");
|
||||
git(&root, &["init", "--quiet"]);
|
||||
git(
|
||||
&root,
|
||||
&[
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"git@github.com:someone/widgets.git",
|
||||
],
|
||||
);
|
||||
git(
|
||||
&root,
|
||||
&[
|
||||
"remote",
|
||||
"add",
|
||||
"upstream",
|
||||
"https://github.com/acme/widgets",
|
||||
],
|
||||
);
|
||||
|
||||
let fake = Arc::new(Fake {
|
||||
asked: Mutex::new(Vec::new()),
|
||||
});
|
||||
let (app, mut vcx, mut pane) = test_window::harness_with_pane(cx);
|
||||
let transport: Arc<dyn Transport> = fake.clone();
|
||||
app.update_in(&mut vcx, |app, _, cx| {
|
||||
app.github.connector = Some(Arc::new(move || Connection {
|
||||
transport: transport.clone(),
|
||||
}));
|
||||
app.right_panel_visible = true;
|
||||
app.right_panel_tab = RightPanelTab::GitHub;
|
||||
cx.notify();
|
||||
});
|
||||
DaemonMsg::Cwd(root.clone())
|
||||
.encode(&mut pane)
|
||||
.expect("the pane's socket takes the cwd");
|
||||
|
||||
// A fork setup: upstream wins over origin.
|
||||
settle(&app, &mut vcx, "the issue list", |app| {
|
||||
app.github
|
||||
.lists
|
||||
.iter()
|
||||
.any(|(q, l)| q.slug.full() == "acme/widgets" && q.kind == Kind::Issues && l.loaded)
|
||||
});
|
||||
let numbers: Vec<u64> = app.update_in(&mut vcx, |app, _, _| {
|
||||
let (_, list) = app.github.lists.iter().next().unwrap();
|
||||
list.items.iter().map(|i| i.number).collect()
|
||||
});
|
||||
assert_eq!(numbers, vec![7], "the pull request is not an issue");
|
||||
|
||||
let slug = app.update_in(&mut vcx, |app, _, cx| {
|
||||
let slug = app.github.lists.keys().next().unwrap().slug.clone();
|
||||
app.github_open_detail(slug.clone(), 7, cx);
|
||||
slug
|
||||
});
|
||||
settle(&app, &mut vcx, "the detail", |app| {
|
||||
app.github
|
||||
.details
|
||||
.get(&(slug.clone(), 7))
|
||||
.is_some_and(|d| d.detail.is_some())
|
||||
});
|
||||
let comments = app.update_in(&mut vcx, |app, _, _| {
|
||||
app.github.details[&(slug.clone(), 7)]
|
||||
.detail
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.comments
|
||||
.len()
|
||||
});
|
||||
assert_eq!(comments, 1);
|
||||
|
||||
let asked = fake.asked.lock().unwrap().clone();
|
||||
assert!(
|
||||
asked.iter().all(|p| p.starts_with("/repos/acme/widgets/")),
|
||||
"{asked:?}"
|
||||
);
|
||||
// Settled: nothing is asked for again frame after frame.
|
||||
let before = asked.len();
|
||||
for _ in 0..5 {
|
||||
app.update_in(&mut vcx, |_, _, cx| cx.notify());
|
||||
vcx.background_executor.run_until_parked();
|
||||
}
|
||||
assert_eq!(fake.asked.lock().unwrap().len(), before);
|
||||
|
||||
test_window::quiesce(&mut vcx, Some(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,8 @@ enum InfoValue {
|
||||
removed: u32,
|
||||
open: Option<(crate::ui::host_ops::HostId, PathBuf)>,
|
||||
},
|
||||
/// Text that opens a web page when clicked — the repository on GitHub.
|
||||
Link { text: String, url: String },
|
||||
}
|
||||
|
||||
/// The table convention for a cell with nothing in it. Needs no translating,
|
||||
@@ -414,6 +416,7 @@ impl InfoRow {
|
||||
self.copy.is_some()
|
||||
|| self.reveal.is_some()
|
||||
|| matches!(self.value, InfoValue::Diff { open: Some(_), .. })
|
||||
|| matches!(self.value, InfoValue::Link { .. })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,6 +1001,19 @@ impl Tty7App {
|
||||
copy: None,
|
||||
reveal: None,
|
||||
});
|
||||
// The same repository on GitHub, at this branch, when one of
|
||||
// its remotes is there.
|
||||
if let Some((text, url)) = self.github_info_link(&git.branch, window, cx) {
|
||||
rows.push(InfoRow {
|
||||
label: t(L10nKey::PanelGitHubTitle),
|
||||
value: InfoValue::Link {
|
||||
text,
|
||||
url: url.clone(),
|
||||
},
|
||||
copy: Some(url),
|
||||
reveal: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,6 +1102,26 @@ impl Tty7App {
|
||||
.child(div().min_w_0().flex_shrink(1.).truncate().child(leaf))
|
||||
.into_any_element()
|
||||
}
|
||||
// Underlined on hover like the counts below: the row's fill says
|
||||
// it reacts, the underline says the text is the button.
|
||||
InfoValue::Link { text, url } => div()
|
||||
.id(("panel-info-link", i))
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(cx.theme().foreground)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.underline())
|
||||
.tooltip(|window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(t(L10nKey::GitHubOpenOnGitHub))
|
||||
.build(window, cx)
|
||||
})
|
||||
.on_click(move |_, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.open_url(&url);
|
||||
})
|
||||
.child(text)
|
||||
.into_any_element(),
|
||||
InfoValue::Text(v) => div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
|
||||
@@ -167,7 +167,7 @@ const PROBE_RETRY: Duration = Duration::from_secs(2);
|
||||
const NOT_A_REPO_RETRY: Duration = Duration::from_secs(10);
|
||||
|
||||
/// What the panel knows about the directory the active pane is sitting in.
|
||||
enum RepoLookup {
|
||||
pub(crate) enum RepoLookup {
|
||||
/// Nothing has answered yet — the tab's own probe is still out.
|
||||
Pending,
|
||||
NotARepo,
|
||||
@@ -1215,7 +1215,7 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
/// The host and directory the panel is looking at.
|
||||
fn scm_pane_target(
|
||||
pub(crate) fn scm_pane_target(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -1239,7 +1239,7 @@ impl Tty7App {
|
||||
/// different directory inside a linked worktree, and it is only filled in
|
||||
/// for panes whose shell reports a cwd — a pane without shell integration
|
||||
/// would leave the panel loading forever.
|
||||
fn scm_repo_root(
|
||||
pub(crate) fn scm_repo_root(
|
||||
&mut self,
|
||||
host: &SharedHost,
|
||||
cwd: &Path,
|
||||
|
||||