CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
search.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3ef4c9d | 1 | /** |
| 2 | * Global search — across repos, users, issues, PRs. | |
| 3 | * | |
| 4 | * GET /search?q=...&type=repos|users|issues|prs | |
| 5 | * | |
| 6 | * Text search uses Postgres ILIKE — good enough for the scale GlueCron is at | |
| 7 | * today. If traffic grows, swap in pgvector + AI embeddings. | |
| 8 | */ | |
| 9 | ||
| 10 | import { Hono } from "hono"; | |
| 11 | import { and, desc, eq, or, sql } from "drizzle-orm"; | |
| 12 | import { db } from "../db"; | |
| 13 | import { | |
| 14 | issues, | |
| 15 | pullRequests, | |
| 16 | repositories, | |
| 17 | users, | |
| 18 | } from "../db/schema"; | |
| 19 | import { Layout } from "../views/layout"; | |
| 20 | import { RepoCard } from "../views/components"; | |
| 21 | import { softAuth } from "../middleware/auth"; | |
| 22 | import type { AuthEnv } from "../middleware/auth"; | |
| 23 | import { getUnreadCount } from "../lib/unread"; | |
| 24 | ||
| 25 | const search = new Hono<AuthEnv>(); | |
| 26 | search.use("*", softAuth); | |
| 27 | ||
| 28 | search.get("/search", async (c) => { | |
| 29 | const user = c.get("user"); | |
| 30 | const q = (c.req.query("q") || "").trim(); | |
| 31 | const type = c.req.query("type") || "repos"; | |
| 32 | const unread = user ? await getUnreadCount(user.id) : 0; | |
| 33 | ||
| 34 | let repoHits: Array<{ | |
| 35 | repo: typeof repositories.$inferSelect; | |
| 36 | ownerName: string; | |
| 37 | }> = []; | |
| 38 | let userHits: Array<typeof users.$inferSelect> = []; | |
| 39 | let issueHits: Array<{ | |
| 40 | id: string; | |
| 41 | number: number; | |
| 42 | title: string; | |
| 43 | state: string; | |
| 44 | repoName: string; | |
| 45 | repoOwner: string; | |
| 46 | }> = []; | |
| 47 | let prHits: Array<{ | |
| 48 | id: string; | |
| 49 | number: number; | |
| 50 | title: string; | |
| 51 | state: string; | |
| 52 | repoName: string; | |
| 53 | repoOwner: string; | |
| 54 | }> = []; | |
| 55 | ||
| 56 | if (q) { | |
| 57 | const pat = `%${q}%`; | |
| 58 | if (type === "repos") { | |
| 59 | const results = await db | |
| 60 | .select({ repo: repositories, ownerName: users.username }) | |
| 61 | .from(repositories) | |
| 62 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 63 | .where( | |
| 64 | and( | |
| 65 | eq(repositories.isPrivate, false), | |
| 66 | sql`(${repositories.name} ILIKE ${pat} OR ${repositories.description} ILIKE ${pat})` | |
| 67 | ) | |
| 68 | ) | |
| 69 | .orderBy(desc(repositories.starCount)) | |
| 70 | .limit(30); | |
| 71 | repoHits = results; | |
| 72 | } else if (type === "users") { | |
| 73 | userHits = await db | |
| 74 | .select() | |
| 75 | .from(users) | |
| 76 | .where( | |
| 77 | sql`(${users.username} ILIKE ${pat} OR ${users.displayName} ILIKE ${pat})` | |
| 78 | ) | |
| 79 | .limit(30); | |
| 80 | } else if (type === "issues") { | |
| 81 | issueHits = await db | |
| 82 | .select({ | |
| 83 | id: issues.id, | |
| 84 | number: issues.number, | |
| 85 | title: issues.title, | |
| 86 | state: issues.state, | |
| 87 | repoName: repositories.name, | |
| 88 | repoOwner: users.username, | |
| 89 | }) | |
| 90 | .from(issues) | |
| 91 | .innerJoin(repositories, eq(issues.repositoryId, repositories.id)) | |
| 92 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 93 | .where( | |
| 94 | and( | |
| 95 | eq(repositories.isPrivate, false), | |
| 96 | sql`(${issues.title} ILIKE ${pat} OR ${issues.body} ILIKE ${pat})` | |
| 97 | ) | |
| 98 | ) | |
| 99 | .orderBy(desc(issues.updatedAt)) | |
| 100 | .limit(30); | |
| 101 | } else if (type === "prs") { | |
| 102 | prHits = await db | |
| 103 | .select({ | |
| 104 | id: pullRequests.id, | |
| 105 | number: pullRequests.number, | |
| 106 | title: pullRequests.title, | |
| 107 | state: pullRequests.state, | |
| 108 | repoName: repositories.name, | |
| 109 | repoOwner: users.username, | |
| 110 | }) | |
| 111 | .from(pullRequests) | |
| 112 | .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id)) | |
| 113 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 114 | .where( | |
| 115 | and( | |
| 116 | eq(repositories.isPrivate, false), | |
| 117 | sql`(${pullRequests.title} ILIKE ${pat} OR ${pullRequests.body} ILIKE ${pat})` | |
| 118 | ) | |
| 119 | ) | |
| 120 | .orderBy(desc(pullRequests.updatedAt)) | |
| 121 | .limit(30); | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | const tab = (id: string, label: string) => ( | |
| 126 | <a | |
| 127 | href={`/search?q=${encodeURIComponent(q)}&type=${id}`} | |
| 128 | class={type === id ? "active" : ""} | |
| 129 | > | |
| 130 | {label} | |
| 131 | </a> | |
| 132 | ); | |
| 133 | ||
| 134 | return c.html( | |
| 135 | <Layout | |
| 136 | title={q ? `Search — ${q}` : "Search"} | |
| 137 | user={user} | |
| 138 | notificationCount={unread} | |
| 139 | > | |
| 01ba63d | 140 | <form method="get" action="/search" style="margin-bottom: 16px"> |
| 3ef4c9d | 141 | <input |
| 142 | type="hidden" | |
| 143 | name="type" | |
| 144 | value={type} | |
| 145 | /> | |
| 146 | <input | |
| 147 | type="search" | |
| 148 | name="q" | |
| 149 | value={q} | |
| 150 | placeholder="Search repositories, users, issues, PRs…" | |
| 151 | style="width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px" | |
| 152 | autofocus | |
| 153 | /> | |
| 154 | </form> | |
| 155 | ||
| 156 | <div class="issue-tabs" style="margin-bottom: 20px"> | |
| 157 | {tab("repos", "Repositories")} | |
| 158 | {tab("users", "Users")} | |
| 159 | {tab("issues", "Issues")} | |
| 160 | {tab("prs", "Pull requests")} | |
| 161 | </div> | |
| 162 | ||
| 163 | {!q && ( | |
| 164 | <div class="empty-state"> | |
| 165 | <p>Type to search across GlueCron.</p> | |
| 166 | </div> | |
| 167 | )} | |
| 168 | ||
| 169 | {q && type === "repos" && ( | |
| 170 | repoHits.length === 0 ? ( | |
| 171 | <div class="empty-state"><p>No repositories match "{q}"</p></div> | |
| 172 | ) : ( | |
| 173 | <div class="card-grid"> | |
| 174 | {repoHits.map(({ repo, ownerName }) => ( | |
| 175 | <RepoCard repo={repo} ownerName={ownerName} /> | |
| 176 | ))} | |
| 177 | </div> | |
| 178 | ) | |
| 179 | )} | |
| 180 | ||
| 181 | {q && type === "users" && ( | |
| 182 | userHits.length === 0 ? ( | |
| 183 | <div class="empty-state"><p>No users match "{q}"</p></div> | |
| 184 | ) : ( | |
| 185 | <div class="panel"> | |
| 186 | {userHits.map((u) => ( | |
| 187 | <div class="panel-item"> | |
| 188 | <div class="dot blue"></div> | |
| 189 | <div style="flex: 1"> | |
| 190 | <a href={`/${u.username}`} style="font-weight: 600"> | |
| 191 | {u.displayName || u.username} | |
| 192 | </a> | |
| 193 | <div class="meta">@{u.username}</div> | |
| 194 | {u.bio && <div class="meta">{u.bio}</div>} | |
| 195 | </div> | |
| 196 | </div> | |
| 197 | ))} | |
| 198 | </div> | |
| 199 | ) | |
| 200 | )} | |
| 201 | ||
| 202 | {q && type === "issues" && ( | |
| 203 | issueHits.length === 0 ? ( | |
| 204 | <div class="empty-state"><p>No issues match "{q}"</p></div> | |
| 205 | ) : ( | |
| 206 | <div class="panel"> | |
| 207 | {issueHits.map((i) => ( | |
| 208 | <div class="panel-item"> | |
| 209 | <div | |
| 210 | class={`dot ${i.state === "open" ? "green" : "yellow"}`} | |
| 211 | ></div> | |
| 212 | <div style="flex: 1"> | |
| 213 | <a | |
| 214 | href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`} | |
| 215 | > | |
| 216 | {i.title} | |
| 217 | </a> | |
| 218 | <div class="meta"> | |
| 219 | {i.repoOwner}/{i.repoName}#{i.number} · {i.state} | |
| 220 | </div> | |
| 221 | </div> | |
| 222 | </div> | |
| 223 | ))} | |
| 224 | </div> | |
| 225 | ) | |
| 226 | )} | |
| 227 | ||
| 228 | {q && type === "prs" && ( | |
| 229 | prHits.length === 0 ? ( | |
| 230 | <div class="empty-state"><p>No pull requests match "{q}"</p></div> | |
| 231 | ) : ( | |
| 232 | <div class="panel"> | |
| 233 | {prHits.map((pr) => ( | |
| 234 | <div class="panel-item"> | |
| 235 | <div | |
| 236 | class={`dot ${pr.state === "open" ? "green" : pr.state === "merged" ? "blue" : "yellow"}`} | |
| 237 | ></div> | |
| 238 | <div style="flex: 1"> | |
| 239 | <a | |
| 240 | href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`} | |
| 241 | > | |
| 242 | {pr.title} | |
| 243 | </a> | |
| 244 | <div class="meta"> | |
| 245 | {pr.repoOwner}/{pr.repoName}#{pr.number} · {pr.state} | |
| 246 | </div> | |
| 247 | </div> | |
| 248 | </div> | |
| 249 | ))} | |
| 250 | </div> | |
| 251 | ) | |
| 252 | )} | |
| 253 | </Layout> | |
| 254 | ); | |
| 255 | }); | |
| 256 | ||
| 257 | // Keyboard shortcuts help page — linked from Cmd+? in nav | |
| 258 | search.get("/shortcuts", async (c) => { | |
| 259 | const user = c.get("user"); | |
| 260 | const unread = user ? await getUnreadCount(user.id) : 0; | |
| 261 | const shortcuts: Array<{ keys: string; desc: string }> = [ | |
| 262 | { keys: "/", desc: "Focus global search" }, | |
| 263 | { keys: "Cmd/Ctrl + K", desc: "Open AI assistant" }, | |
| 264 | { keys: "g d", desc: "Go to dashboard" }, | |
| 265 | { keys: "g n", desc: "Go to notifications" }, | |
| 266 | { keys: "g e", desc: "Go to explore" }, | |
| 267 | { keys: "n", desc: "New repository" }, | |
| 268 | { keys: "?", desc: "Show this help" }, | |
| 269 | ]; | |
| 270 | ||
| 271 | return c.html( | |
| 272 | <Layout title="Keyboard shortcuts" user={user} notificationCount={unread}> | |
| 273 | <h2 style="margin-bottom: 16px">Keyboard shortcuts</h2> | |
| 274 | <div class="panel"> | |
| 275 | {shortcuts.map((s) => ( | |
| 276 | <div class="panel-item" style="justify-content: space-between"> | |
| 277 | <span>{s.desc}</span> | |
| 278 | <kbd | |
| 279 | style="font-family: var(--font-mono); background: var(--bg-tertiary); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px" | |
| 280 | > | |
| 281 | {s.keys} | |
| 282 | </kbd> | |
| 283 | </div> | |
| 284 | ))} | |
| 285 | </div> | |
| 286 | </Layout> | |
| 287 | ); | |
| 288 | }); | |
| 289 | ||
| 290 | export default search; |