git-ents.gitmain
⌘K
foforge
commit 092d0ca
roots: wire the nav search box to a request-time scan

The nav search input was a disabled stub since the four-tab restructure landed. GET /search wires it up as a plain, no-index substring scan — the same design already settled on for this crate: no new state, no new abstraction. It groups matches into files (a case-insensitive walk of the HEAD tree, same pattern as the language breakdown) and meta entity names (member usernames, effect names, toolchain names, off the same ref listings and toolchain::list their own pages read), each capped at 100 with a "more matches not shown" note, and renders with no tab active, like account. The nav search input becomes a real GET form targeting it, dropping the disabled/title attributes and their now-dead CSS hook.

roots: add GET /search as a request-time substring scan over files and meta entities roots: replace the disabled nav search stub with a real GET form Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/router.rs @@ -55,6 +55,7 @@ .route("/meta", get(pages::meta::show::<O>)) .route("/redactions", get(pages::redactions::list::<O>)) .route("/redactions/{id}", get(pages::redactions::show::<O>)) + .route("/search", get(pages::search::show::<O>)) .route("/toolchains", get(pages::toolchains::list::<O>)) .route("/toolchains/{name}", get(pages::toolchains::show::<O>)) .route(
crates/cli/ents-web/tests/router.rs @@ -225,8 +225,8 @@ let body = String::from_utf8(body.to_vec()).expect("utf8 html"); assert!(body.contains("members")); assert!(body.contains("toolchains")); - // The shell chrome renders on every page: the disabled search stub in - // the top nav and the repo-header breadcrumb band above the tabs. + // The shell chrome renders on every page: the nav search form in the + // top nav and the repo-header breadcrumb band above the tabs. assert!(body.contains("nav-search")); assert!(body.contains("Jump to file or symbol")); assert!(body.contains("repo-header")); @@ -956,3 +956,65 @@ .expect("in-process call"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } + +/// `GET /search?q=` finds a known fixture file path, linking into its +/// `/files/...` blob view. +#[tokio::test] +async fn search_finds_a_known_fixture_file_path() { + let dir = seed_repo(&[("src/needle.rs", "fn main() {}\n")]); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state); + + let response = router + .oneshot( + Request::get("/search?q=needle") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 html"); + assert!(body.contains("/files/src/needle.rs")); +} + +/// `GET /search` with no query renders a friendly blankslate rather than +/// an empty or error page. +#[tokio::test] +async fn search_with_no_query_renders_a_blankslate() { + let state = build_state(FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }); + let router = ents_web::router(state); + + let response = router + .oneshot( + Request::get("/search") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 html"); + assert!(body.contains("No matches")); +}
crates/cli/ents-web/src/assets/ents.css @@ -101,8 +101,6 @@ .nav-search .icon { position: absolute; left: .65rem; color: var(--color-text-muted); pointer-events: none; } .nav-search input { width: 100%; font-family: var(--font-sans); font-size: .82rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem .42rem 2rem; transition: border-color .15s; } .nav-search input:focus { border-color: var(--color-accent); } -.nav-search:has(input:disabled) { opacity: .55; } -.nav-search:has(input:disabled) input { opacity: 1; } .id-chip { font-family: var(--font-mono); font-size: .78rem; font-weight: 600; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .25rem .75rem; white-space: nowrap; text-decoration: none; transition: border-color .15s, color .15s; } .id-chip:hover { color: var(--color-accent); border-color: var(--color-accent); text-decoration: none; }
crates/cli/ents-web/src/pages/mod.rs @@ -15,7 +15,9 @@ //! (see [`Tab`]'s own doc); [`meta`] is that group's `GET /meta` landing //! page. [`commits`] is a view of the code, not a tab of its own -- both //! its routes render with [`Tab::Files`] active, reached from -//! [`files`]'s own "history" link. +//! [`files`]'s own "history" link. [`search`] renders with no tab active +//! at all, like [`account`]; it is reached from [`layout`]'s own nav +//! search form rather than any tab. pub mod account; pub mod comments; @@ -27,6 +29,7 @@ pub mod members; pub mod meta; pub mod redactions; +pub mod search; pub mod toolchains; use gix::bstr::ByteSlice as _; @@ -72,7 +75,9 @@ /// nine equal tabs did not scale as page families grew. `Account` matches /// none of [`layout`]'s tab-strip arms, so it highlights nothing there; /// its own link lives in the header's `.id-chip` instead (see [`layout`]'s -/// own doc). +/// own doc). `None` is the same "highlights nothing" case for a page that +/// is not part of any tab's own section at all ([`super::search`]'s +/// results page). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Tab { Overview, @@ -80,6 +85,7 @@ Comments, Meta, Account, + None, } /// One entry in the `meta` tab's registry: a page family reachable from @@ -200,9 +206,9 @@ nav.site-nav { div.nav-inner { a.nav-logo href="/" { span.nav-mark { "✳" } "git-ents" } - div.nav-search { + form.nav-search method="get" action="/search" { (crate::assets::icon_search()) - input type="search" placeholder="Jump to file or symbol" aria-label="Search" disabled title="Not available yet"; + input type="search" name="q" placeholder="Jump to file or symbol" aria-label="Search"; } a.id-chip href="/account" { (identity) } }
crates/cli/ents-web/src/pages/search.rs @@ -1,0 +1,265 @@ +//! `GET /search`: [`super::layout`]'s nav search form's target -- a plain +//! request-time substring scan over the served repository, deliberately +//! with no index and no new state (a design decision this crate settled +//! on rather than relitigated here): every request re-walks the `HEAD` +//! tree and the meta-ref listings the pages that already own them use. +//! Renders with no tab active at all ([`super::Tab::None`]), like +//! [`super::account`], since it is reached from the nav search form +//! rather than any tab. + +use std::sync::Arc; + +use axum::extract::{Query, State}; +use ents_kiln::toolchain; +use gix::bstr::ByteSlice as _; +use gix_object::{Find, Write}; +use maud::{Markup, html}; +use serde::Deserialize; + +use crate::error::Result; +use crate::state::AppState; + +/// The largest number of matches shown per result group -- past it, a +/// "more matches not shown" note replaces the rest rather than rendering +/// an unbounded page. +const MAX_RESULTS: usize = 100; + +/// The query parameters `GET /search` accepts. +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + /// The search term. Empty (the default, and what a bare `GET /search` + /// carries) renders the same friendly blankslate as a query with no + /// matches. + #[serde(default)] + q: String, +} + +/// `GET /search`: grouped, case-insensitive substring matches -- file +/// paths from the `HEAD` tree (linking into `crate::pages::files`) and +/// meta entity names (member usernames, effect names, toolchain names, +/// linking into their own show pages) -- or a blankslate on an empty +/// query or no matches. +/// +/// # Errors +/// +/// Propagates a ref-store read failure. +pub async fn show<O>( + State(state): State<Arc<AppState<O>>>, + Query(params): Query<SearchQuery>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + let query = params.q.trim().to_owned(); + let (files, files_more) = search_files(&state, &query); + let (members, members_more) = meta_names(&state, "refs/meta/member/", &query)?; + let (effects, effects_more) = meta_names(&state, "refs/meta/effects/", &query)?; + let (toolchains, toolchains_more) = search_toolchains(&state, &query)?; + + let any_matches = + !files.is_empty() || !members.is_empty() || !effects.is_empty() || !toolchains.is_empty(); + + Ok(super::layout( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::None, + "search", + html! { + @if !any_matches { + (blankslate(&query)) + } @else { + (result_group("files", &files, files_more, |path| format!("/files/{path}"))) + (result_group("members", &members, members_more, |id| format!("/members/{id}"))) + (result_group("effects", &effects, effects_more, |id| format!("/effects/{id}"))) + (result_group( + "toolchains", + &toolchains, + toolchains_more, + |id| format!("/toolchains/{id}"), + )) + } + }, + )) +} + +/// Truncate `items` to [`MAX_RESULTS`], reporting whether anything was cut. +fn cap(mut items: Vec<String>) -> (Vec<String>, bool) { + if items.len() > MAX_RESULTS { + items.truncate(MAX_RESULTS); + (items, true) + } else { + (items, false) + } +} + +/// File paths under the `HEAD` tree whose path contains `query` +/// (case-insensitive), capped via [`cap`]. Empty on an empty `query` -- +/// no walk is attempted at all, matching every other group. Best-effort: +/// an unopenable repository or unborn `HEAD` degrade to no file matches +/// rather than an error, exactly as `crate::pages::files`/`crate::pages::dashboard` +/// degrade the same reads. +fn search_files<O>(state: &AppState<O>, query: &str) -> (Vec<String>, bool) { + if query.is_empty() { + return (Vec::new(), false); + } + let Ok(repo) = gix::open(&state.path) else { + return (Vec::new(), false); + }; + let Ok(tree) = repo.head_tree() else { + return (Vec::new(), false); + }; + let mut paths = Vec::new(); + collect_paths(&repo, &tree, "", &mut paths); + let needle = query.to_lowercase(); + cap(paths + .into_iter() + .filter(|path| path.to_lowercase().contains(&needle)) + .collect()) +} + +/// Recurse `tree`, pushing every blob's full slash-joined path (relative +/// to the `HEAD` root) onto `out` -- the same walk +/// `crate::pages::dashboard::collect_blobs` performs for the language +/// breakdown, here collecting paths instead of `(name, oid)` pairs. +/// Subtree reads that fail are skipped rather than propagated, matching +/// that same best-effort stance. +fn collect_paths( + repo: &gix::Repository, + tree: &gix::Tree<'_>, + prefix: &str, + out: &mut Vec<String>, +) { + for entry in tree.iter() { + let Ok(entry) = entry else { continue }; + let name = entry.filename().to_str_lossy(); + let path = if prefix.is_empty() { + name.into_owned() + } else { + format!("{prefix}/{name}") + }; + if entry.mode().is_tree() { + if let Ok(object) = repo.find_object(entry.oid().to_owned()) + && let Ok(subtree) = object.try_into_tree() + { + collect_paths(repo, &subtree, &path, out); + } + } else if entry.mode().is_blob() { + out.push(path); + } + } +} + +/// The ids of every ref directly under `prefix` (a meta-ref namespace, +/// e.g. `refs/meta/member/`) whose id contains `query` (case-insensitive), +/// capped via [`cap`] -- the same `state.refs.iter_prefix` listing +/// `crate::pages::members`/`crate::pages::effects` read their own rows +/// from, here matched against `query` instead of fully deserialized. +/// +/// # Errors +/// +/// Propagates a ref-store read failure. +fn meta_names<O>(state: &AppState<O>, prefix: &str, query: &str) -> Result<(Vec<String>, bool)> { + if query.is_empty() { + return Ok((Vec::new(), false)); + } + let needle = query.to_lowercase(); + let mut names = Vec::new(); + for entry in state.refs.iter_prefix(prefix)? { + let (name, _) = entry?; + let path = name.as_bstr().to_string(); + if let Some(id) = path.strip_prefix(prefix) + && id.to_lowercase().contains(&needle) + { + names.push(id.to_owned()); + } + } + Ok(cap(names)) +} + +/// Toolchain names containing `query` (case-insensitive), capped via +/// [`cap`] -- reads through the same [`toolchain::list`] +/// `crate::pages::toolchains::list` itself calls. +/// +/// # Errors +/// +/// Propagates a ref-store read failure. +fn search_toolchains<O>(state: &AppState<O>, query: &str) -> Result<(Vec<String>, bool)> { + if query.is_empty() { + return Ok((Vec::new(), false)); + } + let needle = query.to_lowercase(); + let names = toolchain::list(state.refs.as_ref())? + .into_iter() + .filter(|name| name.to_lowercase().contains(&needle)) + .collect(); + Ok(cap(names)) +} + +/// One result group's card: `label` as its header, `rows` linked via +/// `href_for`, and a trailing "more matches not shown" row when `rows` +/// was capped. Renders nothing at all when `rows` is empty, so an +/// unmatched group leaves no empty card behind. +fn result_group( + label: &str, + rows: &[String], + truncated: bool, + href_for: impl Fn(&str) -> String, +) -> Markup { + if rows.is_empty() { + return html! {}; + } + html! { + div.card { + div.card-header { (label) } + ul.string-list { + @for row in rows { + li { a href=(href_for(row)) { (row) } } + } + } + @if truncated { + div.card-row.muted { "More matches not shown." } + } + } + } +} + +/// The empty-results placeholder, shown for an empty query and for a +/// non-empty one with no matches at all. +fn blankslate(query: &str) -> Markup { + html! { + div.card { + div.blankslate { + h2 { "No matches" } + @if query.is_empty() { + p { "Type a search term above to look through files and meta entities." } + } @else { + p { "Nothing matched " code { (query) } "." } + } + } + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use super::*; + + #[test] + fn cap_truncates_and_reports_when_it_cut_something() { + let (kept, truncated) = cap((0..150).map(|n| n.to_string()).collect()); + assert_eq!(kept.len(), MAX_RESULTS); + assert!(truncated); + + let (kept, truncated) = cap(vec!["a".to_owned(), "b".to_owned()]); + assert_eq!(kept.len(), 2); + assert!(!truncated); + } + + #[test] + fn result_group_renders_nothing_for_an_empty_group() { + let rendered = result_group("files", &[], false, |row| row.to_owned()).into_string(); + assert!(rendered.is_empty()); + } +}