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 `#`, `\`, `/`) 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:///...` 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.
@@ -9,6 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Find in files, in the right panel's new Search tab.** Type and the active
|
||||
tab's project — the same roots the Files tab shows — is searched as you go,
|
||||
with hits grouped by file, a count per file, and each match highlighted in
|
||||
its line. Click a hit to open the file in the built-in editor at that line
|
||||
and column; Enter searches again. Toggles for match case, whole word and
|
||||
regular expressions sit at the end of the field. The walk honours
|
||||
`.gitignore` with or without a repository, skips dot-directories, binary
|
||||
files and files over 1 MB, and says so when a cap (2,000 lines, 100 per
|
||||
file, 20,000 files, ten seconds) cut it short. In a remote workspace the
|
||||
search runs on the remote machine through `tty7-server`; a server that
|
||||
predates it is never sent the request — the tab asks for the server to be
|
||||
updated instead of showing an empty result.
|
||||
|
||||
- **A GitHub tab in the right panel: issues and pull requests, read-only.** It
|
||||
follows the focused pane's repository, binds to its `github.com` remote
|
||||
(`upstream` over `origin` in a fork, with a menu to pick another), and lists
|
||||
open or closed issues or pull requests with state glyphs, labels and
|
||||
relative times; a label click filters by it. A row opens the detail —
|
||||
description and comments as Markdown, and for a pull request its branches,
|
||||
size and changed files, each of which opens its patch in the diff overlay.
|
||||
Sign-in reuses the GitHub CLI (`GH_TOKEN`, `GITHUB_TOKEN`, then
|
||||
`gh auth token`, found even from a Finder launch); public repositories work
|
||||
signed out, and a private repository or a spent rate limit says to run
|
||||
`gh auth login`. Screenshots pasted into an issue are shown; images from
|
||||
any other host are shown as links rather than loaded, and non-web link
|
||||
targets are disabled. The Info tab gains a GitHub
|
||||
row that opens the branch you are on. The tab talks to `api.github.com` only
|
||||
while you use it.
|
||||
|
||||
- **Reorder the active tab from the keyboard** (`MoveTabLeft` / `MoveTabRight`).
|
||||
The tab moves one slot past its neighbour — the keyboard form of dragging it
|
||||
in the tab strip or the sidebar — and wraps past either end, so one held key
|
||||
|
||||
@@ -3365,7 +3365,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component"
|
||||
version = "0.5.2"
|
||||
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#51a6925955adda598c9363915a06eae6de5dd8df"
|
||||
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#6af19d91285bde86151287addcbb9d81266bbf30"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -3448,7 +3448,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component-assets"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#51a6925955adda598c9363915a06eae6de5dd8df"
|
||||
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#6af19d91285bde86151287addcbb9d81266bbf30"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
@@ -3462,7 +3462,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component-macros"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#51a6925955adda598c9363915a06eae6de5dd8df"
|
||||
source = "git+https://github.com/l0ng-ai/gpui-component?branch=tty7#6af19d91285bde86151287addcbb9d81266bbf30"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -9974,6 +9974,7 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
"notify 8.2.0",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"rusqlite",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
|
||||
@@ -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"><path d="M9.8 16.3C6.9 15.9 4.6 14 4.6 11.1C4.6 9.9 5.1 8.8 5.9 7.9C5.6 6.6 5.6 5.3 6.1 4L8.9 5.8C9.9 5.5 10.9 5.4 12 5.4C13.1 5.4 14.1 5.5 15.1 5.8L17.9 4C18.4 5.3 18.4 6.6 18.1 7.9C18.9 8.8 19.4 9.9 19.4 11.1C19.4 14 17.1 15.9 14.2 16.3"/><path d="M9.8 16.3C9.4 16.9 9.3 17.6 9.3 18.3V20"/><path d="M14.2 16.3C14.6 16.9 14.7 17.6 14.7 18.3V20"/></svg>
|
||||
|
After Width: | Height: | Size: 537 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="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 |
@@ -48,6 +48,14 @@ sha2 = "0.11"
|
||||
# implementation.
|
||||
ignore = "0.4"
|
||||
|
||||
# The matcher behind `Host::search_content` (the right panel's Search tab):
|
||||
# a literal, whole-word or regular-expression query compiled once and run over
|
||||
# each file the `ignore` walk above hands it. Lives here for the same reason
|
||||
# `ignore` does — a remote `tty7-server` answers the search with the identical
|
||||
# implementation. Already in the tree via the GUI and `ignore`, so this pins no
|
||||
# new code.
|
||||
regex = "1"
|
||||
|
||||
# Format-preserving TOML editing for `core::agent_hooks`: Kimi Code takes its
|
||||
# hooks as `[[hooks]]` entries in the same `config.toml` that holds the user's
|
||||
# providers, models and comments, so installing must edit that file in place
|
||||
@@ -231,6 +239,10 @@ remote-install = [
|
||||
"dep:system-configuration",
|
||||
"dep:system-configuration-sys",
|
||||
]
|
||||
# The GitHub tab's REST client (`core::github::http`). It rides the same HTTPS
|
||||
# stack and system-proxy resolution as the installer, so it is that feature
|
||||
# plus a name of its own: the server, which never reads GitHub, builds neither.
|
||||
github = ["remote-install"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2641,6 +2641,14 @@ mod tests {
|
||||
) -> io::Result<Vec<crate::host::SearchHit>> {
|
||||
self.0.search(roots, query, limit, max_dirs, show_hidden)
|
||||
}
|
||||
fn search_content(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
query: &crate::host::ContentQuery,
|
||||
limits: &crate::host::ContentLimits,
|
||||
) -> io::Result<crate::host::ContentResults> {
|
||||
self.0.search_content(roots, query, limits)
|
||||
}
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<crate::host::Meta> {
|
||||
self.0.write_file(p, bytes)
|
||||
}
|
||||
|
||||
@@ -1517,13 +1517,18 @@ pub enum RightPanelTab {
|
||||
/// The source control panel. Renamed from `Changes` in place rather than
|
||||
/// added alongside it: `rename` works in both directions, so a config
|
||||
/// written by this version still says `"changes"` and an older build reads
|
||||
/// it back unchanged. A fourth variant could not do that — the old build
|
||||
/// would fall through `de_lenient` to `Info` and kick anyone who rolled
|
||||
/// back off the panel they were sitting on. 260px has no room for a fourth
|
||||
/// tab tile either.
|
||||
/// it back unchanged.
|
||||
#[serde(rename = "changes", alias = "scm", alias = "git")]
|
||||
Scm,
|
||||
Files,
|
||||
/// Project-wide content search. Added as a tab of its own, which costs a
|
||||
/// rolled-back build one thing: it does not know `"search"`, falls through
|
||||
/// `de_lenient` to `Info`, and opens there. Nothing else is lost.
|
||||
Search,
|
||||
/// The bound GitHub repository's issues and pull requests. Same rollback
|
||||
/// cost as `Search`.
|
||||
#[serde(rename = "github")]
|
||||
GitHub,
|
||||
}
|
||||
|
||||
/// What opens when a file link in the grid is clicked.
|
||||
|
||||
@@ -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,639 @@
|
||||
//! The REST calls the panel makes, over an injected [`Transport`].
|
||||
//!
|
||||
//! Everything here is request shaping and response decoding; the bytes move
|
||||
//! through a `Transport`, which the GUI backs with HTTPS
|
||||
//! ([`super::http::HttpTransport`]) and tests back with fixtures. No test in
|
||||
//! this crate touches the network.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use super::model::{
|
||||
Comment, Detail, Item, Kind, PrFile, RawComment, RawFile, RawIssue, RawPull, StateFilter,
|
||||
};
|
||||
use super::remote::RepoSlug;
|
||||
|
||||
/// Rows per page. GitHub's maximum is 100; 50 keeps the first answer quick on
|
||||
/// a slow link while still filling a tall panel.
|
||||
pub const PAGE_SIZE: u32 = 50;
|
||||
|
||||
/// Comments and files fetched with a detail view. One page each: past that the
|
||||
/// view says there is more and links to GitHub.
|
||||
pub const DETAIL_PAGE: u32 = 100;
|
||||
|
||||
/// Why a call failed, in the terms the panel explains to the user.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ApiError {
|
||||
/// 401: a token was sent and GitHub refused it (revoked, expired).
|
||||
Unauthorized,
|
||||
/// 404: no such repository — or a private one this request cannot see,
|
||||
/// which GitHub deliberately does not distinguish.
|
||||
NotFound,
|
||||
/// The rate limit is spent. `reset` is when it refills, unix seconds.
|
||||
RateLimited { reset: Option<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>;
|
||||
/// The same GET, asking for the `full` media type: text fields come with
|
||||
/// their rendered `*_html` too, which is where GitHub puts the signed
|
||||
/// URLs a pasted attachment can actually be downloaded from (see
|
||||
/// [`markdown::sign_attachments`](super::markdown::sign_attachments)).
|
||||
/// Only the detail asks for it; a list does not need 50 bodies twice.
|
||||
fn get_full(&self, path: &str) -> Result<Reply, ApiError> {
|
||||
self.get(path)
|
||||
}
|
||||
/// Whether requests carry a token. Decides how a 404 or a rate limit is
|
||||
/// explained: to a signed-out user, both usually mean "sign in".
|
||||
fn authenticated(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Map a response's status and headers to success or an [`ApiError`].
|
||||
///
|
||||
/// `header` looks a response header up by lower-case name. `body` is only read
|
||||
/// for GitHub's `message`, which tells a secondary rate limit apart from any
|
||||
/// other 403.
|
||||
pub fn classify(
|
||||
status: u16,
|
||||
header: &dyn Fn(&str) -> Option<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.saturating_add(after))
|
||||
})
|
||||
};
|
||||
match status {
|
||||
401 => Err(ApiError::Unauthorized),
|
||||
404 => Err(ApiError::NotFound),
|
||||
429 => Err(ApiError::RateLimited { reset: reset() }),
|
||||
403 => {
|
||||
let spent = header("x-ratelimit-remaining").is_some_and(|v| v.trim() == "0");
|
||||
let msg = message();
|
||||
if spent
|
||||
|| header("retry-after").is_some()
|
||||
|| msg.to_ascii_lowercase().contains("rate limit")
|
||||
{
|
||||
Err(ApiError::RateLimited { reset: reset() })
|
||||
} else {
|
||||
Err(ApiError::Forbidden(msg))
|
||||
}
|
||||
}
|
||||
code => Err(ApiError::Http(code)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `Link` header names a `rel="next"` page.
|
||||
pub fn link_has_next(link: &str) -> bool {
|
||||
link.split(',').any(|part| {
|
||||
part.split(';')
|
||||
.skip(1)
|
||||
.any(|p| p.trim().eq_ignore_ascii_case("rel=\"next\""))
|
||||
})
|
||||
}
|
||||
|
||||
/// What a list shows: which repository, which kind, which state, which label.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ListQuery {
|
||||
pub slug: RepoSlug,
|
||||
pub kind: Kind,
|
||||
pub state: StateFilter,
|
||||
pub label: Option<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_full(&format!("{base}/issues/{number}"))?)?;
|
||||
let is_pr = issue.is_pr();
|
||||
let body = super::markdown::sign_attachments(
|
||||
issue.body.as_deref().unwrap_or_default(),
|
||||
issue.body_html.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let mut item = issue.into_item();
|
||||
|
||||
let reply = t.get_full(&format!(
|
||||
"{base}/issues/{number}/comments?per_page={DETAIL_PAGE}"
|
||||
))?;
|
||||
let comments: Vec<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,97 @@
|
||||
//! [`Transport`] over HTTPS, with the same stack and proxy resolution the
|
||||
//! installer and the update check use.
|
||||
//!
|
||||
//! Blocking — every call runs on a worker, never on the UI thread.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::api::{ApiError, Reply, Transport, classify, link_has_next};
|
||||
use super::token::Token;
|
||||
|
||||
const API: &str = "https://api.github.com";
|
||||
|
||||
/// Interactive reads: long enough for a slow link, short enough that a dead
|
||||
/// one is reported rather than sat on.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// A pull request's file list with every patch inlined is the largest answer
|
||||
/// the panel asks for; GitHub itself caps a patch well under this.
|
||||
const MAX_BODY: u64 = 32 * 1024 * 1024;
|
||||
|
||||
pub struct HttpTransport {
|
||||
agent: ureq::Agent,
|
||||
token: Option<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> {
|
||||
self.request(path, "application/vnd.github+json")
|
||||
}
|
||||
|
||||
fn get_full(&self, path: &str) -> Result<Reply, ApiError> {
|
||||
self.request(path, "application/vnd.github.full+json")
|
||||
}
|
||||
|
||||
fn authenticated(&self) -> bool {
|
||||
self.token.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpTransport {
|
||||
fn request(&self, path: &str, accept: &str) -> Result<Reply, ApiError> {
|
||||
let mut request = self
|
||||
.agent
|
||||
.get(format!("{API}{path}"))
|
||||
.header("Accept", accept)
|
||||
.header("X-GitHub-Api-Version", "2022-11-28");
|
||||
if let Some(token) = &self.token {
|
||||
request = request.header("Authorization", format!("Bearer {}", token.expose()));
|
||||
}
|
||||
// ureq's error text names the URL and the transport failure, never
|
||||
// the request headers, so it is safe to show.
|
||||
let mut response = request
|
||||
.call()
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
let headers = response.headers().clone();
|
||||
let header = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let body = response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_BODY)
|
||||
.read_to_vec()
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
classify(status, &header, &body)?;
|
||||
Ok(Reply {
|
||||
has_next: header("link").is_some_and(|l| link_has_next(&l)),
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
//! Making an issue's Markdown safe to hand to the text view.
|
||||
//!
|
||||
//! Issue bodies and comments are written by anyone on the internet. The text
|
||||
//! view renders Markdown natively (no browser, no script), so there is nothing
|
||||
//! to execute — but two things in it act on the reader's machine:
|
||||
//!
|
||||
//! - **Images load themselves.** An `` or `<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. Only images GitHub itself
|
||||
//! hosts (a screenshot pasted into an issue, see [`is_github_hosted`]) are
|
||||
//! drawn — reading the issue already told GitHub as much. Any other image
|
||||
//! becomes a plain link: the reader can still open it, deliberately, in
|
||||
//! the browser. (github.com proxies third-party images through its own
|
||||
//! servers for the same reason; tty7 has no proxy, so it does not load them.)
|
||||
//! - **Links open whatever they name.** A click hands the URL to the OS, and
|
||||
//! `file:///…` or an app's custom scheme can launch programs. Only `http`,
|
||||
//! `https` and `mailto` targets survive; anything else is pointed at `#`.
|
||||
//!
|
||||
//! This is a rewrite of the *source text*, not a Markdown parser. It knows
|
||||
//! enough structure to leave code alone (fenced blocks and inline code spans
|
||||
//! are copied verbatim, since `` inside backticks is text) and to find
|
||||
//! the link and image forms Markdown and the HTML subset actually have.
|
||||
|
||||
/// Rewrite `src` as described in the module docs. `image_label` names an image
|
||||
/// whose alt text is empty ("image"), in the reader's language.
|
||||
pub fn sanitize(src: &str, image_label: &str) -> String {
|
||||
let mut out = String::with_capacity(src.len() + 16);
|
||||
let mut fence: Option<(char, usize)> = None;
|
||||
for line in src.split_inclusive('\n') {
|
||||
let trimmed = line.trim_start_matches(' ');
|
||||
let indent = line.len() - trimmed.len();
|
||||
let marker = fence_marker(trimmed).filter(|_| indent <= 3);
|
||||
match (fence, marker) {
|
||||
(None, Some(m)) => {
|
||||
fence = Some(m);
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
(Some((ch, n)), Some((mch, mn))) if ch == mch && mn >= n && closes_fence(trimmed) => {
|
||||
fence = None;
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
(Some(_), _) => {
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
out.push_str(&sanitize_line(line, image_label));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A fence opener/closer: three or more of one of `` ` `` / `~`.
|
||||
fn fence_marker(line: &str) -> Option<(char, usize)> {
|
||||
let ch = line.chars().next().filter(|c| *c == '`' || *c == '~')?;
|
||||
let n = line.chars().take_while(|c| *c == ch).count();
|
||||
(n >= 3).then_some((ch, n))
|
||||
}
|
||||
|
||||
/// A closing fence carries nothing after its marker but whitespace.
|
||||
fn closes_fence(line: &str) -> bool {
|
||||
let ch = line.chars().next().unwrap_or(' ');
|
||||
line.trim_start_matches(ch).trim().is_empty()
|
||||
}
|
||||
|
||||
/// One line outside a fenced block: code spans kept, the rest rewritten.
|
||||
fn sanitize_line(line: &str, image_label: &str) -> String {
|
||||
// A reference definition, `[id]: url`, is a link target of its own.
|
||||
if let Some(rewritten) = reference_definition(line) {
|
||||
return rewritten;
|
||||
}
|
||||
// A table row cannot be split across paragraphs without ending the table.
|
||||
let own_block = !line.trim_start().starts_with('|');
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut rest = line;
|
||||
while !rest.is_empty() {
|
||||
match rest.find('`') {
|
||||
Some(start) => {
|
||||
out.push_str(&sanitize_text(&rest[..start], image_label, own_block));
|
||||
let after = &rest[start..];
|
||||
let ticks = after.chars().take_while(|c| *c == '`').count();
|
||||
let closer = "`".repeat(ticks);
|
||||
match after[ticks..].find(&closer) {
|
||||
Some(end) => {
|
||||
let span = ticks + end + ticks;
|
||||
out.push_str(&after[..span]);
|
||||
rest = &after[span..];
|
||||
}
|
||||
// An unclosed run of backticks is literal text.
|
||||
None => {
|
||||
out.push_str(&after[..ticks]);
|
||||
rest = &after[ticks..];
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push_str(&sanitize_text(rest, image_label, own_block));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reference_definition(line: &str) -> Option<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. `own_block` puts each image kept as an
|
||||
/// image in a paragraph of its own: the text view draws an image that shares
|
||||
/// a paragraph with text at line height, a screenshot shrunk to an icon.
|
||||
fn sanitize_text(text: &str, image_label: &str, own_block: bool) -> String {
|
||||
let image = |alt: &str, url: &str| match own_block {
|
||||
true => format!("\n\n\n\n"),
|
||||
false => format!(""),
|
||||
};
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < text.len() {
|
||||
let rest = &text[i..];
|
||||
// A GitHub-hosted `` stays an image.
|
||||
if rest.starts_with("` / `![alt][ref]` → a link with the same target.
|
||||
if rest.starts_with("![") && !escaped(bytes, i) {
|
||||
out.push('[');
|
||||
if rest[2..].starts_with(']') {
|
||||
out.push_str(image_label);
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// `](target` — the target half of an inline link or image.
|
||||
if rest.starts_with("](") {
|
||||
out.push_str("](");
|
||||
i += 2;
|
||||
let target = &text[i..];
|
||||
let lead = target.len() - target.trim_start().len();
|
||||
let body = &target[lead..];
|
||||
let (url, len) = if let Some(inner) = body.strip_prefix('<') {
|
||||
let end = inner.find('>').unwrap_or(inner.len());
|
||||
(&inner[..end], end + 2)
|
||||
} else {
|
||||
let end = body
|
||||
.find(|c: char| c.is_whitespace() || c == ')')
|
||||
.unwrap_or(body.len());
|
||||
(&body[..end], end)
|
||||
};
|
||||
out.push_str(&target[..lead]);
|
||||
if is_safe_target(url) {
|
||||
// Copied, not scanned, so nothing in it may read as markup
|
||||
// of its own: were the parser to end the link elsewhere
|
||||
// (an unbalanced `(`, a title left open), a `` or a
|
||||
// tag inside the URL would come alive unsanitized.
|
||||
let pointy = body.starts_with('<');
|
||||
if pointy {
|
||||
out.push('<');
|
||||
}
|
||||
out.push_str(&neutralize_markup(url));
|
||||
if pointy && len <= body.len() && body[..len].ends_with('>') {
|
||||
out.push('>');
|
||||
}
|
||||
} else {
|
||||
out.push('#');
|
||||
}
|
||||
i += lead + len.min(body.len());
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with('<') {
|
||||
// `<img …>` → a link to what it would have loaded.
|
||||
if starts_with_tag(rest, "img") || starts_with_tag(rest, "image") {
|
||||
let end = rest.find('>').map_or(rest.len(), |e| e + 1);
|
||||
let tag = &rest[..end];
|
||||
let src = attr(tag, "src").unwrap_or_default();
|
||||
let alt = attr(tag, "alt").filter(|a| !a.trim().is_empty());
|
||||
let label = alt.as_deref().unwrap_or(image_label);
|
||||
let label = label.replace(['[', ']'], "");
|
||||
if is_github_hosted(&src) {
|
||||
out.push_str(&image(&label, &escape_destination(&src)));
|
||||
} else if is_safe_target(&src) && !src.is_empty() {
|
||||
out.push_str(&format!("[{label}]({})", escape_destination(&src)));
|
||||
} else {
|
||||
out.push_str(&format!("[{label}](#)"));
|
||||
}
|
||||
i += end;
|
||||
continue;
|
||||
}
|
||||
// `<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() || c == '/');
|
||||
let after = lower[from..].trim_start();
|
||||
if !before_ok || !after.starts_with('=') {
|
||||
continue;
|
||||
}
|
||||
let offset = tag.len() - tag[from..].trim_start().len() + 1;
|
||||
let value = tag[offset..].trim_start();
|
||||
return Some(match value.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => value[1..].split(q).next().unwrap_or("").to_string(),
|
||||
_ => value
|
||||
.split(|c: char| c.is_whitespace() || c == '>')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('/')
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The scheme of `url`, when it has one: letters first, then letters, digits,
|
||||
/// `+`, `-`, `.`, up to a `:` that comes before any `/`, `?` or `#`.
|
||||
fn scheme(url: &str) -> Option<&str> {
|
||||
let colon = url.find(':')?;
|
||||
let head = &url[..colon];
|
||||
if head.is_empty()
|
||||
|| url[..colon].contains(['/', '?', '#'])
|
||||
|| !head.chars().next()?.is_ascii_alphabetic()
|
||||
|| !head
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(head)
|
||||
}
|
||||
|
||||
/// Point each pasted attachment at a URL it can be downloaded from.
|
||||
///
|
||||
/// An image pasted into an issue is written into the Markdown as
|
||||
/// `https://github.com/user-attachments/assets/<uuid>`. On a public repository
|
||||
/// that URL answers anyone; on a private one it wants a browser session, and
|
||||
/// an API token does not count. The rendered HTML GitHub returns alongside
|
||||
/// (`body_html`, from the `full` media type) carries the same image as a
|
||||
/// `private-user-images.githubusercontent.com/…<uuid>…?jwt=…` URL that
|
||||
/// downloads without credentials for a few minutes. Swap each attachment for
|
||||
/// its signed twin by the uuid both carry; one with no twin is left alone.
|
||||
pub fn sign_attachments(markdown: &str, html: &str) -> String {
|
||||
const PREFIX: &str = "https://github.com/user-attachments/assets/";
|
||||
if html.is_empty() || !markdown.contains(PREFIX) {
|
||||
return markdown.to_string();
|
||||
}
|
||||
let signed: Vec<String> = html
|
||||
.split(['"', '\''])
|
||||
.filter(|v| v.starts_with("https://private-user-images.githubusercontent.com/"))
|
||||
.map(|v| v.replace("&", "&"))
|
||||
.collect();
|
||||
let mut out = String::with_capacity(markdown.len());
|
||||
let mut rest = markdown;
|
||||
while let Some(at) = rest.find(PREFIX) {
|
||||
out.push_str(&rest[..at]);
|
||||
let tail = &rest[at + PREFIX.len()..];
|
||||
let id_len = tail
|
||||
.find(|c: char| !(c.is_ascii_hexdigit() || c == '-'))
|
||||
.unwrap_or(tail.len());
|
||||
let id = &tail[..id_len];
|
||||
match signed
|
||||
.iter()
|
||||
.find(|url| id.len() >= 32 && url.split('?').next().is_some_and(|p| p.contains(id)))
|
||||
{
|
||||
Some(url) => out.push_str(url),
|
||||
None => out.push_str(&rest[at..at + PREFIX.len() + id_len]),
|
||||
}
|
||||
rest = &tail[id_len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `` or `` at the start of `s`: the alt text,
|
||||
/// the URL, and how many bytes the whole form takes. `None` for anything
|
||||
/// else, including the reference form `![alt][id]`.
|
||||
fn inline_image(s: &str) -> Option<(&str, &str, usize)> {
|
||||
let rest = s.strip_prefix("![")?;
|
||||
let alt_end = rest.find(']')?;
|
||||
let alt = &rest[..alt_end];
|
||||
if alt.contains('[') {
|
||||
return None;
|
||||
}
|
||||
let target = rest[alt_end + 1..].strip_prefix('(')?;
|
||||
let close = target.find(')')?;
|
||||
let inside = target[..close].trim();
|
||||
let url = inside.split_whitespace().next()?;
|
||||
let url = url
|
||||
.strip_prefix('<')
|
||||
.and_then(|u| u.strip_suffix('>'))
|
||||
.unwrap_or(url);
|
||||
Some((alt, url, 2 + alt_end + 1 + 1 + close + 1))
|
||||
}
|
||||
|
||||
/// Whether an image URL is one GitHub serves itself: attachments pasted into
|
||||
/// an issue (`github.com/user-attachments/…`, a repository's `/assets/…`) and
|
||||
/// anything under `githubusercontent.com` (older attachments, raw files,
|
||||
/// avatars, GitHub's own image proxy). Only `https`.
|
||||
pub fn is_github_hosted(url: &str) -> bool {
|
||||
let Some(rest) = url.strip_prefix("https://") else {
|
||||
return false;
|
||||
};
|
||||
// The authority ends at the first of these for a URL parser (`\\` counts
|
||||
// as `/` in an `https` URL), so `https://evil.io?.githubusercontent.com`
|
||||
// is evil.io.
|
||||
let (host, path) = rest.split_at(rest.find(['/', '?', '#', '\\']).unwrap_or(rest.len()));
|
||||
// Only plain DNS characters: userinfo, a port, an entity or a percent
|
||||
// escape would each make the "host" above something else.
|
||||
if host.is_empty()
|
||||
|| !host
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let host = host.to_ascii_lowercase();
|
||||
if host == "github.com" {
|
||||
let mut parts = path.trim_start_matches('/').split('/');
|
||||
return match (parts.next(), parts.next(), parts.next()) {
|
||||
(Some("user-attachments"), Some(_), _) => true,
|
||||
(Some(_), Some(_), Some("assets")) => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
host.ends_with(".githubusercontent.com")
|
||||
}
|
||||
|
||||
/// Whether a link target may be handed to the OS opener.
|
||||
///
|
||||
/// Relative targets and fragments carry no scheme and so cannot name a program;
|
||||
/// they are kept. Whitespace and control characters are stripped first, the
|
||||
/// way a browser does, so ` jav\tascript:` is judged as `javascript:`.
|
||||
///
|
||||
/// Character references are decoded before judging too — both the Markdown
|
||||
/// and the HTML parser decode them in a URL, so `file:///x` is `file:///x`
|
||||
/// by the time it is opened. An `&` still standing before the path, after
|
||||
/// that, is a reference this decoder does not know, and is not guessed at.
|
||||
pub fn is_safe_target(url: &str) -> bool {
|
||||
let judge = |url: &str| {
|
||||
let cleaned: String = url
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && !c.is_control())
|
||||
.collect();
|
||||
let head = &cleaned[..cleaned.find(['/', '?', '#']).unwrap_or(cleaned.len())];
|
||||
if head.contains('&') {
|
||||
return false;
|
||||
}
|
||||
match scheme(&cleaned) {
|
||||
None => !cleaned.contains(':') || cleaned.starts_with(['/', '#', '?', '.']),
|
||||
Some(s) => matches!(s.to_ascii_lowercase().as_str(), "http" | "https" | "mailto"),
|
||||
}
|
||||
};
|
||||
judge(&decode_char_refs(url))
|
||||
}
|
||||
|
||||
/// Decode the character references a parser would decode in a URL: numeric
|
||||
/// ones (with or without the `;`, as HTML allows) and the few named ones that
|
||||
/// spell URL syntax or whitespace. Anything else is left as written.
|
||||
fn decode_char_refs(s: &str) -> String {
|
||||
const NAMED: [(&str, char); 10] = [
|
||||
("colon", ':'),
|
||||
("Tab", '\t'),
|
||||
("NewLine", '\n'),
|
||||
("sol", '/'),
|
||||
("quest", '?'),
|
||||
("num", '#'),
|
||||
("period", '.'),
|
||||
("amp", '&'),
|
||||
("lpar", '('),
|
||||
("rpar", ')'),
|
||||
];
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at]);
|
||||
let tail = &rest[at + 1..];
|
||||
if let Some(num) = tail.strip_prefix('#') {
|
||||
let (hex, digits) = match num.strip_prefix(['x', 'X']) {
|
||||
Some(h) => (true, h),
|
||||
None => (false, num),
|
||||
};
|
||||
let n = digits
|
||||
.find(|c: char| {
|
||||
!(if hex {
|
||||
c.is_ascii_hexdigit()
|
||||
} else {
|
||||
c.is_ascii_digit()
|
||||
})
|
||||
})
|
||||
.unwrap_or(digits.len());
|
||||
if n > 0 {
|
||||
let ch = u32::from_str_radix(&digits[..n], if hex { 16 } else { 10 })
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
.unwrap_or('\u{FFFD}');
|
||||
out.push(ch);
|
||||
let mut used = 1 + usize::from(hex) + n;
|
||||
if tail[used..].starts_with(';') {
|
||||
used += 1;
|
||||
}
|
||||
rest = &tail[used..];
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
let n = tail
|
||||
.find(|c: char| !c.is_ascii_alphanumeric())
|
||||
.unwrap_or(tail.len());
|
||||
if tail[n..].starts_with(';')
|
||||
&& let Some((_, ch)) = NAMED.iter().find(|(name, _)| *name == &tail[..n])
|
||||
{
|
||||
out.push(*ch);
|
||||
rest = &tail[n + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push('&');
|
||||
rest = tail;
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `url` percent-encoded where Markdown would read it as syntax — the link's
|
||||
/// own end, a nested image or link, a tag, an escape, a code span — for
|
||||
/// writing as an inline link's destination.
|
||||
fn escape_destination(url: &str) -> String {
|
||||
let mut out = String::with_capacity(url.len());
|
||||
for c in url.chars() {
|
||||
match c {
|
||||
' ' | '(' | ')' | '<' | '>' | '[' | ']' | '\\' | '`' | '"' | '\'' => {
|
||||
out.push_str(&format!("%{:02X}", c as u32));
|
||||
}
|
||||
c if c.is_whitespace() || c.is_control() => {
|
||||
let mut buf = [0u8; 4];
|
||||
for b in c.encode_utf8(&mut buf).bytes() {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`escape_destination`]'s lighter cousin for a destination copied from the
|
||||
/// source as written: only what could open markup of its own is encoded, so
|
||||
/// a URL with balanced parentheses (`…/Foo_(bar)`) still reads the same.
|
||||
fn neutralize_markup(url: &str) -> String {
|
||||
let mut out = String::with_capacity(url.len());
|
||||
for c in url.chars() {
|
||||
match c {
|
||||
'<' | '>' | '[' | ']' => out.push_str(&format!("%{:02X}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a fragment of raw HTML, as the Markdown parser hands it over, is
|
||||
/// one [`sanitize`] could have produced: no `<img>` (every one is rewritten
|
||||
/// into Markdown, and the HTML parser reads `<image>` as `<img>`), and every
|
||||
/// `href`/`src` a target [`is_safe_target`] allows.
|
||||
///
|
||||
/// For checking what the parser actually saw, after the fact — the rewrite is
|
||||
/// line-based, and a construct it reads differently from the parser (a code
|
||||
/// span that is really inside a tag, a fence the parser does not accept)
|
||||
/// would otherwise slip through.
|
||||
pub fn html_is_safe(html: &str) -> bool {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
if lower.contains("<img") || lower.contains("<image") {
|
||||
return false;
|
||||
}
|
||||
for name in ["href", "src"] {
|
||||
let mut from = 0;
|
||||
while let Some(pos) = lower[from..].find(name) {
|
||||
let at = from + pos;
|
||||
from = at + name.len();
|
||||
let after = &html[from..];
|
||||
let Some(value) = after.trim_start().strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim_start();
|
||||
let value = match value.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => value[1..].split(q).next().unwrap_or(""),
|
||||
_ => value
|
||||
.split(|c: char| c.is_whitespace() || c == '>')
|
||||
.next()
|
||||
.unwrap_or(""),
|
||||
};
|
||||
if !is_safe_target(value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn s(src: &str) -> String {
|
||||
sanitize(src, "image")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_hosted_images_stay_images() {
|
||||
let pasted = "https://github.com/user-attachments/assets/352d14e0-d11c";
|
||||
// Each in a paragraph of its own, so it is drawn at its size.
|
||||
assert_eq!(
|
||||
s(&format!("")),
|
||||
format!("\n\n\n\n")
|
||||
);
|
||||
assert_eq!(
|
||||
s(&format!("a  b")),
|
||||
format!("a \n\n\n\n b")
|
||||
);
|
||||
assert_eq!(
|
||||
s(
|
||||
r#"<img width="300" alt="Screenshot" src="https://user-images.githubusercontent.com/1/a.png">"#
|
||||
),
|
||||
"\n\n\n\n"
|
||||
);
|
||||
assert_eq!(
|
||||
s(&format!(r#"<img src="{pasted}" />"#)),
|
||||
format!("\n\n\n\n")
|
||||
);
|
||||
// In a table row the image stays in its cell.
|
||||
assert_eq!(
|
||||
s(&format!("|  |")),
|
||||
format!("|  |")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_github_itself_counts_as_github_hosted() {
|
||||
for yes in [
|
||||
"https://github.com/user-attachments/assets/x",
|
||||
"https://github.com/owner/repo/assets/1/x",
|
||||
"https://private-user-images.githubusercontent.com/1/x.png?jwt=y",
|
||||
"https://camo.githubusercontent.com/abc",
|
||||
"https://RAW.githubusercontent.com/o/r/main/a.png",
|
||||
] {
|
||||
assert!(is_github_hosted(yes), "{yes}");
|
||||
}
|
||||
for no in [
|
||||
"http://github.com/user-attachments/assets/x",
|
||||
"https://github.com/owner/repo",
|
||||
"https://github.com.evil.io/user-attachments/assets/x",
|
||||
"https://evil.io/x.githubusercontent.com/a.png",
|
||||
"https://githubusercontent.com.evil.io/a.png",
|
||||
"https://github.com@evil.io/user-attachments/assets/x",
|
||||
"https://x.io/a.png",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_github_hosted(no), "{no}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_become_links_to_the_same_target() {
|
||||
assert_eq!(
|
||||
s("see  here"),
|
||||
"see [shot](https://x.io/a.png) here"
|
||||
);
|
||||
assert_eq!(s(""), "[image](https://x.io/a.png)");
|
||||
assert_eq!(s("![ref style][1]"), "[ref style][1]");
|
||||
// An escaped bang is text, and stays text.
|
||||
assert_eq!(s(r"\"), r"\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_images_become_links_too() {
|
||||
assert_eq!(
|
||||
s(r#"<img width="300" alt="Screenshot" src="https://x.io/u/a.png">"#),
|
||||
"[Screenshot](https://x.io/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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_is_what_a_url_parser_would_call_the_host() {
|
||||
for no in [
|
||||
"https://evil.io?.githubusercontent.com/a.png",
|
||||
"https://evil.io#.githubusercontent.com/a.png",
|
||||
"https://evil.io\\.githubusercontent.com/a.png",
|
||||
"https://evil.io/.githubusercontent.com/a.png",
|
||||
"https://evil.io%2F.githubusercontent.com/a.png",
|
||||
"https://x.githubusercontent.com:8443/a.png",
|
||||
] {
|
||||
assert!(!is_github_hosted(no), "{no}");
|
||||
}
|
||||
assert!(is_github_hosted(
|
||||
"https://camo.githubusercontent.com/abc?x=1#y"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_url_cannot_smuggle_a_second_image() {
|
||||
// `)` in an attribute value would close the Markdown image early and
|
||||
// open a new one from the rest of the value.
|
||||
let out = s(r#"<img src="https://x.githubusercontent.com/a);
|
||||
assert!(!out.contains(", "{out}");
|
||||
assert!(out.contains("/a%29!%5B%5D%28https://evil.io"), "{out}");
|
||||
// A kept link target cannot hide an image in itself either.
|
||||
let out = s("[x](https://ok.io/ \"open title");
|
||||
assert!(!out.contains("![b]"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_html_parsers_image_alias_is_an_image_too() {
|
||||
assert_eq!(
|
||||
s(r#"<image src="https://x.io/a.png">"#),
|
||||
"[image](https://x.io/a.png)"
|
||||
);
|
||||
assert_eq!(
|
||||
s("<IMAGE SRC=https://x.io/a.png>"),
|
||||
"[image](https://x.io/a.png)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_references_do_not_disguise_a_scheme() {
|
||||
for no in [
|
||||
"file:///System/Applications/Calculator.app",
|
||||
"file:///x",
|
||||
"file:///x",
|
||||
"file:///x",
|
||||
"jav	ascript:alert(1)",
|
||||
"file:///x",
|
||||
"&unknown;:x",
|
||||
] {
|
||||
assert!(!is_safe_target(no), "{no}");
|
||||
}
|
||||
assert!(is_safe_target("https://x.io/?a=1&b=2"));
|
||||
assert!(is_safe_target("https://x.io/a&b"));
|
||||
assert_eq!(
|
||||
s("[x](file:///System/Applications/Calculator.app)"),
|
||||
"[x](#)"
|
||||
);
|
||||
assert_eq!(
|
||||
s(r#"<a href="file:///x">x</a>"#),
|
||||
r##"<a href="#">x</a>"##
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_left_standing_is_checked_as_the_parser_sees_it() {
|
||||
assert!(html_is_safe(r#"<a href="https://ok.io">"#));
|
||||
assert!(html_is_safe("<details><summary>x</summary>"));
|
||||
assert!(!html_is_safe(r#"<img src="https://x.io/a.png">"#));
|
||||
assert!(!html_is_safe(r#"<IMAGE src="https://x.io/a.png">"#));
|
||||
assert!(!html_is_safe(r#"<a href='file:///x'>"#));
|
||||
assert!(!html_is_safe(r#"<a/href=file:///x>"#));
|
||||
assert!(!html_is_safe(r#"<a href="file:///x">"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_are_swapped_for_their_signed_twins() {
|
||||
let id = "352d14e0-d11c-42db-b8b3-042b31e34ce7";
|
||||
let md = format!(
|
||||
"see <img alt=\"x\" src=\"https://github.com/user-attachments/assets/{id}\" /> and \
|
||||
https://github.com/user-attachments/assets/00000000-0000-0000-0000-000000000000"
|
||||
);
|
||||
let signed = format!(
|
||||
"https://private-user-images.githubusercontent.com/1/2-{id}.png?jwt=a.b&x=1"
|
||||
);
|
||||
let html = format!(r#"<a href="{signed}"><img src="{signed}" alt="x"></a>"#);
|
||||
let out = sign_attachments(&md, &html);
|
||||
assert!(
|
||||
out.contains(&format!(
|
||||
"https://private-user-images.githubusercontent.com/1/2-{id}.png?jwt=a.b&x=1\""
|
||||
)),
|
||||
"{out}"
|
||||
);
|
||||
// No twin in the HTML: left as written.
|
||||
assert!(
|
||||
out.ends_with("assets/00000000-0000-0000-0000-000000000000"),
|
||||
"{out}"
|
||||
);
|
||||
// No HTML at all (a fixture, an old reply): nothing changes.
|
||||
assert_eq!(sign_attachments(&md, ""), md);
|
||||
}
|
||||
}
|
||||
@@ -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,534 @@
|
||||
//! What the panel shows, decoded out of GitHub's REST JSON.
|
||||
//!
|
||||
//! The wire structs (`Raw*`) mirror only the fields read; everything else in
|
||||
//! GitHub's (large) payloads is ignored, so a field GitHub adds or drops later
|
||||
//! cannot break decoding. The public types are what the UI draws from, already
|
||||
//! reduced to the decisions it needs — one [`ItemState`] rather than the three
|
||||
//! fields (`state`, `state_reason`, `merged_at`/`draft`) GitHub spreads it over.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::git::diff::{DiffBudget, DiffParser, FileDiff, FileStatus};
|
||||
|
||||
/// Issues or pull requests.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Kind {
|
||||
#[default]
|
||||
Issues,
|
||||
Pulls,
|
||||
}
|
||||
|
||||
/// The list's open/closed switch. "Closed" includes merged pull requests, as
|
||||
/// it does on GitHub.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
|
||||
pub enum StateFilter {
|
||||
#[default]
|
||||
Open,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl StateFilter {
|
||||
pub fn as_query(self) -> &'static str {
|
||||
match self {
|
||||
StateFilter::Open => "open",
|
||||
StateFilter::Closed => "closed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an issue or a pull request stands. Each one gets its own glyph
|
||||
/// *shape*, not only its own colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ItemState {
|
||||
Open,
|
||||
/// A pull request marked as a draft (and still open).
|
||||
Draft,
|
||||
/// An issue closed as completed, or a pull request closed unmerged.
|
||||
Closed,
|
||||
/// An issue closed as "not planned" / duplicate.
|
||||
NotPlanned,
|
||||
Merged,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Label {
|
||||
pub name: String,
|
||||
/// `0xRRGGBB`, when GitHub sent a usable colour.
|
||||
pub color: Option<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>,
|
||||
/// Only with the `full` media type.
|
||||
#[serde(default)]
|
||||
pub(crate) body_html: 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>,
|
||||
/// Only with the `full` media type.
|
||||
#[serde(default)]
|
||||
body_html: 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: super::markdown::sign_attachments(
|
||||
self.body.as_deref().unwrap_or_default(),
|
||||
self.body_html.as_deref().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,401 @@
|
||||
//! 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('/')?;
|
||||
// `?`, `#` and `\` end the authority for a URL parser, so
|
||||
// `https://evil.io#@github.com/o/r` is a URL for evil.io.
|
||||
if authority.contains(['?', '#', '\\']) {
|
||||
return None;
|
||||
}
|
||||
// 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",
|
||||
"https://evil.io#@github.com/owner/name",
|
||||
"https://evil.io?@github.com/owner/name",
|
||||
"https://evil.io\\@github.com/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,283 @@
|
||||
//! 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 crate::core::proc::{hide_console, output_within};
|
||||
let mut cmd = std::process::Command::new(gh);
|
||||
cmd.args(["auth", "token", "--hostname", "github.com"])
|
||||
.env("GH_PROMPT_DISABLED", "1")
|
||||
.env("GH_NO_UPDATE_NOTIFIER", "1");
|
||||
// A GUI app spawning a console program on Windows flashes a console
|
||||
// window unless told not to.
|
||||
let out = output_within(hide_console(&mut cmd), GH_TIMEOUT).ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(out.stdout).ok()
|
||||
}
|
||||
|
||||
#[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", " ");
|
||||
// A `gh` on PATH, spelled the way this platform spells it: the
|
||||
// Homebrew fallbacks don't exist on Windows, so the test must not
|
||||
// lean on them.
|
||||
let path = if cfg!(windows) {
|
||||
r"C:\fake\bin"
|
||||
} else {
|
||||
"/fake/bin"
|
||||
};
|
||||
f.vars.insert("PATH", path);
|
||||
let gh = gh_candidates(Some(path)).remove(0);
|
||||
f.files.push(gh.clone());
|
||||
f.gh_out = Some("gho_cli\n");
|
||||
let (token, source) = f.resolve().unwrap();
|
||||
assert_eq!(token.expose(), "gho_cli");
|
||||
assert_eq!(source, TokenSource::GhCli);
|
||||
assert_eq!(*f.asked.borrow(), vec![gh]);
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,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)]
|
||||
|
||||
@@ -153,9 +153,16 @@ pub mod feature {
|
||||
/// while keeping their screens (#762). Needs both a tree and panes, so a
|
||||
/// peer serving either alone does not say it.
|
||||
pub const TAB_HIBERNATE: &str = "tab-hibernate";
|
||||
/// The peer answers [`ControlRequest::SearchContent`] — find in files for
|
||||
/// the right panel's Search tab. A peer without it has never heard of the
|
||||
/// request, and sending it would take the link down, so a client asks for
|
||||
/// the name first and tells the user the server needs updating instead.
|
||||
pub const CONTENT_SEARCH: &str = "content-search";
|
||||
}
|
||||
|
||||
pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
|
||||
pub use crate::host::{
|
||||
ContentLimits, ContentQuery, ContentResults, Entry, MTime, Meta, Output, SearchHit,
|
||||
};
|
||||
|
||||
pub use crate::core::shells::{DetectedShell, ShellInventory};
|
||||
|
||||
@@ -191,6 +198,12 @@ pub enum ControlRequest {
|
||||
max_dirs: u64,
|
||||
show_hidden: bool,
|
||||
},
|
||||
/// Find in files. Gated on [`feature::CONTENT_SEARCH`].
|
||||
SearchContent {
|
||||
roots: Vec<String>,
|
||||
query: ContentQuery,
|
||||
limits: ContentLimits,
|
||||
},
|
||||
|
||||
WriteFile {
|
||||
path: String,
|
||||
@@ -420,6 +433,9 @@ impl ControlRequest {
|
||||
Duration::from_secs(10)
|
||||
}
|
||||
Git { .. } | GitStream { .. } | Search { .. } => Duration::from_secs(20),
|
||||
// Past the host's own time budget (`ContentLimits::max_millis`),
|
||||
// so a search that ran long still gets to say what it found.
|
||||
SearchContent { .. } => Duration::from_secs(30),
|
||||
Shells | AgentSessions { .. } => Duration::from_secs(20),
|
||||
WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10),
|
||||
GuiOpen { .. } => Duration::from_secs(5),
|
||||
@@ -484,6 +500,7 @@ pub enum ReplyOk {
|
||||
OptPath(Option<String>),
|
||||
FileMeta { meta: Meta },
|
||||
Hits(Vec<SearchHit>),
|
||||
ContentHits(ContentResults),
|
||||
Output(Output),
|
||||
WatchId(u64),
|
||||
Shells(ShellInventory),
|
||||
@@ -1577,6 +1594,17 @@ mod tests {
|
||||
max_dirs: 2000,
|
||||
show_hidden: false,
|
||||
},
|
||||
ControlRequest::SearchContent {
|
||||
roots: vec!["/home/me/proj".into()],
|
||||
query: ContentQuery {
|
||||
pattern: r"fn \w+".into(),
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
regex: true,
|
||||
show_hidden: false,
|
||||
},
|
||||
limits: ContentLimits::default(),
|
||||
},
|
||||
ControlRequest::WriteFile {
|
||||
path: "/home/me/proj/src/main.rs".into(),
|
||||
},
|
||||
@@ -1658,6 +1686,17 @@ mod tests {
|
||||
is_dir: false,
|
||||
ignored: false,
|
||||
}])),
|
||||
ControlReply::Ok(ReplyOk::ContentHits(ContentResults {
|
||||
hits: vec![crate::host::ContentHit {
|
||||
path: PathBuf::from("/home/me/proj/src/widget.rs"),
|
||||
line: 12,
|
||||
column: 5,
|
||||
text: "fn widget() {".into(),
|
||||
ranges: vec![crate::host::ContentRange { start: 3, end: 9 }],
|
||||
}],
|
||||
truncated: true,
|
||||
files_searched: 40,
|
||||
})),
|
||||
ControlReply::Ok(ReplyOk::Output(Output {
|
||||
status: Some(1),
|
||||
stdout: b"?? src/new.rs\n".to_vec(),
|
||||
@@ -2339,6 +2378,14 @@ mod tests {
|
||||
},
|
||||
s(20),
|
||||
),
|
||||
(
|
||||
R::SearchContent {
|
||||
roots: vec![],
|
||||
query: ContentQuery::default(),
|
||||
limits: ContentLimits::default(),
|
||||
},
|
||||
s(30),
|
||||
),
|
||||
(
|
||||
R::GuiOpen {
|
||||
path: None,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{Host, MTime, SearchHit};
|
||||
use super::{ContentLimits, ContentQuery, ContentResults, Host, MTime, SearchHit};
|
||||
use crate::daemon::control::WATCH_COALESCE_WINDOW;
|
||||
|
||||
pub type Case = fn(h: &dyn Host, sandbox: &dyn Sandbox);
|
||||
@@ -64,6 +64,12 @@ macro_rules! for_each_host_case {
|
||||
search_skips_ignored_dirs,
|
||||
search_respects_limit,
|
||||
search_respects_max_dirs,
|
||||
search_content_reports_line_column_and_ranges,
|
||||
search_content_honours_gitignore_without_a_repo,
|
||||
search_content_skips_binary_and_oversized_files,
|
||||
search_content_caps_and_says_so,
|
||||
search_content_options_change_what_matches,
|
||||
search_content_rejects_a_bad_expression,
|
||||
shells_are_named_and_have_a_default,
|
||||
watch_reports_create_and_delete,
|
||||
watch_ignores_reads,
|
||||
@@ -905,6 +911,204 @@ pub fn search_respects_max_dirs(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
assert_eq!(hit_names(&hits), vec!["needle.txt"]);
|
||||
}
|
||||
|
||||
fn content(
|
||||
h: &dyn Host,
|
||||
root: &Path,
|
||||
query: ContentQuery,
|
||||
limits: ContentLimits,
|
||||
) -> ContentResults {
|
||||
h.search_content(&[root.to_path_buf()], &query, &limits)
|
||||
.unwrap_or_else(|e| panic!("search_content {query:?}: {e}"))
|
||||
}
|
||||
|
||||
fn text_query(pattern: &str) -> ContentQuery {
|
||||
ContentQuery {
|
||||
pattern: pattern.into(),
|
||||
..ContentQuery::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_files(found: &ContentResults) -> Vec<String> {
|
||||
let mut names: Vec<String> = found
|
||||
.hits
|
||||
.iter()
|
||||
.filter_map(|h| h.path.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn search_content_reports_line_column_and_ranges(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
let src = h.join(sandbox, "src");
|
||||
mkdir(h, &src);
|
||||
write(
|
||||
h,
|
||||
&h.join(&src, "lib.rs"),
|
||||
"fn main() {}\n\n let needle = 1; // needle\n",
|
||||
);
|
||||
write(h, &h.join(sandbox, "notes.txt"), "no match here\n");
|
||||
|
||||
let found = content(h, sandbox, text_query("needle"), ContentLimits::default());
|
||||
assert!(!found.truncated);
|
||||
assert_eq!(found.files_searched, 2);
|
||||
assert_eq!(found.hits.len(), 1, "one hit per line: {:?}", found.hits);
|
||||
let hit = &found.hits[0];
|
||||
assert_eq!(hit.path, h.join(&src, "lib.rs"), "an absolute host path");
|
||||
assert_eq!((hit.line, hit.column), (3, 9));
|
||||
assert_eq!(hit.text, "let needle = 1; // needle");
|
||||
let marked: Vec<&str> = hit
|
||||
.ranges
|
||||
.iter()
|
||||
.map(|r| &hit.text[r.start as usize..r.end as usize])
|
||||
.collect();
|
||||
assert_eq!(marked, vec!["needle", "needle"]);
|
||||
}
|
||||
|
||||
pub fn search_content_honours_gitignore_without_a_repo(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
write(h, &h.join(sandbox, ".gitignore"), "build/\n*.log\n");
|
||||
let build = h.join(sandbox, "build");
|
||||
mkdir(h, &build);
|
||||
write(h, &h.join(&build, "out.rs"), "needle\n");
|
||||
write(h, &h.join(sandbox, "trace.log"), "needle\n");
|
||||
let dot = h.join(sandbox, ".cache");
|
||||
mkdir(h, &dot);
|
||||
write(h, &h.join(&dot, "hidden.rs"), "needle\n");
|
||||
write(h, &h.join(sandbox, "kept.rs"), "needle\n");
|
||||
|
||||
let found = content(h, sandbox, text_query("needle"), ContentLimits::default());
|
||||
assert_eq!(hit_files(&found), vec!["kept.rs"]);
|
||||
|
||||
let everything = ContentQuery {
|
||||
show_hidden: true,
|
||||
..text_query("needle")
|
||||
};
|
||||
let mut all = hit_files(&content(h, sandbox, everything, ContentLimits::default()));
|
||||
all.sort_unstable();
|
||||
assert_eq!(all, vec!["hidden.rs", "kept.rs", "out.rs", "trace.log"]);
|
||||
}
|
||||
|
||||
pub fn search_content_skips_binary_and_oversized_files(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
put(h, &h.join(sandbox, "blob.bin"), b"needle\0\x01\x02");
|
||||
write(h, &h.join(sandbox, "big.txt"), &"needle\n".repeat(200));
|
||||
write(h, &h.join(sandbox, "small.txt"), "needle\n");
|
||||
put(h, &h.join(sandbox, "latin1.txt"), b"caf\xe9 needle\n");
|
||||
|
||||
let limits = ContentLimits {
|
||||
max_file_bytes: 100,
|
||||
..ContentLimits::default()
|
||||
};
|
||||
let found = content(h, sandbox, text_query("needle"), limits);
|
||||
let mut files = hit_files(&found);
|
||||
files.sort_unstable();
|
||||
assert_eq!(
|
||||
files,
|
||||
vec!["latin1.txt", "small.txt"],
|
||||
"binary and oversized files are stepped past; bad UTF-8 is not"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn search_content_caps_and_says_so(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
for i in 0..5 {
|
||||
write(h, &h.join(sandbox, &format!("f{i}.txt")), "x\nx\nx\n");
|
||||
}
|
||||
|
||||
let exact = content(h, sandbox, text_query("x"), ContentLimits::default());
|
||||
assert_eq!(exact.hits.len(), 15);
|
||||
assert!(!exact.truncated, "everything found is not truncated");
|
||||
|
||||
let total = ContentLimits {
|
||||
max_hits: 4,
|
||||
..ContentLimits::default()
|
||||
};
|
||||
let capped = content(h, sandbox, text_query("x"), total);
|
||||
assert_eq!(capped.hits.len(), 4);
|
||||
assert!(capped.truncated);
|
||||
|
||||
let per_file = ContentLimits {
|
||||
max_hits_per_file: 1,
|
||||
..ContentLimits::default()
|
||||
};
|
||||
let capped = content(h, sandbox, text_query("x"), per_file);
|
||||
assert_eq!(capped.hits.len(), 5, "one line from each file");
|
||||
assert!(capped.truncated);
|
||||
|
||||
let files = ContentLimits {
|
||||
max_files: 2,
|
||||
..ContentLimits::default()
|
||||
};
|
||||
let capped = content(h, sandbox, text_query("x"), files);
|
||||
assert_eq!(capped.files_searched, 2);
|
||||
assert_eq!(capped.hits.len(), 6);
|
||||
assert!(capped.truncated);
|
||||
}
|
||||
|
||||
pub fn search_content_options_change_what_matches(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
write(
|
||||
h,
|
||||
&h.join(sandbox, "a.rs"),
|
||||
"Widget\nwidget\nwidgets\nfn widget_new()\n",
|
||||
);
|
||||
let lines = |q: ContentQuery| -> Vec<u32> {
|
||||
content(h, sandbox, q, ContentLimits::default())
|
||||
.hits
|
||||
.iter()
|
||||
.map(|hit| hit.line)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(lines(text_query("widget")), vec![1, 2, 3, 4]);
|
||||
assert_eq!(
|
||||
lines(ContentQuery {
|
||||
case_sensitive: true,
|
||||
..text_query("Widget")
|
||||
}),
|
||||
vec![1]
|
||||
);
|
||||
assert_eq!(
|
||||
lines(ContentQuery {
|
||||
whole_word: true,
|
||||
..text_query("widget")
|
||||
}),
|
||||
vec![1, 2]
|
||||
);
|
||||
assert_eq!(
|
||||
lines(ContentQuery {
|
||||
regex: true,
|
||||
..text_query(r"^widgets?$")
|
||||
}),
|
||||
vec![1, 2, 3]
|
||||
);
|
||||
assert!(
|
||||
lines(text_query("widget.")).is_empty(),
|
||||
"literal by default"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn search_content_rejects_a_bad_expression(h: &dyn Host, sb: &dyn Sandbox) {
|
||||
let sandbox = sb.path();
|
||||
write(h, &h.join(sandbox, "a.rs"), "(\n");
|
||||
let bad = ContentQuery {
|
||||
regex: true,
|
||||
..text_query("(")
|
||||
};
|
||||
let e = h
|
||||
.search_content(&[sandbox.to_path_buf()], &bad, &ContentLimits::default())
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::InvalidInput, "{e}");
|
||||
|
||||
let literal = content(h, sandbox, text_query("("), ContentLimits::default());
|
||||
assert_eq!(
|
||||
literal.hits.len(),
|
||||
1,
|
||||
"the same text, taken literally, is fine"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn shells_are_named_and_have_a_default(h: &dyn Host, _sb: &dyn Sandbox) {
|
||||
let inv = h.shells().expect("a host can list its shells");
|
||||
assert!(
|
||||
@@ -1162,7 +1366,7 @@ mod tests {
|
||||
}
|
||||
assert_eq!(defined.len(), registered.len(), "duplicate registration");
|
||||
assert!(
|
||||
defined.len() >= 46,
|
||||
defined.len() >= 52,
|
||||
"the suite lost cases: {}",
|
||||
defined.len()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! The engine behind [`Host::search_content`](super::Host::search_content):
|
||||
//! "find in files" over one machine's filesystem.
|
||||
//!
|
||||
//! The local host runs it directly and a remote `tty7-server` runs the very
|
||||
//! same code on its side of the wire, so what a query matches, which files it
|
||||
//! skips and where each cap bites cannot differ between a local and a remote
|
||||
//! pane.
|
||||
//!
|
||||
//! The walk agrees with the name search and the file tree about what is in
|
||||
//! the project: a directory's `.gitignore` applies whether or not there is a
|
||||
//! repository around it, dot-entries are skipped, and `show_hidden` turns
|
||||
//! both off. Parent directories' ignore files, `.git/info/exclude` and the
|
||||
//! user's global excludes are *not* consulted — the tree does not dim what
|
||||
//! they name either, and a file that is on screen in the Files tab but never
|
||||
//! found by Search would be the harder surprise.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use regex::{Regex, RegexBuilder};
|
||||
|
||||
use super::{ContentHit, ContentLimits, ContentQuery, ContentRange, ContentResults};
|
||||
|
||||
/// How much of a file is inspected for a NUL byte before deciding it is
|
||||
/// binary — the same sniff git and ripgrep use.
|
||||
const BINARY_SNIFF: usize = 8000;
|
||||
|
||||
/// The longest excerpt a hit carries, in characters, before the `…` marks.
|
||||
pub const EXCERPT_CHARS: usize = 200;
|
||||
|
||||
/// How much of the line before its first match an excerpt keeps when the line
|
||||
/// has to be cut, in characters.
|
||||
const EXCERPT_LEAD: usize = 40;
|
||||
|
||||
/// The mark put where an excerpt was cut.
|
||||
pub const ELLIPSIS: &str = "…";
|
||||
|
||||
/// Compile a query into the one regex every file is matched with.
|
||||
///
|
||||
/// A literal query is escaped, so `a.b` finds `a.b` and not `axb`; whole-word
|
||||
/// wraps whatever came out in `\b…\b`. An expression that does not compile is
|
||||
/// `InvalidInput`, carrying the parser's own explanation.
|
||||
pub fn compile(query: &ContentQuery) -> io::Result<Regex> {
|
||||
let body = if query.regex {
|
||||
query.pattern.clone()
|
||||
} else {
|
||||
regex::escape(&query.pattern)
|
||||
};
|
||||
let pattern = if query.whole_word {
|
||||
format!(r"\b(?:{body})\b")
|
||||
} else {
|
||||
body
|
||||
};
|
||||
RegexBuilder::new(&pattern)
|
||||
.case_insensitive(!query.case_sensitive)
|
||||
// A line is searched on its own, so `^`/`$` mean its ends either way;
|
||||
// multi-line keeps them meaning that when the whole file is scanned
|
||||
// for the first candidate line.
|
||||
.multi_line(true)
|
||||
.size_limit(8 * (1 << 20))
|
||||
.build()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))
|
||||
}
|
||||
|
||||
/// Search `roots` on this machine's filesystem.
|
||||
pub fn search(
|
||||
roots: &[PathBuf],
|
||||
query: &ContentQuery,
|
||||
limits: &ContentLimits,
|
||||
) -> io::Result<ContentResults> {
|
||||
let mut out = ContentResults::default();
|
||||
if query.pattern.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let re = compile(query)?;
|
||||
let roots = distinct_roots(roots);
|
||||
for root in &roots {
|
||||
// A missing root is the one failure worth reporting: every other
|
||||
// unreadable thing below it is a file the walk steps past.
|
||||
std::fs::metadata(root)?;
|
||||
}
|
||||
|
||||
// The limits come off the wire on a server; a budget too large to add to
|
||||
// a clock is no budget, not a panic.
|
||||
let deadline = Instant::now().checked_add(Duration::from_millis(limits.max_millis));
|
||||
let max_hits = limits.max_hits as usize;
|
||||
let per_file = limits.max_hits_per_file.max(1) as usize;
|
||||
|
||||
'roots: for root in &roots {
|
||||
let walk = walker(root, query.show_hidden);
|
||||
for entry in walk {
|
||||
let Ok(entry) = entry else { continue };
|
||||
if !entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
if out.files_searched >= limits.max_files
|
||||
|| deadline.is_some_and(|d| Instant::now() >= d)
|
||||
{
|
||||
out.truncated = true;
|
||||
break 'roots;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Some(text) = read_text(path, limits.max_file_bytes) else {
|
||||
continue;
|
||||
};
|
||||
out.files_searched += 1;
|
||||
match search_text(&re, &text, path, per_file, max_hits, &mut out.hits) {
|
||||
Scan::Done => {}
|
||||
Scan::FileCapped => out.truncated = true,
|
||||
Scan::TotalCapped => {
|
||||
out.truncated = true;
|
||||
break 'roots;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The roots with duplicates and roots nested inside another root dropped, so
|
||||
/// one file is never walked — and listed — twice.
|
||||
fn distinct_roots(roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for root in roots {
|
||||
if roots
|
||||
.iter()
|
||||
.any(|other| other != root && root.starts_with(other))
|
||||
|| out.contains(root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(root.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walker(root: &Path, show_hidden: bool) -> ignore::Walk {
|
||||
let mut b = ignore::WalkBuilder::new(root);
|
||||
b.standard_filters(false)
|
||||
.hidden(!show_hidden)
|
||||
.git_ignore(!show_hidden)
|
||||
// A plain folder's `.gitignore` counts too, as it does in the tree.
|
||||
.require_git(false)
|
||||
.parents(false)
|
||||
.follow_links(false)
|
||||
.sort_by_file_name(|a, b| a.cmp(b))
|
||||
// `.git` is never the project's content, even with hidden files on:
|
||||
// the name search marks it ignored on the same grounds.
|
||||
.filter_entry(|e| e.file_name() != ".git");
|
||||
b.build()
|
||||
}
|
||||
|
||||
/// The file as text, or `None` for a file to step past: unreadable, over the
|
||||
/// size cap, or binary. Bytes that are not UTF-8 are replaced rather than
|
||||
/// losing the whole file to one Latin-1 comment.
|
||||
fn read_text(path: &Path, max_bytes: u64) -> Option<String> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > max_bytes {
|
||||
return None;
|
||||
}
|
||||
// Read through the cap rather than trusting the size just seen: a log
|
||||
// that grows between the two would otherwise be read whole.
|
||||
let mut bytes = Vec::new();
|
||||
file.take(max_bytes.saturating_add(1))
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
if bytes.len() as u64 > max_bytes {
|
||||
return None;
|
||||
}
|
||||
if is_binary(&bytes) {
|
||||
return None;
|
||||
}
|
||||
Some(match String::from_utf8(bytes) {
|
||||
Ok(s) => s,
|
||||
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
memchr::memchr(0, &bytes[..bytes.len().min(BINARY_SNIFF)]).is_some()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Scan {
|
||||
/// Every matching line in the file was taken.
|
||||
Done,
|
||||
/// The file had more matching lines than the per-file cap.
|
||||
FileCapped,
|
||||
/// The search as a whole is full; nothing more should be read.
|
||||
TotalCapped,
|
||||
}
|
||||
|
||||
/// Collect the matching lines of one file's text into `out`.
|
||||
///
|
||||
/// Rather than run the regex over every line, it asks the whole text for the
|
||||
/// next match and only then looks at the line that match starts on — most of
|
||||
/// a file has nothing to find, and one pass over it is the cheap way to learn
|
||||
/// that. A match that runs across a newline (`\s+` can) finds its line, but
|
||||
/// the line is still matched on its own, so no hit ever spans two lines.
|
||||
pub fn search_text(
|
||||
re: &Regex,
|
||||
text: &str,
|
||||
path: &Path,
|
||||
per_file: usize,
|
||||
max_hits: usize,
|
||||
out: &mut Vec<ContentHit>,
|
||||
) -> Scan {
|
||||
let mut taken = 0usize;
|
||||
let mut pos = 0usize;
|
||||
let mut line_no = 1u32;
|
||||
let mut counted_to = 0usize;
|
||||
while pos <= text.len() {
|
||||
let Some(m) = re.find_at(text, pos) else {
|
||||
break;
|
||||
};
|
||||
let line_start = text[..m.start()].rfind('\n').map_or(0, |i| i + 1);
|
||||
let line_end = text[m.start()..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |i| m.start() + i);
|
||||
line_no +=
|
||||
memchr::memchr_iter(b'\n', &text.as_bytes()[counted_to..line_start]).count() as u32;
|
||||
counted_to = line_start;
|
||||
|
||||
let line = text[line_start..line_end]
|
||||
.strip_suffix('\r')
|
||||
.unwrap_or(&text[line_start..line_end]);
|
||||
let matches: Vec<(usize, usize)> = re
|
||||
.find_iter(line)
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(|m| (m.start(), m.end()))
|
||||
.collect();
|
||||
if !matches.is_empty() {
|
||||
if taken == per_file {
|
||||
return Scan::FileCapped;
|
||||
}
|
||||
if out.len() >= max_hits {
|
||||
return Scan::TotalCapped;
|
||||
}
|
||||
let column = line[..matches[0].0].chars().count() as u32 + 1;
|
||||
let (excerpt, ranges) = excerpt(line, &matches);
|
||||
out.push(ContentHit {
|
||||
path: path.to_path_buf(),
|
||||
line: line_no,
|
||||
column,
|
||||
text: excerpt,
|
||||
ranges,
|
||||
});
|
||||
taken += 1;
|
||||
}
|
||||
pos = line_end + 1;
|
||||
}
|
||||
Scan::Done
|
||||
}
|
||||
|
||||
/// The part of `line` a hit shows, and where its matches fall within it.
|
||||
///
|
||||
/// Leading indentation goes, unless a match is in it. A line longer than
|
||||
/// [`EXCERPT_CHARS`] is cut to a window that opens [`EXCERPT_LEAD`]
|
||||
/// characters before the first match, with [`ELLIPSIS`] at each cut; matches
|
||||
/// outside the window are dropped and one straddling its edge is clipped.
|
||||
pub fn excerpt(line: &str, matches: &[(usize, usize)]) -> (String, Vec<ContentRange>) {
|
||||
let first = matches.first().map_or(0, |m| m.0);
|
||||
let indent = line.len() - line.trim_start().len();
|
||||
let trim = indent.min(first);
|
||||
let body = &line[trim..];
|
||||
let shifted = matches.iter().map(|&(s, e)| (s - trim, e - trim));
|
||||
|
||||
let (from, to) = if body.chars().count() <= EXCERPT_CHARS {
|
||||
(0, body.len())
|
||||
} else {
|
||||
let first = first - trim;
|
||||
let from = body[..first]
|
||||
.char_indices()
|
||||
.rev()
|
||||
.nth(EXCERPT_LEAD - 1)
|
||||
.map_or(0, |(i, _)| i);
|
||||
let to = body[from..]
|
||||
.char_indices()
|
||||
.nth(EXCERPT_CHARS)
|
||||
.map_or(body.len(), |(i, _)| from + i);
|
||||
(from, to)
|
||||
};
|
||||
|
||||
let lead = if from > 0 { ELLIPSIS } else { "" };
|
||||
let tail = if to < body.len() { ELLIPSIS } else { "" };
|
||||
let text = format!("{lead}{}{tail}", &body[from..to]);
|
||||
let ranges = shifted
|
||||
.filter(|&(s, e)| e > from && s < to)
|
||||
.map(|(s, e)| ContentRange {
|
||||
start: (s.max(from) - from + lead.len()) as u32,
|
||||
end: (e.min(to) - from + lead.len()) as u32,
|
||||
})
|
||||
.collect();
|
||||
(text, ranges)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn q(pattern: &str) -> ContentQuery {
|
||||
ContentQuery {
|
||||
pattern: pattern.into(),
|
||||
..ContentQuery::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(text: &str, query: &ContentQuery) -> Vec<(u32, u32, String)> {
|
||||
let re = compile(query).unwrap();
|
||||
let mut out = Vec::new();
|
||||
search_text(&re, text, Path::new("f"), 100, 100, &mut out);
|
||||
out.into_iter()
|
||||
.map(|h| (h.line, h.column, h.text))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn marked(hit: &ContentHit) -> String {
|
||||
let mut s = String::new();
|
||||
let mut at = 0;
|
||||
for r in &hit.ranges {
|
||||
s.push_str(&hit.text[at..r.start as usize]);
|
||||
s.push('[');
|
||||
s.push_str(&hit.text[r.start as usize..r.end as usize]);
|
||||
s.push(']');
|
||||
at = r.end as usize;
|
||||
}
|
||||
s.push_str(&hit.text[at..]);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_literal_query_is_not_an_expression() {
|
||||
let text = "a.b\naxb\n";
|
||||
assert_eq!(lines(text, &q("a.b")), vec![(1, 1, "a.b".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_is_ignored_unless_asked_for() {
|
||||
let text = "Needle\nneedle\nNEEDLE\n";
|
||||
assert_eq!(lines(text, &q("needle")).len(), 3);
|
||||
let exact = ContentQuery {
|
||||
case_sensitive: true,
|
||||
..q("needle")
|
||||
};
|
||||
assert_eq!(lines(text, &exact), vec![(2, 1, "needle".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_word_needs_a_boundary_on_both_sides() {
|
||||
let text = "foo\nfoobar\nbar_foo\n(foo)\n";
|
||||
let word = ContentQuery {
|
||||
whole_word: true,
|
||||
..q("foo")
|
||||
};
|
||||
let found: Vec<u32> = lines(text, &word).into_iter().map(|l| l.0).collect();
|
||||
assert_eq!(found, vec![1, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_regex_query_is_an_expression_and_a_bad_one_says_why() {
|
||||
let re = ContentQuery {
|
||||
regex: true,
|
||||
..q(r"fn \w+\(")
|
||||
};
|
||||
assert_eq!(lines("fn main() {}\nlet f = 1;\n", &re).len(), 1);
|
||||
|
||||
let bad = ContentQuery {
|
||||
regex: true,
|
||||
..q("(unclosed")
|
||||
};
|
||||
let err = compile(&bad).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(err.to_string().contains("unclosed"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_column_are_one_based_and_count_characters() {
|
||||
let text = "first\n\n héllo wörld needle\r\nlast needle";
|
||||
let got = lines(text, &q("needle"));
|
||||
assert_eq!(got[0].0, 3);
|
||||
assert_eq!(got[0].1, 15, "counted in chars, indent included");
|
||||
assert_eq!(got[0].2, "héllo wörld needle", "indent and \\r dropped");
|
||||
assert_eq!((got[1].0, got[1].1), (4, 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_match_on_a_line_is_one_hit_with_several_ranges() {
|
||||
let re = compile(&q("ab")).unwrap();
|
||||
let mut out = Vec::new();
|
||||
search_text(&re, " ab ab x AB\n", Path::new("f"), 10, 10, &mut out);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(marked(&out[0]), "[ab] [ab] x [AB]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_match_is_not_a_hit() {
|
||||
let re = ContentQuery {
|
||||
regex: true,
|
||||
..q("x*")
|
||||
};
|
||||
assert_eq!(lines("abc\nxx\n", &re), vec![(2, 1, "xx".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_across_a_newline_does_not_make_a_two_line_hit() {
|
||||
let re = ContentQuery {
|
||||
regex: true,
|
||||
..q(r"a\s+b")
|
||||
};
|
||||
assert!(lines("a\nb\n", &re).is_empty());
|
||||
assert_eq!(lines("x\na b\n", &re), vec![(2, 1, "a b".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_line_is_cut_around_its_first_match() {
|
||||
let line = format!("{}needle{}", "a".repeat(500), "b".repeat(500));
|
||||
let (text, ranges) = excerpt(&line, &[(500, 506)]);
|
||||
assert!(text.starts_with(ELLIPSIS) && text.ends_with(ELLIPSIS));
|
||||
assert_eq!(text.chars().count(), EXCERPT_CHARS + 2);
|
||||
let r = ranges[0];
|
||||
assert_eq!(&text[r.start as usize..r.end as usize], "needle");
|
||||
assert_eq!(text[..r.start as usize].chars().count(), EXCERPT_LEAD + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_never_splits_a_character() {
|
||||
let line = format!("{}針{}", "字".repeat(300), "字".repeat(300));
|
||||
let at = line.find('針').unwrap();
|
||||
let (text, ranges) = excerpt(&line, &[(at, at + '針'.len_utf8())]);
|
||||
let r = ranges[0];
|
||||
assert_eq!(&text[r.start as usize..r.end as usize], "針");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_in_the_indent_keeps_the_indent() {
|
||||
let (text, ranges) = excerpt("\tx", &[(0, 1)]);
|
||||
assert_eq!(text, "\tx");
|
||||
assert_eq!(ranges, vec![ContentRange { start: 0, end: 1 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_say_which_one_was_hit() {
|
||||
let re = compile(&q("x")).unwrap();
|
||||
let text = "x\nx\nx\n";
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(
|
||||
search_text(&re, text, Path::new("f"), 2, 10, &mut out),
|
||||
Scan::FileCapped
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(
|
||||
search_text(&re, text, Path::new("f"), 10, 3, &mut out),
|
||||
Scan::Done,
|
||||
"exactly full is not truncated"
|
||||
);
|
||||
assert_eq!(
|
||||
search_text(&re, text, Path::new("g"), 10, 3, &mut out),
|
||||
Scan::TotalCapped
|
||||
);
|
||||
assert_eq!(out.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_and_repeated_roots_are_walked_once() {
|
||||
let roots = [
|
||||
PathBuf::from("/r"),
|
||||
PathBuf::from("/r/sub"),
|
||||
PathBuf::from("/r"),
|
||||
PathBuf::from("/other"),
|
||||
];
|
||||
assert_eq!(
|
||||
distinct_roots(&roots),
|
||||
vec![PathBuf::from("/r"), PathBuf::from("/other")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unbounded_time_budget_is_not_a_panic() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-cs-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.txt"), "needle\n").unwrap();
|
||||
let limits = ContentLimits {
|
||||
max_millis: u64::MAX,
|
||||
..ContentLimits::default()
|
||||
};
|
||||
let found = search(&[dir.clone()], &q("needle"), &limits).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(found.hits.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nul_in_the_head_means_binary() {
|
||||
assert!(is_binary(b"abc\0def"));
|
||||
assert!(!is_binary(b"plain text"));
|
||||
let mut late = vec![b'a'; BINARY_SNIFF + 10];
|
||||
late.push(0);
|
||||
assert!(!is_binary(&late), "only the head is sniffed");
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ use notify::{RecursiveMode, Watcher};
|
||||
use crate::core::git;
|
||||
use crate::core::gitignore::GitignoreChain;
|
||||
use crate::host::{
|
||||
Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle,
|
||||
WatchSub, guard_off_ui,
|
||||
ContentLimits, ContentQuery, ContentResults, Entry, Host, HostId, MTime, Meta, Output,
|
||||
SearchHit, SharedHost, ShellInventory, WatchHandle, WatchSub, guard_off_ui,
|
||||
};
|
||||
|
||||
const COALESCE_WINDOW: Duration = Duration::from_millis(100);
|
||||
@@ -245,6 +245,16 @@ impl Host for LocalHost {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn search_content(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
query: &ContentQuery,
|
||||
limits: &ContentLimits,
|
||||
) -> io::Result<ContentResults> {
|
||||
guard_off_ui();
|
||||
crate::host::content_search::search(roots, query, limits)
|
||||
}
|
||||
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
|
||||
guard_off_ui();
|
||||
fs::write(p, bytes)?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod conformance;
|
||||
pub mod content_search;
|
||||
pub mod local;
|
||||
pub mod remote;
|
||||
pub mod server;
|
||||
@@ -147,6 +148,96 @@ pub struct SearchHit {
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
/// What [`Host::search_content`] looks for.
|
||||
///
|
||||
/// Every flag defaults to off, so a peer that grows a field later still reads
|
||||
/// an older client's query the way that client meant it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentQuery {
|
||||
/// The text to find — or, with `regex`, the expression.
|
||||
pub pattern: String,
|
||||
#[serde(default)]
|
||||
pub case_sensitive: bool,
|
||||
/// Only where the match starts and ends on a word boundary.
|
||||
#[serde(default)]
|
||||
pub whole_word: bool,
|
||||
/// `pattern` is a regular expression (Rust `regex` syntax) rather than
|
||||
/// literal text.
|
||||
#[serde(default)]
|
||||
pub regex: bool,
|
||||
/// Walk dot-directories and gitignored paths too, as the name search's
|
||||
/// flag of the same name does.
|
||||
#[serde(default)]
|
||||
pub show_hidden: bool,
|
||||
}
|
||||
|
||||
/// How far one [`Host::search_content`] may go before it stops and says it
|
||||
/// stopped. Every cap reached is reported as `truncated`, never as silence.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentLimits {
|
||||
/// Matching lines across every file.
|
||||
pub max_hits: u64,
|
||||
/// Matching lines taken from one file; the rest of that file is skipped.
|
||||
pub max_hits_per_file: u64,
|
||||
/// Files opened and read. The walk stops at this many.
|
||||
pub max_files: u64,
|
||||
/// A file longer than this is not read at all — a minified bundle or a
|
||||
/// data dump is not source anyone is searching.
|
||||
pub max_file_bytes: u64,
|
||||
/// Wall-clock budget for the whole search, so a huge tree answers with
|
||||
/// what it found instead of running into the caller's deadline.
|
||||
pub max_millis: u64,
|
||||
}
|
||||
|
||||
impl Default for ContentLimits {
|
||||
fn default() -> Self {
|
||||
ContentLimits {
|
||||
max_hits: 2000,
|
||||
max_hits_per_file: 100,
|
||||
max_files: 20_000,
|
||||
max_file_bytes: 1024 * 1024,
|
||||
max_millis: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One matching line.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentHit {
|
||||
pub path: PathBuf,
|
||||
/// 1-based.
|
||||
pub line: u32,
|
||||
/// 1-based, in characters, of the first match on the line in the file —
|
||||
/// what an editor's cursor wants, independent of how `text` was cut.
|
||||
pub column: u32,
|
||||
/// The line as shown: leading whitespace dropped, and a line too long to
|
||||
/// show cut down to a window around its first match, with `…` marking
|
||||
/// each cut.
|
||||
pub text: String,
|
||||
/// Byte ranges of each match within `text`, in order, never overlapping,
|
||||
/// always on character boundaries.
|
||||
pub ranges: Vec<ContentRange>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentRange {
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
/// What a [`Host::search_content`] found, in walk order: files sorted by path
|
||||
/// within each directory, lines in file order.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentResults {
|
||||
pub hits: Vec<ContentHit>,
|
||||
/// A limit in [`ContentLimits`] cut the search short, so there may be
|
||||
/// more matches than `hits` holds.
|
||||
pub truncated: bool,
|
||||
/// How many files were actually read.
|
||||
#[serde(default)]
|
||||
pub files_searched: u64,
|
||||
}
|
||||
|
||||
pub struct WatchSub {
|
||||
rx: smol::channel::Receiver<Vec<PathBuf>>,
|
||||
inner: Box<dyn WatchHandle>,
|
||||
@@ -211,6 +302,22 @@ pub trait Host: Send + Sync + 'static {
|
||||
show_hidden: bool,
|
||||
) -> io::Result<Vec<SearchHit>>;
|
||||
|
||||
/// Search file *contents* under `roots` — the right panel's Search tab.
|
||||
///
|
||||
/// Walks the way [`Host::search`] does: `.gitignore` honoured (with or
|
||||
/// without a repository), dot-directories skipped, both unless
|
||||
/// `query.show_hidden`. Binary files and files over
|
||||
/// `limits.max_file_bytes` are skipped. A pattern that does not compile is
|
||||
/// [`io::ErrorKind::InvalidInput`]; a host that cannot search contents at
|
||||
/// all is [`io::ErrorKind::Unsupported`], which callers must tell apart
|
||||
/// from "no matches".
|
||||
fn search_content(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
query: &ContentQuery,
|
||||
limits: &ContentLimits,
|
||||
) -> io::Result<ContentResults>;
|
||||
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta>;
|
||||
|
||||
fn create_file_new(&self, p: &Path) -> io::Result<()>;
|
||||
|
||||
@@ -11,7 +11,8 @@ use crate::daemon::control::{
|
||||
KEEPALIVE_PING_INTERVAL, LinkShutdown, ReplyOk,
|
||||
};
|
||||
use crate::host::{
|
||||
Entry, Host, HostId, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle, WatchSub,
|
||||
ContentLimits, ContentQuery, ContentResults, Entry, Host, HostId, Meta, Output, SearchHit,
|
||||
SharedHost, ShellInventory, WatchHandle, WatchSub,
|
||||
};
|
||||
|
||||
pub struct RemoteHost {
|
||||
@@ -225,6 +226,41 @@ impl Host for RemoteHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Asked only of a peer that announces [`feature::CONTENT_SEARCH`]: one
|
||||
/// that predates the request cannot decode it, and a frame it cannot
|
||||
/// decode takes the whole link down. Such a peer answers `Unsupported`
|
||||
/// here without a byte on the wire, which the panel words as "update the
|
||||
/// server" rather than as a search that found nothing.
|
||||
///
|
||||
/// [`feature::CONTENT_SEARCH`]: crate::daemon::control::feature::CONTENT_SEARCH
|
||||
fn search_content(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
query: &ContentQuery,
|
||||
limits: &ContentLimits,
|
||||
) -> io::Result<ContentResults> {
|
||||
if !self
|
||||
.peer()
|
||||
.has_feature(crate::daemon::control::feature::CONTENT_SEARCH)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"the tty7-server on this host (build {}) cannot search file contents",
|
||||
self.peer().build
|
||||
),
|
||||
));
|
||||
}
|
||||
match self.call(ControlRequest::SearchContent {
|
||||
roots: wire_paths(roots),
|
||||
query: query.clone(),
|
||||
limits: *limits,
|
||||
})? {
|
||||
ReplyOk::ContentHits(found) => Ok(found),
|
||||
other => Err(wrong_shape("content search results", &other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
|
||||
match self
|
||||
.client
|
||||
@@ -740,6 +776,21 @@ mod tests {
|
||||
where
|
||||
F: Fn(&ControlRequest) -> Option<(ControlReply, Vec<u8>)> + Send + 'static,
|
||||
{
|
||||
host_with_peer_announcing(separator, &[], answer)
|
||||
}
|
||||
|
||||
/// [`host_with_peer`], with a peer whose hello names `extra` features on
|
||||
/// top of the two every control peer has.
|
||||
fn host_with_peer_announcing<F>(
|
||||
separator: char,
|
||||
extra: &[&str],
|
||||
answer: F,
|
||||
) -> (Arc<RemoteHost>, mpsc::Receiver<ControlRequest>)
|
||||
where
|
||||
F: Fn(&ControlRequest) -> Option<(ControlReply, Vec<u8>)> + Send + 'static,
|
||||
{
|
||||
let mut hello = hello_ok(separator);
|
||||
hello.features.extend(extra.iter().map(|f| f.to_string()));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (seen_tx, seen_rx) = mpsc::channel();
|
||||
@@ -749,9 +800,7 @@ mod tests {
|
||||
ControlClientMsg::Hello(_) => {}
|
||||
other => panic!("expected Hello, got {other:?}"),
|
||||
}
|
||||
ControlServerMsg::HelloOk(hello_ok(separator))
|
||||
.encode(&mut sock)
|
||||
.unwrap();
|
||||
ControlServerMsg::HelloOk(hello).encode(&mut sock).unwrap();
|
||||
sock.flush().unwrap();
|
||||
loop {
|
||||
let (req_id, req) = match ControlClientMsg::read(&mut sock) {
|
||||
@@ -1508,4 +1557,68 @@ mod tests {
|
||||
let shared: SharedHost = host.into_shared();
|
||||
assert_eq!(shared.separator(), '/');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_without_content_search_is_never_sent_the_request() {
|
||||
let (host, seen) = host_with_peer('/', |req| {
|
||||
panic!("an old peer was sent {req:?}");
|
||||
});
|
||||
let e = host
|
||||
.search_content(
|
||||
&[PathBuf::from("/p")],
|
||||
&crate::host::ContentQuery {
|
||||
pattern: "x".into(),
|
||||
..Default::default()
|
||||
},
|
||||
&crate::host::ContentLimits::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::Unsupported);
|
||||
assert!(
|
||||
seen.recv_timeout(Duration::from_millis(200)).is_err(),
|
||||
"nothing may reach a peer that cannot decode it"
|
||||
);
|
||||
assert!(host.is_connected(), "the link survives the refusal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_with_content_search_gets_one_request_carrying_the_query() {
|
||||
let (host, seen) = host_with_peer_announcing(
|
||||
'/',
|
||||
&[crate::daemon::control::feature::CONTENT_SEARCH],
|
||||
|_| {
|
||||
Some((
|
||||
ControlReply::Ok(ReplyOk::ContentHits(ContentResults {
|
||||
hits: vec![],
|
||||
truncated: true,
|
||||
files_searched: 3,
|
||||
})),
|
||||
vec![],
|
||||
))
|
||||
},
|
||||
);
|
||||
let query = crate::host::ContentQuery {
|
||||
pattern: "needle".into(),
|
||||
whole_word: true,
|
||||
..Default::default()
|
||||
};
|
||||
let found = host
|
||||
.search_content(
|
||||
&[PathBuf::from("/p")],
|
||||
&query,
|
||||
&crate::host::ContentLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(found.truncated);
|
||||
assert_eq!(found.files_searched, 3);
|
||||
match seen.recv().unwrap() {
|
||||
ControlRequest::SearchContent {
|
||||
roots, query: q, ..
|
||||
} => {
|
||||
assert_eq!(roots, vec!["/p".to_string()]);
|
||||
assert_eq!(q, query);
|
||||
}
|
||||
other => panic!("expected SearchContent, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +377,10 @@ fn handshake<R: Read>(
|
||||
feature::CONTROL.to_string(),
|
||||
feature::HOST_RPC.to_string(),
|
||||
feature::STDIO_BRIDGE.to_string(),
|
||||
// Every host this server fronts can search its own files; the
|
||||
// request only needs a name so an older client's peer is never sent
|
||||
// it (see `RemoteHost::search_content`).
|
||||
feature::CONTENT_SEARCH.to_string(),
|
||||
];
|
||||
// Only where there are panes to ask about. A control peer serving no panes
|
||||
// would answer every ask with an empty list, which reads to a client as
|
||||
@@ -563,6 +567,20 @@ fn drop_unsendable_hits(hits: &mut Vec<SearchHit>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`drop_unsendable_hits`] for content hits: a path that is not UTF-8 cannot
|
||||
/// cross the JSON wire intact, and a hit the client cannot open is worse than
|
||||
/// one it never sees.
|
||||
fn drop_unsendable_content_hits(hits: &mut Vec<crate::host::ContentHit>) {
|
||||
let before = hits.len();
|
||||
hits.retain(|hit| hit.path.to_str().is_some());
|
||||
if hits.len() != before {
|
||||
log::debug!(
|
||||
"content search dropped {} hit(s) whose paths are not UTF-8",
|
||||
before - hits.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn machine_with_live_panes(conn: &Conn) -> io::Result<machine::Machine> {
|
||||
let mut machine = conn.machine()?.machine();
|
||||
let Some(panes) = conn.panes.as_ref().map(|p| p.panes()) else {
|
||||
@@ -634,6 +652,16 @@ fn run_request(
|
||||
drop_unsendable_hits(&mut hits);
|
||||
(ReplyOk::Hits(hits), Vec::new())
|
||||
}
|
||||
ControlRequest::SearchContent {
|
||||
roots,
|
||||
query,
|
||||
limits,
|
||||
} => {
|
||||
let roots: Vec<PathBuf> = roots.iter().map(|r| p(r)).collect();
|
||||
let mut found = h.search_content(&roots, &query, &limits)?;
|
||||
drop_unsendable_content_hits(&mut found.hits);
|
||||
(ReplyOk::ContentHits(found), Vec::new())
|
||||
}
|
||||
|
||||
ControlRequest::WriteFile { path } => {
|
||||
(ReplyOk::Meta(h.write_file(&p(&path), &blob)?), Vec::new())
|
||||
@@ -2434,6 +2462,14 @@ mod tests {
|
||||
self.inner
|
||||
.search(roots, query, limit, max_dirs, show_hidden)
|
||||
}
|
||||
fn search_content(
|
||||
&self,
|
||||
roots: &[PathBuf],
|
||||
query: &crate::host::ContentQuery,
|
||||
limits: &crate::host::ContentLimits,
|
||||
) -> io::Result<crate::host::ContentResults> {
|
||||
self.inner.search_content(roots, query, limits)
|
||||
}
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
|
||||
self.inner.write_file(p, bytes)
|
||||
}
|
||||
@@ -2621,6 +2657,7 @@ mod tests {
|
||||
assert_eq!(peer.separator, std::path::MAIN_SEPARATOR);
|
||||
assert!(peer.has_feature(feature::CONTROL));
|
||||
assert!(peer.has_feature(feature::HOST_RPC));
|
||||
assert!(peer.has_feature(feature::CONTENT_SEARCH));
|
||||
assert!(
|
||||
!peer.home.is_empty(),
|
||||
"the server's $HOME backs `new workspace in ~`"
|
||||
|
||||
@@ -78,7 +78,7 @@ fn the_whole_suite_ran_against_the_server() {
|
||||
.map(|(n, _)| *n)
|
||||
.collect();
|
||||
assert!(
|
||||
names.len() >= 46,
|
||||
names.len() >= 52,
|
||||
"the conformance suite shrank to {} cases: {names:?}",
|
||||
names.len()
|
||||
);
|
||||
|
||||
@@ -113,7 +113,7 @@ Keybindings**:
|
||||
| Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` |
|
||||
| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:<slug>` (one per agent, e.g. `LaunchAgent:claude`) |
|
||||
| Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` |
|
||||
| Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` |
|
||||
| Panels | `ShowRightPanelInfo` · `ShowRightPanelFiles` · `ShowRightPanelSearch` · `ShowRightPanelChanges` · `ShowRightPanelGitHub` |
|
||||
| Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` |
|
||||
| SSH | `ToggleSftp` · `ShowSshForwards` · `OpenSshProfiles` |
|
||||
| Application | `CloseWindow` · `About` · `CheckForUpdates` · `OpenDocumentation` · `OpenDiscord` · `ReportIssue` · `ShowAll` · `ZoomWindow` |
|
||||
|
||||
@@ -46,9 +46,18 @@ 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 fetched only when GitHub hosts them (screenshots pasted into
|
||||
an issue, `*.githubusercontent.com`); any other image is shown as a link, 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
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ then everything else in groups — the groups are the map of the app:
|
||||
|---|---|
|
||||
| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session |
|
||||
| **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… |
|
||||
| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff |
|
||||
| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Files / Search / Changes / GitHub · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff |
|
||||
| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch |
|
||||
| **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All |
|
||||
| **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding |
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "The side panel"
|
||||
description: "Info, Source Control, and Files — plus the built-in editor."
|
||||
description: "Info, Files, Search, Source Control and GitHub — plus the built-in editor."
|
||||
---
|
||||
|
||||
<kbd>⌘ J</kbd> opens a panel on the right of the window with three tabs. It is
|
||||
hidden by default; whichever tab you leave it on is where it opens next time.
|
||||
<kbd>⌘ J</kbd> opens a panel on the right of the window. It is hidden by
|
||||
default; whichever tab you leave it on is where it opens next time.
|
||||
|
||||
The three icons at the top switch tabs, and clicking the lit one puts the panel
|
||||
away again.
|
||||
The icons at the top switch tabs — hover one for its name — and clicking the
|
||||
lit one puts the panel away again.
|
||||
|
||||
<Frame caption="The Info tab beside a terminal: session, processes, listening ports">
|
||||
<img src="/images/side-panel.webp" alt="The tty7 side panel" />
|
||||
@@ -34,14 +34,6 @@ The ports section is the quickest answer to "what is this pane serving, and
|
||||
where" — the same data `tty7 procs` prints. A port row copies `localhost:PORT`,
|
||||
and on a local pane opens it in your browser.
|
||||
|
||||
## Source Control
|
||||
|
||||
The git panel for the focused pane's repository, in four groups — **Merge
|
||||
Changes**, **Staged Changes**, **Changes**, **Untracked**. Write a message,
|
||||
commit, and push without leaving the window.
|
||||
|
||||
[Source control →](/git/source-control)
|
||||
|
||||
## Files
|
||||
|
||||
A file tree rooted at the pane's working directory, with git status decorations
|
||||
@@ -60,6 +52,93 @@ 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).
|
||||
|
||||
## Search
|
||||
|
||||
Find in files across the project the Files tab shows: the repository root of
|
||||
each pane in the tab, or its working directory outside a repository. Opening the
|
||||
tab — from its icon, **Right Panel: Search** in Search Everywhere, or the
|
||||
`ShowRightPanelSearch` action — puts the cursor in the search field.
|
||||
|
||||
Results arrive as you type, grouped by file with a count per file; click a
|
||||
file's row to fold it away. Each hit is its line with the matches highlighted,
|
||||
and **clicking one opens the file in the built-in editor with the cursor on that
|
||||
line and column**. <kbd>Enter</kbd> in the field searches again, for when the
|
||||
files changed underneath you.
|
||||
|
||||
The three toggles at the end of the field:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Aa** | Match case — off by default, so `needle` finds `Needle` |
|
||||
| **ab** | Whole word — `foo` no longer finds `foobar` or `foo_bar` |
|
||||
| **.\*** | Regular expression, in Rust [`regex`](https://docs.rs/regex) syntax. A pattern that does not parse says why in red instead of searching |
|
||||
|
||||
What is searched is what the tree shows: a folder's `.gitignore` applies whether
|
||||
or not it is in a repository, and dot-files and dot-directories are skipped.
|
||||
Binary files and files over 1 MB are stepped past. A search stops at 2,000
|
||||
matching lines, 100 per file, 20,000 files or ten seconds, whichever comes
|
||||
first, and says so under the results — narrow the query to see the rest.
|
||||
|
||||
In a [remote workspace](/remote/workspaces) the search runs on the remote
|
||||
machine and only the hits cross the network. A remote `tty7-server` older than
|
||||
this feature cannot search; the tab says so and asks you to update the server
|
||||
rather than showing an empty result. An SSH pane's files are browsed over
|
||||
[SFTP](/remote/sftp), which cannot search them — open the host as a remote
|
||||
workspace for that.
|
||||
|
||||
## Source Control
|
||||
|
||||
The git panel for the focused pane's repository, in four groups — **Merge
|
||||
Changes**, **Staged Changes**, **Changes**, **Untracked**. Write a message,
|
||||
commit, and push without leaving the window.
|
||||
|
||||
[Source control →](/git/source-control)
|
||||
|
||||
## 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: only images GitHub hosts itself — the screenshots pasted into an issue
|
||||
— are loaded, and any other image 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
|
||||
|
||||
@@ -85,6 +85,8 @@ actions!(
|
||||
// orphan every custom binding already on disk.
|
||||
ShowRightPanelChanges,
|
||||
ShowRightPanelFiles,
|
||||
ShowRightPanelSearch,
|
||||
ShowRightPanelGitHub,
|
||||
ScmCommit,
|
||||
ScmCommitAmend,
|
||||
ScmStageAll,
|
||||
|
||||
@@ -1830,6 +1830,20 @@ fn version_from_assets(assets: &[GitHubAsset]) -> Option<String> {
|
||||
.map(|(_, version)| version)
|
||||
}
|
||||
|
||||
/// Give gpui an HTTP client, so an `img` with a web URL can load.
|
||||
///
|
||||
/// Until this runs gpui holds a null client and every remote image quietly
|
||||
/// fails. The only remote images tty7 draws are the GitHub-hosted ones in an
|
||||
/// issue's text (see `core::github::markdown`). Built with the update check's
|
||||
/// user agent and proxy; a proxy changed later applies from the next launch.
|
||||
pub(crate) fn install_image_client(cx: &mut App) {
|
||||
let manual_proxy = cx.global::<Config>().http_proxy.clone();
|
||||
match build_http_client(manual_proxy.as_deref()) {
|
||||
Ok(client) => cx.set_http_client(std::sync::Arc::new(client)),
|
||||
Err(err) => log::warn!("no HTTP client for images: {err:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_http_client(manual_proxy: Option<&str>) -> Result<ReqwestClient> {
|
||||
let user_agent = concat!("tty7/", env!("CARGO_PKG_VERSION"));
|
||||
// Normalise through the same helper the downloader uses, so a bare
|
||||
|
||||
@@ -723,6 +723,7 @@ fn main() {
|
||||
crate::ui::theme::apply_cursor_hide_mode(cx);
|
||||
spawn_config_watcher(cx);
|
||||
crate::core::update::spawn_check(cx);
|
||||
crate::core::update::install_image_client(cx);
|
||||
cx.background_executor()
|
||||
.spawn(async {
|
||||
crate::core::agent_hooks::refresh_hooks_at_launch();
|
||||
|
||||
@@ -904,11 +904,13 @@ 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:
|
||||
std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>,
|
||||
pub(crate) file_tree: crate::ui::file_tree::FileTreeState,
|
||||
pub(crate) panel_search: crate::ui::panel_search::PanelSearchState,
|
||||
pub(crate) editor: crate::ui::code_editor::EditorPanelState,
|
||||
pub(crate) sidebar_width: Rc<Cell<f32>>,
|
||||
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
|
||||
@@ -1371,6 +1373,7 @@ impl Tty7App {
|
||||
};
|
||||
let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx);
|
||||
let file_tree = crate::ui::file_tree::FileTreeState::new(window, cx);
|
||||
let panel_search = crate::ui::panel_search::PanelSearchState::new(window, cx);
|
||||
let editor = crate::ui::code_editor::EditorPanelState::new(window, cx);
|
||||
let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1"));
|
||||
let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080"));
|
||||
@@ -1544,9 +1547,11 @@ impl Tty7App {
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
github: Default::default(),
|
||||
diff_probes_inflight: Default::default(),
|
||||
diff_probes_restale: Default::default(),
|
||||
file_tree,
|
||||
panel_search,
|
||||
editor,
|
||||
sidebar_width: Rc::new(Cell::new(sidebar_width)),
|
||||
sidebar_dragging: Rc::new(Cell::new(false)),
|
||||
@@ -6773,6 +6778,9 @@ impl Tty7App {
|
||||
self.file_search.update(cx, |state, cx| {
|
||||
state.set_placeholder(t(L10nKey::SearchFiles), window, cx)
|
||||
});
|
||||
self.panel_search.input.update(cx, |state, cx| {
|
||||
state.set_placeholder(t(L10nKey::PanelSearchPlaceholder), window, cx)
|
||||
});
|
||||
// The remote Files panel is built once with the app, so its placeholder
|
||||
// is the one input that would otherwise keep the old language.
|
||||
self.sftp_panel.filter_input.update(cx, |state, cx| {
|
||||
@@ -9090,6 +9098,12 @@ impl Render for Tty7App {
|
||||
.on_action(cx.listener(|this, _: &ShowRightPanelFiles, _window, cx| {
|
||||
this.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ShowRightPanelSearch, _window, cx| {
|
||||
this.set_right_panel_tab(crate::core::config::RightPanelTab::Search, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ShowRightPanelGitHub, _window, cx| {
|
||||
this.set_right_panel_tab(crate::core::config::RightPanelTab::GitHub, cx)
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|this, _: &ScmToggleGraph, _window, cx| this.scm_toggle_graph(cx)),
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
|
||||
"icons/info.svg" => include_bytes!("../../assets/icons/info.svg"),
|
||||
"icons/eye.svg" => include_bytes!("../../assets/icons/eye.svg"),
|
||||
"icons/search.svg" => include_bytes!("../../assets/icons/search.svg"),
|
||||
"icons/github.svg" => include_bytes!("../../assets/icons/github.svg"),
|
||||
"icons/copy.svg" => include_bytes!("../../assets/icons/copy.svg"),
|
||||
"icons/folder.svg" => include_bytes!("../../assets/icons/folder.svg"),
|
||||
"icons/file.svg" => include_bytes!("../../assets/icons/file.svg"),
|
||||
@@ -100,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)
|
||||
|
||||
@@ -1255,10 +1255,13 @@ impl Tty7App {
|
||||
.track_scroll(&scroll)
|
||||
.px_4()
|
||||
.py_3()
|
||||
.child(gpui_component::text::TextView::markdown(
|
||||
"editor-md-preview-body",
|
||||
markdown,
|
||||
)),
|
||||
.child(
|
||||
gpui_component::text::TextView::markdown(
|
||||
"editor-md-preview-body",
|
||||
markdown,
|
||||
)
|
||||
.style(crate::ui::theme::markdown_style(cx)),
|
||||
),
|
||||
&scroll,
|
||||
))
|
||||
.into_any_element()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -755,9 +755,42 @@ impl Tty7App {
|
||||
|
||||
pub(crate) fn file_tree_refresh_roots(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = self.spawn_host(cx);
|
||||
let Some(host) = self.active_host(cx) else {
|
||||
let Some((host, mut roots)) = self.project_roots(cx) else {
|
||||
return;
|
||||
};
|
||||
if roots.is_empty()
|
||||
&& id.is_local()
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
roots.push(PathBuf::from(home));
|
||||
}
|
||||
let _ = window;
|
||||
let Some(code) = self.tab_code_mut_or_init() else {
|
||||
return;
|
||||
};
|
||||
if roots != code.roots {
|
||||
code.roots = roots;
|
||||
self.file_tree.invalidate_all();
|
||||
cx.notify();
|
||||
}
|
||||
self.file_tree_sync_watch(host, cx);
|
||||
}
|
||||
|
||||
/// The project directories behind the active tab, on the host they live
|
||||
/// on: each pane's repository root, or its directory where it is not in
|
||||
/// one. `None` while a root is still being asked for (the ask is sent from
|
||||
/// here); an empty list where no pane has a directory on this host.
|
||||
///
|
||||
/// The tree and the Search tab both start from this, so what Search looks
|
||||
/// through is always what the tree shows. Only the tree falls back to the
|
||||
/// home directory — a folder to browse is harmless, a search of all of it
|
||||
/// is not.
|
||||
pub(crate) fn project_roots(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<(SharedHost, Vec<PathBuf>)> {
|
||||
let id = self.spawn_host(cx);
|
||||
let host = self.active_host(cx)?;
|
||||
let leaves = match self.tabs.get(self.active) {
|
||||
Some(tab) => tab.pane.terminals(),
|
||||
None => Vec::new(),
|
||||
@@ -788,25 +821,7 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !resolved {
|
||||
return;
|
||||
}
|
||||
if roots.is_empty()
|
||||
&& id.is_local()
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
roots.push(PathBuf::from(home));
|
||||
}
|
||||
let _ = window;
|
||||
let Some(code) = self.tab_code_mut_or_init() else {
|
||||
return;
|
||||
};
|
||||
if roots != code.roots {
|
||||
code.roots = roots;
|
||||
self.file_tree.invalidate_all();
|
||||
cx.notify();
|
||||
}
|
||||
self.file_tree_sync_watch(host, cx);
|
||||
resolved.then_some((host, roots))
|
||||
}
|
||||
|
||||
fn file_tree_sync_watch(&mut self, host: SharedHost, cx: &mut Context<Self>) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -90,6 +90,33 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
|
||||
L10nKey::SearchTabs => "Search tabs…",
|
||||
L10nKey::SearchFiles => "Search files…",
|
||||
L10nKey::PanelSearchPlaceholder => "Search in files…",
|
||||
L10nKey::PanelSearchWholeWord => "Match whole word",
|
||||
L10nKey::PanelSearchIdle => "Search the contents of every file under:",
|
||||
L10nKey::PanelSearchNoFolder => "No folder to search.",
|
||||
L10nKey::PanelSearchNoFolderHint => {
|
||||
"Search looks through the project the active tab is in."
|
||||
}
|
||||
L10nKey::PanelSearchSshPane => "Search can't look inside an SSH pane's files.",
|
||||
L10nKey::PanelSearchSshPaneHint => {
|
||||
"Open the host as a remote workspace to search it, or browse it in Files."
|
||||
}
|
||||
L10nKey::PanelSearchSearching => "Searching…",
|
||||
L10nKey::PanelSearchNoMatches => "No results for “{query}”.",
|
||||
L10nKey::PanelSearchBadPattern => "Not a valid regular expression: {e}",
|
||||
L10nKey::PanelSearchServerTooOld => {
|
||||
"The tty7-server on this machine is too old to search file contents."
|
||||
}
|
||||
L10nKey::PanelSearchServerTooOldHint => "Update the server on that host to use Search.",
|
||||
L10nKey::PanelSearchFailed => "Search failed: {e}",
|
||||
L10nKey::PanelSearchResultCount => "{count} results",
|
||||
L10nKey::PanelSearchFileCount => "{count} files",
|
||||
L10nKey::PanelSearchSummary => "{results} in {files}",
|
||||
L10nKey::PanelSearchTruncated => {
|
||||
"Not every match is shown. Narrow the search to see the rest."
|
||||
}
|
||||
L10nKey::PanelSearchLineTooltip => "Line {line}, column {column}",
|
||||
L10nKey::PanelSearchHostGone => "The machine this project is on is not connected.",
|
||||
L10nKey::SearchThemes => "Search themes…",
|
||||
L10nKey::SearchSettings => "Search settings…",
|
||||
L10nKey::FilterHosts => "Filter hosts…",
|
||||
@@ -1159,6 +1186,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::PanelChangesTitle => "Changes",
|
||||
L10nKey::PanelScmTitle => "Changes",
|
||||
L10nKey::PanelFilesTitle => "Files",
|
||||
L10nKey::PanelSearchTitle => "Search",
|
||||
L10nKey::PanelGitHubTitle => "GitHub",
|
||||
L10nKey::PanelNoSession => "No active session.",
|
||||
L10nKey::PanelNoSessionHint => {
|
||||
"Open a tab to see its shell, directory, and processes here."
|
||||
@@ -1604,6 +1633,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::CmdRightPanelInfo => "Right Panel: Info",
|
||||
L10nKey::CmdRightPanelChanges => "Right Panel: Changes",
|
||||
L10nKey::CmdRightPanelFiles => "Right Panel: Files",
|
||||
L10nKey::CmdRightPanelSearch => "Right Panel: Search",
|
||||
L10nKey::CmdRightPanelGitHub => "Right Panel: GitHub",
|
||||
L10nKey::CmdChangeTheme => "Change Theme…",
|
||||
L10nKey::CmdResetFontSize => "Reset Font Size",
|
||||
L10nKey::CmdEnterFullScreen => "Enter Full Screen",
|
||||
@@ -1997,6 +2028,48 @@ 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",
|
||||
L10nKey::GitHubOpenedAt => "opened {when}",
|
||||
L10nKey::GitHubUpdatedAt => "updated {when}",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2005,6 +2078,12 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::SettingsMatchCount, "zero") => "No matches",
|
||||
(L10nKey::SettingsMatchCount, "one") => "1 match",
|
||||
(L10nKey::SettingsMatchCount, "other") => "{count} matches",
|
||||
(L10nKey::PanelSearchResultCount, "zero") => "No results",
|
||||
(L10nKey::PanelSearchResultCount, "one") => "1 result",
|
||||
(L10nKey::PanelSearchResultCount, "other") => "{count} results",
|
||||
(L10nKey::PanelSearchFileCount, "zero") => "no files",
|
||||
(L10nKey::PanelSearchFileCount, "one") => "1 file",
|
||||
(L10nKey::PanelSearchFileCount, "other") => "{count} files",
|
||||
(L10nKey::SettingsRestoreChanged, "zero") => "Restore changes",
|
||||
(L10nKey::SettingsRestoreChanged, "one") => "Restore 1 changed",
|
||||
(L10nKey::SettingsRestoreChanged, "other") => "Restore {count} changed",
|
||||
@@ -2151,6 +2230,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)
|
||||
|
||||
@@ -91,6 +91,33 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
|
||||
L10nKey::SearchTabs => "タブを検索…",
|
||||
L10nKey::SearchFiles => "ファイルを検索…",
|
||||
L10nKey::PanelSearchPlaceholder => "ファイル内を検索…",
|
||||
L10nKey::PanelSearchWholeWord => "単語単位で検索",
|
||||
L10nKey::PanelSearchIdle => "次のフォルダー内のすべてのファイルの内容を検索します:",
|
||||
L10nKey::PanelSearchNoFolder => "検索するフォルダーがありません",
|
||||
L10nKey::PanelSearchNoFolderHint => "アクティブなタブのプロジェクト内を検索します。",
|
||||
L10nKey::PanelSearchSshPane => "SSH ペインのファイルの内容は検索できません",
|
||||
L10nKey::PanelSearchSshPaneHint => {
|
||||
"ホストをリモートワークスペースとして開くと検索できます。ファイルタブで閲覧することもできます。"
|
||||
}
|
||||
L10nKey::PanelSearchSearching => "検索中…",
|
||||
L10nKey::PanelSearchNoMatches => "「{query}」の結果はありません",
|
||||
L10nKey::PanelSearchBadPattern => "正規表現が正しくありません: {e}",
|
||||
L10nKey::PanelSearchServerTooOld => {
|
||||
"このマシンの tty7-server は古いため、ファイルの内容を検索できません"
|
||||
}
|
||||
L10nKey::PanelSearchServerTooOldHint => {
|
||||
"検索を使うには、そのホストのサーバーを更新してください。"
|
||||
}
|
||||
L10nKey::PanelSearchFailed => "検索に失敗しました: {e}",
|
||||
L10nKey::PanelSearchResultCount => "{count} 件",
|
||||
L10nKey::PanelSearchFileCount => "{count} 個のファイル",
|
||||
L10nKey::PanelSearchSummary => "{files}で {results}",
|
||||
L10nKey::PanelSearchTruncated => {
|
||||
"一致した結果の一部だけを表示しています。検索条件を絞り込むと残りを確認できます。"
|
||||
}
|
||||
L10nKey::PanelSearchLineTooltip => "{line} 行目、{column} 列目",
|
||||
L10nKey::PanelSearchHostGone => "このプロジェクトがあるマシンに接続されていません",
|
||||
L10nKey::SearchThemes => "テーマを検索…",
|
||||
L10nKey::SearchSettings => "設定を検索…",
|
||||
L10nKey::FilterHosts => "ホストを絞り込み…",
|
||||
@@ -1203,6 +1230,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelChangesTitle => "変更",
|
||||
L10nKey::PanelScmTitle => "変更",
|
||||
L10nKey::PanelFilesTitle => "ファイル",
|
||||
L10nKey::PanelSearchTitle => "検索",
|
||||
L10nKey::PanelGitHubTitle => "GitHub",
|
||||
L10nKey::PanelNoSession => "アクティブなセッションがありません",
|
||||
L10nKey::PanelNoSessionHint => {
|
||||
"タブを開くと、そのシェル、ディレクトリ、プロセスがここに表示されます"
|
||||
@@ -1645,6 +1674,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::CmdRightPanelInfo => "右パネル: 情報",
|
||||
L10nKey::CmdRightPanelChanges => "右パネル: 変更",
|
||||
L10nKey::CmdRightPanelFiles => "右パネル: ファイル",
|
||||
L10nKey::CmdRightPanelSearch => "右パネル: 検索",
|
||||
L10nKey::CmdRightPanelGitHub => "右パネル: GitHub",
|
||||
L10nKey::CmdChangeTheme => "テーマを変更…",
|
||||
L10nKey::CmdResetFontSize => "フォントサイズをリセット",
|
||||
L10nKey::CmdEnterFullScreen => "全画面表示",
|
||||
@@ -2046,6 +2077,52 @@ 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} 件のコミット",
|
||||
L10nKey::GitHubOpenedAt => "作成 {when}",
|
||||
L10nKey::GitHubUpdatedAt => "更新 {when}",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2054,6 +2131,12 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::SettingsMatchCount, "zero") => "0 件",
|
||||
(L10nKey::SettingsMatchCount, "one") => "1 件",
|
||||
(L10nKey::SettingsMatchCount, "other") => "{count} 件",
|
||||
(L10nKey::PanelSearchResultCount, "zero") => "0 件",
|
||||
(L10nKey::PanelSearchResultCount, "one") => "1 件",
|
||||
(L10nKey::PanelSearchResultCount, "other") => "{count} 件",
|
||||
(L10nKey::PanelSearchFileCount, "zero") => "0 個のファイル",
|
||||
(L10nKey::PanelSearchFileCount, "one") => "1 個のファイル",
|
||||
(L10nKey::PanelSearchFileCount, "other") => "{count} 個のファイル",
|
||||
(L10nKey::SettingsRestoreChanged, "zero") => "変更を戻す",
|
||||
(L10nKey::SettingsRestoreChanged, "one") => "変更した 1 件を戻す",
|
||||
(L10nKey::SettingsRestoreChanged, "other") => "変更した {count} 件を戻す",
|
||||
@@ -2193,6 +2276,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)
|
||||
|
||||
@@ -146,6 +146,26 @@ l10n_keys! {
|
||||
|
||||
SearchTabs,
|
||||
SearchFiles,
|
||||
// The right panel's Search tab (find in files).
|
||||
PanelSearchPlaceholder,
|
||||
PanelSearchWholeWord,
|
||||
PanelSearchIdle,
|
||||
PanelSearchNoFolder,
|
||||
PanelSearchNoFolderHint,
|
||||
PanelSearchSshPane,
|
||||
PanelSearchSshPaneHint,
|
||||
PanelSearchSearching,
|
||||
PanelSearchNoMatches,
|
||||
PanelSearchBadPattern,
|
||||
PanelSearchServerTooOld,
|
||||
PanelSearchServerTooOldHint,
|
||||
PanelSearchFailed,
|
||||
PanelSearchResultCount,
|
||||
PanelSearchFileCount,
|
||||
PanelSearchSummary,
|
||||
PanelSearchTruncated,
|
||||
PanelSearchLineTooltip,
|
||||
PanelSearchHostGone,
|
||||
SearchThemes,
|
||||
SearchSettings,
|
||||
FilterHosts,
|
||||
@@ -906,6 +926,8 @@ l10n_keys! {
|
||||
PanelChangesTitle,
|
||||
PanelScmTitle,
|
||||
PanelFilesTitle,
|
||||
PanelSearchTitle,
|
||||
PanelGitHubTitle,
|
||||
PanelNoSession,
|
||||
PanelNoSessionHint,
|
||||
PanelNoWorkingDirectory,
|
||||
@@ -1365,6 +1387,8 @@ l10n_keys! {
|
||||
CmdRightPanelInfo,
|
||||
CmdRightPanelChanges,
|
||||
CmdRightPanelFiles,
|
||||
CmdRightPanelSearch,
|
||||
CmdRightPanelGitHub,
|
||||
CmdChangeTheme,
|
||||
CmdResetFontSize,
|
||||
CmdEnterFullScreen,
|
||||
@@ -1569,6 +1593,42 @@ 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,
|
||||
GitHubOpenedAt,
|
||||
GitHubUpdatedAt,
|
||||
}
|
||||
|
||||
/// The source control strings that are translated but not yet displayed.
|
||||
@@ -1779,6 +1839,8 @@ mod tests {
|
||||
// The quick-launch rows, which the New Tab menu finds by typing
|
||||
// this one word into the palette in every locale.
|
||||
L10nKey::AppCmdAgentLaunchTitle,
|
||||
// A product name, spelled the same in every language.
|
||||
L10nKey::PanelGitHubTitle,
|
||||
];
|
||||
|
||||
for &key in KEPT_IN_ENGLISH {
|
||||
@@ -1881,6 +1943,8 @@ mod tests {
|
||||
L10nKey::HomeTimeDaysAgo,
|
||||
L10nKey::HomeTimeWeeksAgo,
|
||||
L10nKey::HomeTimeMonthsAgo,
|
||||
L10nKey::GitHubComments,
|
||||
L10nKey::GitHubCommits,
|
||||
];
|
||||
for key in plural_keys {
|
||||
for branch in ["zero", "one", "other"] {
|
||||
|
||||
@@ -83,6 +83,25 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
|
||||
L10nKey::SearchTabs => "搜索标签页…",
|
||||
L10nKey::SearchFiles => "搜索文件…",
|
||||
L10nKey::PanelSearchPlaceholder => "在文件中搜索…",
|
||||
L10nKey::PanelSearchWholeWord => "全字匹配",
|
||||
L10nKey::PanelSearchIdle => "搜索以下目录中所有文件的内容:",
|
||||
L10nKey::PanelSearchNoFolder => "没有可搜索的文件夹。",
|
||||
L10nKey::PanelSearchNoFolderHint => "搜索范围是当前标签页所在的项目。",
|
||||
L10nKey::PanelSearchSshPane => "无法搜索 SSH 窗格中的文件内容。",
|
||||
L10nKey::PanelSearchSshPaneHint => "将该主机作为远程工作区打开即可搜索,或在“文件”中浏览。",
|
||||
L10nKey::PanelSearchSearching => "正在搜索…",
|
||||
L10nKey::PanelSearchNoMatches => "没有找到“{query}”。",
|
||||
L10nKey::PanelSearchBadPattern => "不是有效的正则表达式:{e}",
|
||||
L10nKey::PanelSearchServerTooOld => "这台机器上的 tty7-server 版本过旧,无法搜索文件内容。",
|
||||
L10nKey::PanelSearchServerTooOldHint => "请更新该主机上的服务器后再使用搜索。",
|
||||
L10nKey::PanelSearchFailed => "搜索失败:{e}",
|
||||
L10nKey::PanelSearchResultCount => "{count} 个结果",
|
||||
L10nKey::PanelSearchFileCount => "{count} 个文件",
|
||||
L10nKey::PanelSearchSummary => "{files}中有 {results}",
|
||||
L10nKey::PanelSearchTruncated => "未显示全部匹配项。缩小搜索范围以查看其余结果。",
|
||||
L10nKey::PanelSearchLineTooltip => "第 {line} 行,第 {column} 列",
|
||||
L10nKey::PanelSearchHostGone => "该项目所在的机器未连接。",
|
||||
L10nKey::SearchThemes => "搜索主题…",
|
||||
L10nKey::SearchSettings => "搜索设置…",
|
||||
L10nKey::FilterHosts => "筛选主机…",
|
||||
@@ -1091,6 +1110,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelChangesTitle => "更改",
|
||||
L10nKey::PanelScmTitle => "更改",
|
||||
L10nKey::PanelFilesTitle => "文件",
|
||||
L10nKey::PanelSearchTitle => "搜索",
|
||||
L10nKey::PanelGitHubTitle => "GitHub",
|
||||
L10nKey::PanelNoSession => "没有活动会话。",
|
||||
L10nKey::PanelNoSessionHint => "打开一个标签页以在此处查看其 shell、目录和进程。",
|
||||
L10nKey::PanelNoWorkingDirectory => "没有工作目录。",
|
||||
@@ -1493,6 +1514,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::CmdRightPanelInfo => "右侧面板:信息",
|
||||
L10nKey::CmdRightPanelChanges => "右侧面板:变更",
|
||||
L10nKey::CmdRightPanelFiles => "右侧面板:文件",
|
||||
L10nKey::CmdRightPanelSearch => "右侧面板:搜索",
|
||||
L10nKey::CmdRightPanelGitHub => "右侧面板:GitHub",
|
||||
L10nKey::CmdChangeTheme => "更改主题…",
|
||||
L10nKey::CmdResetFontSize => "重置字号",
|
||||
L10nKey::CmdEnterFullScreen => "进入全屏",
|
||||
@@ -1870,6 +1893,44 @@ 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} 个提交",
|
||||
L10nKey::GitHubOpenedAt => "创建 {when}",
|
||||
L10nKey::GitHubUpdatedAt => "更新 {when}",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1878,6 +1939,12 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat
|
||||
(L10nKey::SettingsMatchCount, "zero") => "没有结果",
|
||||
(L10nKey::SettingsMatchCount, "one") => "1 个结果",
|
||||
(L10nKey::SettingsMatchCount, "other") => "{count} 个结果",
|
||||
(L10nKey::PanelSearchResultCount, "zero") => "没有结果",
|
||||
(L10nKey::PanelSearchResultCount, "one") => "1 个结果",
|
||||
(L10nKey::PanelSearchResultCount, "other") => "{count} 个结果",
|
||||
(L10nKey::PanelSearchFileCount, "zero") => "0 个文件",
|
||||
(L10nKey::PanelSearchFileCount, "one") => "1 个文件",
|
||||
(L10nKey::PanelSearchFileCount, "other") => "{count} 个文件",
|
||||
(L10nKey::SettingsRestoreChanged, "zero") => "恢复修改",
|
||||
(L10nKey::SettingsRestoreChanged, "one") => "恢复 1 项修改",
|
||||
(L10nKey::SettingsRestoreChanged, "other") => "恢复 {count} 项修改",
|
||||
@@ -1993,6 +2060,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)
|
||||
|
||||
@@ -573,8 +573,10 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
// in config.json was dropped without a word, and the Keybindings page —
|
||||
// which reads this list — never showed them at all.
|
||||
("ShowRightPanelInfo", ""),
|
||||
("ShowRightPanelChanges", ""),
|
||||
("ShowRightPanelFiles", ""),
|
||||
("ShowRightPanelSearch", ""),
|
||||
("ShowRightPanelChanges", ""),
|
||||
("ShowRightPanelGitHub", ""),
|
||||
("EditorSave", "secondary-s"),
|
||||
("OpenSshProfiles", ""),
|
||||
("RestartSshSession", "secondary-shift-r"),
|
||||
@@ -822,6 +824,14 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
|
||||
CommandGroup::View,
|
||||
t(L10nKey::CmdRightPanelFiles).to_string(),
|
||||
),
|
||||
"ShowRightPanelSearch" => (
|
||||
CommandGroup::View,
|
||||
t(L10nKey::CmdRightPanelSearch).to_string(),
|
||||
),
|
||||
"ShowRightPanelGitHub" => (
|
||||
CommandGroup::View,
|
||||
t(L10nKey::CmdRightPanelGitHub).to_string(),
|
||||
),
|
||||
"FindInTerminal" => (
|
||||
CommandGroup::Terminal,
|
||||
t(L10nKey::CmdFindInTerminal).to_string(),
|
||||
@@ -1473,6 +1483,8 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
"ShowRightPanelInfo" => KeyBinding::new(keystroke, ShowRightPanelInfo, None),
|
||||
"ShowRightPanelChanges" => KeyBinding::new(keystroke, ShowRightPanelChanges, None),
|
||||
"ShowRightPanelFiles" => KeyBinding::new(keystroke, ShowRightPanelFiles, None),
|
||||
"ShowRightPanelSearch" => KeyBinding::new(keystroke, ShowRightPanelSearch, None),
|
||||
"ShowRightPanelGitHub" => KeyBinding::new(keystroke, ShowRightPanelGitHub, None),
|
||||
"ScmCommit" => KeyBinding::new(keystroke, ScmCommit, action_context(action)),
|
||||
"ScmCommitAmend" => KeyBinding::new(keystroke, ScmCommitAmend, action_context(action)),
|
||||
"ScmStageAll" => KeyBinding::new(keystroke, ScmStageAll, None),
|
||||
@@ -1593,6 +1605,8 @@ mod tests {
|
||||
"ShowRightPanelInfo",
|
||||
"ShowRightPanelChanges",
|
||||
"ShowRightPanelFiles",
|
||||
"ShowRightPanelSearch",
|
||||
"ShowRightPanelGitHub",
|
||||
] {
|
||||
assert!(bindable.contains(action), "{action} has nowhere to bind to");
|
||||
assert!(
|
||||
|
||||
@@ -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)]
|
||||
@@ -23,6 +24,8 @@ pub mod machine_mirror;
|
||||
pub mod notice;
|
||||
pub mod pane;
|
||||
pub mod pane_drag;
|
||||
pub mod panel_github;
|
||||
pub mod panel_search;
|
||||
pub mod path_display;
|
||||
pub mod pending_pane;
|
||||
pub mod perf;
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
//! 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, 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 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: one line, the Source Control tab's row pitch.
|
||||
const ROW_H: f32 = 26.;
|
||||
/// How many label chips a hovered row shows before the age.
|
||||
const MAX_ROW_LABELS: usize = 3;
|
||||
/// 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(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let target = self.github_target(window, cx);
|
||||
// No trailing tile: on macOS that would add a "GitHub" heading row no
|
||||
// other tab has, just to hold ↻. Refresh sits with the repository's
|
||||
// other actions instead — in the repo row, and in a detail's header.
|
||||
let title = self.panel_title(t(L10nKey::PanelGitHubTitle), None, None, 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()
|
||||
}
|
||||
|
||||
pub(crate) 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(self.github_refresh_tile(Some(repo.clone()), cx))
|
||||
.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));
|
||||
}
|
||||
// An empty page with more behind it is not "no issues": `/issues`
|
||||
// pages filtered to one kind can come back empty (see `api::list`).
|
||||
if items.is_empty() && next_page.is_none() {
|
||||
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()
|
||||
}
|
||||
|
||||
/// One line per item: state glyph, `#number`, title. The labels and the
|
||||
/// age of the last update wait for the pointer — the resting list reads
|
||||
/// as a column of titles, and hovering a row answers "what labels, how
|
||||
/// fresh" without a second line under every one of them.
|
||||
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 hovered = self.github.hovered == Some(number);
|
||||
let title = SharedString::from(item.title.clone());
|
||||
let labels = item
|
||||
.labels
|
||||
.iter()
|
||||
.take(MAX_ROW_LABELS)
|
||||
.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))
|
||||
});
|
||||
let age = (item.updated_at > 0).then(|| relative_time(now, item.updated_at));
|
||||
// Out of the layout at rest, so the title has the whole row to
|
||||
// itself; on hover it takes its room from the title's tail. Built
|
||||
// from state rather than a `group_hover` display switch: an element
|
||||
// that is `display: none` at layout is never prepainted, and gpui
|
||||
// panics when a hover style then asks to paint it.
|
||||
let meta = hovered.then(|| {
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(4.))
|
||||
.children(labels)
|
||||
.children(age.map(|age| {
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.child(age)
|
||||
}))
|
||||
});
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("panel-github-item-{number}")))
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.h(px(ROW_H))
|
||||
.w_full()
|
||||
.px(px(ROW_INSET))
|
||||
.rounded(ROW_FILL_RADIUS)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(title.clone()).build(window, cx)
|
||||
})
|
||||
.on_hover(cx.listener(move |this, over: &bool, _window, cx| {
|
||||
let next = match (*over, this.github.hovered) {
|
||||
(true, _) => Some(number),
|
||||
(false, Some(n)) if n == number => None,
|
||||
(false, other) => other,
|
||||
};
|
||||
if next != this.github.hovered {
|
||||
this.github.hovered = next;
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.github_open_detail(slug.clone(), number, cx);
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.child(state_glyph(item.state, item.is_pr, cx)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(rems(META))
|
||||
.font_family(mono)
|
||||
.text_color(muted)
|
||||
.child(format!("#{number}")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(gpui::rgb(sf.text_resting))
|
||||
.child(item.title.clone()),
|
||||
)
|
||||
.children(meta)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
//! The right panel's Search tab: find in files across the active tab's
|
||||
//! project, on whichever machine that project lives.
|
||||
//!
|
||||
//! The roots are the Files tab's own ([`Tty7App::project_roots`]), and the
|
||||
//! search runs through [`Host::search_content`](tty7_core::host::Host), so a
|
||||
//! remote workspace is searched on the remote end and only the hits cross
|
||||
//! the wire. Typing re-searches after a short pause; a generation counter
|
||||
//! (see [`model::SearchRun`]) makes sure only the answer to the last thing
|
||||
//! typed is ever drawn.
|
||||
|
||||
mod model;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, HighlightStyle, MouseButton, ScrollHandle, SharedString,
|
||||
StyledText, Subscription, Window, div, px, rems,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::{InputEvent, InputState};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
use tty7_core::host::{ContentHit, ContentLimits, ContentQuery};
|
||||
|
||||
use crate::ui::app::{CONTENT_INSET, Tty7App};
|
||||
use crate::ui::host_ops::HostOps;
|
||||
use crate::ui::host_registry::HostRegistry;
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
|
||||
use crate::ui::right_panel::{META, ROW_FILL_RADIUS, ROW_GLYPH, ROW_INSET, TEXT_MONO};
|
||||
use crate::ui::scrollbar::with_vertical_scrollbar;
|
||||
|
||||
use model::{FileGroup, Outcome, SearchKey, SearchRun};
|
||||
|
||||
/// How long typing has to pause before the search goes out. A little longer
|
||||
/// than the name search's: every keystroke that does get through reads files.
|
||||
const DEBOUNCE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// The pause between the search well and the results — the Files tab's.
|
||||
const SEARCH_GAP: f32 = 10.;
|
||||
|
||||
const FILE_ROW_H: f32 = 26.;
|
||||
const HIT_ROW_H: f32 = 22.;
|
||||
const CHEVRON_W: f32 = 10.;
|
||||
const ROW_GAP: f32 = 6.;
|
||||
|
||||
/// Where a hit's excerpt starts: under its file's name, past the chevron and
|
||||
/// the icon.
|
||||
const HIT_LEAD: f32 = ROW_INSET + CHEVRON_W + ROW_GAP + ROW_GLYPH + ROW_GAP;
|
||||
|
||||
const PAD_BOTTOM: f32 = 16.;
|
||||
|
||||
pub(crate) struct PanelSearchState {
|
||||
pub(crate) input: Entity<InputState>,
|
||||
pub(crate) case_sensitive: bool,
|
||||
pub(crate) whole_word: bool,
|
||||
pub(crate) regex: bool,
|
||||
pub(crate) run: SearchRun,
|
||||
/// Files whose hits are folded away. Cleared by a new search, kept by a
|
||||
/// refresh of the same one.
|
||||
pub(crate) collapsed: HashSet<PathBuf>,
|
||||
/// The field takes focus on the next frame — set whenever the tab is
|
||||
/// brought forward, which happens where there is no `Window` to focus with.
|
||||
pub(crate) focus_pending: bool,
|
||||
scroll: ScrollHandle,
|
||||
_input_sub: Subscription,
|
||||
}
|
||||
|
||||
impl PanelSearchState {
|
||||
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self {
|
||||
let input = cx
|
||||
.new(|cx| InputState::new(window, cx).placeholder(t(L10nKey::PanelSearchPlaceholder)));
|
||||
let sub = cx.subscribe_in(&input, window, |app, _input, ev, _window, cx| match ev {
|
||||
InputEvent::Change => cx.notify(),
|
||||
InputEvent::PressEnter { .. } => app.panel_search_refresh(cx),
|
||||
_ => {}
|
||||
});
|
||||
PanelSearchState {
|
||||
input,
|
||||
case_sensitive: false,
|
||||
whole_word: false,
|
||||
regex: false,
|
||||
run: SearchRun::default(),
|
||||
collapsed: HashSet::new(),
|
||||
focus_pending: false,
|
||||
scroll: ScrollHandle::new(),
|
||||
_input_sub: sub,
|
||||
}
|
||||
}
|
||||
|
||||
fn query(&self, cx: &App) -> ContentQuery {
|
||||
ContentQuery {
|
||||
pattern: self.input.read(cx).value().to_string(),
|
||||
case_sensitive: self.case_sensitive,
|
||||
whole_word: self.whole_word,
|
||||
regex: self.regex,
|
||||
show_hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the body of the tab says, worked out before anything is drawn.
|
||||
enum Body {
|
||||
/// An SSH pane's files are SFTP's, and a search cannot run there.
|
||||
SshPane,
|
||||
/// The active tab has no directory on its host.
|
||||
NoFolder,
|
||||
/// Roots are still being asked for.
|
||||
Resolving,
|
||||
/// Nothing typed yet.
|
||||
Idle(String),
|
||||
Searching,
|
||||
Results,
|
||||
NoMatches(String),
|
||||
BadPattern(String),
|
||||
ServerTooOld,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// Enter in the field: search again now, even for the same query — the
|
||||
/// files may have changed under it.
|
||||
fn panel_search_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((generation, key)) = self.panel_search.run.refresh() else {
|
||||
return;
|
||||
};
|
||||
self.panel_search_spawn(generation, key, Duration::ZERO, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Bring the run in line with what the tab now asks for.
|
||||
fn panel_search_sync(&mut self, key: Option<SearchKey>, cx: &mut Context<Self>) {
|
||||
if self.panel_search.run.wanted() != key.as_ref() {
|
||||
self.panel_search.collapsed.clear();
|
||||
}
|
||||
if let Some(generation) = self.panel_search.run.retarget(key.clone())
|
||||
&& let Some(key) = key
|
||||
{
|
||||
self.panel_search_spawn(generation, key, DEBOUNCE, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_search_spawn(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
key: SearchKey,
|
||||
delay: Duration,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.spawn(async move |app, cx| {
|
||||
if !delay.is_zero() {
|
||||
cx.background_executor().timer(delay).await;
|
||||
}
|
||||
let _ = app.update(cx, |app, cx| {
|
||||
if !app.panel_search.run.is_current(generation) {
|
||||
return;
|
||||
}
|
||||
let Some(host) = HostRegistry::lookup(cx, key.host) else {
|
||||
app.panel_search.run.accept(
|
||||
generation,
|
||||
Outcome::Failed(t(L10nKey::PanelSearchHostGone).to_string()),
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
let SearchKey { roots, query, .. } = key;
|
||||
HostOps::run(
|
||||
host,
|
||||
cx,
|
||||
move |h| {
|
||||
Outcome::from_result(h.search_content(
|
||||
&roots,
|
||||
&query,
|
||||
&ContentLimits::default(),
|
||||
))
|
||||
},
|
||||
move |app, outcome, cx| {
|
||||
if app.panel_search.run.accept(generation, outcome) {
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn panel_search_body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Body {
|
||||
// The Files tab hands an SSH pane to the SFTP browser; there is no
|
||||
// host here that could run a search over there.
|
||||
if self.panel_search_ssh_pane(window, cx) {
|
||||
self.panel_search_sync(None, cx);
|
||||
return Body::SshPane;
|
||||
}
|
||||
let Some((host, roots)) = self.project_roots(cx) else {
|
||||
return Body::Resolving;
|
||||
};
|
||||
if roots.is_empty() {
|
||||
self.panel_search_sync(None, cx);
|
||||
return Body::NoFolder;
|
||||
}
|
||||
let query = self.panel_search.query(cx);
|
||||
if query.pattern.is_empty() {
|
||||
self.panel_search_sync(None, cx);
|
||||
// `~/…`, the way the Info tab spells the working directory: a
|
||||
// full `/Users/…` path broke across the narrow column mid-name.
|
||||
let home = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|tab| tab.detail_pane(window, cx))
|
||||
.and_then(|leaf| leaf.read(cx).display_home(cx));
|
||||
let root = roots
|
||||
.iter()
|
||||
.map(|r| {
|
||||
crate::ui::path_display::abbreviate_home(&r.to_string_lossy(), home.as_deref())
|
||||
.into_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Body::Idle(root);
|
||||
}
|
||||
let pattern = query.pattern.clone();
|
||||
self.panel_search_sync(
|
||||
Some(SearchKey {
|
||||
host: host.id(),
|
||||
roots,
|
||||
query,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
let run = &self.panel_search.run;
|
||||
// Until the answer for this query arrives, the last one stays on
|
||||
// screen under a "Searching…" summary rather than blinking out on
|
||||
// every keystroke. A state with no hits to keep says "Searching…" on
|
||||
// its own. Only hits from the same host are kept: a row opens its
|
||||
// path on the active host, so another machine's hits would open the
|
||||
// wrong file (or none) there.
|
||||
let Some(landed) = run.landed().filter(|l| Some(&l.key) == run.wanted()) else {
|
||||
return match run.landed() {
|
||||
Some(l)
|
||||
if matches!(l.outcome, Outcome::Found(_))
|
||||
&& !l.groups.is_empty()
|
||||
&& run.wanted().is_some_and(|w| w.host == l.key.host) =>
|
||||
{
|
||||
Body::Results
|
||||
}
|
||||
_ => Body::Searching,
|
||||
};
|
||||
};
|
||||
match &landed.outcome {
|
||||
Outcome::Found(_) if landed.groups.is_empty() => Body::NoMatches(pattern),
|
||||
Outcome::Found(_) => Body::Results,
|
||||
Outcome::BadPattern(e) => Body::BadPattern(e.clone()),
|
||||
Outcome::ServerTooOld => Body::ServerTooOld,
|
||||
Outcome::Failed(e) => Body::Failed(e.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_search_ssh_pane(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
use crate::daemon::protocol::RemoteKind;
|
||||
let Some(leaf) = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|tab| tab.detail_pane(window, cx))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
leaf.read(cx)
|
||||
.remote_context()
|
||||
.is_some_and(|remote| remote.kind == RemoteKind::NativeSsh)
|
||||
}
|
||||
|
||||
pub(crate) fn render_panel_search(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if std::mem::take(&mut self.panel_search.focus_pending) {
|
||||
self.panel_search
|
||||
.input
|
||||
.update(cx, |input, cx| input.focus(window, cx));
|
||||
}
|
||||
let title = self.panel_title(t(L10nKey::PanelSearchTitle), None, None, window, cx);
|
||||
let toggles = self.panel_search_toggles(cx);
|
||||
let search = self.panel_search_with(&self.panel_search.input.clone(), Some(toggles), cx);
|
||||
let body = self.panel_search_body(window, cx);
|
||||
let content = self.panel_search_content(body, cx);
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(title)
|
||||
.child(div().flex_none().pb(px(SEARCH_GAP)).child(search))
|
||||
.child(content)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn panel_search_toggles(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let state = &self.panel_search;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let toggle = |id: &'static str,
|
||||
label: &'static str,
|
||||
tip: L10nKey,
|
||||
on: bool,
|
||||
flip: fn(&mut PanelSearchState)| {
|
||||
Button::new(id)
|
||||
.label(label)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.selected(on)
|
||||
// Off reads as a hint of what can be turned on, not as three
|
||||
// words in body ink beside the query.
|
||||
.when(!on, |b| b.text_color(muted))
|
||||
.tooltip(t(tip))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
flip(&mut this.panel_search);
|
||||
cx.notify();
|
||||
}))
|
||||
};
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.gap(px(2.))
|
||||
.child(toggle(
|
||||
"panel-search-case",
|
||||
"Aa",
|
||||
L10nKey::SearchMatchCase,
|
||||
state.case_sensitive,
|
||||
|s| s.case_sensitive = !s.case_sensitive,
|
||||
))
|
||||
.child(toggle(
|
||||
"panel-search-word",
|
||||
"ab",
|
||||
L10nKey::PanelSearchWholeWord,
|
||||
state.whole_word,
|
||||
|s| s.whole_word = !s.whole_word,
|
||||
))
|
||||
.child(toggle(
|
||||
"panel-search-regex",
|
||||
".*",
|
||||
L10nKey::SearchUseRegex,
|
||||
state.regex,
|
||||
|s| s.regex = !s.regex,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn panel_search_content(&mut self, body: Body, cx: &mut Context<Self>) -> AnyElement {
|
||||
let danger = cx.theme().danger;
|
||||
let note = |text: String, hint: Option<String>, ink: Option<gpui::Hsla>, cx: &App| {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
v_flex()
|
||||
.flex_none()
|
||||
.px(px(ROW_INSET))
|
||||
.py(px(4.))
|
||||
.gap(px(3.))
|
||||
.text_size(rems(crate::ui::right_panel::TEXT))
|
||||
.text_color(ink.unwrap_or(muted))
|
||||
.child(text)
|
||||
.children(hint.map(|h| div().text_size(rems(META)).text_color(muted).child(h)))
|
||||
.into_any_element()
|
||||
};
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
match body {
|
||||
Body::SshPane => rows.push(note(
|
||||
t(L10nKey::PanelSearchSshPane).into(),
|
||||
Some(t(L10nKey::PanelSearchSshPaneHint).into()),
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
Body::NoFolder => rows.push(note(
|
||||
t(L10nKey::PanelSearchNoFolder).into(),
|
||||
Some(t(L10nKey::PanelSearchNoFolderHint).into()),
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
Body::Resolving | Body::Searching => rows.push(note(
|
||||
t(L10nKey::PanelSearchSearching).into(),
|
||||
None,
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
// The path on a line of its own: run into the sentence, a narrow
|
||||
// column broke it at a slash, mid-name.
|
||||
Body::Idle(root) => rows.push(note(
|
||||
t(L10nKey::PanelSearchIdle).into(),
|
||||
Some(root),
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
Body::NoMatches(query) => rows.push(note(
|
||||
t_fmt(L10nKey::PanelSearchNoMatches, &[("query", &query)]),
|
||||
None,
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
Body::BadPattern(e) => rows.push(note(
|
||||
t_fmt(L10nKey::PanelSearchBadPattern, &[("e", first_line(&e))]),
|
||||
None,
|
||||
Some(danger),
|
||||
cx,
|
||||
)),
|
||||
Body::ServerTooOld => rows.push(note(
|
||||
t(L10nKey::PanelSearchServerTooOld).into(),
|
||||
Some(t(L10nKey::PanelSearchServerTooOldHint).into()),
|
||||
Some(danger),
|
||||
cx,
|
||||
)),
|
||||
Body::Failed(e) => rows.push(note(
|
||||
t_fmt(L10nKey::PanelSearchFailed, &[("e", &e)]),
|
||||
None,
|
||||
Some(danger),
|
||||
cx,
|
||||
)),
|
||||
Body::Results => rows.extend(self.panel_search_results(cx)),
|
||||
}
|
||||
let column = v_flex()
|
||||
.id("panel-search-results")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.panel_search.scroll)
|
||||
.px(px(CONTENT_INSET))
|
||||
.pb(px(PAD_BOTTOM))
|
||||
.children(rows);
|
||||
with_vertical_scrollbar("panel-search-scrollbar", column, &self.panel_search.scroll)
|
||||
}
|
||||
|
||||
fn panel_search_results(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||
let Some(landed) = self.panel_search.run.landed() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Outcome::Found(found) = &landed.outcome else {
|
||||
return Vec::new();
|
||||
};
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let hits = model::hit_count(&landed.groups);
|
||||
let summary = match self.panel_search.run.running() {
|
||||
true => t(L10nKey::PanelSearchSearching).to_string(),
|
||||
false => t_fmt(
|
||||
L10nKey::PanelSearchSummary,
|
||||
&[
|
||||
(
|
||||
"results",
|
||||
&t_plural(L10nKey::PanelSearchResultCount, hits, &[]),
|
||||
),
|
||||
(
|
||||
"files",
|
||||
&t_plural(L10nKey::PanelSearchFileCount, landed.groups.len(), &[]),
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
let mut rows: Vec<AnyElement> = vec![
|
||||
div()
|
||||
.flex_none()
|
||||
.px(px(ROW_INSET))
|
||||
.pb(px(4.))
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.child(summary)
|
||||
.into_any_element(),
|
||||
];
|
||||
for (index, group) in landed.groups.iter().enumerate() {
|
||||
let collapsed = self.panel_search.collapsed.contains(&group.path);
|
||||
rows.push(self.panel_search_file_row(index, group, collapsed, cx));
|
||||
if !collapsed {
|
||||
for (n, hit) in group.hits.iter().enumerate() {
|
||||
rows.push(self.panel_search_hit_row(index, n, hit, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.truncated {
|
||||
rows.push(
|
||||
div()
|
||||
.flex_none()
|
||||
.px(px(ROW_INSET))
|
||||
.pt(px(6.))
|
||||
.text_size(rems(META))
|
||||
.italic()
|
||||
.text_color(muted)
|
||||
.child(t(L10nKey::PanelSearchTruncated))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn panel_search_file_row(
|
||||
&self,
|
||||
index: usize,
|
||||
group: &FileGroup,
|
||||
collapsed: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().popover;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let path = group.path.clone();
|
||||
h_flex()
|
||||
.id(("panel-search-file", index))
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.gap(px(ROW_GAP))
|
||||
.h(px(FILE_ROW_H))
|
||||
.px(px(ROW_INSET))
|
||||
.rounded(ROW_FILL_RADIUS)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(px(CHEVRON_W))
|
||||
.flex()
|
||||
.justify_center()
|
||||
.child(
|
||||
Icon::new(match collapsed {
|
||||
true => IconName::ChevronRight,
|
||||
false => IconName::ChevronDown,
|
||||
})
|
||||
.size(px(CHEVRON_W))
|
||||
.text_color(muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Icon::new(IconName::File)
|
||||
.size(px(ROW_GLYPH))
|
||||
.text_color(muted),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.items_baseline()
|
||||
.gap(px(6.))
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(SharedString::from(group.name.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.child(SharedString::from(group.dir.clone())),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.px(px(5.))
|
||||
.rounded(px(4.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_size(rems(crate::ui::right_panel::META_MONO))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_color(muted)
|
||||
.child(group.hits.len().to_string()),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, _window, cx| {
|
||||
let folded = &mut this.panel_search.collapsed;
|
||||
if !folded.remove(&path) {
|
||||
folded.insert(path.clone());
|
||||
}
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn panel_search_hit_row(
|
||||
&self,
|
||||
file: usize,
|
||||
n: usize,
|
||||
hit: &ContentHit,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().popover;
|
||||
let mark = HighlightStyle {
|
||||
background_color: Some(cx.theme().yellow.opacity(0.35)),
|
||||
..Default::default()
|
||||
};
|
||||
let highlights: Vec<(std::ops::Range<usize>, HighlightStyle)> = hit
|
||||
.ranges
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
(r.end as usize) <= hit.text.len()
|
||||
&& hit.text.is_char_boundary(r.start as usize)
|
||||
&& hit.text.is_char_boundary(r.end as usize)
|
||||
&& r.start < r.end
|
||||
})
|
||||
.map(|r| (r.start as usize..r.end as usize, mark))
|
||||
.collect();
|
||||
let text =
|
||||
StyledText::new(SharedString::from(hit.text.clone())).with_highlights(highlights);
|
||||
let (path, line, column) = (hit.path.clone(), hit.line, hit.column);
|
||||
let tip = t_fmt(
|
||||
L10nKey::PanelSearchLineTooltip,
|
||||
&[("line", &line.to_string()), ("column", &column.to_string())],
|
||||
);
|
||||
div()
|
||||
.id(("panel-search-hit", file * 1_000_000 + n))
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(px(HIT_ROW_H))
|
||||
.pl(px(HIT_LEAD))
|
||||
.pr(px(ROW_INSET))
|
||||
.rounded(ROW_FILL_RADIUS)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.text_size(rems(TEXT_MONO))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(text),
|
||||
)
|
||||
.tooltip(move |window, cx| {
|
||||
gpui_component::tooltip::Tooltip::new(tip.clone()).build(window, cx)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.open_linked_file(&path, Some(line), Some(column), false, window, cx);
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// A regex parser's error is a small diagram over several lines; the panel
|
||||
/// has room for its verdict, which is the last one.
|
||||
fn first_line(e: &str) -> &str {
|
||||
e.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or(e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gpui_tests {
|
||||
use super::*;
|
||||
use crate::core::config::RightPanelTab;
|
||||
use crate::daemon::protocol::DaemonMsg;
|
||||
use crate::ui::app::{render_probe, test_window};
|
||||
use gpui::{Focusable as _, TestAppContext, VisualTestContext};
|
||||
use std::path::Path;
|
||||
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-psearch-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::canonicalize(&dir).unwrap()
|
||||
}
|
||||
|
||||
fn search_panel_on(
|
||||
cx: &mut TestAppContext,
|
||||
root: &Path,
|
||||
) -> (
|
||||
Entity<Tty7App>,
|
||||
VisualTestContext,
|
||||
crate::daemon::transport::Stream,
|
||||
) {
|
||||
let (app, mut vcx, mut pane) = test_window::harness_with_pane(cx);
|
||||
DaemonMsg::Cwd(root.to_path_buf())
|
||||
.encode(&mut pane)
|
||||
.expect("the pane's socket takes the cwd");
|
||||
app.update_in(&mut vcx, |app, _, cx| {
|
||||
app.set_right_panel_tab(RightPanelTab::Search, cx);
|
||||
});
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
vcx.background_executor.run_until_parked();
|
||||
let rooted = app.update_in(&mut vcx, |app, _, cx| {
|
||||
app.project_roots(cx).map(|(_, roots)| roots) == Some(vec![root.to_path_buf()])
|
||||
});
|
||||
if rooted {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the pane never reported its cwd"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
(app, vcx, pane)
|
||||
}
|
||||
|
||||
fn type_query(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, text: &str) {
|
||||
app.update_in(vcx, |app, window, cx| {
|
||||
app.panel_search
|
||||
.input
|
||||
.update(cx, |st, cx| st.set_value(text, window, cx));
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Drives frames and the debounce until the search asked for last has an
|
||||
/// answer on screen. The host call runs on a real thread, so real time
|
||||
/// has to pass too.
|
||||
fn landed(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> Outcome {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
app.update_in(vcx, |_, _, cx| cx.notify());
|
||||
vcx.executor().advance_clock(DEBOUNCE);
|
||||
vcx.background_executor.run_until_parked();
|
||||
let got = app.update_in(vcx, |app, _, _| {
|
||||
let run = &app.panel_search.run;
|
||||
let wanted = run.wanted().cloned();
|
||||
run.landed()
|
||||
.filter(|l| !run.running() && Some(&l.key) == wanted.as_ref())
|
||||
.map(|l| l.outcome.clone())
|
||||
});
|
||||
if let Some(outcome) = got {
|
||||
return outcome;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the search never landed"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn groups(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> Vec<(String, Vec<u32>)> {
|
||||
app.update_in(vcx, |app, _, _| {
|
||||
app.panel_search
|
||||
.run
|
||||
.landed()
|
||||
.map(|l| {
|
||||
l.groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let rel = match g.dir.is_empty() {
|
||||
true => g.name.clone(),
|
||||
false => format!("{}/{}", g.dir, g.name),
|
||||
};
|
||||
(rel, g.hits.iter().map(|h| h.line).collect())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn opening_the_tab_puts_the_cursor_in_the_field(cx: &mut TestAppContext) {
|
||||
let _serial = serial();
|
||||
let root = scratch("focus");
|
||||
let (app, mut vcx, _pane) = search_panel_on(cx, &root);
|
||||
app.update_in(&mut vcx, |_, _, cx| cx.notify());
|
||||
vcx.background_executor.run_until_parked();
|
||||
let focused = app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.panel_search
|
||||
.input
|
||||
.read(cx)
|
||||
.focus_handle(cx)
|
||||
.is_focused(window)
|
||||
});
|
||||
assert!(focused, "the Search tab opened without focusing its field");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_query_lands_as_hits_grouped_by_file(cx: &mut TestAppContext) {
|
||||
let _serial = serial();
|
||||
let root = scratch("grouped");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("src/lib.rs"),
|
||||
"fn needle() {}\nlet x = 1;\n// Needle\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("notes.md"), "a needle here\n").unwrap();
|
||||
std::fs::write(root.join(".gitignore"), "build/\n").unwrap();
|
||||
std::fs::create_dir_all(root.join("build")).unwrap();
|
||||
std::fs::write(root.join("build/out.rs"), "needle\n").unwrap();
|
||||
let (app, mut vcx, _pane) = search_panel_on(cx, &root);
|
||||
|
||||
type_query(&app, &mut vcx, "needle");
|
||||
assert!(matches!(landed(&app, &mut vcx), Outcome::Found(_)));
|
||||
assert_eq!(
|
||||
groups(&app, &mut vcx),
|
||||
vec![
|
||||
("notes.md".to_string(), vec![1]),
|
||||
("src/lib.rs".to_string(), vec![1, 3]),
|
||||
],
|
||||
"sorted walk, gitignored build/ left out, case ignored"
|
||||
);
|
||||
|
||||
// A toggle is part of the query: flipping it searches again.
|
||||
app.update_in(&mut vcx, |app, _, cx| {
|
||||
app.panel_search.case_sensitive = true;
|
||||
cx.notify();
|
||||
});
|
||||
landed(&app, &mut vcx);
|
||||
assert_eq!(
|
||||
groups(&app, &mut vcx),
|
||||
vec![
|
||||
("notes.md".to_string(), vec![1]),
|
||||
("src/lib.rs".to_string(), vec![1]),
|
||||
]
|
||||
);
|
||||
|
||||
type_query(&app, &mut vcx, "nothing-like-this");
|
||||
match landed(&app, &mut vcx) {
|
||||
Outcome::Found(f) => assert!(f.hits.is_empty()),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
|
||||
app.update_in(&mut vcx, |app, _, cx| {
|
||||
app.panel_search.regex = true;
|
||||
cx.notify();
|
||||
});
|
||||
type_query(&app, &mut vcx, "(");
|
||||
assert!(matches!(landed(&app, &mut vcx), Outcome::BadPattern(_)));
|
||||
|
||||
type_query(&app, &mut vcx, "");
|
||||
app.update_in(&mut vcx, |_, _, cx| cx.notify());
|
||||
vcx.background_executor.run_until_parked();
|
||||
assert!(
|
||||
app.update_in(&mut vcx, |app, _, _| app
|
||||
.panel_search
|
||||
.run
|
||||
.landed()
|
||||
.is_none()),
|
||||
"an emptied field clears the results"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_settled_search_panel_reaches_render_idle(cx: &mut TestAppContext) {
|
||||
let _serial = serial();
|
||||
let root = scratch("idle");
|
||||
for n in 0..8 {
|
||||
std::fs::write(root.join(format!("f{n}.rs")), "needle\n").unwrap();
|
||||
}
|
||||
let (app, mut vcx, _pane) = search_panel_on(cx, &root);
|
||||
type_query(&app, &mut vcx, "needle");
|
||||
landed(&app, &mut vcx);
|
||||
assert_eq!(groups(&app, &mut vcx).len(), 8);
|
||||
|
||||
// A focused field blinks its caret, which is a frame every half
|
||||
// second by design; what is measured is the panel, so the pane gets
|
||||
// focus back first.
|
||||
app.update_in(&mut vcx, |app, window, cx| app.focus_active(window, cx));
|
||||
test_window::quiesce(&mut vcx, Some(&root));
|
||||
render_probe::arm(200);
|
||||
vcx.executor()
|
||||
.advance_clock(std::time::Duration::from_secs(9));
|
||||
vcx.background_executor.run_until_parked();
|
||||
assert_eq!(render_probe::draws(), 0, "a finished search kept drawing");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! What the Search tab knows, apart from how it is drawn: which search is
|
||||
//! current, what came back for it, and how the hits fall into files.
|
||||
//!
|
||||
//! Kept free of gpui so every rule here — above all "a stale result never
|
||||
//! lands" — is a plain unit test.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tty7_core::host::{ContentHit, ContentQuery, ContentResults};
|
||||
|
||||
use crate::ui::host_ops::HostId;
|
||||
|
||||
/// One search, fully named: the same key twice is the same search, and a
|
||||
/// search is only ever re-run when its key changes or someone asks.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SearchKey {
|
||||
pub(crate) host: HostId,
|
||||
pub(crate) roots: Vec<PathBuf>,
|
||||
pub(crate) query: ContentQuery,
|
||||
}
|
||||
|
||||
/// How a search came back.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Outcome {
|
||||
Found(ContentResults),
|
||||
/// The pattern did not compile. Carries the parser's explanation.
|
||||
BadPattern(String),
|
||||
/// The host cannot search contents at all — an older `tty7-server`.
|
||||
ServerTooOld,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub(crate) fn from_result(found: io::Result<ContentResults>) -> Outcome {
|
||||
match found {
|
||||
Ok(found) => Outcome::Found(found),
|
||||
Err(e) => match e.kind() {
|
||||
io::ErrorKind::InvalidInput => Outcome::BadPattern(e.to_string()),
|
||||
io::ErrorKind::Unsupported => Outcome::ServerTooOld,
|
||||
_ => Outcome::Failed(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hits of one file, in the order the search met them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct FileGroup {
|
||||
pub(crate) path: PathBuf,
|
||||
/// The file's name.
|
||||
pub(crate) name: String,
|
||||
/// The directory it is in, relative to the root it was found under and
|
||||
/// spelled with `/` — empty for a file at the top of a root.
|
||||
pub(crate) dir: String,
|
||||
pub(crate) hits: Vec<ContentHit>,
|
||||
}
|
||||
|
||||
/// What has landed, with the key it answers.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Landed {
|
||||
pub(crate) key: SearchKey,
|
||||
pub(crate) outcome: Outcome,
|
||||
pub(crate) groups: Vec<FileGroup>,
|
||||
}
|
||||
|
||||
/// The search lifecycle, with a generation counter so that only the answer to
|
||||
/// the search asked for last is ever shown.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SearchRun {
|
||||
generation: u64,
|
||||
/// The search asked for last, whether or not it has come back.
|
||||
wanted: Option<SearchKey>,
|
||||
running: bool,
|
||||
landed: Option<Landed>,
|
||||
}
|
||||
|
||||
impl SearchRun {
|
||||
/// Point the run at `key`. `Some(generation)` means a search has to be
|
||||
/// started for it; `None` means there is nothing new to do, either
|
||||
/// because `key` is what is already wanted or because there is nothing to
|
||||
/// search (`key` is `None`), in which case what was shown goes.
|
||||
pub(crate) fn retarget(&mut self, key: Option<SearchKey>) -> Option<u64> {
|
||||
if self.wanted == key {
|
||||
return None;
|
||||
}
|
||||
self.generation += 1;
|
||||
self.wanted = key;
|
||||
match self.wanted {
|
||||
Some(_) => {
|
||||
self.running = true;
|
||||
Some(self.generation)
|
||||
}
|
||||
None => {
|
||||
self.running = false;
|
||||
self.landed = None;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the wanted search again — Enter in the field. The results on
|
||||
/// screen stay until the new ones replace them.
|
||||
pub(crate) fn refresh(&mut self) -> Option<(u64, SearchKey)> {
|
||||
let key = self.wanted.clone()?;
|
||||
self.generation += 1;
|
||||
self.running = true;
|
||||
Some((self.generation, key))
|
||||
}
|
||||
|
||||
/// Whether `generation` is still the search being waited for.
|
||||
pub(crate) fn is_current(&self, generation: u64) -> bool {
|
||||
self.generation == generation
|
||||
}
|
||||
|
||||
/// Take an answer. One for any search but the last asked is dropped, and
|
||||
/// `false` says so.
|
||||
pub(crate) fn accept(&mut self, generation: u64, outcome: Outcome) -> bool {
|
||||
if generation != self.generation {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = self.wanted.clone() else {
|
||||
return false;
|
||||
};
|
||||
let groups = match &outcome {
|
||||
Outcome::Found(found) => group_hits(&found.hits, &key.roots),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
self.running = false;
|
||||
self.landed = Some(Landed {
|
||||
key,
|
||||
outcome,
|
||||
groups,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn running(&self) -> bool {
|
||||
self.running
|
||||
}
|
||||
|
||||
pub(crate) fn wanted(&self) -> Option<&SearchKey> {
|
||||
self.wanted.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn landed(&self) -> Option<&Landed> {
|
||||
self.landed.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gather hits into one group per file, files in the order first met.
|
||||
pub(crate) fn group_hits(hits: &[ContentHit], roots: &[PathBuf]) -> Vec<FileGroup> {
|
||||
let mut groups: Vec<FileGroup> = Vec::new();
|
||||
let mut index: std::collections::HashMap<&Path, usize> = std::collections::HashMap::new();
|
||||
for hit in hits {
|
||||
let at = *index.entry(hit.path.as_path()).or_insert_with(|| {
|
||||
let (name, dir) = split_relative(&hit.path, roots);
|
||||
groups.push(FileGroup {
|
||||
path: hit.path.clone(),
|
||||
name,
|
||||
dir,
|
||||
hits: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
});
|
||||
groups[at].hits.push(hit.clone());
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// A hit's path as the list shows it: the file name, and the directory it is
|
||||
/// in below the root it was found under. Several roots each keep their own
|
||||
/// name in front, or two `src/` would read as one.
|
||||
///
|
||||
/// Done on the text, not with `Path::strip_prefix`: a remote host's paths are
|
||||
/// not this machine's, and a Windows peer's `C:\repo\src` has no components a
|
||||
/// macOS `Path` would recognise.
|
||||
pub(crate) fn split_relative(path: &Path, roots: &[PathBuf]) -> (String, String) {
|
||||
let full = path.to_string_lossy();
|
||||
let is_sep = |c: char| c == '/' || c == '\\';
|
||||
let root = roots
|
||||
.iter()
|
||||
.map(|r| r.to_string_lossy())
|
||||
.filter(|r| {
|
||||
full.strip_prefix(r.trim_end_matches(is_sep))
|
||||
.is_some_and(|rest| rest.starts_with(is_sep))
|
||||
})
|
||||
.max_by_key(|r| r.len());
|
||||
let (lead, rel) = match &root {
|
||||
Some(root) => {
|
||||
let rest = &full[root.trim_end_matches(is_sep).len()..];
|
||||
let lead = match roots.len() > 1 {
|
||||
true => root
|
||||
.trim_end_matches(is_sep)
|
||||
.rsplit(is_sep)
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
false => String::new(),
|
||||
};
|
||||
(lead, rest.trim_start_matches(is_sep).to_string())
|
||||
}
|
||||
None => (String::new(), full.to_string()),
|
||||
};
|
||||
let rel = rel.replace('\\', "/");
|
||||
let (dir, name) = match rel.rsplit_once('/') {
|
||||
Some((dir, name)) => (dir.to_string(), name.to_string()),
|
||||
None => (String::new(), rel),
|
||||
};
|
||||
let dir = match (lead.is_empty(), dir.is_empty()) {
|
||||
(true, _) => dir,
|
||||
(false, true) => lead,
|
||||
(false, false) => format!("{lead}/{dir}"),
|
||||
};
|
||||
(name, dir)
|
||||
}
|
||||
|
||||
/// Every hit in `groups`, counted.
|
||||
pub(crate) fn hit_count(groups: &[FileGroup]) -> usize {
|
||||
groups.iter().map(|g| g.hits.len()).sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tty7_core::host::ContentRange;
|
||||
|
||||
fn hit(path: &str, line: u32) -> ContentHit {
|
||||
ContentHit {
|
||||
path: PathBuf::from(path),
|
||||
line,
|
||||
column: 1,
|
||||
text: "x".into(),
|
||||
ranges: vec![ContentRange { start: 0, end: 1 }],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(pattern: &str) -> SearchKey {
|
||||
SearchKey {
|
||||
host: HostId::LOCAL,
|
||||
roots: vec![PathBuf::from("/r")],
|
||||
query: ContentQuery {
|
||||
pattern: pattern.into(),
|
||||
..ContentQuery::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn found(hits: Vec<ContentHit>, truncated: bool) -> Outcome {
|
||||
Outcome::Found(ContentResults {
|
||||
hits,
|
||||
truncated,
|
||||
files_searched: 1,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_search_is_not_started_twice() {
|
||||
let mut run = SearchRun::default();
|
||||
let first = run.retarget(Some(key("a"))).expect("a new search runs");
|
||||
assert!(run.running());
|
||||
assert_eq!(run.retarget(Some(key("a"))), None);
|
||||
assert!(run.is_current(first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_answer_never_lands() {
|
||||
let mut run = SearchRun::default();
|
||||
let old = run.retarget(Some(key("a"))).unwrap();
|
||||
let new = run.retarget(Some(key("ab"))).unwrap();
|
||||
assert!(!run.accept(old, found(vec![hit("/r/a.rs", 1)], false)));
|
||||
assert!(run.landed().is_none(), "the old answer is dropped");
|
||||
assert!(run.running(), "and the new one is still awaited");
|
||||
|
||||
assert!(run.accept(new, found(vec![], false)));
|
||||
let landed = run.landed().unwrap();
|
||||
assert_eq!(landed.key, key("ab"));
|
||||
assert!(!run.running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_outranks_the_search_it_repeats() {
|
||||
let mut run = SearchRun::default();
|
||||
let first = run.retarget(Some(key("a"))).unwrap();
|
||||
let (again, k) = run.refresh().unwrap();
|
||||
assert_eq!(k, key("a"));
|
||||
assert!(!run.accept(first, found(vec![], false)));
|
||||
assert!(run.accept(again, found(vec![], false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_search_clears_what_was_shown() {
|
||||
let mut run = SearchRun::default();
|
||||
let g = run.retarget(Some(key("a"))).unwrap();
|
||||
run.accept(g, found(vec![hit("/r/a.rs", 1)], false));
|
||||
assert_eq!(run.retarget(None), None);
|
||||
assert!(run.landed().is_none());
|
||||
assert!(!run.running());
|
||||
assert_eq!(run.refresh(), None, "there is nothing to refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_after_clearing_does_not_bring_results_back() {
|
||||
let mut run = SearchRun::default();
|
||||
let g = run.retarget(Some(key("a"))).unwrap();
|
||||
run.retarget(None);
|
||||
assert!(!run.accept(g, found(vec![hit("/r/a.rs", 1)], false)));
|
||||
assert!(run.landed().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hits_group_by_file_in_the_order_first_met() {
|
||||
let hits = vec![
|
||||
hit("/r/src/b.rs", 3),
|
||||
hit("/r/src/b.rs", 9),
|
||||
hit("/r/a.rs", 1),
|
||||
hit("/r/src/b.rs", 12),
|
||||
];
|
||||
let groups = group_hits(&hits, &[PathBuf::from("/r")]);
|
||||
let shape: Vec<(&str, &str, Vec<u32>)> = groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
(
|
||||
g.name.as_str(),
|
||||
g.dir.as_str(),
|
||||
g.hits.iter().map(|h| h.line).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
vec![("b.rs", "src", vec![3, 9, 12]), ("a.rs", "", vec![1])]
|
||||
);
|
||||
assert_eq!(hit_count(&groups), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_landed_search_keeps_its_truncation() {
|
||||
let mut run = SearchRun::default();
|
||||
let g = run.retarget(Some(key("x"))).unwrap();
|
||||
run.accept(g, found(vec![hit("/r/a.rs", 1)], true));
|
||||
match &run.landed().unwrap().outcome {
|
||||
Outcome::Found(f) => assert!(f.truncated),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(run.landed().unwrap().groups.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_come_from_the_innermost_root() {
|
||||
let roots = [PathBuf::from("/r")];
|
||||
assert_eq!(
|
||||
split_relative(Path::new("/r/src/ui/app.rs"), &roots),
|
||||
("app.rs".into(), "src/ui".into())
|
||||
);
|
||||
assert_eq!(
|
||||
split_relative(Path::new("/r/top.rs"), &roots),
|
||||
("top.rs".into(), String::new())
|
||||
);
|
||||
// `/rx` is not under `/r`.
|
||||
assert_eq!(
|
||||
split_relative(Path::new("/rx/a.rs"), &roots),
|
||||
("a.rs".into(), "/rx".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_roots_keep_their_names() {
|
||||
let roots = [PathBuf::from("/w/api"), PathBuf::from("/w/web/")];
|
||||
assert_eq!(
|
||||
split_relative(Path::new("/w/api/src/a.rs"), &roots),
|
||||
("a.rs".into(), "api/src".into())
|
||||
);
|
||||
assert_eq!(
|
||||
split_relative(Path::new("/w/web/index.ts"), &roots),
|
||||
("index.ts".into(), "web".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_windows_peers_paths_split_on_backslashes() {
|
||||
let roots = [PathBuf::from(r"C:\repo")];
|
||||
assert_eq!(
|
||||
split_relative(Path::new(r"C:\repo\src\main.rs"), &roots),
|
||||
("main.rs".into(), "src".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_sorted_into_what_the_panel_can_say() {
|
||||
let e = |kind| Err(io::Error::new(kind, "why"));
|
||||
assert_eq!(
|
||||
Outcome::from_result(e(io::ErrorKind::InvalidInput)),
|
||||
Outcome::BadPattern("why".into())
|
||||
);
|
||||
assert_eq!(
|
||||
Outcome::from_result(e(io::ErrorKind::Unsupported)),
|
||||
Outcome::ServerTooOld
|
||||
);
|
||||
assert_eq!(
|
||||
Outcome::from_result(e(io::ErrorKind::NotFound)),
|
||||
Outcome::Failed("why".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,10 @@ fn right_panel_tabs_floor(window: &Window, cx: &gpui::App) -> f32 {
|
||||
(TAB_ROW_LEAD + crate::ui::tab_strip::right_panel_tab_labels_w(window, cx) + chrome).ceil()
|
||||
}
|
||||
|
||||
/// The tab row's leading inset: the first label lands 22px in, and each tab's
|
||||
/// click target already reaches `TAB_OUTER_PAD` past its label.
|
||||
///
|
||||
/// The tabs sit flush against each other — no row gap — so two labels are
|
||||
/// exactly `2 × TAB_OUTER_PAD` = 18px apart, which is the whole spacing rule.
|
||||
const TAB_ROW_LEAD: f32 = 22. - crate::ui::tab_strip::TAB_OUTER_PAD;
|
||||
/// The tab row's leading inset: the selected tab's pill starts on
|
||||
/// `CONTENT_INSET`, the edge every row fill below it starts on, and each tab's
|
||||
/// click target reaches `TAB_OUTER_PAD` past its pill.
|
||||
const TAB_ROW_LEAD: f32 = crate::ui::app::CONTENT_INSET - crate::ui::tab_strip::TAB_OUTER_PAD;
|
||||
|
||||
/// The panel toggle and the app menu where they sit in this panel's own tab
|
||||
/// row (macOS): 26px tiles, 4px apart, the last one 12px from the panel's
|
||||
@@ -347,6 +345,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 +414,7 @@ impl InfoRow {
|
||||
self.copy.is_some()
|
||||
|| self.reveal.is_some()
|
||||
|| matches!(self.value, InfoValue::Diff { open: Some(_), .. })
|
||||
|| matches!(self.value, InfoValue::Link { .. })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +517,11 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
pub(crate) fn set_right_panel_tab(&mut self, tab: RightPanelTab, cx: &mut Context<Self>) {
|
||||
// Every way to the Search tab ends here, and none of them has a
|
||||
// `Window` to move focus with; the tab takes it on its next frame.
|
||||
if tab == RightPanelTab::Search {
|
||||
self.panel_search.focus_pending = true;
|
||||
}
|
||||
self.right_panel_tab = tab;
|
||||
self.right_panel_visible = true;
|
||||
self.update_config(cx, |cfg| {
|
||||
@@ -547,6 +553,8 @@ impl Tty7App {
|
||||
RightPanelTab::Info => self.render_panel_info(window, cx),
|
||||
RightPanelTab::Scm => self.render_panel_scm(window, cx),
|
||||
RightPanelTab::Files => self.render_panel_files(window, cx),
|
||||
RightPanelTab::Search => self.render_panel_search(window, cx),
|
||||
RightPanelTab::GitHub => self.render_panel_github(window, cx),
|
||||
};
|
||||
let (backing, handle) = self.right_panel_resize(window, cx);
|
||||
|
||||
@@ -576,11 +584,7 @@ impl Tty7App {
|
||||
.items_center()
|
||||
.pl(px(TAB_ROW_LEAD))
|
||||
.relative()
|
||||
.children(self.right_panel_tabs(
|
||||
width - TAB_ROW_LEAD - PANEL_CHROME_W,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.children(self.right_panel_tabs(cx))
|
||||
.child(div().flex_1())
|
||||
// Navigation controls stay visible on both sidebars —
|
||||
// here at the panel row's smaller size.
|
||||
@@ -591,15 +595,16 @@ impl Tty7App {
|
||||
cx,
|
||||
))
|
||||
}))
|
||||
// Only the Files tab steps down 8px. Its first row is a filled
|
||||
// search well whose top edge is the first thing you see, so it
|
||||
// wants air under the tab row. Info and Source Control open on
|
||||
// Only the Files and Search tabs step down 8px. Their first row
|
||||
// is a filled search well whose top edge is the first thing you
|
||||
// see, so it wants air under the tab row. Info and Source Control open on
|
||||
// bare text centred in a 28px row, which already sits ~7px
|
||||
// under the row's top; adding 8 more put their first line a
|
||||
// visible step lower than the Files well beside them.
|
||||
.children(
|
||||
(cfg!(target_os = "macos") && tab == RightPanelTab::Files)
|
||||
.then(|| div().flex_none().h(px(8.))),
|
||||
(cfg!(target_os = "macos")
|
||||
&& matches!(tab, RightPanelTab::Files | RightPanelTab::Search))
|
||||
.then(|| div().flex_none().h(px(8.))),
|
||||
)
|
||||
.child(body)
|
||||
.children(self.sftp_transfers_footer(cx))
|
||||
@@ -721,15 +726,7 @@ impl Tty7App {
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let has_trailing = trailing.is_some();
|
||||
let tabs = (!cfg!(target_os = "macos")).then(|| {
|
||||
let width = self.right_panel_px(window, cx);
|
||||
let trailing_w = match has_trailing {
|
||||
true => 2. * TILE_SIZE_SM + 6.,
|
||||
false => 0.,
|
||||
};
|
||||
let avail = width - TAB_ROW_LEAD - tile_trailing_inset() - trailing_w;
|
||||
self.right_panel_tabs(avail, window, cx)
|
||||
});
|
||||
let tabs = (!cfg!(target_os = "macos")).then(|| self.right_panel_tabs(cx));
|
||||
if tabs.is_none() && !has_trailing {
|
||||
return div().flex_none().into_any_element();
|
||||
}
|
||||
@@ -996,6 +993,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1084,6 +1094,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,
|
||||
|
||||
@@ -207,6 +207,8 @@ impl CommandKind {
|
||||
// even though the panel is now called Source Control.
|
||||
ShowRightPanel(RightPanelTab::Scm) => "right-panel-changes",
|
||||
ShowRightPanel(RightPanelTab::Files) => "right-panel-files",
|
||||
ShowRightPanel(RightPanelTab::Search) => "right-panel-search",
|
||||
ShowRightPanel(RightPanelTab::GitHub) => "right-panel-github",
|
||||
ClearTerminal => "clear-scrollback",
|
||||
FindInTerminal => "find",
|
||||
FindNext => "find-next",
|
||||
@@ -336,6 +338,8 @@ impl CommandKind {
|
||||
RightPanelTab::Info => "ShowRightPanelInfo",
|
||||
RightPanelTab::Scm => "ShowRightPanelChanges",
|
||||
RightPanelTab::Files => "ShowRightPanelFiles",
|
||||
RightPanelTab::Search => "ShowRightPanelSearch",
|
||||
RightPanelTab::GitHub => "ShowRightPanelGitHub",
|
||||
},
|
||||
ClearTerminal => "ClearScrollback",
|
||||
FindInTerminal => "FindInTerminal",
|
||||
@@ -649,13 +653,21 @@ impl Item {
|
||||
L10nKey::CmdRightPanelInfo,
|
||||
ShowRightPanel(RightPanelTab::Info),
|
||||
),
|
||||
Item::localized(
|
||||
L10nKey::CmdRightPanelFiles,
|
||||
ShowRightPanel(RightPanelTab::Files),
|
||||
),
|
||||
Item::localized(
|
||||
L10nKey::CmdRightPanelSearch,
|
||||
ShowRightPanel(RightPanelTab::Search),
|
||||
),
|
||||
Item::localized(
|
||||
L10nKey::CmdRightPanelChanges,
|
||||
ShowRightPanel(RightPanelTab::Scm),
|
||||
),
|
||||
Item::localized(
|
||||
L10nKey::CmdRightPanelFiles,
|
||||
ShowRightPanel(RightPanelTab::Files),
|
||||
L10nKey::CmdRightPanelGitHub,
|
||||
ShowRightPanel(RightPanelTab::GitHub),
|
||||
),
|
||||
Item::localized(L10nKey::CmdChangeTheme, OpenThemePicker),
|
||||
Item::localized(L10nKey::CmdResetFontSize, ResetFontSize),
|
||||
@@ -860,6 +872,8 @@ mod tests {
|
||||
CommandKind::Quit,
|
||||
CommandKind::ShowRightPanel(RightPanelTab::Info),
|
||||
CommandKind::ShowRightPanel(RightPanelTab::Files),
|
||||
CommandKind::ShowRightPanel(RightPanelTab::Search),
|
||||
CommandKind::ShowRightPanel(RightPanelTab::GitHub),
|
||||
] {
|
||||
let id = kind.id().expect("static command has an id");
|
||||
assert!(seen.insert(id), "duplicate command id {id:?}");
|
||||
|
||||
@@ -148,6 +148,15 @@ impl Host for SftpHost {
|
||||
Err(unsupported("search"))
|
||||
}
|
||||
|
||||
fn search_content(
|
||||
&self,
|
||||
_roots: &[PathBuf],
|
||||
_query: &tty7_core::host::ContentQuery,
|
||||
_limits: &tty7_core::host::ContentLimits,
|
||||
) -> io::Result<tty7_core::host::ContentResults> {
|
||||
Err(unsupported("search"))
|
||||
}
|
||||
|
||||
fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> {
|
||||
match self.op(SftpOp::WriteFile {
|
||||
path: rpath(p),
|
||||
|
||||
@@ -618,48 +618,56 @@ pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> But
|
||||
chrome_tile_sized(button, TILE_SIZE, TILE_GLYPH, selected, cx)
|
||||
}
|
||||
|
||||
const RIGHT_PANEL_TABS: [(RightPanelTab, L10nKey); 3] = [
|
||||
(RightPanelTab::Info, L10nKey::PanelInfoTitle),
|
||||
(RightPanelTab::Scm, L10nKey::PanelChangesTitle),
|
||||
(RightPanelTab::Files, L10nKey::PanelFilesTitle),
|
||||
/// The right panel's tabs, left to right: which pane, what its tooltip calls
|
||||
/// it, and the glyph the row draws for it. Info leads as the default and the
|
||||
/// pane's overview; then two pairs — the project's files (Files, Search) and
|
||||
/// its version control, local to remote (Changes, GitHub). GitHub is last so
|
||||
/// that hiding it for a repository without a GitHub remote moves nothing.
|
||||
const RIGHT_PANEL_TABS: [(RightPanelTab, L10nKey, &str); 5] = [
|
||||
(
|
||||
RightPanelTab::Info,
|
||||
L10nKey::PanelInfoTitle,
|
||||
"icons/info.svg",
|
||||
),
|
||||
(
|
||||
RightPanelTab::Files,
|
||||
L10nKey::PanelFilesTitle,
|
||||
"icons/folder.svg",
|
||||
),
|
||||
(
|
||||
RightPanelTab::Search,
|
||||
L10nKey::PanelSearchTitle,
|
||||
"icons/search.svg",
|
||||
),
|
||||
(
|
||||
RightPanelTab::Scm,
|
||||
L10nKey::PanelChangesTitle,
|
||||
"icons/git-branch.svg",
|
||||
),
|
||||
(
|
||||
RightPanelTab::GitHub,
|
||||
L10nKey::PanelGitHubTitle,
|
||||
"icons/github.svg",
|
||||
),
|
||||
];
|
||||
|
||||
fn right_panel_tab_size(window: &Window) -> f32 {
|
||||
window.rem_size().as_f32() * crate::ui::right_panel::TAB_TEXT
|
||||
/// The glyph each tab draws, in px.
|
||||
const RIGHT_PANEL_TAB_ICON: f32 = 15.;
|
||||
|
||||
/// What the bare tab glyphs take, padding included. The panel's floor is
|
||||
/// built on it, so the chrome tiles beside them always fit.
|
||||
pub(crate) fn right_panel_tab_labels_w(_window: &Window, _cx: &gpui::App) -> f32 {
|
||||
RIGHT_PANEL_TABS.len() as f32 * (RIGHT_PANEL_TAB_ICON + 2. * (TAB_OUTER_PAD + TAB_INNER_PAD))
|
||||
}
|
||||
|
||||
fn right_panel_tab_font(cx: &gpui::App) -> gpui::Font {
|
||||
gpui::Font {
|
||||
family: cx.theme().font_family.clone(),
|
||||
features: Default::default(),
|
||||
fallbacks: None,
|
||||
// The current tab's weight, which is the widest any label is drawn at.
|
||||
weight: FontWeight::MEDIUM,
|
||||
style: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the three bare tab labels take, padding included, at the live
|
||||
/// interface size and language. The panel's floor is built on it, so a larger
|
||||
/// UI font widens the panel instead of pushing its chrome tiles off the edge.
|
||||
pub(crate) fn right_panel_tab_labels_w(window: &Window, cx: &gpui::App) -> f32 {
|
||||
let size = right_panel_tab_size(window);
|
||||
let font = right_panel_tab_font(cx);
|
||||
RIGHT_PANEL_TABS
|
||||
.iter()
|
||||
.map(|(_, key)| {
|
||||
measure_text(window.text_system(), &font, size, t(*key))
|
||||
+ 2. * (TAB_OUTER_PAD + TAB_INNER_PAD)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Right panel tab geometry: each label's click target reaches half the 18px
|
||||
/// gap to its neighbour, there is no pill inside it, and the Changes count
|
||||
/// hangs 5px off its label.
|
||||
pub(crate) const TAB_OUTER_PAD: f32 = 9.;
|
||||
const TAB_INNER_PAD: f32 = 0.;
|
||||
const TAB_COUNT_GAP: f32 = 5.;
|
||||
/// Right panel tab geometry: each tab's click target reaches this far past
|
||||
/// its pill, so neighbouring pills sit twice this apart.
|
||||
pub(crate) const TAB_OUTER_PAD: f32 = 2.;
|
||||
/// The selected tab's pill reaches this far past its glyph.
|
||||
const TAB_INNER_PAD: f32 = 5.;
|
||||
/// The pill's height and corner.
|
||||
const TAB_PILL_H: f32 = 24.;
|
||||
const TAB_PILL_RADIUS: f32 = 6.;
|
||||
|
||||
/// How wide the two chrome tiles at the trailing end of the title bar are, with
|
||||
/// the padding around them.
|
||||
@@ -1270,59 +1278,28 @@ impl Tty7App {
|
||||
)
|
||||
}
|
||||
|
||||
/// The right panel's word tabs, laid out in `avail` px.
|
||||
/// The right panel's tabs: a glyph each, named in a tooltip.
|
||||
///
|
||||
/// A label is never elided — "C…" is not a tab anyone can read — so when
|
||||
/// the row runs short the Changes count is what gives way: it repeats what
|
||||
/// the panel body says, the label does not. `right_panel::MIN_WIDTH` is
|
||||
/// what guarantees the three bare labels always fit.
|
||||
pub(crate) fn right_panel_tabs(
|
||||
&self,
|
||||
avail: f32,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
/// No count on Changes: a number beside one glyph of five read as a badge
|
||||
/// on that tab alone, and the Changes tab itself leads with the same count
|
||||
/// under its own heading.
|
||||
pub(crate) fn right_panel_tabs(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||
let active_tab = self.right_panel_tab;
|
||||
// The count the source control tile carries. It reads the same status
|
||||
// the panel draws, so the badge and the group headers can never
|
||||
// disagree — and it counts entries, not files, because a path that is
|
||||
// both staged and modified is two things to do, which is what the
|
||||
// groups show.
|
||||
let changed = self
|
||||
.scm
|
||||
.active_repo()
|
||||
.and_then(|repo| crate::terminal::git_data::status_of(cx, repo.host, &repo.root))
|
||||
.map(|status| status.entries.len())
|
||||
.filter(|n| *n > 0);
|
||||
let size = right_panel_tab_size(window);
|
||||
let font = right_panel_tab_font(cx);
|
||||
let ts = window.text_system();
|
||||
let labels_w = right_panel_tab_labels_w(window, cx);
|
||||
let changed = changed.filter(|n| {
|
||||
let regular = gpui::Font {
|
||||
weight: FontWeight::NORMAL,
|
||||
..font.clone()
|
||||
};
|
||||
labels_w + TAB_COUNT_GAP + measure_text(ts, ®ular, size, &n.to_string()) <= avail
|
||||
});
|
||||
let body_ink = cx.theme().foreground;
|
||||
let selected_fill = cx.global::<crate::ui::presets::Surfaces>().sidebar.selected;
|
||||
RIGHT_PANEL_TABS
|
||||
.into_iter()
|
||||
.map(|(tab, label_key)| {
|
||||
.map(|(tab, label_key, icon)| {
|
||||
let current = active_tab == tab;
|
||||
// Words, not glyphs: three tabs is few enough to name, and a name
|
||||
// is what a new user has to guess at when the tab is an icon. The
|
||||
// current one is told apart by ink and weight alone — this is
|
||||
// secondary navigation, not an action, so it gets neither a
|
||||
// pill nor a bar.
|
||||
// Glyphs, not words: five names do not fit the panel's 280px
|
||||
// resting width, so each tab is an icon and says its name in a
|
||||
// tooltip. Ink alone could not carry "this one" between five
|
||||
// glyphs of one weight, so the current tab also sits on the
|
||||
// sidebar's selected fill.
|
||||
let ink = match current {
|
||||
true => body_ink,
|
||||
false => cx.theme().muted_foreground,
|
||||
};
|
||||
let count = match tab {
|
||||
RightPanelTab::Scm => changed,
|
||||
_ => None,
|
||||
};
|
||||
div()
|
||||
.id(("right-panel-tab", tab as usize))
|
||||
// The press must not start a window drag from the title bar
|
||||
@@ -1337,27 +1314,22 @@ impl Tty7App {
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.h(px(TAB_PILL_H))
|
||||
.px(px(TAB_INNER_PAD))
|
||||
.gap(px(TAB_COUNT_GAP))
|
||||
.rounded(px(TAB_PILL_RADIUS))
|
||||
.when(current, |pill| pill.bg(gpui::rgb(selected_fill)))
|
||||
.items_center()
|
||||
.text_size(gpui::rems(crate::ui::right_panel::TAB_TEXT))
|
||||
.font_weight(match current {
|
||||
true => FontWeight::MEDIUM,
|
||||
false => FontWeight::NORMAL,
|
||||
})
|
||||
.text_color(ink)
|
||||
.hover(move |s| s.text_color(body_ink))
|
||||
.child(div().flex_shrink_0().child(t(label_key)))
|
||||
.when_some(count, |row, n| {
|
||||
row.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.font_weight(FontWeight::NORMAL)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(n.to_string()),
|
||||
)
|
||||
}),
|
||||
.child(
|
||||
gpui::svg()
|
||||
.flex_shrink_0()
|
||||
.path(icon)
|
||||
.size(px(RIGHT_PANEL_TAB_ICON))
|
||||
.text_color(ink),
|
||||
),
|
||||
)
|
||||
.tooltip(move |window, cx| Tooltip::new(t(label_key)).build(window, cx))
|
||||
// Another tab switches to it; the current one puts the panel
|
||||
// away, the way an activity bar behaves everywhere else.
|
||||
// (These only exist while the panel is open, so
|
||||
|
||||
@@ -1270,3 +1270,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The text view's style for rendered Markdown, wherever tty7 shows it.
|
||||
///
|
||||
/// Inline `code` gets a faint neutral fill. The text view's own default is the
|
||||
/// theme's `accent`, which tty7 also paints selection with, so every code span
|
||||
/// read as selected text.
|
||||
pub(crate) fn markdown_style(cx: &gpui::App) -> gpui_component::text::TextViewStyle {
|
||||
gpui_component::text::TextViewStyle::default()
|
||||
.inline_code_background(cx.theme().foreground.opacity(0.08))
|
||||
}
|
||||
|
||||