Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

search.tsxBlame290 lines · 1 contributor
3ef4c9dClaude1/**
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
10import { Hono } from "hono";
11import { and, desc, eq, or, sql } from "drizzle-orm";
12import { db } from "../db";
13import {
14 issues,
15 pullRequests,
16 repositories,
17 users,
18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoCard } from "../views/components";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { getUnreadCount } from "../lib/unread";
24
25const search = new Hono<AuthEnv>();
26search.use("*", softAuth);
27
28search.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 >
01ba63dClaude140 <form method="get" action="/search" style="margin-bottom: 16px">
3ef4c9dClaude141 <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
258search.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
290export default search;