From 50201e04bb8228dcd26043f3680f22cded92aca6 Mon Sep 17 00:00:00 2001 From: Mayfield Date: Fri, 31 Jul 2026 01:30:46 -0400 Subject: [PATCH 1/3] feat(filter): search loaded PR metadata (#78) --- CONTEXT.md | 5 +- src/app/model.rs | 43 +++++++++- src/app/msg.rs | 8 +- src/app/pr_list.rs | 54 ++++++++++--- src/app/pr_list/tests.rs | 42 +++++----- src/app/update.rs | 16 +++- src/app/update/tests/detail.rs | 4 +- src/app/update/tests/filter.rs | 138 +++++++++++++++++++++++++++++++++ src/github/rest.rs | 4 +- 9 files changed, 267 insertions(+), 47 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3935cb9..547ab11 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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). @@ -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**: diff --git a/src/app/model.rs b/src/app/model.rs index 1cb2c8f..abcdf57 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -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, }; @@ -103,6 +103,11 @@ pub struct Enrichment { pub reviews: HashMap>, pub issue_comments: HashMap>, pub checks: HashMap<(String, String), Vec>, + /// 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, /// 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 `
` groups fold per the @@ -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. @@ -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 @@ -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), + ); } } diff --git a/src/app/msg.rs b/src/app/msg.rs index aaae774..bf28a36 100644 --- a/src/app/msg.rs +++ b/src/app/msg.rs @@ -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, diff --git a/src/app/pr_list.rs b/src/app/pr_list.rs index 0f08978..8f5bdc0 100644 --- a/src/app/pr_list.rs +++ b/src/app/pr_list.rs @@ -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 @@ -62,8 +69,9 @@ impl Filter { /// - 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`). +/// - Otherwise, a case-insensitive substring over title, author, labels, +/// requested reviewers, already-loaded enrichment, and number; the number +/// also matches with a leading `#` (`#42`). /// /// Both paste shapes fall back to `Substring` while incomplete, so ordinary /// matching still applies as the user types. @@ -75,7 +83,10 @@ enum FilterQuery { slug: String, number: u64, }, - WorktreePath(u64), + WorktreePath { + number: u64, + needle: String, + }, Substring(String), } @@ -89,23 +100,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) } } } @@ -304,7 +326,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 @@ -313,11 +336,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) { + pub fn relayout( + &mut self, + scope: Option<&str>, + tier_of: impl Fn(&PR) -> Option, + loaded_fields_match: impl Fn(&PR, &str) -> bool, + ) { let query = FilterQuery::parse(self.filter.text()); let mut visible: Vec = (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 diff --git a/src/app/pr_list/tests.rs b/src/app/pr_list/tests.rs index 929fd98..13e19f9 100644 --- a/src/app/pr_list/tests.rs +++ b/src/app/pr_list/tests.rs @@ -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 } @@ -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]); } @@ -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]); } @@ -110,7 +110,7 @@ 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 @@ -118,7 +118,7 @@ fn untouched_selection_follows_the_top_row_as_arrivals_resort() { 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, @@ -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, @@ -143,7 +143,7 @@ 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); @@ -151,11 +151,11 @@ fn wheel_up_on_empty_list_does_not_detach_default_selection() { // 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, @@ -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(); @@ -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 { @@ -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 } @@ -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"); diff --git a/src/app/update.rs b/src/app/update.rs index ddf3e5e..de023c3 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -948,13 +948,18 @@ fn apply(model: &mut Model, msg: Msg, now: DateTime) -> Vec { // -> FetchPRDetail), in which case dispatch it and stop. if model.list.filter().is_editing() { handle_filter_editing_key(model, key.code); + return Vec::new(); } else { let cmds = handle_list_key(model, key.code, now); // Refresh owns the whole keypress even when deduplication makes // it commandless; falling through would dispatch FetchFiles // and turn the documented no-op into a partial refresh. let refresh_key = matches!(key.code, KeyCode::Char('r' | 'R')); - if !cmds.is_empty() || refresh_key { + // Opening the filter is also a pure in-memory action. It can + // relayout to a different selected PR, but that must not turn + // `/` into an implicit enrichment fetch. + let filter_opened = model.list.filter().is_editing(); + if !cmds.is_empty() || refresh_key || filter_opened { return cmds; } } @@ -1171,6 +1176,7 @@ fn apply(model: &mut Model, msg: Msg, now: DateTime) -> Vec { .enrichment .files .insert(pr, FilesState::Loaded(categorization)); + model.relayout(); Vec::new() } Msg::FilesFetchFailed { pr } => { @@ -1263,14 +1269,16 @@ fn apply(model: &mut Model, msg: Msg, now: DateTime) -> Vec { // Parse the markdown description to blocks exactly once, here on // arrival, and cache the result — the view then flattens it (per the // body's `
` expansion) every frame instead of re-parsing. - // Store it only when the view is still open for this PR; discard it - // if the user already navigated back to the list or entered a - // different PR's detail. + // The open detail keeps only its own parsed blocks, while the raw + // description stays in enrichment so the list filter can search it + // after the user navigates back. if let ViewMode::Detail(detail) = &mut model.view_mode && detail.key == pr { detail.body = Some(detail_layout::render_description_blocks(&body)); } + model.enrichment.store_description(pr, body); + model.relayout(); Vec::new() } Msg::RefreshSelected => refresh::refresh_selected_cmds(model), diff --git a/src/app/update/tests/detail.rs b/src/app/update/tests/detail.rs index 4343fc4..cac1054 100644 --- a/src/app/update/tests/detail.rs +++ b/src/app/update/tests/detail.rs @@ -288,7 +288,7 @@ fn pr_detail_arrived_stores_detail_when_still_in_detail_view() { } #[test] -fn pr_detail_arrived_discarded_after_navigating_back() { +fn pr_detail_arrived_does_not_reopen_detail_after_navigating_back() { let mut model = model_with_one_pr(); update(&mut model, key_event(KeyCode::Enter)); // Navigate back before the fetch completes @@ -306,7 +306,7 @@ fn pr_detail_arrived_discarded_after_navigating_back() { assert_eq!( model.view_mode, ViewMode::List, - "a late-arriving body for a closed view must be discarded" + "a late-arriving body must not reopen a closed detail view" ); } diff --git a/src/app/update/tests/filter.rs b/src/app/update/tests/filter.rs index 4e5be90..63025eb 100644 --- a/src/app/update/tests/filter.rs +++ b/src/app/update/tests/filter.rs @@ -44,6 +44,144 @@ fn filter_matches_title_and_author_case_insensitively() { ); } +fn model_with_all_search_fields_loaded() -> Model { + let mut model = tabbed_model(); + let pr = model.list.pr_mut(&key(1)).expect("target PR"); + pr.labels.push(crate::github::rest::Label { + name: "backend".to_owned(), + color: None, + }); + pr.requested_reviewers.push("alice".to_owned()); + update( + &mut model, + Msg::ReviewsArrived { + pr: key(1), + reviews: vec![crate::github::types::Review { + user: "carol".to_owned(), + state: "APPROVED".to_owned(), + }], + }, + ); + update( + &mut model, + Msg::FilesArrived { + pr: key(1), + files: vec![crate::file_category::FileChange { + path: "src/search.rs".to_owned(), + additions: 12, + deletions: 3, + }], + }, + ); + update( + &mut model, + Msg::PRDetailArrived { + pr: key(1), + body: "Ready for the release train".to_owned(), + }, + ); + model +} + +#[test] +fn filter_matches_each_loaded_full_text_field() { + for (field, needle, expected) in [ + ("label", "BACKEND", vec![1]), + ("requested reviewer", "ALICE", vec![1]), + ("reviewer", "CAROL", vec![1]), + ("changed file path", "SRC/SEARCH.RS", vec![1]), + ("description", "RELEASE TRAIN", vec![1]), + ("no field", "not present", vec![]), + ("empty needle", "", vec![0, 1]), + ] { + let mut model = model_with_all_search_fields_loaded(); + update(&mut model, key_event(KeyCode::Char('/'))); + type_filter(&mut model, needle); + + assert_eq!(visible(&model), expected, "{field} match"); + } +} + +#[test] +fn filter_starts_matching_changed_paths_when_files_arrive() { + let mut model = tabbed_model(); + update(&mut model, key_event(KeyCode::Char('/'))); + type_filter(&mut model, "migrations/123-add-users.sql"); + assert!( + visible(&model).is_empty(), + "unloaded files do not contribute" + ); + + update( + &mut model, + Msg::FilesArrived { + pr: key(1), + files: vec![crate::file_category::FileChange { + path: "migrations/123-add-users.sql".to_owned(), + additions: 12, + deletions: 3, + }], + }, + ); + + assert_eq!( + visible(&model), + vec![1], + "the active filter relayouts when files arrive" + ); +} + +#[test] +fn filter_starts_matching_description_when_body_arrives() { + let mut model = tabbed_model(); + update(&mut model, key_event(KeyCode::Char('2'))); + update(&mut model, key_event(KeyCode::Enter)); + update(&mut model, key_event(KeyCode::Esc)); + + update(&mut model, key_event(KeyCode::Char('/'))); + type_filter(&mut model, "release train"); + assert!( + visible(&model).is_empty(), + "an unloaded description does not contribute" + ); + + update( + &mut model, + Msg::PRDetailArrived { + pr: key(1), + body: "Ready for the Release Train".to_owned(), + }, + ); + + assert_eq!( + visible(&model), + vec![1], + "the active filter relayouts when the description arrives" + ); +} + +#[test] +fn typing_filter_text_never_fetches_enrichment() { + let mut model = enriched_model(&[1, 2]); + model.list.pr_mut(&key(1)).expect("PR 1").title = "alpha".to_owned(); + model.list.pr_mut(&key(2)).expect("PR 2").title = "beta".to_owned(); + model.relayout(); + + let open_cmds = update(&mut model, key_event(KeyCode::Char('/'))); + assert!( + open_cmds.is_empty(), + "opening the in-memory filter must not fetch: {open_cmds:?}" + ); + + let cmds = update(&mut model, key_event(KeyCode::Char('b'))); + + assert_eq!(visible(&model), vec![1], "the typed filter still relayouts"); + assert!( + cmds.is_empty(), + "typing must remain a pure in-memory operation: {cmds:?}" + ); +} + #[test] fn filter_matches_pr_number() { // tabbed_model PRs: index 0 is #10 "web pr", index 1 is #1 "legit pr". diff --git a/src/github/rest.rs b/src/github/rest.rs index c2d7710..773e022 100644 --- a/src/github/rest.rs +++ b/src/github/rest.rs @@ -54,8 +54,8 @@ pub struct PR { /// its Label Chip. The colour rides in on the existing label payload (no new /// request); it is optional because GitHub may leave it blank, in which case the /// chip falls back to a stable hashed colour. Mirrors the TS `PullRequestLabel` -/// (`{ name, color }`). Labels stay domain-inert — purely contextual metadata -/// with no sort, filter, or Smart-status effect. +/// (`{ name, color }`). Labels stay domain-inert — contextual metadata with no +/// sort or Smart-status effect — while their names remain searchable. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Label { pub name: String, From a9762e3e0bca18bf8fb68a737b8ad3a22746b856 Mon Sep 17 00:00:00 2001 From: Mayfield Date: Tue, 11 Aug 2026 13:05:32 -0400 Subject: [PATCH 2/3] docs(filter): note worktree-path fallback to loaded-enrichment match --- src/app/pr_list.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/pr_list.rs b/src/app/pr_list.rs index 8f5bdc0..6d6a882 100644 --- a/src/app/pr_list.rs +++ b/src/app/pr_list.rs @@ -67,8 +67,11 @@ 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. +/// (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 /// also matches with a leading `#` (`#42`). From ff1664cc258e84e368b763d637ee1bbc6be65afa Mon Sep 17 00:00:00 2001 From: Mayfield Date: Tue, 11 Aug 2026 13:05:32 -0400 Subject: [PATCH 3/3] fix(filter): fetch selected PR's files when the editor closes Typing keeps its per-keystroke fetch suppression, but Esc/Enter now run the just-in-time files fetch so the summary panel doesn't sit on the Loading placeholder until an unrelated keypress. --- src/app/update.rs | 9 ++++++++ src/app/update/tests/filter.rs | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/app/update.rs b/src/app/update.rs index de023c3..b6b866d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -948,6 +948,15 @@ fn apply(model: &mut Model, msg: Msg, now: DateTime) -> Vec { // -> FetchPRDetail), in which case dispatch it and stop. if model.list.filter().is_editing() { handle_filter_editing_key(model, key.code); + // Typing stays a pure in-memory operation — mid-edit + // selections are transient, so fetching per keystroke would + // spam requests. But Esc/Enter close the editor (Esc's + // relayout can also move the selection), landing on a PR + // whose files may never have been requested; fetch them once + // so the summary panel doesn't sit on its placeholder. + if !model.list.filter().is_editing() { + return maybe_fetch_selected_files(model); + } return Vec::new(); } else { let cmds = handle_list_key(model, key.code, now); diff --git a/src/app/update/tests/filter.rs b/src/app/update/tests/filter.rs index 63025eb..b125c18 100644 --- a/src/app/update/tests/filter.rs +++ b/src/app/update/tests/filter.rs @@ -182,6 +182,44 @@ fn typing_filter_text_never_fetches_enrichment() { ); } +#[test] +fn closing_the_editor_fetches_the_selected_prs_files() { + // Typing narrows the list and moves the follow-top selection with no + // fetches; closing the editor is when the landed-on PR's files load. + let mut model = enriched_model(&[1, 2]); + model.list.pr_mut(&key(1)).expect("PR 1").title = "alpha".to_owned(); + model.list.pr_mut(&key(2)).expect("PR 2").title = "beta".to_owned(); + model.relayout(); + + update(&mut model, key_event(KeyCode::Char('/'))); + type_filter(&mut model, "beta"); + + let cmds = update(&mut model, key_event(KeyCode::Enter)); + assert!( + matches!(cmds.as_slice(), [Cmd::FetchFiles { number: 2, .. }]), + "Enter fetches the filtered-to PR's files: {cmds:?}" + ); +} + +#[test] +fn esc_closing_the_editor_fetches_the_reselected_prs_files() { + let mut model = enriched_model(&[1, 2]); + model.list.pr_mut(&key(1)).expect("PR 1").title = "alpha".to_owned(); + model.list.pr_mut(&key(2)).expect("PR 2").title = "beta".to_owned(); + model.relayout(); + + update(&mut model, key_event(KeyCode::Char('/'))); + type_filter(&mut model, "beta"); + + // Esc restores the full list, which snaps the follow-top selection back + // to its top PR — the fetch must target that PR, not the filtered one. + let cmds = update(&mut model, key_event(KeyCode::Esc)); + assert!( + matches!(cmds.as_slice(), [Cmd::FetchFiles { number: 1, .. }]), + "Esc fetches the reselected PR's files: {cmds:?}" + ); +} + #[test] fn filter_matches_pr_number() { // tabbed_model PRs: index 0 is #10 "web pr", index 1 is #1 "legit pr".