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.tsxBlame291 lines · 2 contributors
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…"
e1c0fbecopilot-swe-agent[bot]151 aria-label="Search"
3ef4c9dClaude152 style="width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
153 autofocus
154 />
155 </form>
156
157 <div class="issue-tabs" style="margin-bottom: 20px">
158 {tab("repos", "Repositories")}
159 {tab("users", "Users")}
160 {tab("issues", "Issues")}
161 {tab("prs", "Pull requests")}
162 </div>
163
164 {!q && (
165 <div class="empty-state">
166 <p>Type to search across GlueCron.</p>
167 </div>
168 )}
169
170 {q && type === "repos" && (
171 repoHits.length === 0 ? (
172 <div class="empty-state"><p>No repositories match "{q}"</p></div>
173 ) : (
174 <div class="card-grid">
175 {repoHits.map(({ repo, ownerName }) => (
176 <RepoCard repo={repo} ownerName={ownerName} />
177 ))}
178 </div>
179 )
180 )}
181
182 {q && type === "users" && (
183 userHits.length === 0 ? (
184 <div class="empty-state"><p>No users match "{q}"</p></div>
185 ) : (
186 <div class="panel">
187 {userHits.map((u) => (
188 <div class="panel-item">
189 <div class="dot blue"></div>
190 <div style="flex: 1">
191 <a href={`/${u.username}`} style="font-weight: 600">
192 {u.displayName || u.username}
193 </a>
194 <div class="meta">@{u.username}</div>
195 {u.bio && <div class="meta">{u.bio}</div>}
196 </div>
197 </div>
198 ))}
199 </div>
200 )
201 )}
202
203 {q && type === "issues" && (
204 issueHits.length === 0 ? (
205 <div class="empty-state"><p>No issues match "{q}"</p></div>
206 ) : (
207 <div class="panel">
208 {issueHits.map((i) => (
209 <div class="panel-item">
210 <div
211 class={`dot ${i.state === "open" ? "green" : "yellow"}`}
212 ></div>
213 <div style="flex: 1">
214 <a
215 href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}
216 >
217 {i.title}
218 </a>
219 <div class="meta">
220 {i.repoOwner}/{i.repoName}#{i.number} · {i.state}
221 </div>
222 </div>
223 </div>
224 ))}
225 </div>
226 )
227 )}
228
229 {q && type === "prs" && (
230 prHits.length === 0 ? (
231 <div class="empty-state"><p>No pull requests match "{q}"</p></div>
232 ) : (
233 <div class="panel">
234 {prHits.map((pr) => (
235 <div class="panel-item">
236 <div
237 class={`dot ${pr.state === "open" ? "green" : pr.state === "merged" ? "blue" : "yellow"}`}
238 ></div>
239 <div style="flex: 1">
240 <a
241 href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}
242 >
243 {pr.title}
244 </a>
245 <div class="meta">
246 {pr.repoOwner}/{pr.repoName}#{pr.number} · {pr.state}
247 </div>
248 </div>
249 </div>
250 ))}
251 </div>
252 )
253 )}
254 </Layout>
255 );
256});
257
258// Keyboard shortcuts help page — linked from Cmd+? in nav
259search.get("/shortcuts", async (c) => {
260 const user = c.get("user");
261 const unread = user ? await getUnreadCount(user.id) : 0;
262 const shortcuts: Array<{ keys: string; desc: string }> = [
263 { keys: "/", desc: "Focus global search" },
264 { keys: "Cmd/Ctrl + K", desc: "Open AI assistant" },
265 { keys: "g d", desc: "Go to dashboard" },
266 { keys: "g n", desc: "Go to notifications" },
267 { keys: "g e", desc: "Go to explore" },
268 { keys: "n", desc: "New repository" },
269 { keys: "?", desc: "Show this help" },
270 ];
271
272 return c.html(
273 <Layout title="Keyboard shortcuts" user={user} notificationCount={unread}>
274 <h2 style="margin-bottom: 16px">Keyboard shortcuts</h2>
275 <div class="panel">
276 {shortcuts.map((s) => (
277 <div class="panel-item" style="justify-content: space-between">
278 <span>{s.desc}</span>
279 <kbd
280 style="font-family: var(--font-mono); background: var(--bg-tertiary); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px"
281 >
282 {s.keys}
283 </kbd>
284 </div>
285 ))}
286 </div>
287 </Layout>
288 );
289});
290
291export default search;