feat(panel): Search and GitHub tabs in the right panel (#978)

* feat(panel): add Search and GitHub tabs to the right panel

The right panel grows from three tabs to five. Five word labels do not
fit the panel's 280px resting width, so the tab row now draws a glyph
per tab and names it in a tooltip.

Both new panes are placeholders here; the content search and the
GitHub issues/PR browser land on top of this.

* feat(panel): find in files in the right panel's Search tab

The Search tab replaces its placeholder with a content search over the
active tab's project -- the same roots the Files tab shows -- on the host
that project lives on. Hits arrive as you type (debounced, with a
generation counter so a stale answer never lands), grouped by file with a
count, each line excerpted with its matches highlighted. Clicking a hit
opens the built-in editor at that line and column; Enter searches again.
Match-case, whole-word and regex toggles sit at the end of the field, and
the tab focuses its field whenever it is brought forward.

Host::search_content is new on the Host trait, implemented once in
host::content_search (ignore walk + regex) and run by LocalHost directly
and by tty7-server over a new SearchContent control request. The walk
honours .gitignore with or without a repository, skips dot-entries, binary
files and files over 1 MB, and reports a capped search as truncated. The
request is gated on a new `content-search` hello feature, so a server that
predates it is never sent it; the panel says the server needs updating
instead of showing no results. Conformance cases cover local and the
stdio server alike.

* feat(panel): browse GitHub issues and pull requests in the right panel

The GitHub tab follows the focused pane's repository: its root is resolved
the way the Source Control tab does, its remotes are read through the Host
(the tree may be on another machine), and the github.com remote is bound,
upstream over origin in a fork, with a menu to pick another.

The list switches between issues and pull requests, open and closed, 50 rows
a page with Load more; rows carry a state glyph distinct by shape, labels
(click one to filter by it) and relative times. A row opens the detail in
place: title, state, author, labels, description and comments as Markdown,
and for a pull request its branches, size and changed files. A file opens in
the diff overlay through a new supplied-patch DiffSource, so GitHub's patch
renders exactly like a local one without a git probe.

Read-only, and sign-in reuses the GitHub CLI: GH_TOKEN, GITHUB_TOKEN, then
`gh auth token`, found on PATH or at the Homebrew locations a Finder launch
cannot see. Signed out, public repositories still work; 401, 403, 404 and
rate limits are told apart and explained. Requests go out from this machine
over the installer's ureq stack and proxy settings, on threads of their own,
cached per repository with background revalidation. Remote images in issue
text become links instead of loading, and non-web link targets are disarmed.

The Info tab gains a GitHub row that opens the branch on the remote it
tracks, or the repository for a branch never pushed.

* docs: list ShowRightPanelGitHub with the other panel actions

* feat(panel): one-line GitHub rows, a pill for the current tab

- GitHub list rows are one line: state glyph, #number, title. Labels and
  the age of the last update appear on hover, from state rather than a
  group_hover display switch, which gpui cannot paint.
- The current right panel tab sits on the sidebar's selected fill; ink
  alone could not tell five same-weight glyphs apart.
- The GitHub glyph is a 1.8px outline like the other tab icons, not the
  filled mark.
- The detail byline names both times (opened / updated) so it no longer
  reads as disagreeing with the list's update age.

* feat(github): show screenshots pasted into issues

Images GitHub hosts itself (github.com/user-attachments, a repo's
/assets, *.githubusercontent.com) now render in issue and PR text, each
in a paragraph of its own so the text view draws it at its size rather
than at line height. Images from any other host stay links, so opening
an issue still tells no third party that you read it.

gpui held a null HTTP client, so no remote image could load; the app now
installs the update check's reqwest client (same user agent and proxy)
at launch.

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

- Pasted attachments (github.com/user-attachments/assets/<uuid>) want a
  browser session on a private repository, which an API token is not.
  The detail and comment requests now ask for the full media type, and
  each attachment is swapped for the signed private-user-images URL the
  rendered body_html carries for the same uuid.
- Inline code in rendered Markdown (the GitHub tab and the editor's
  preview) sits on a faint neutral fill instead of the theme accent,
  which is also the selection colour. Needs gpui-component 6af19d91 for
  TextViewStyle::inline_code_background.

* style(panel): tidy the GitHub and Search tabs' top rows

- GitHub drops its heading row on macOS. It existed only to hold the
  refresh tile, and no other tab has one; refresh now sits with the
  repository's other actions, in the repo row and a detail's header.
- Search's Aa / ab / .* toggles are muted while off instead of body ink.
- Search's idle note puts the folder on its own line, spelled ~/…, so
  the narrow column no longer breaks the path at a slash.

* feat(panel): order the right panel's tabs Info, Files, Search, Changes, GitHub

Info stays first as the default and the pane's overview; after it come
two pairs, the project's files (Files, Search) and its version control
from local to remote (Changes, GitHub), where Changes and GitHub were
split by the file tabs before. The palette, the Keybindings list and the
docs follow the same order.

* style(panel): drop the change count from the Changes tab

Beside one glyph of five, the number read as a badge on that tab alone,
and the Changes tab already leads with the same count under its own
heading. right_panel_tabs no longer needs the row's width, which it only
measured to decide whether the count fit.

* style(icons): fit the GitHub glyph to the other tab icons' size

The Lucide mark filled its whole 24px box, edge to edge, where tty7's
own icons keep about 3.5px clear, so at 15px it drew a size larger than
the four tabs beside it. Scale it to 0.9 about the centre, and raise the
stroke to 2.0 so it still renders at the others' 1.8.

* style(icons): a simpler GitHub glyph

Drop the Lucide mark's tail and redraw the head and legs on tty7's own
grid: the same ~15px live area and 1.8 stroke as the other tab icons, no
scale transform. The legs keep it reading as the Octocat; a head alone
read as any cat.

* test(github): find gh on PATH in the blank-variable token test

The test placed gh only at /opt/homebrew/bin/gh, which gh_candidates never
offers on Windows, so the Windows CI job panicked at unwrap. Put gh on a PATH
directory spelled with the platform's exe name instead.

* fix(github): close image and link bypasses in the issue Markdown sanitiser

Checked against markdown-rs (the parser TextView uses), several inputs got
past the line-based rewrite:

- is_github_hosted cut the host only at `/`, so
  `https://evil.io?.githubusercontent.com/x.png` (and `#`, `\`, `&#47;`)
  counted as GitHub-hosted and was fetched from evil.io. The host now ends
  at the first of `/?#\` and may hold only DNS characters.
- `<img src>` values were written into `![..](..)` unescaped, so a `)` in
  the value closed the image and opened a second one from any host. Written
  destinations are now percent-encoded.
- `<image>` (which the HTML parser reads as `<img>`) passed as an ordinary
  tag and loaded its src.
- A kept link target was copied without scanning; when the parser ended
  the link elsewhere (open title, unbalanced paren) a `![..](..)` inside it
  came alive. Markup characters in it are now encoded.
- `file&#58;///...` and similar character references passed is_safe_target
  and decoded to a `file:` link. References are decoded before judging.

The rewrite still cannot see every construct the way the parser does
(code spans inside tag attributes, fences the parser rejects, multi-line
link definitions), so the detail view now also checks the parsed tree: a
block containing a non-GitHub image, an unsafe link or definition, or raw
`<img>` is drawn as its plain source instead.

* fix(github): hide gh's console, bound Retry-After, and reject URL authorities with ?#\

- run gh through proc::output_within with hide_console, so a Windows GUI
  launch does not flash a console window and stdout is drained while gh runs.
- saturating_add a hostile Retry-After instead of overflowing i64.
- parse_github_url no longer accepts `https://evil.io#@github.com/o/r`.

* fix(search): no panic on an unbounded time budget, and read files through the size cap

ContentLimits arrive off the wire on a server; Instant + u64::MAX ms
panicked. A file that grew between the size check and the read was read
whole; it is now read through a take() at the cap.

* fix(panel): keep Load more on an empty filtered page, and drop another host's hits

- /issues pages filtered to one kind can come back empty while later pages
  hold matches; the GitHub list said "No issues" and hid Load more. It now
  reads on through up to five such pages and keeps Load more offered.
- While a new search runs, the previous hits stay on screen; if they came
  from another host, a click opened their path on the active host. They are
  now kept only when the host is the same.
This commit is contained in:
l0ng-ai
2026-09-27 19:04:42 +08:00
committed by GitHub
parent d6c223751d
commit fd2c4f7d4e
61 changed files with 8049 additions and 189 deletions
+609
View File
@@ -0,0 +1,609 @@
//! 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_fmt, 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(repo, 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,
repo: &RepoKey,
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(self.github_refresh_tile(Some(repo.clone()), cx))
.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();
// Both times, each named: the list row shows the last update, and a
// bare "4d" here beside a "13h" there read as the two disagreeing.
let when = |key, at| t_fmt(key, &[("when", &relative_time(now, at))]);
let mut byline: Vec<String> = Vec::new();
if !item.author.is_empty() {
byline.push(item.author.clone());
}
if item.created_at > 0 {
byline.push(when(L10nKey::GitHubOpenedAt, item.created_at));
}
// An update within a minute of opening is the opening itself.
if item.updated_at > item.created_at + 60 {
byline.push(when(L10nKey::GitHubUpdatedAt, item.updated_at));
}
let byline = byline.join(" · ");
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(cx: &gpui::App) -> gpui_component::text::TextViewStyle {
gpui_component::text::TextViewStyle {
heading_font_size: Some(Arc::new(|level, base| match level {
1 => base * 1.25,
2 => base * 1.15,
_ => base * 1.05,
})),
..crate::ui::theme::markdown_style(cx)
}
}
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(cx))
.selectable(true)
// The rewrite above works on lines of source; this checks what
// the parser actually made of them. A block holding anything
// the rewrite should have removed is drawn as its plain source
// instead, so a construct the two read differently can never
// load a third-party image or hand the OS a `file:` link.
.markdown_block_parser(|node, parse| {
(!renders_safely(node)).then(|| {
let text = parse.node_source(node).unwrap_or_default().to_string();
gpui_component::text::MarkdownNode::new(UNSAFE_BLOCK, text.clone())
.text(text)
})
})
.markdown_block_renderer(UNSAFE_BLOCK, |node, _window, cx| {
div()
.text_color(cx.theme().muted_foreground)
.whitespace_normal()
.child(node.data::<String>().cloned().unwrap_or_default())
}),
)
.into_any_element()
}
/// The custom block a Markdown block that failed [`renders_safely`] becomes.
const UNSAFE_BLOCK: &str = "tty7-github-unsafe-block";
/// Whether `node` and everything under it is what
/// `core::github::markdown::sanitize` promises: images only from GitHub's own
/// hosts, links (and link definitions) only to web and mail targets, and raw
/// HTML with no `<img>` or unsafe `href`/`src`.
fn renders_safely(node: &gpui_component::text::markdown_ast::Node) -> bool {
use gpui_component::text::markdown_ast::Node;
use tty7_core::core::github::markdown::{html_is_safe, is_github_hosted, is_safe_target};
let here = match node {
Node::Image(image) => is_github_hosted(&image.url),
// Never written by the rewrite (it makes every `![…][…]` a link).
Node::ImageReference(_) => false,
Node::Link(link) => is_safe_target(&link.url),
Node::Definition(def) => is_safe_target(&def.url),
Node::Html(html) => html_is_safe(&html.value),
_ => true,
};
here && node
.children()
.is_none_or(|children| children.iter().all(renders_safely))
}
#[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()
}
+526
View File
@@ -0,0 +1,526 @@
//! 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);
/// How far a list reads on by itself through pages that filter down to
/// nothing (a pull-request-heavy repository's `/issues`), before it leaves
/// the rest to "load more".
const EMPTY_PAGES_FOLLOWED: u32 = 5;
/// 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,
/// The list row under the pointer, by number: the one that shows its
/// labels and age.
pub(crate) hovered: Option<u64>,
}
/// 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;
let mut follow = None;
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
};
// Nothing of this kind on the pages read so far, but
// more pages: read on (a few, not the whole history)
// rather than say the repository has none.
if items.is_empty() && page < EMPTY_PAGES_FOLLOWED {
follow = got.next_page;
}
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);
}
}
if let Some(next) = follow {
this.github_fetch_list(q.clone(), next, cx);
}
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)
}