crates/cli/ents-web/src/pages/search.rs
search.rshistorycomment on this file
| 1 | //! `GET /search`: `super::layout`'s nav search form's target -- a plain |
| 2 | //! request-time substring scan over the served repository, deliberately |
| 3 | //! with no index and no new state (a design decision this crate settled |
| 4 | //! on rather than relitigated here): every request re-walks the `HEAD` |
| 5 | //! tree and the meta-ref listings the pages that already own them use. |
| 6 | //! Renders with no tab active at all (`super::Tab::None`), like |
| 7 | //! [`super::account`], since it is reached from the nav search form |
| 8 | //! rather than any tab. |
| 9 | |
| 10 | use std::sync::Arc; |
| 11 | |
| 12 | use axum::extract::{Query, State}; |
| 13 | use ents_kiln::toolchain; |
| 14 | use gix::bstr::ByteSlice as _; |
| 15 | use gix_object::{Find, Write}; |
| 16 | use maud::{Markup, html}; |
| 17 | use serde::Deserialize; |
| 18 | |
| 19 | use crate::error::Result; |
| 20 | use crate::state::AppState; |
| 21 | |
| 22 | /// The largest number of matches shown per result group -- past it, a |
| 23 | /// "more matches not shown" note replaces the rest rather than rendering |
| 24 | /// an unbounded page. |
| 25 | const MAX_RESULTS: usize = 100; |
| 26 | |
| 27 | /// The query parameters `GET /search` accepts. |
| 28 | #[derive(Debug, Deserialize)] |
| 29 | pub struct SearchQuery { |
| 30 | /// The search term. Empty (the default, and what a bare `GET /search` |
| 31 | /// carries) renders a "type to search" blankslate rather than a |
| 32 | /// no-matches one -- nothing was searched yet, so nothing "failed to |
| 33 | /// match" (see [`blankslate`]'s own doc). |
| 34 | #[serde(default)] |
| 35 | q: String, |
| 36 | } |
| 37 | |
| 38 | /// `GET /search`: grouped, case-insensitive substring matches -- file |
| 39 | /// paths from the `HEAD` tree (linking into `crate::pages::files`) and |
| 40 | /// meta entity names (member usernames, effect names, toolchain names, |
| 41 | /// linking into their own show pages) -- or a blankslate on an empty |
| 42 | /// query or no matches. |
| 43 | /// |
| 44 | /// # Errors |
| 45 | /// |
| 46 | /// Propagates a ref-store read failure. |
| 47 | pub async fn show<O>( |
| 48 | State(state): State<Arc<AppState<O>>>, |
| 49 | Query(params): Query<SearchQuery>, |
| 50 | ) -> Result<Markup> |
| 51 | where |
| 52 | O: Find + Write + Send + 'static, |
| 53 | { |
| 54 | let query = params.q.trim().to_owned(); |
| 55 | let (files, files_more) = search_files(&state, &query); |
| 56 | let (members, members_more) = meta_names(&state, "refs/meta/member/", &query)?; |
| 57 | let (effects, effects_more) = meta_names(&state, "refs/meta/effects/", &query)?; |
| 58 | let (toolchains, toolchains_more) = search_toolchains(&state, &query)?; |
| 59 | |
| 60 | let any_matches = |
| 61 | !files.is_empty() || !members.is_empty() || !effects.is_empty() || !toolchains.is_empty(); |
| 62 | |
| 63 | Ok(super::layout( |
| 64 | &super::RepoHeader::from_state(&state), |
| 65 | &super::identity_label(&state), |
| 66 | super::Tab::None, |
| 67 | "Search", |
| 68 | html! { |
| 69 | @if !any_matches { |
| 70 | (blankslate(&query)) |
| 71 | } @else { |
| 72 | (result_group("files", &files, files_more, |path| format!("/files/{path}"))) |
| 73 | (result_group("members", &members, members_more, |id| format!("/members/{id}"))) |
| 74 | (result_group("effects", &effects, effects_more, |id| format!("/effects/{id}"))) |
| 75 | (result_group( |
| 76 | "toolchains", |
| 77 | &toolchains, |
| 78 | toolchains_more, |
| 79 | |id| format!("/toolchains/{id}"), |
| 80 | )) |
| 81 | } |
| 82 | }, |
| 83 | )) |
| 84 | } |
| 85 | |
| 86 | /// Truncate `items` to [`MAX_RESULTS`], reporting whether anything was cut. |
| 87 | fn cap(mut items: Vec<String>) -> (Vec<String>, bool) { |
| 88 | if items.len() > MAX_RESULTS { |
| 89 | items.truncate(MAX_RESULTS); |
| 90 | (items, true) |
| 91 | } else { |
| 92 | (items, false) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /// File paths under the `HEAD` tree whose path contains `query` |
| 97 | /// (case-insensitive), capped via [`cap`]. Empty on an empty `query` -- |
| 98 | /// no walk is attempted at all, matching every other group. Best-effort: |
| 99 | /// an unopenable repository or unborn `HEAD` degrade to no file matches |
| 100 | /// rather than an error, exactly as `crate::pages::files`/`crate::pages::dashboard` |
| 101 | /// degrade the same reads. |
| 102 | fn search_files<O>(state: &AppState<O>, query: &str) -> (Vec<String>, bool) { |
| 103 | if query.is_empty() { |
| 104 | return (Vec::new(), false); |
| 105 | } |
| 106 | let Ok(repo) = gix::open(&state.path) else { |
| 107 | return (Vec::new(), false); |
| 108 | }; |
| 109 | let Ok(tree) = repo.head_tree() else { |
| 110 | return (Vec::new(), false); |
| 111 | }; |
| 112 | let mut paths = Vec::new(); |
| 113 | collect_paths(&repo, &tree, "", &mut paths); |
| 114 | let needle = query.to_lowercase(); |
| 115 | cap(paths |
| 116 | .into_iter() |
| 117 | .filter(|path| path.to_lowercase().contains(&needle)) |
| 118 | .collect()) |
| 119 | } |
| 120 | |
| 121 | /// Recurse `tree`, pushing every blob's full slash-joined path (relative |
| 122 | /// to the `HEAD` root) onto `out` -- the same walk |
| 123 | /// `crate::pages::dashboard::collect_blobs` performs for the language |
| 124 | /// breakdown, here collecting paths instead of `(name, oid)` pairs. |
| 125 | /// Subtree reads that fail are skipped rather than propagated, matching |
| 126 | /// that same best-effort stance. |
| 127 | fn collect_paths( |
| 128 | repo: &gix::Repository, |
| 129 | tree: &gix::Tree<'_>, |
| 130 | prefix: &str, |
| 131 | out: &mut Vec<String>, |
| 132 | ) { |
| 133 | for entry in tree.iter() { |
| 134 | let Ok(entry) = entry else { continue }; |
| 135 | let name = entry.filename().to_str_lossy(); |
| 136 | let path = if prefix.is_empty() { |
| 137 | name.into_owned() |
| 138 | } else { |
| 139 | format!("{prefix}/{name}") |
| 140 | }; |
| 141 | if entry.mode().is_tree() { |
| 142 | if let Ok(object) = repo.find_object(entry.oid().to_owned()) |
| 143 | && let Ok(subtree) = object.try_into_tree() |
| 144 | { |
| 145 | collect_paths(repo, &subtree, &path, out); |
| 146 | } |
| 147 | } else if entry.mode().is_blob() { |
| 148 | out.push(path); |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | /// The ids of every ref directly under `prefix` (a meta-ref namespace, |
| 154 | /// e.g. `refs/meta/member/`) whose id contains `query` (case-insensitive), |
| 155 | /// capped via [`cap`] -- the same `state.refs.iter_prefix` listing |
| 156 | /// `crate::pages::members`/`crate::pages::effects` read their own rows |
| 157 | /// from, here matched against `query` instead of fully deserialized. |
| 158 | /// |
| 159 | /// # Errors |
| 160 | /// |
| 161 | /// Propagates a ref-store read failure. |
| 162 | fn meta_names<O>(state: &AppState<O>, prefix: &str, query: &str) -> Result<(Vec<String>, bool)> { |
| 163 | if query.is_empty() { |
| 164 | return Ok((Vec::new(), false)); |
| 165 | } |
| 166 | let needle = query.to_lowercase(); |
| 167 | let mut names = Vec::new(); |
| 168 | for entry in state.refs.iter_prefix(prefix)? { |
| 169 | let (name, _) = entry?; |
| 170 | let path = name.as_bstr().to_string(); |
| 171 | if let Some(id) = path.strip_prefix(prefix) |
| 172 | && id.to_lowercase().contains(&needle) |
| 173 | { |
| 174 | names.push(id.to_owned()); |
| 175 | } |
| 176 | } |
| 177 | Ok(cap(names)) |
| 178 | } |
| 179 | |
| 180 | /// Toolchain names containing `query` (case-insensitive), capped via |
| 181 | /// [`cap`] -- reads through the same [`toolchain::list`] |
| 182 | /// `crate::pages::toolchains::list` itself calls. |
| 183 | /// |
| 184 | /// # Errors |
| 185 | /// |
| 186 | /// Propagates a ref-store read failure. |
| 187 | fn search_toolchains<O>(state: &AppState<O>, query: &str) -> Result<(Vec<String>, bool)> { |
| 188 | if query.is_empty() { |
| 189 | return Ok((Vec::new(), false)); |
| 190 | } |
| 191 | let needle = query.to_lowercase(); |
| 192 | let names = toolchain::list(state.refs.as_ref())? |
| 193 | .into_iter() |
| 194 | .filter(|name| name.to_lowercase().contains(&needle)) |
| 195 | .collect(); |
| 196 | Ok(cap(names)) |
| 197 | } |
| 198 | |
| 199 | /// One result group's card: `label` as its header, `rows` linked via |
| 200 | /// `href_for`, and a trailing "more matches not shown" row when `rows` |
| 201 | /// was capped. Renders nothing at all when `rows` is empty, so an |
| 202 | /// unmatched group leaves no empty card behind. |
| 203 | fn result_group( |
| 204 | label: &str, |
| 205 | rows: &[String], |
| 206 | truncated: bool, |
| 207 | href_for: impl Fn(&str) -> String, |
| 208 | ) -> Markup { |
| 209 | if rows.is_empty() { |
| 210 | return html! {}; |
| 211 | } |
| 212 | html! { |
| 213 | div.card { |
| 214 | div.card-header { (label) } |
| 215 | ul.string-list { |
| 216 | @for row in rows { |
| 217 | li { a href=(href_for(row)) { (row) } } |
| 218 | } |
| 219 | } |
| 220 | @if truncated { |
| 221 | div.card-row.muted { "More matches not shown." } |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /// The empty-results placeholder ([`super::blankslate`]): a "type to |
| 228 | /// search" prompt before any query has been typed at all (naming the |
| 229 | /// header's own "Jump to file or symbol" search input, this page's only |
| 230 | /// entry point), or a "no matches" note for a non-empty query that found |
| 231 | /// nothing. |
| 232 | fn blankslate(query: &str) -> Markup { |
| 233 | if query.is_empty() { |
| 234 | super::blankslate( |
| 235 | "Type to search", |
| 236 | html! { |
| 237 | "Use the header's \u{201c}Jump to file or symbol\u{201d} search " |
| 238 | "to look through files and meta entities." |
| 239 | }, |
| 240 | ) |
| 241 | } else { |
| 242 | super::blankslate( |
| 243 | "No matches", |
| 244 | html! { "Nothing matched " code { (query) } "." }, |
| 245 | ) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | #[cfg(test)] |
| 250 | mod tests { |
| 251 | #![allow(clippy::expect_used, reason = "unit test")] |
| 252 | |
| 253 | use super::*; |
| 254 | |
| 255 | #[test] |
| 256 | fn cap_truncates_and_reports_when_it_cut_something() { |
| 257 | let (kept, truncated) = cap((0..150).map(|n| n.to_string()).collect()); |
| 258 | assert_eq!(kept.len(), MAX_RESULTS); |
| 259 | assert!(truncated); |
| 260 | |
| 261 | let (kept, truncated) = cap(vec!["a".to_owned(), "b".to_owned()]); |
| 262 | assert_eq!(kept.len(), 2); |
| 263 | assert!(!truncated); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn result_group_renders_nothing_for_an_empty_group() { |
| 268 | let rendered = result_group("files", &[], false, |row| row.to_owned()).into_string(); |
| 269 | assert!(rendered.is_empty()); |
| 270 | } |
| 271 | } |