src/pages/search.rs
//! Search — every axis the server's proto actually offers: name, namespace,
//! version, path, checksum, a repository restriction, and custom-property
//! filters (`PropertyMatch`). The URL is the search state: filters prefill
//! from it, a shared address auto-runs, and running a search IS navigating.
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::hooks::{use_navigate, use_query_map};
use crate::components::feedback::{EmptyState, ErrorText};
use crate::grpc;
use crate::util::{
artifact_href, display_name, fmt_bytes, parse_prop_param, search_href, search_href_from_chips,
SEARCH_AXES,
};
/// The active filters in the address, as (kind, raw value) — the axes in
/// `SEARCH_AXES` order, then one entry per `prop`. It is the chip row AND the
/// list each ✕ re-navigates without, so both are built from the same walk.
fn active_filters(q: &leptos_router::params::ParamsMap) -> Vec<(String, String)> {
let mut chips: Vec<(String, String)> = Vec::new();
for k in SEARCH_AXES {
if let Some(v) = q.get(k) {
if !v.is_empty() {
chips.push((k.to_string(), v));
}
}
}
for raw in q.get_all("prop").into_iter().flatten() {
chips.push(("prop".to_string(), raw));
}
chips
}
#[component]
pub fn Search() -> impl IntoView {
crate::webio::set_title("Search");
let query = use_query_map();
let navigate = use_navigate();
let name = RwSignal::new(String::new());
let namespace = RwSignal::new(String::new());
let version = RwSignal::new(String::new());
let path_axis = RwSignal::new(String::new());
let checksum = RwSignal::new(String::new());
let repo_filter = RwSignal::new(String::new());
// Property filters as (key, value) rows; empty value = "key present".
let prop_filters: RwSignal<Vec<(String, String)>> = RwSignal::new(Vec::new());
let results: RwSignal<Option<grpc::pb::SearchResponse>> = RwSignal::new(None);
let error: RwSignal<Option<String>> = RwSignal::new(None);
let loading = RwSignal::new(false);
// Monotone generation: rapid back/forward can leave several searches in
// flight, and gRPC-web has no cancellation — so stale responses are
// IGNORED instead (only the newest generation may write the signals).
let generation: StoredValue<u64> = StoredValue::new(0);
let repos = LocalResource::new(|| async {
grpc::admin()
.list_repositories(grpc::pb::ListRepositoriesRequest {})
.await
.map(|r| {
r.into_inner()
.repositories
.into_iter()
.map(|r| r.name)
.collect::<Vec<_>>()
})
.map_err(|s| grpc::status_text(&s))
});
// The URL is the search state: prefill the axes from it and run whenever
// it changes — /search?name=firmware shared cold runs by itself.
Effect::new(move |_| {
let q = query.read();
let vals = [
q.get("name").unwrap_or_default(),
q.get("namespace").unwrap_or_default(),
q.get("version").unwrap_or_default(),
q.get("path").unwrap_or_default(),
q.get("checksum").unwrap_or_default(),
q.get("repo").unwrap_or_default(),
];
let props: Vec<(String, String)> = q
.get_all("prop")
.into_iter()
.flatten()
.map(|raw| parse_prop_param(&raw))
.collect();
name.set(vals[0].clone());
namespace.set(vals[1].clone());
version.set(vals[2].clone());
path_axis.set(vals[3].clone());
checksum.set(vals[4].clone());
repo_filter.set(vals[5].clone());
prop_filters.set(props.clone());
// Bump FIRST: clearing the filters must also invalidate whatever is
// in flight, or a response from the previous query lands afterwards
// and shows results under an empty search.
let my_gen = generation.with_value(|g| g + 1);
generation.set_value(my_gen);
if vals.iter().all(|v| v.is_empty()) && props.is_empty() {
results.set(None);
error.set(None);
loading.set(false);
return;
}
let [n, ns, v, p, c, repo] = vals;
loading.set(true);
error.set(None);
spawn_local(async move {
let res = grpc::search()
.search(grpc::pb::SearchRequest {
name: n,
namespace: ns,
version: v,
checksum: c,
path: p,
repositories: if repo.is_empty() { vec![] } else { vec![repo] },
limit: 200,
properties: props
.into_iter()
.map(|(key, value)| grpc::pb::PropertyMatch { key, value })
.collect(),
})
.await;
if generation.get_value() != my_gen {
return; // a newer search superseded this one
}
loading.set(false);
match res {
Ok(r) => results.set(Some(r.into_inner())),
Err(s) => {
// A refused search has no hits. Keeping the previous
// query's tables would present them as THIS query's answer
// — undimmed once loading ends, under the new query's
// chips and its own row count. Same rule as the browse
// listing in repository_detail.
results.set(None);
error.set(Some(grpc::status_text(&s)));
}
}
});
});
// Running a search = navigating to its address.
let run = move || {
let axes: Vec<(&str, String)> = SEARCH_AXES
.iter()
.zip([name, namespace, version, path_axis, checksum, repo_filter])
.map(|(k, sig)| (*k, sig.get_untracked()))
.collect();
navigate(
&search_href(&axes, &prop_filters.get_untracked()),
Default::default(),
);
};
let run_click = run.clone();
let reset = {
let navigate = use_navigate();
move |_| navigate("/search", Default::default())
};
let axis = move |label: &'static str, placeholder: &'static str, sig: RwSignal<String>| {
let run = run.clone();
view! {
<label class="axis">
<span>{label}</span>
<input
type="text"
placeholder=placeholder
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
on:keydown=move |ev| { if ev.key() == "Enter" { run(); } }
/>
</label>
}
};
// Active filters as removable chips — one glance says what constrained the
// result, and each ✕ re-navigates without that constraint.
let active_chips = move || active_filters(&query.read());
let remove_chip = {
let navigate = use_navigate();
move |kind: String, value: String| {
let kept: Vec<(String, String)> = active_filters(&query.read_untracked())
.into_iter()
.filter(|(k, v)| !(*k == kind && *v == value))
.collect();
navigate(&search_href_from_chips(&kept), Default::default());
}
};
view! {
<section class="page">
<crate::pages::repositories::RepoTabs/>
<h2>"Search"</h2>
<div class="axes">
{axis("Name", "substring, e.g. firmware", name)}
{axis("Namespace", "substring (groupId / scope)", namespace)}
{axis("Version", "exact, e.g. 2.0.0", version)}
{axis("Path", "archive path substring", path_axis)}
{axis("Checksum", "sha256 hex — expensive, hashes candidates", checksum)}
<label class="axis">
<span>"Repository"</span>
{move || match repos.get() {
Some(Ok(names)) => view! {
<select
prop:value=move || repo_filter.get()
on:change=move |ev| repo_filter.set(event_target_value(&ev))>
<option value="">"— all repositories —"</option>
{names.into_iter().map(|n| { let val = n.clone(); view! { <option value=val>{n}</option> } }).collect_view()}
</select>
}.into_any(),
_ => view! { <span class="pending">"…"</span> }.into_any(),
}}
</label>
</div>
<h3>"Property filters"</h3>
<p class="count measurenote">
"AND-composed: an artifact matches only if it carries every listed property. \
Empty value = the key is present with any value."
</p>
{move || prop_filters.get().into_iter().enumerate().map(|(i, (k, v))| {
view! {
<div class="attach">
<input type="text" placeholder="key" prop:value=k.clone()
on:input=move |ev| prop_filters.update(|p| { if let Some(r) = p.get_mut(i) { r.0 = event_target_value(&ev); } })/>
<input type="text" placeholder="value (empty = any)" prop:value=v.clone()
on:input=move |ev| prop_filters.update(|p| { if let Some(r) = p.get_mut(i) { r.1 = event_target_value(&ev); } })/>
<button class="danger" on:click=move |_| prop_filters.update(|p| { p.remove(i); })>
"Remove"
</button>
</div>
}
}).collect_view()}
<div class="toolbar">
<button class="quiet" on:click=move |_| prop_filters.update(|p| p.push((String::new(), String::new())))>
"+ property filter"
</button>
</div>
<div class="toolbar">
<button on:click=move |_| run_click()>"Search"</button>
<button class="quiet" on:click=reset>"Reset filters"</button>
{move || loading.get().then(|| view! { <span class="pending" role="status">"searching…"</span> })}
</div>
{move || {
let chips = active_chips();
(!chips.is_empty()).then(|| {
let remove_chip = remove_chip.clone();
view! {
<p class="chips">
<span class="count">"active: "</span>
{chips.into_iter().map(|(k, v)| {
let remove = {
let remove_chip = remove_chip.clone();
let k = k.clone(); let v = v.clone();
move |_| remove_chip(k.clone(), v.clone())
};
view! {
<span class="chip">
{format!("{k}: {v} ")}
<button class="chip-x" aria-label=format!("remove filter {k}") on:click=remove>"✕"</button>
</span>
}
}).collect_view()}
</p>
}
})
}}
// A failed CALL and an empty RESULT are different facts — the
// error renders red and alone; empty results render per-kind.
{move || error.get().map(|e| view! { <ErrorText context="search failed" message=e/> })}
{move || results.get().map(|r| {
let truncated = r.truncated;
let artifacts = r.artifacts;
let paths = r.paths;
view! {
{truncated.then(|| view! {
<p class="banner banner-warn" role="alert">"result capped at the limit — more hits exist, narrow the query"</p>
})}
<h3>{format!("Artifacts ({})", artifacts.len())}</h3>
{if artifacts.is_empty() {
view! { <EmptyState message="no artifact hits"/> }.into_any()
} else {
view! {
<div class="tablewrap" class:stale=move || loading.get()
aria-busy=move || loading.get().to_string()>
<table>
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col">"Artifact"</th>
<th scope="col">"Version"</th>
<th scope="col">"Size"</th>
<th scope="col">"Checksum"</th>
</tr>
</thead>
<tbody>
{artifacts.into_iter().map(|h| {
let id = h.id.unwrap_or_default();
let full_name = display_name(&id.namespace, &id.name);
let href = artifact_href(&h.repository, &id.namespace, &id.name, &id.version);
view! {
<tr>
<td>{h.repository.clone()}</td>
<td><a href=href>{full_name}</a></td>
<td>{id.version.clone()}</td>
<td class="num">{fmt_bytes(h.size_bytes)}</td>
<td class="mono">{if h.checksum.is_empty() { "—".into() } else { h.checksum }}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}}
<h3>{format!("Paths ({})", paths.len())}</h3>
{if paths.is_empty() {
view! { <EmptyState message="no path hits"/> }.into_any()
} else {
view! {
<div class="tablewrap" class:stale=move || loading.get()
aria-busy=move || loading.get().to_string()>
<table>
<thead>
<tr><th scope="col">"Repository"</th><th scope="col">"Path"</th></tr>
</thead>
<tbody>
{paths.into_iter().map(|p| view! {
<tr><td>{p.repository}</td><td class="mono">{p.path}</td></tr>
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}}
}
})}
</section>
}
}