branch: main
search.rs
15495 bytesRaw
use super::*;
struct SearchPage {
route_path: String,
owner: String,
repo_name: String,
default_branch: String,
raw_query: String,
effective_query: String,
requested_scope: String,
scope: String,
path_filter: Option<String>,
ext_filter: Option<String>,
scope_inferred: bool,
state: SearchPageState,
}
enum SearchPageState {
Idle,
Code {
results: Vec<store::CodeSearchResult>,
total_matches: usize,
},
Commits {
results: Vec<store::CommitSearchResult>,
},
}
impl SearchPage {
fn current_path(&self) -> String {
self.search_ui_path(&self.scope)
}
fn search_ui_path(&self, scope: &str) -> String {
build_query_path(
&self.route_path,
&[
(
"q",
(!self.raw_query.is_empty()).then_some(self.raw_query.as_str()),
),
("scope", Some(scope)),
],
)
}
fn json_search_path(&self) -> String {
build_query_path(
&format!("/{}/{}/search", self.owner, self.repo_name),
&[
(
"q",
(!self.raw_query.is_empty()).then_some(self.raw_query.as_str()),
),
("scope", Some(self.scope.as_str())),
],
)
}
fn commit_path(&self, hash: &str) -> String {
format!("/{}/{}/commit/{}", self.owner, self.repo_name, hash)
}
fn blob_path(&self, path: &str) -> String {
format!(
"/{}/{}/blob/{}/{}",
self.owner, self.repo_name, self.default_branch, path
)
}
fn blob_line_path(&self, path: &str, line_number: usize) -> String {
format!("{}#L{}", self.blob_path(path), line_number)
}
}
fn build_search_page(
sql: &SqlStorage,
owner: &str,
repo_name: &str,
url: &Url,
) -> Result<SearchPage> {
let raw_query = api::get_query(url, "q").unwrap_or_default();
let requested_scope = api::get_query(url, "scope").unwrap_or_else(|| "code".to_string());
let parsed = api::parse_search_query(&raw_query);
let effective_query = if parsed.fts_query.is_empty() {
raw_query.clone()
} else {
parsed.fts_query.clone()
};
let scope = parsed
.scope
.map(|value| value.to_string())
.unwrap_or_else(|| requested_scope.clone());
let scope_inferred = scope != requested_scope;
let (default_branch, _) = resolve_default_branch(sql)?;
let state = if raw_query.is_empty() && effective_query.is_empty() {
SearchPageState::Idle
} else if scope == "commits" {
SearchPageState::Commits {
results: store::search_commits(sql, &effective_query, 50)?,
}
} else {
let results = store::search_code(
sql,
&effective_query,
parsed.path_filter.as_deref(),
parsed.ext_filter.as_deref(),
50,
)?;
let total_matches = results.iter().map(|result| result.matches.len()).sum();
SearchPageState::Code {
results,
total_matches,
}
};
Ok(SearchPage {
route_path: url.path().to_string(),
owner: owner.to_string(),
repo_name: repo_name.to_string(),
default_branch,
raw_query,
effective_query,
requested_scope,
scope,
path_filter: parsed.path_filter,
ext_filter: parsed.ext_filter,
scope_inferred,
state,
})
}
fn render_search_html(page: &SearchPage, actor_name: Option<&str>) -> String {
let mut html = String::new();
html.push_str("<h1>Search</h1>");
let code_active = if page.scope == "code" {
" style=\"font-weight:700;text-decoration:underline\""
} else {
""
};
let commits_active = if page.scope == "commits" {
" style=\"font-weight:700;text-decoration:underline\""
} else {
""
};
html.push_str(&format!(
r#"<div style="margin-bottom:12px;display:flex;gap:16px">
<a href="{code_path}"{ca}>Code</a>
<a href="{commits_path}"{cc}>Commits</a>
</div>"#,
code_path = html_escape(&page.search_ui_path("code")),
commits_path = html_escape(&page.search_ui_path("commits")),
ca = code_active,
cc = commits_active,
));
html.push_str(&format!(
r#"<form class="search-form" action="/{owner}/{repo}/search-ui" method="get">
<input type="hidden" name="scope" value="{scope}">
<input type="text" name="q" value="{q}" placeholder="Search... (@author: @message: @path: @ext: @content:)">
<button type="submit">Search</button>
</form>"#,
owner = page.owner,
repo = page.repo_name,
scope = html_escape(&page.scope),
q = html_escape(&page.raw_query),
));
match &page.state {
SearchPageState::Idle => {}
SearchPageState::Commits { results } => {
if results.is_empty() {
html.push_str("<p>No matching commits found.</p>");
} else {
html.push_str(&format!(
"<p>{} commit{} found</p>",
results.len(),
if results.len() == 1 { "" } else { "s" }
));
html.push_str(r#"<ul class="commit-list">"#);
for commit in results {
html.push_str(&format!(
r#"<li class="commit-item">
<a class="commit-hash" href="/{owner}/{repo}/commit/{hash}">{short}</a>
<span class="commit-msg"><a href="/{owner}/{repo}/commit/{hash}">{msg}</a></span>
<span class="commit-author">{author}</span>
<span class="commit-time">{time}</span>
</li>"#,
owner = page.owner,
repo = page.repo_name,
hash = commit.hash,
short = &commit.hash[..7.min(commit.hash.len())],
msg = html_escape(&first_line(&commit.message)),
author = html_escape(&commit.author),
time = format_time(commit.commit_time),
));
}
html.push_str("</ul>");
}
}
SearchPageState::Code {
results,
total_matches,
} => {
if results.is_empty() {
html.push_str("<p>No results found.</p>");
} else {
html.push_str(&format!(
"<p>{} match{} across {} file{}</p>",
total_matches,
if *total_matches == 1 { "" } else { "es" },
results.len(),
if results.len() == 1 { "" } else { "s" },
));
for result in results {
html.push_str(r#"<div class="search-result">"#);
html.push_str(&format!(
r#"<div class="search-result-path"><a href="/{owner}/{repo}/blob/{branch}/{path}">{path}</a> ({n} match{s})</div>"#,
owner = page.owner,
repo = page.repo_name,
branch = page.default_branch,
path = html_escape(&result.path),
n = result.matches.len(),
s = if result.matches.len() == 1 { "" } else { "es" },
));
html.push_str(r#"<table class="diff-table" style="margin-top:4px">"#);
for item in &result.matches {
html.push_str(&format!(
r#"<tr class="diff-line-add"><td class="diff-ln"><a href="/{owner}/{repo}/blob/{branch}/{path}#L{ln}" style="color:#656d76">{ln}</a></td><td>{text}</td></tr>"#,
owner = page.owner,
repo = page.repo_name,
branch = page.default_branch,
path = html_escape(&result.path),
ln = item.line_number,
text = html_escape(&item.line_text),
));
}
html.push_str("</table>");
html.push_str("</div>");
}
}
}
}
layout(
"Search",
&page.owner,
&page.repo_name,
&page.default_branch,
actor_name,
&html,
)
}
fn render_search_markdown(page: &SearchPage, selection: &NegotiatedRepresentation) -> String {
let mut markdown = format!("# {}/{} search\n", page.owner, page.repo_name);
markdown.push_str(&format!(
"\nQuery: {}\nScope: `{}`\n",
markdown_value(&page.raw_query),
page.scope
));
if page.scope_inferred {
markdown.push_str(&format!("Requested scope: `{}`\n", page.requested_scope));
}
if !page.effective_query.is_empty() && page.effective_query != page.raw_query {
markdown.push_str(&format!(
"Effective search terms: {}\n",
markdown_value(&page.effective_query)
));
}
if let Some(path_filter) = &page.path_filter {
markdown.push_str(&format!("Path filter: {}\n", markdown_value(path_filter)));
}
if let Some(ext_filter) = &page.ext_filter {
markdown.push_str(&format!(
"Extension filter: {}\n",
markdown_value(ext_filter)
));
}
if page.scope == "code" {
markdown.push_str(&format!("Indexed branch: `{}`\n", page.default_branch));
}
markdown.push_str("\n## Scope Navigation (GET paths)\n");
markdown.push_str(&format!(
"- `code`{} - `{}`\n",
if page.scope == "code" {
" (current)"
} else {
""
},
page.search_ui_path("code")
));
markdown.push_str(&format!(
"- `commits`{} - `{}`\n",
if page.scope == "commits" {
" (current)"
} else {
""
},
page.search_ui_path("commits")
));
match &page.state {
SearchPageState::Idle => {
markdown.push_str("\nProvide `q=` to search code or commits.\n");
}
SearchPageState::Code {
results,
total_matches,
} => {
markdown.push_str(&format!(
"\nResults: `{}` match{} across `{}` file{}\n",
total_matches,
if *total_matches == 1 { "" } else { "es" },
results.len(),
if results.len() == 1 { "" } else { "s" },
));
markdown.push_str("\n## Code Results (GET paths)\n");
if results.is_empty() {
markdown.push_str("No code results found.\n");
} else {
for result in results {
markdown.push_str(&format!(
"- `{}` - {} match{} - `{}`\n",
result.path,
result.matches.len(),
if result.matches.len() == 1 { "" } else { "es" },
page.blob_path(&result.path)
));
for item in &result.matches {
markdown.push_str(&format!(
" - `L{}` - {} - `{}`\n",
item.line_number,
concise_line_text(&item.line_text),
page.blob_line_path(&result.path, item.line_number)
));
}
}
}
}
SearchPageState::Commits { results } => {
markdown.push_str(&format!(
"\nResults: `{}` commit{}\n",
results.len(),
if results.len() == 1 { "" } else { "s" }
));
markdown.push_str("\n## Commit Results (GET paths)\n");
if results.is_empty() {
markdown.push_str("No matching commits found.\n");
} else {
for commit in results {
markdown.push_str(&format!(
"- `{}` - {} - {} - {} - `{}`\n",
&commit.hash[..7.min(commit.hash.len())],
first_line(&commit.message),
commit.author,
format_time(commit.commit_time),
page.commit_path(&commit.hash)
));
}
}
}
}
let actions = vec![
Action::get(page.current_path(), "reload this search page"),
Action::get(page.search_ui_path("code"), "switch to code search"),
Action::get(page.search_ui_path("commits"), "switch to commit search"),
Action::get(
page.json_search_path(),
"fetch the structured JSON search endpoint",
),
];
let mut hints = vec![
presentation::text_navigation_hint(*selection),
Hint::new("Use `/search-ui` for the human-readable page and `/search` for structured JSON results."),
Hint::new("Supported filters: `@author:`, `@message:`, `@path:`, `@ext:`, and `@content:`."),
Hint::new("`@author:` and `@message:` imply commit scope; `@path:` and `@ext:` narrow code search results."),
];
if page.scope == "code" {
hints.push(Hint::new(format!(
"Code results come from the default branch index: `{}`.",
page.default_branch
)));
}
markdown.push_str(&presentation::render_actions_section(&actions));
markdown.push_str(&presentation::render_hints_section(&hints));
markdown
}
fn build_query_path(base_path: &str, params: &[(&str, Option<&str>)]) -> String {
let mut url = match Url::parse("https://ripgit.local/") {
Ok(url) => url,
Err(_) => return base_path.to_string(),
};
url.set_path(base_path);
{
let mut query = url.query_pairs_mut();
for (key, value) in params {
if let Some(value) = value {
query.append_pair(key, value);
}
}
}
match url.query() {
Some(query) if !query.is_empty() => format!("{}?{}", url.path(), query),
_ => url.path().to_string(),
}
}
fn markdown_value(value: &str) -> String {
if value.is_empty() {
"(empty)".to_string()
} else {
format!("`{}`", value)
}
}
fn concise_line_text(text: &str) -> String {
const MAX_CHARS: usize = 160;
if text.chars().count() <= MAX_CHARS {
return text.to_string();
}
let mut output = String::new();
for (idx, ch) in text.chars().enumerate() {
if idx >= MAX_CHARS - 3 {
break;
}
output.push(ch);
}
output.push_str("...");
output
}
pub fn page_search(
sql: &SqlStorage,
owner: &str,
repo_name: &str,
url: &Url,
actor_name: Option<&str>,
) -> Result<Response> {
let page = build_search_page(sql, owner, repo_name, url)?;
html_response(&render_search_html(&page, actor_name))
}
pub fn page_search_markdown(
sql: &SqlStorage,
owner: &str,
repo_name: &str,
url: &Url,
selection: &NegotiatedRepresentation,
) -> Result<Response> {
let page = build_search_page(sql, owner, repo_name, url)?;
presentation::markdown_response(&render_search_markdown(&page, selection), selection)
}