tirbofish/kitgit · commit
e83b4e1141f5f98ad8ff385122bcd8004fd7f2c9
feat: improved search across repos, users, issues, and pulls
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -812,6 +812,253 @@ pub async fn list_public_repos( .collect()) } +fn ilike_contains(term: &str) -> String { + format!("%{}%", term.replace('%', "\\%").replace('_', "\\_")) +} + +pub async fn search_repos( + pool: &PgPool, + query: &str, + viewer: Option<Uuid>, + limit: i64, +) -> Result<Vec<(Repository, String)>> { + #[derive(sqlx::FromRow)] + struct Row { + id: Uuid, + owner_id: Uuid, + name: String, + description: String, + visibility: String, + default_branch: String, + archived: bool, + issues_enabled: bool, + pulls_enabled: bool, + releases_enabled: bool, + allow_merge: bool, + allow_squash: bool, + allow_rebase: bool, + default_merge_style: String, + protect_default_branch: bool, + protect_block_force_push: bool, + fork_of_id: Option<Uuid>, + stars_count: i32, + watches_count: i32, + forks_count: i32, + created_at: DateTime<Utc>, + updated_at: DateTime<Utc>, + owner_username: String, + } + + let like = ilike_contains(query.trim()); + let rows: Vec<Row> = sqlx::query_as( + r#" + SELECT r.*, u.username AS owner_username + FROM repositories r + JOIN users u ON u.id = r.owner_id + WHERE r.archived = false + AND ( + r.visibility = 'public' + OR ( + $3::uuid IS NOT NULL + AND ( + r.owner_id = $3 + OR EXISTS ( + SELECT 1 FROM collaborators c + WHERE c.repo_id = r.id AND c.user_id = $3 + ) + ) + ) + ) + AND ( + r.name ILIKE $1 ESCAPE '\' + OR r.description ILIKE $1 ESCAPE '\' + OR u.username ILIKE $1 ESCAPE '\' + ) + ORDER BY r.updated_at DESC + LIMIT $2 + "#, + ) + .bind(&like) + .bind(limit) + .bind(viewer) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| { + ( + Repository { + id: r.id, + owner_id: r.owner_id, + name: r.name, + description: r.description, + visibility: r.visibility, + default_branch: r.default_branch, + archived: r.archived, + issues_enabled: r.issues_enabled, + pulls_enabled: r.pulls_enabled, + releases_enabled: r.releases_enabled, + allow_merge: r.allow_merge, + allow_squash: r.allow_squash, + allow_rebase: r.allow_rebase, + default_merge_style: r.default_merge_style, + protect_default_branch: r.protect_default_branch, + protect_block_force_push: r.protect_block_force_push, + fork_of_id: r.fork_of_id, + stars_count: r.stars_count, + watches_count: r.watches_count, + forks_count: r.forks_count, + created_at: r.created_at, + updated_at: r.updated_at, + }, + r.owner_username, + ) + }) + .collect()) +} + +pub async fn search_users_public( + pool: &PgPool, + query: &str, + limit: i64, +) -> Result<Vec<User>> { + let like = ilike_contains(query.trim()); + Ok(sqlx::query_as::<_, User>( + r#" + SELECT * FROM users + WHERE is_suspended = FALSE + AND ( + username ILIKE $1 ESCAPE '\' + OR display_name ILIKE $1 ESCAPE '\' + OR bio ILIKE $1 ESCAPE '\' + ) + ORDER BY username + LIMIT $2 + "#, + ) + .bind(&like) + .bind(limit) + .fetch_all(pool) + .await?) +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct SearchIssueHit { + pub owner: String, + pub repo_name: String, + pub number: i32, + pub title: String, + pub state: String, + pub visibility: String, + pub updated_at: DateTime<Utc>, +} + +pub async fn search_issues( + pool: &PgPool, + query: &str, + viewer: Option<Uuid>, + limit: i64, +) -> Result<Vec<SearchIssueHit>> { + let like = ilike_contains(query.trim()); + let exact_number = query.trim().trim_start_matches('#'); + Ok(sqlx::query_as::<_, SearchIssueHit>( + r#" + SELECT u.username AS owner, r.name AS repo_name, i.number, i.title, i.state, + r.visibility, i.updated_at + FROM issues i + JOIN repositories r ON r.id = i.repo_id + JOIN users u ON u.id = r.owner_id + WHERE r.archived = false + AND r.issues_enabled = TRUE + AND ( + r.visibility = 'public' + OR ( + $3::uuid IS NOT NULL + AND ( + r.owner_id = $3 + OR EXISTS ( + SELECT 1 FROM collaborators c + WHERE c.repo_id = r.id AND c.user_id = $3 + ) + ) + ) + ) + AND ( + i.title ILIKE $1 ESCAPE '\' + OR i.body ILIKE $1 ESCAPE '\' + OR CAST(i.number AS TEXT) = $4 + ) + ORDER BY i.updated_at DESC + LIMIT $2 + "#, + ) + .bind(&like) + .bind(limit) + .bind(viewer) + .bind(exact_number) + .fetch_all(pool) + .await?) +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct SearchPullHit { + pub owner: String, + pub repo_name: String, + pub number: i32, + pub title: String, + pub state: String, + pub visibility: String, + pub updated_at: DateTime<Utc>, +} + +pub async fn search_pulls( + pool: &PgPool, + query: &str, + viewer: Option<Uuid>, + limit: i64, +) -> Result<Vec<SearchPullHit>> { + let like = ilike_contains(query.trim()); + let exact_number = query.trim().trim_start_matches('#'); + Ok(sqlx::query_as::<_, SearchPullHit>( + r#" + SELECT u.username AS owner, r.name AS repo_name, p.number, p.title, p.state, + r.visibility, p.updated_at + FROM pull_requests p + JOIN repositories r ON r.id = p.repo_id + JOIN users u ON u.id = r.owner_id + WHERE r.archived = false + AND r.pulls_enabled = TRUE + AND ( + r.visibility = 'public' + OR ( + $3::uuid IS NOT NULL + AND ( + r.owner_id = $3 + OR EXISTS ( + SELECT 1 FROM collaborators c + WHERE c.repo_id = r.id AND c.user_id = $3 + ) + ) + ) + ) + AND ( + p.title ILIKE $1 ESCAPE '\' + OR p.body ILIKE $1 ESCAPE '\' + OR CAST(p.number AS TEXT) = $4 + ) + ORDER BY p.updated_at DESC + LIMIT $2 + "#, + ) + .bind(&like) + .bind(limit) + .bind(viewer) + .bind(exact_number) + .fetch_all(pool) + .await?) +} + pub async fn delete_repo(pool: &PgPool, id: Uuid) -> Result<()> { sqlx::query("DELETE FROM repositories WHERE id = $1") .bind(id) @@ -56,6 +56,7 @@ pub fn app_router(state: AppState) -> Router { .route("/", get(routes::home)) .route("/og.png", get(routes::site_og_image)) .route("/explore", get(routes::explore)) + .route("/search", get(routes::explore)) .route( "/auth/login", get(routes::auth_login_page).post(routes::auth_login_submit), @@ -874,6 +874,19 @@ pub async fn home( #[derive(Deserialize)] pub struct ExploreQuery { pub q: Option<String>, + /// `all` | `repos` | `users` | `issues` | `pulls` + #[serde(rename = "type")] + pub search_type: Option<String>, +} + +fn normalize_search_type(raw: Option<&str>) -> &'static str { + match raw.map(|s| s.trim().to_ascii_lowercase()).as_deref() { + Some("repos") | Some("repositories") => "repos", + Some("users") | Some("people") => "users", + Some("issues") => "issues", + Some("pulls") | Some("prs") | Some("pull") => "pulls", + _ => "all", + } } pub async fn explore( @@ -882,37 +895,94 @@ pub async fn explore( Query(q): Query<ExploreQuery>, ) -> AppResult<impl IntoResponse> { let viewer = current_user(&state.auth, &headers).await?; + let viewer_id = viewer.as_ref().map(|u| u.id); let query = q.q.unwrap_or_default(); - let rows = queries::list_public_repos( - &state.pool, - if query.trim().is_empty() { - None + let search_type = normalize_search_type(q.search_type.as_deref()).to_string(); + let qtrim = query.trim(); + let has_query = !qtrim.is_empty(); + + let want_repos = search_type == "all" || search_type == "repos"; + let want_users = has_query && (search_type == "all" || search_type == "users"); + let want_issues = has_query && (search_type == "all" || search_type == "issues"); + let want_pulls = has_query && (search_type == "all" || search_type == "pulls"); + + let per = if search_type == "all" { 15 } else { 50 }; + + let repos = if want_repos { + let rows = if has_query { + queries::search_repos(&state.pool, qtrim, viewer_id, per).await? } else { - Some(query.as_str()) - }, - 50, - ) - .await?; - let repos = rows - .into_iter() - .map(|(repo, owner)| ExploreRepo { owner, repo }) - .collect(); - let social = if query.trim().is_empty() { + queries::list_public_repos(&state.pool, None, per).await? + }; + rows.into_iter() + .map(|(repo, owner)| ExploreRepo { owner, repo }) + .collect() + } else { + Vec::new() + }; + + let users = if want_users { + queries::search_users_public(&state.pool, qtrim, per).await? + } else { + Vec::new() + }; + + let issues = if want_issues { + queries::search_issues(&state.pool, qtrim, viewer_id, per) + .await? + .into_iter() + .map(|h| ExploreIssueHit { + owner: h.owner, + repo_name: h.repo_name, + number: h.number, + title: h.title, + state: h.state, + visibility: h.visibility, + updated_at: h.updated_at, + }) + .collect() + } else { + Vec::new() + }; + + let pulls = if want_pulls { + queries::search_pulls(&state.pool, qtrim, viewer_id, per) + .await? + .into_iter() + .map(|h| ExplorePullHit { + owner: h.owner, + repo_name: h.repo_name, + number: h.number, + title: h.title, + state: h.state, + visibility: h.visibility, + updated_at: h.updated_at, + }) + .collect() + } else { + Vec::new() + }; + + let social = if !has_query { og::site_social_meta( &state.config.public_url, "/explore", - "explore repositories - kitgit", - "Browse public repositories on kitgit.", + "explore - kitgit", + "Browse and search repositories, users, issues, and pulls on kitgit.", ) } else { let title = format!("search '{query}' - kitgit"); - let desc = format!("Public repositories matching '{query}' on kitgit."); + let desc = format!("Search results for '{query}' on kitgit."); og::site_social_meta(&state.config.public_url, "/explore", &title, &desc) }; Ok(ExploreTemplate { viewer, - repos, query, + search_type, + repos, + users, + issues, + pulls, social, }) } @@ -126,12 +126,36 @@ pub struct ExploreRepo { pub repo: Repository, } +pub struct ExploreIssueHit { + pub owner: String, + pub repo_name: String, + pub number: i32, + pub title: String, + pub state: String, + pub visibility: String, + pub updated_at: DateTime<Utc>, +} + +pub struct ExplorePullHit { + pub owner: String, + pub repo_name: String, + pub number: i32, + pub title: String, + pub state: String, + pub visibility: String, + pub updated_at: DateTime<Utc>, +} + #[derive(Template, WebTemplate)] #[template(path = "explore.html")] pub struct ExploreTemplate { pub viewer: Option<User>, - pub repos: Vec<ExploreRepo>, pub query: String, + pub search_type: String, + pub repos: Vec<ExploreRepo>, + pub users: Vec<User>, + pub issues: Vec<ExploreIssueHit>, + pub pulls: Vec<ExplorePullHit>, pub social: SocialMeta, } @@ -1116,6 +1116,31 @@ a:hover { color: var(--kg-fg); } +.kg-search-filters { + margin-top: var(--kg-space-3); +} + +.kg-search-filters a[aria-current="page"], +.kg-search-filters__active { + border-color: var(--kg-fg); + color: var(--kg-fg); +} + +.kg-search-block { + margin-top: var(--kg-space-4); +} + +.kg-search-block .kg-kicker { + display: block; + margin-bottom: var(--kg-space-2); +} + +.kg-search-user { + display: flex; + align-items: flex-start; + gap: 0.65rem; +} + .kg-sr-only { position: absolute; width: 1px; @@ -9,34 +9,143 @@ {% block content %} <section class="kg-section"> <span class="kg-kicker">explore</span> - <h1 class="kg-display">public repositories</h1> + <h1 class="kg-display">search</h1> <form class="kg-explore-search" method="get" action="/explore" role="search"> <label class="kg-sr-only" for="q">Search</label> - <input id="q" type="search" name="q" value="{{ query }}" placeholder="Search name, description, owner…" /> + <input id="q" type="search" name="q" value="{{ query }}" placeholder="Search repos, users, issues, pullsΓǪ" /> + <input type="hidden" name="type" value="{{ search_type }}" /> <button class="kg-btn" type="submit">search</button> </form> - {% if repos.is_empty() %} + <div class="kg-actions kg-search-filters" role="navigation" aria-label="Result type"> + <a class="kg-btn kg-btn--ghost{% if search_type == "all" %} kg-search-filters__active{% endif %}" href="/explore?q={{ query }}&type=all"{% if search_type == "all" %} aria-current="page"{% endif %}>all</a> + <a class="kg-btn kg-btn--ghost{% if search_type == "repos" %} kg-search-filters__active{% endif %}" href="/explore?q={{ query }}&type=repos"{% if search_type == "repos" %} aria-current="page"{% endif %}>repos</a> + <a class="kg-btn kg-btn--ghost{% if search_type == "users" %} kg-search-filters__active{% endif %}" href="/explore?q={{ query }}&type=users"{% if search_type == "users" %} aria-current="page"{% endif %}>users</a> + <a class="kg-btn kg-btn--ghost{% if search_type == "issues" %} kg-search-filters__active{% endif %}" href="/explore?q={{ query }}&type=issues"{% if search_type == "issues" %} aria-current="page"{% endif %}>issues</a> + <a class="kg-btn kg-btn--ghost{% if search_type == "pulls" %} kg-search-filters__active{% endif %}" href="/explore?q={{ query }}&type=pulls"{% if search_type == "pulls" %} aria-current="page"{% endif %}>pulls</a> + </div> + + {% let show_repos = search_type == "all" || search_type == "repos" %} + {% let show_users = search_type == "all" || search_type == "users" %} + {% let show_issues = search_type == "all" || search_type == "issues" %} + {% let show_pulls = search_type == "all" || search_type == "pulls" %} + {% let empty_all = repos.is_empty() && users.is_empty() && issues.is_empty() && pulls.is_empty() %} + + {% if empty_all %} <p class="kg-muted" style="margin-top:1.5rem"> - {% if !query.is_empty() %}No public repositories match “{{ query }}”.{% else %}No public repositories yet.{% endif %} + {% if !query.is_empty() %} + No results match ΓÇ£{{ query }}ΓÇ¥. + {% else if search_type == "users" || search_type == "issues" || search_type == "pulls" %} + Enter a search to find {{ search_type }}. + {% else %} + No public repositories yet. + {% endif %} </p> {% else %} - <ul class="kg-list" style="margin-top:1.5rem"> - {% for item in repos %} - <li> - <div> - <a href="/{{ item.owner }}/{{ item.repo.name }}"> - <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> - {{ item.owner }}/{{ item.repo.name }} - </a> - {% if !item.repo.description.is_empty() %} - <p class="kg-meta" style="margin:0.25rem 0 0">{{ item.repo.description }}</p> - {% endif %} - </div> - <span class="kg-meta">{{ item.repo.updated_at }}</span> - </li> - {% endfor %} - </ul> + + {% if show_repos && (!repos.is_empty() || search_type == "repos") %} + <div class="kg-search-block"> + <span class="kg-kicker">repositories</span> + {% if repos.is_empty() %} + <p class="kg-muted">No repositories match.</p> + {% else %} + <ul class="kg-list"> + {% for item in repos %} + <li> + <div> + <a href="/{{ item.owner }}/{{ item.repo.name }}"> + <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> + {{ item.owner }}/{{ item.repo.name }} + </a> + {% if item.repo.visibility == "private" %}<span class="kg-badge">private</span>{% endif %} + {% if !item.repo.description.is_empty() %} + <p class="kg-meta" style="margin:0.25rem 0 0">{{ item.repo.description }}</p> + {% endif %} + </div> + <span class="kg-meta">{{ item.repo.updated_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} + </div> + {% endif %} + + {% if show_users && (!users.is_empty() || (search_type == "users" && !query.is_empty())) %} + <div class="kg-search-block"> + <span class="kg-kicker">users</span> + {% if users.is_empty() %} + <p class="kg-muted">No users match.</p> + {% else %} + <ul class="kg-list"> + {% for u in users %} + <li> + <div class="kg-search-user"> + <img class="kg-avatar kg-avatar--sm" src="/avatars/{{ u.id }}?v={{ u.updated_at.timestamp() }}" alt="" /> + <div> + <a href="/{{ u.username }}"> + <span class="kg-icon" style="--icon:url('/static/icons/user.svg')"></span> + {% if !u.display_name.is_empty() %}{{ u.display_name }}{% else %}{{ u.username }}{% endif %} + </a> + <p class="kg-meta" style="margin:0.15rem 0 0">@{{ u.username }}{% if !u.bio.is_empty() %} ┬╖ {{ u.bio }}{% endif %}</p> + </div> + </div> + </li> + {% endfor %} + </ul> + {% endif %} + </div> + {% endif %} + + {% if show_issues && (!issues.is_empty() || (search_type == "issues" && !query.is_empty())) %} + <div class="kg-search-block"> + <span class="kg-kicker">issues</span> + {% if issues.is_empty() %} + <p class="kg-muted">No issues match.</p> + {% else %} + <ul class="kg-list"> + {% for item in issues %} + <li> + <div> + <span class="kg-badge {% if item.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.state }}</span> + {% if item.visibility == "private" %}<span class="kg-badge">private</span>{% endif %} + <a href="/{{ item.owner }}/{{ item.repo_name }}/issues/{{ item.number }}"> + <span class="kg-icon" style="--icon:url('/static/icons/circle.svg')"></span> + {{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }} + </a> + </div> + <span class="kg-meta">{{ item.updated_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} + </div> + {% endif %} + + {% if show_pulls && (!pulls.is_empty() || (search_type == "pulls" && !query.is_empty())) %} + <div class="kg-search-block"> + <span class="kg-kicker">pulls</span> + {% if pulls.is_empty() %} + <p class="kg-muted">No pulls match.</p> + {% else %} + <ul class="kg-list"> + {% for item in pulls %} + <li> + <div> + <span class="kg-badge {% if item.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ item.state }}</span> + {% if item.visibility == "private" %}<span class="kg-badge">private</span>{% endif %} + <a href="/{{ item.owner }}/{{ item.repo_name }}/pulls/{{ item.number }}"> + <span class="kg-icon" style="--icon:url('/static/icons/git-pull-request.svg')"></span> + {{ item.owner }}/{{ item.repo_name }}#{{ item.number }} {{ item.title }} + </a> + </div> + <span class="kg-meta">{{ item.updated_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} + </div> + {% endif %} + {% endif %} </section> {% endblock %}