Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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>
    }
}