roots: wire the nav search box to a request-time scan
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
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/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());
+ }
+}