Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ A PR plus its body (markdown), used in the detail view. Fetched lazily when the
**Open PR List**:
The list of open PRs for the current Tracked Repo, plus the user's selection cursor and the scroll viewport that keeps the cursor on-screen as the list grows. Populated by REST streaming during a fetch; rendered as one row per PR in the list view.

**Substring Filter**:
The case-insensitive query that narrows the **Open PR List** across a PR's title, description, author, reviewers, labels, number, and changed file paths. Enrichment-backed text contributes once available; filtering never initiates a fetch.

**Repo Tab**:
A UI tab showing PRs from a single configured repo (or `All` showing every tracked repo combined).

Expand Down Expand Up @@ -151,7 +154,7 @@ The **Open PR List** row under the cursor, highlighted by a subtle full-width ba
_Avoid_: highlighted row, active row, reverse-video row.

**Label Chip**:
A PR label rendered as a filled badge — the label's own GitHub colour as background, a contrast-flipped foreground — shown in the summary panel and detail header (not the list). Pure presentation: chips assign no domain meaning and drive no sort, filter, or **Smart-status**; they are a cosmetic rendering of the same contextual metadata the labels are otherwise.
A PR label rendered as a filled badge — the label's own GitHub colour as background, a contrast-flipped foreground — shown in the summary panel and detail header (not the list). Pure presentation: chips assign no domain meaning and drive no sort or **Smart-status**; the underlying label text remains searchable by the **Substring Filter**.
_Avoid_: tag, badge (badge is the generic shape; chip is the legit term).

**Repo Color**:
Expand Down
43 changes: 39 additions & 4 deletions src/app/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::{
use super::{
cmd::Cmd,
detail_items::{DetailFilters, DetailFocus},
pr_list::PrList,
pr_list::{PrList, contains_case_insensitive},
summary_layout::SummaryState,
};

Expand Down Expand Up @@ -103,6 +103,11 @@ pub struct Enrichment {
pub reviews: HashMap<PrKey, Vec<Review>>,
pub issue_comments: HashMap<PrKey, Vec<IssueComment>>,
pub checks: HashMap<(String, String), Vec<CheckRun>>,
/// Raw PR descriptions that have arrived through the detail fetch. The
/// open detail owns its parsed render blocks separately; retaining the raw
/// text here lets the list filter use already-fetched descriptions after
/// the user returns to the list.
descriptions: HashMap<PrKey, String>,
/// Comment bodies parsed to markdown blocks once on arrival, keyed by the
/// comment's URL (the same stable key `DetailState::expanded` uses). Blocks
/// rather than flat lines so each comment's `<details>` groups fold per the
Expand All @@ -125,6 +130,12 @@ pub struct Enrichment {
}

impl Enrichment {
/// Retain a fetched PR description for list filtering after the detail view
/// that requested it closes.
pub(super) fn store_description(&mut self, pr: PrKey, body: String) {
self.descriptions.insert(pr, body);
}

/// Store an arrived thread list, parsing each comment's markdown body to
/// blocks exactly once. The one write path for `review_threads`, so the
/// parsed cache always covers what the maps hold.
Expand Down Expand Up @@ -189,6 +200,27 @@ impl Enrichment {
self.issue_comments.get(pr).map(Vec::as_slice)
}

/// Whether already-arrived enrichment adds a case-insensitive substring
/// match for `needle`. Purely reads cached data; filtering never fetches.
fn matches_filter(&self, pr: &PR, needle: &str) -> bool {
let key = pr.key();
self.reviews.get(&key).is_some_and(|reviews| {
reviews
.iter()
.any(|review| contains_case_insensitive(&review.user, needle))
}) || matches!(
self.files.get(&key),
Some(FilesState::Loaded(files))
if files
.files
.iter()
.any(|file| contains_case_insensitive(&file.path, needle))
) || self
.descriptions
.get(&key)
.is_some_and(|body| contains_case_insensitive(body, needle))
}

/// The check runs fetched for `pr`'s head commit, or `None` until they
/// arrive. The `checks` map is keyed by (repo slug, head SHA) — not `PrKey`
/// — because check runs are repo-scoped on GitHub (a fork PR shares its
Expand Down Expand Up @@ -598,9 +630,12 @@ impl Model {
// `blockers` is a field disjoint from `self.list`, so it can be borrowed
// by the tier closure while `self.list` is borrowed mutably.
let blockers = &self.blockers;
self.list.relayout(scope.as_deref(), |pr| {
blockers.get(&pr.key()).map(|b| b.tier)
});
let enrichment = &self.enrichment;
self.list.relayout(
scope.as_deref(),
|pr| blockers.get(&pr.key()).map(|b| b.tier),
|pr, needle| enrichment.matches_filter(pr, needle),
);
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/app/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,10 @@ pub enum Msg {
},
/// The detail fetch for a PR completed. Carries the PR's key (`pr`, matching
/// the other enrichment arrivals) so `update` can check whether the view is
/// still open for this PR, and the body (markdown) to display. The PR itself
/// is sourced from the enriched list (`model.list.pr(pr)`) rather than
/// stored here, so the detail view always reads the up-to-date
/// mergeable/head_commit_sha/etc.
/// still open for this PR, and the body (markdown) to display and retain as
/// searchable enrichment. The PR itself is sourced from the enriched list
/// (`model.list.pr(pr)`) rather than stored here, so the detail view always
/// reads the up-to-date mergeable/head_commit_sha/etc.
PRDetailArrived {
pr: PrKey,
body: String,
Expand Down
61 changes: 48 additions & 13 deletions src/app/pr_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ use crate::app::grouping::{DisplayRow, Grouping, display_rows};
use crate::blocker::Tier;
use crate::github::rest::{PR, PrKey};

/// Case-insensitive substring comparison shared by the PR-owned and
/// enrichment-owned fields of the Substring Filter. `needle` is already
/// lowercased once per relayout.
pub(super) fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
haystack.to_lowercase().contains(needle)
}

/// The substring filter over the Open PR List. `/` opens editing; Enter locks
/// the text in; Esc clears. `Applied("")` is unrepresentable — submitting an
/// empty filter returns to `Inactive` — so "filter active" is exactly
Expand Down Expand Up @@ -60,10 +67,14 @@ impl Filter {
/// scheme, `www.`, and trailing segments (`/changes`, `/files`, …). Matches
/// that exact `owner/repo` + number.
/// - Worktree path — any string containing `/` whose leaf is `{N}-{branch}`
/// (legit's worktree directory naming). Matches by PR number. Requiring a
/// separator keeps a title search like `1-click` on the substring path.
/// - Otherwise, a case-insensitive substring over title, author, and number;
/// the number also matches with a leading `#` (`#42`).
/// (legit's worktree directory naming). Matches by PR number, falling back
/// to the loaded-enrichment substring match so a changed-file path that is
/// worktree-shaped by accident (`migrations/123-add-users.sql`) still
/// matches. Requiring a separator keeps a title search like `1-click` on
/// the substring path.
/// - Otherwise, a case-insensitive substring over title, author, labels,
/// requested reviewers, already-loaded enrichment, and number; the number
Comment thread
mayfieldiv marked this conversation as resolved.
/// also matches with a leading `#` (`#42`).
///
/// Both paste shapes fall back to `Substring` while incomplete, so ordinary
/// matching still applies as the user types.
Expand All @@ -75,7 +86,10 @@ enum FilterQuery {
slug: String,
number: u64,
},
WorktreePath(u64),
WorktreePath {
number: u64,
needle: String,
},
Substring(String),
}

Expand All @@ -89,23 +103,34 @@ impl FilterQuery {
return Self::PrUrl { slug, number };
}
if let Some(number) = parse_worktree_path_filter(&needle) {
return Self::WorktreePath(number);
return Self::WorktreePath { number, needle };
}
Self::Substring(needle)
}

fn matches(&self, pr: &PR) -> bool {
fn matches(&self, pr: &PR, loaded_fields_match: impl FnOnce(&str) -> bool) -> bool {
match self {
Self::All => true,
Self::PrUrl { slug, number } => {
pr.repo_slug.eq_ignore_ascii_case(slug) && pr.number == *number
}
Self::WorktreePath(number) => pr.number == *number,
Self::WorktreePath { number, needle } => {
pr.number == *number || loaded_fields_match(needle)
}
Self::Substring(needle) => {
let number_needle = needle.strip_prefix('#').unwrap_or(needle);
pr.title.to_lowercase().contains(needle)
|| pr.author.to_lowercase().contains(needle)
contains_case_insensitive(&pr.title, needle)
|| contains_case_insensitive(&pr.author, needle)
|| pr
.labels
.iter()
.any(|label| contains_case_insensitive(&label.name, needle))
|| pr
.requested_reviewers
.iter()
.any(|reviewer| contains_case_insensitive(reviewer, needle))
|| (!number_needle.is_empty() && pr.number.to_string().contains(number_needle))
|| loaded_fields_match(needle)
}
}
}
Expand Down Expand Up @@ -304,7 +329,8 @@ impl PrList {
/// grouping, showing only the PRs in `scope` (a Repo Tab's slug, or `None`
/// for the All tab) and ordering each group by most recent GitHub activity.
/// `tier_of(pr)` returns the Smart-status tier for a PR, or `None` when its
/// enrichment hasn't been derived yet; the repo-grouping key is read
/// enrichment hasn't been derived yet; `loaded_fields_match` searches only
/// enrichment already held by the model. The repo-grouping key is read
/// straight off each PR's `repo_slug`. Once the user has navigated,
/// selection sticks to the same PR while it remains visible and snaps to
/// the top display row otherwise; until then it follows the top row as
Expand All @@ -313,11 +339,20 @@ impl PrList {
/// refreshes do not undo wheel scrolling; if the selection changes, scroll
/// follows the new selection. Called by `update` after PRs arrive,
/// enrichment lands, or the grouping/scope changes.
pub fn relayout(&mut self, scope: Option<&str>, tier_of: impl Fn(&PR) -> Option<Tier>) {
pub fn relayout(
&mut self,
scope: Option<&str>,
tier_of: impl Fn(&PR) -> Option<Tier>,
loaded_fields_match: impl Fn(&PR, &str) -> bool,
) {
let query = FilterQuery::parse(self.filter.text());
let mut visible: Vec<usize> = (0..self.prs.len())
.filter(|&i| scope.is_none_or(|slug| self.prs[i].repo_slug == slug))
.filter(|&i| query.matches(&self.prs[i]))
.filter(|&i| {
query.matches(&self.prs[i], |needle| {
loaded_fields_match(&self.prs[i], needle)
})
})
.collect();
visible.sort_by(|&a, &b| compare_recent_activity(&self.prs[a], &self.prs[b]));
// `display_rows` keys on PR index; adapt the &PR closure (and the slug
Expand Down
42 changes: 23 additions & 19 deletions src/app/pr_list/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ fn flat_list(n: u64) -> PrList {
list.push(sample_pr(i));
}
list.grouping = Grouping::None;
list.relayout(None, |_| None);
list.relayout(None, |_| None, |_, _| false);
list
}

Expand Down Expand Up @@ -85,7 +85,7 @@ fn smart_status_groups_order_prs_by_most_recent_github_activity() {
list.push(pr);
}

list.relayout(None, |_| Some(Tier::NeedsReview));
list.relayout(None, |_| Some(Tier::NeedsReview), |_, _| false);

assert_eq!(list.pr_numbers_in_display_order(), vec![2, 3, 1]);
}
Expand All @@ -101,7 +101,7 @@ fn equal_activity_times_put_the_newer_pr_first() {
list.push(pr);
}

list.relayout(None, |_| Some(Tier::NeedsReview));
list.relayout(None, |_| Some(Tier::NeedsReview), |_, _| false);

assert_eq!(list.pr_numbers_in_display_order(), vec![2, 1]);
}
Expand All @@ -110,15 +110,15 @@ fn equal_activity_times_put_the_newer_pr_first() {
fn untouched_selection_follows_the_top_row_as_arrivals_resort() {
let mut list = PrList::new();
list.push(sample_pr(1));
list.relayout(None, |_| Some(Tier::NeedsReview));
list.relayout(None, |_| Some(Tier::NeedsReview), |_, _| false);
assert_eq!(list.prs()[list.selected()].number, 1);

// A more recently active PR arrives and sorts above the first-streamed
// one; the never-touched cursor follows the top row.
let mut newer = sample_pr(2);
newer.updated_at = chrono::Utc.with_ymd_and_hms(2026, 5, 2, 0, 0, 0).unwrap();
list.push(newer);
list.relayout(None, |_| Some(Tier::NeedsReview));
list.relayout(None, |_| Some(Tier::NeedsReview), |_, _| false);
assert_eq!(
list.prs()[list.selected()].number,
2,
Expand All @@ -131,7 +131,7 @@ fn untouched_selection_follows_the_top_row_as_arrivals_resort() {
let mut newest = sample_pr(3);
newest.updated_at = chrono::Utc.with_ymd_and_hms(2026, 5, 3, 0, 0, 0).unwrap();
list.push(newest);
list.relayout(None, |_| Some(Tier::NeedsReview));
list.relayout(None, |_| Some(Tier::NeedsReview), |_, _| false);
assert_eq!(
list.prs()[list.selected()].number,
1,
Expand All @@ -143,19 +143,19 @@ fn untouched_selection_follows_the_top_row_as_arrivals_resort() {
fn wheel_up_on_empty_list_does_not_detach_default_selection() {
let mut list = PrList::new();
list.resize(10);
list.relayout(None, |_| None);
list.relayout(None, |_| None, |_, _| false);

list.scroll_up(3);

// PRs stream in oldest-first; the second sorts above the first. A
// wheel-up over the empty list must not have pinned the selection to
// the first arrival.
list.push(sample_pr(1));
list.relayout(None, |_| Some(Tier::NeedsReview));
list.relayout(None, |_| Some(Tier::NeedsReview), |_, _| false);
let mut newer = sample_pr(2);
newer.updated_at = chrono::Utc.with_ymd_and_hms(2026, 5, 2, 0, 0, 0).unwrap();
list.push(newer);
list.relayout(None, |_| Some(Tier::NeedsReview));
list.relayout(None, |_| Some(Tier::NeedsReview), |_, _| false);

assert_eq!(
list.prs()[list.selected()].number,
Expand Down Expand Up @@ -246,13 +246,17 @@ fn navigation_skips_group_headers() {
let mut list = PrList::new();
list.push(sample_pr(1));
list.push(sample_pr(2));
list.relayout(None, |pr| {
Some(if pr.number == 1 {
Tier::MeBlocking
} else {
Tier::WaitingOnAuthor
})
});
list.relayout(
None,
|pr| {
Some(if pr.number == 1 {
Tier::MeBlocking
} else {
Tier::WaitingOnAuthor
})
},
|_, _| false,
);

assert_eq!(list.selected(), 0);
list.move_down();
Expand Down Expand Up @@ -398,7 +402,7 @@ fn apply_filter(list: &mut PrList, text: &str) {
list.filter_push(c);
}
list.filter_submit();
list.relayout(None, |_| None);
list.relayout(None, |_| None, |_, _| false);
}

fn multi_repo_list() -> PrList {
Expand All @@ -416,7 +420,7 @@ fn multi_repo_list() -> PrList {
list.push(other);
list.push(manager);
list.grouping = Grouping::None;
list.relayout(None, |_| None);
list.relayout(None, |_| None, |_, _| false);
list
}

Expand Down Expand Up @@ -504,7 +508,7 @@ fn filter_bare_hyphenated_text_still_uses_substring_not_path_parse() {
other.title = "unrelated".to_owned();
list.push(other);
list.grouping = Grouping::None;
list.relayout(None, |_| None);
list.relayout(None, |_| None, |_, _| false);

apply_filter(&mut list, "1-click");

Expand Down
Loading