kitgit

tirbofish/kitgit · commit

74330dd2e702c19312674a028064e5d313f58876

merge: feat/better-search into integrate/all-features

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-29 00:08

view full diff

diff --git a/src/db/queries.rs b/src/db/queries.rs
index f1005e5..ce0cbc9 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -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)
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 35669fe..e453c0e 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -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),
diff --git a/src/web/routes.rs b/src/web/routes.rs
index 065c9fd..bb5df31 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -963,6 +963,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(
@@ -971,37 +984,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,
     })
 }
diff --git a/src/web/templates.rs b/src/web/templates.rs
index 0affa5e..2fe471e 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -167,12 +167,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,
 }
 
diff --git a/static/brand.css b/static/brand.css
index c6363ed..538af34 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -1139,6 +1139,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;
diff --git a/templates/explore.html b/templates/explore.html
index 55810e9..52b996b 100644
--- a/templates/explore.html
+++ b/templates/explore.html
@@ -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 }}&amp;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 }}&amp;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 }}&amp;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 }}&amp;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 }}&amp;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 %}