crates/gunnar-ui-web/src/pages/search.rs
//! Code search — a literal string across the LATEST revision of a repository.
//!
//! gunnar answers `/api/repos/{ns}/{name}/search` by running its own search
//! walk (`Refs.Search`) over one revision's files: the default branch unless
//! `rev` says otherwise. It is deliberately not a history search — "where is
//! this string now" — so the answer is bounded and cheap. Each hit is a file
//! and a one-based line, and the page links each to that line's blame so a
//! search flows straight into "who wrote this".
use leptos::prelude::*;
use leptos_router::hooks::{use_params_map, use_query_map};
use serde::Deserialize;
use crate::api::segment;
use crate::pages::resolved;
use crate::ui::{PageHeader, Panel};
/// One matching line.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Match {
pub path: String,
pub line: u32,
pub text: String,
pub binary: bool,
}
/// A search result.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Results {
pub rev: String,
pub pattern: String,
pub matches: Vec<Match>,
pub truncated: bool,
}
#[component]
pub fn SearchView() -> impl IntoView {
let params = use_params_map();
let query = use_query_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
let q = Memo::new(move |_| query.read().get("q").unwrap_or_default());
let rev = Memo::new(move |_| query.read().get("rev").unwrap_or_default());
let typed_q = RwSignal::new(String::new());
let typed_rev = RwSignal::new(String::new());
Effect::new(move |_| typed_q.set(q.get()));
Effect::new(move |_| typed_rev.set(rev.get()));
let navigate = leptos_router::hooks::use_navigate();
let go = move || {
let target = format!(
"/{}/{}/search?q={}&rev={}",
segment(&namespace.get_untracked()),
segment(&name.get_untracked()),
crate::auth::percent_encode(typed_q.get_untracked().trim()),
crate::auth::percent_encode(typed_rev.get_untracked().trim()),
);
navigate(&target, Default::default());
};
let go_click = go.clone();
let results = LocalResource::new(move || {
let ns = segment(&namespace.get());
let nm = segment(&name.get());
let q = q.get();
let rev = rev.get();
async move {
if q.trim().is_empty() {
return None;
}
let url = format!(
"/api/repos/{}/{}/search?q={}&rev={}",
ns,
nm,
crate::auth::percent_encode(&q),
crate::auth::percent_encode(&rev),
);
Some(crate::api::get::<Results>(&url).await)
}
});
let ns_for_row = namespace;
let name_for_row = name;
view! {
<PageHeader
title=Signal::derive(move || format!("{}/{}", namespace.get(), name.get()))
/>
<crate::pages::repo::RepoTabs/>
<div class="stack">
<Panel title="Search">
<div class="panel-body row" style="align-items:flex-end">
<div class="field" style="flex:1">
<label for="q">"Text"</label>
<input
id="q"
type="text"
placeholder="a literal string, not a regex"
prop:value=move || typed_q.get()
on:input=move |ev| typed_q.set(event_target_value(&ev))
on:keydown={
let go = go.clone();
move |ev: leptos::ev::KeyboardEvent| {
if ev.key() == "Enter" {
go();
}
}
}
/>
</div>
<div class="field">
<label for="rev">"Revision"</label>
<input
id="rev"
type="text"
placeholder="HEAD (default)"
prop:value=move || typed_rev.get()
on:input=move |ev| typed_rev.set(event_target_value(&ev))
/>
</div>
<button class="btn primary" on:click=move |_| go_click()>"Search"</button>
</div>
</Panel>
{move || match results.get().flatten() {
None => view! {
<Panel title="Nothing searched yet">
<p class="panel-body truncation">
"Type a literal string above to find it in the latest revision."
</p>
</Panel>
}.into_any(),
some => resolved(some, None, move |results: Results| {
let ns = segment(&ns_for_row.get_untracked());
let nm = segment(&name_for_row.get_untracked());
let rev = results.rev.clone();
let heading = if results.matches.is_empty() {
format!("No line matches {:?}", results.pattern)
} else {
format!("{} line(s) match {:?}", results.matches.len(), results.pattern)
};
view! {
<Panel title=heading>
{results.truncated.then(|| view! {
<p class="panel-body truncation">
"Gunnar stopped at its match ceiling — this is not the whole \
answer. Narrow the search."
</p>
})}
<div class="table-wrap">
<table class="search">
<caption class="vh">"Search results"</caption>
<thead>
<tr>
<th scope="col">"File"</th>
<th scope="col">"Line"</th>
<th scope="col">"Match"</th>
</tr>
</thead>
<tbody>
{results.matches.into_iter().map(|m| {
let blame = format!(
"/{}/{}/blame?rev={}&path={}",
ns, nm,
crate::auth::percent_encode(&rev),
crate::auth::percent_encode(&m.path),
);
view! {
<tr
data-search-path=m.path.clone()
data-search-line=m.line.to_string()
>
<td><a href=blame><code>{m.path.clone()}</code></a></td>
<td class="num">{m.line}</td>
<td>
{if m.binary {
view! { <em class="truncation">"binary"</em> }.into_any()
} else {
view! { <code class="src">{m.text}</code> }.into_any()
}}
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
</Panel>
}
})
}}
</div>
}
}