Skip to content

g1t/apps/web/app/routes/search.tsx

316 lines12,654 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Search across all of g1t, Explore, and a command palette1import { ChevronLeft, ChevronRight, CircleHelp, Search as SearchIcon, X } from "lucide-react";
2import type { ReactNode } from "react";
3import { Form, Link, redirect, useNavigate } from "react-router";
4
5import { SEARCH_TYPES, type SearchType, searchType } from "@g1t/contracts";
6
7import type { Route } from "./+types/search";
8import { SearchHitView } from "../components/search";
9import { EmptyState, notACredential } from "../components/ui";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit10import { TabStrip } from "../components/ui/tab-strip";
Search across all of g1t, Explore, and a command palette11import { Combobox } from "../components/ui/combobox";
12import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
13import { page } from "../lib/meta";
14import {
15 LANGUAGES,
16 QUALIFIERS,
17 TYPE_LABELS,
18 isValue,
19 qualifier,
20 searchHref,
21 shortCount,
22 withIs,
23 withQualifier,
24} from "../lib/search";
25import { search } from "../lib/services.server";
26import { getViewer } from "../lib/session.server";
27
28export function meta({ loaderData, ...args }: Route.MetaArgs) {
29 const q = loaderData?.q;
30 return page(args, {
31 title: q ? `${q} · Search · g1t` : "Search · g1t",
32 description: q
33 ? `Repositories, code, issues, pull requests and people on g1t matching "${q}".`
34 : "Search all of g1t: repositories, code, issues, pull requests and people.",
35 });
36}
37
38export async function loader({ request, context }: Route.LoaderArgs) {
39 const url = new URL(request.url);
40 const q = (url.searchParams.get("q") ?? "").trim().slice(0, 256);
41 // From a repository's own search box: the same search, within it.
42 const within = url.searchParams.get("repo")?.trim();
43 if (within && /^[\w.-]+\/[\w.-]+$/.test(within)) {
44 const scoped = withQualifier(q, "repo", within);
45 throw redirect(searchHref(scoped, searchType(url.searchParams.get("type"))));
46 }
47 const type = searchType(url.searchParams.get("type"));
48 const pageNumber = Math.min(Math.max(Number(url.searchParams.get("page")) || 1, 1), 50);
49 if (!q) return { q, type, results: null, error: null };
50 try {
51 const found = await search.search(getViewer(context), q, { type, page: pageNumber });
52 return found.ok
53 ? { q, type, results: found.value, error: null }
54 : { q, type, results: null, error: found.error.message };
55 } catch {
56 return { q, type, results: null, error: "Search cannot be reached right now. Try again in a moment." };
57 }
58}
59
60const STATES: Record<"issues" | "pulls", { value: string | null; label: string }[]> = {
61 issues: [
62 { value: null, label: "All" },
63 { value: "open", label: "Open" },
64 { value: "closed", label: "Closed" },
65 ],
66 pulls: [
67 { value: null, label: "All" },
68 { value: "open", label: "Open" },
69 { value: "merged", label: "Merged" },
70 { value: "closed", label: "Closed" },
71 ],
72};
73const STATE_FAMILY = ["open", "closed", "merged", "draft"];
74
75function SyntaxHelp() {
76 return (
77 <Popover>
78 <PopoverTrigger className="flex h-10 shrink-0 items-center gap-1.5 rounded-lg border border-line px-3 text-sm text-muted transition-colors hover:border-line-strong hover:text-fg">
79 <CircleHelp size={15} />
80 <span className="hidden sm:inline">Syntax</span>
81 </PopoverTrigger>
82 <PopoverContent align="end" className="w-96 p-0">
83 <p className="border-b border-line px-4 py-3 text-xs text-muted">
84 Words match anywhere; the last word also matches longer words it starts. Put qualifiers anywhere in the
85 query.
86 </p>
87 <dl className="divide-y divide-line">
88 {QUALIFIERS.map((item) => (
89 <div key={item.example} className="grid grid-cols-[minmax(0,10rem)_1fr] gap-3 px-4 py-2">
90 <dt className="font-mono text-xs text-fg">{item.example}</dt>
91 <dd className="text-xs text-muted">{item.means}</dd>
92 </div>
93 ))}
94 </dl>
95 <a
96 href="https://docs.g1t.sh/guides/search/"
97 className="block border-t border-line px-4 py-2.5 text-xs text-accent hover:underline"
98 >
99 Everything search understands
100 </a>
101 </PopoverContent>
102 </Popover>
103 );
104}
105
106function Pill({ to, current, children }: { to: string; current: boolean; children: ReactNode }) {
107 return (
108 <Link
109 to={to}
110 aria-current={current ? "true" : undefined}
111 className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
112 current ? "bg-raised text-fg ring-1 ring-line" : "text-muted hover:text-fg"
113 }`}
114 >
115 {children}
116 </Link>
117 );
118}
119
120export default function Search({ loaderData }: Route.ComponentProps) {
121 const { q, results, error } = loaderData;
122 const navigate = useNavigate();
123 const type: SearchType = results?.type ?? loaderData.type ?? "repositories";
124 const repo = qualifier(q, "repo");
125 const language = qualifier(q, "language") ?? qualifier(q, "lang");
126 const go = (next: string, nextType: SearchType | null = type) => navigate(searchHref(next, nextType));
127 const languages = [...new Set([...(language ? [language] : []), ...LANGUAGES])];
128 return (
129 <main className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
130 <h1 className="text-2xl font-semibold tracking-tight">Search</h1>
131 <p className="mt-1 text-sm text-muted">
132 Repositories, code, issues, pull requests and people: everything public on g1t, and what is private to the
133 workspaces you belong to.
134 </p>
135 <div className="mt-6 flex gap-2">
136 <Form action="/search" role="search" className="relative grow">
137 <SearchIcon
138 size={16}
139 className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-faint"
140 />
141 <input
142 key={q}
143 name="q"
144 {...notACredential()}
145 defaultValue={q}
146 autoFocus={!q}
147 placeholder="Search g1t, or try language:rust parse_query"
148 aria-label="Search g1t"
149 className="h-10 w-full rounded-lg border border-line bg-surface pr-4 pl-10 font-mono text-sm outline-none transition-colors placeholder:font-sans placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
150 />
151 {results && <input type="hidden" name="type" value={type} />}
152 </Form>
153 <SyntaxHelp />
154 </div>
155
156 {repo && (
157 <p className="mt-3 flex flex-wrap items-center gap-2 text-sm text-muted">
158 Searching <span className="font-mono text-fg">{repo}</span>
159 <Link
160 to={searchHref(withQualifier(q, "repo", null), type)}
161 className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted ring-1 ring-line hover:text-fg"
162 >
163 <X size={12} />
164 Search all of g1t
165 </Link>
166 </p>
167 )}
168
169 {q && (
Usage, Billing settings and prepaid AI credit; fixes from the UX audit170 <TabStrip label="Kinds of results" className="mt-6 gap-1 border-b border-line">
Search across all of g1t, Explore, and a command palette171 {SEARCH_TYPES.map((each) => (
172 <Link
173 key={each}
174 to={searchHref(q, each)}
175 aria-current={each === type ? "page" : undefined}
Fast pages, required checks on the branch, self-hosted runners, honest incidents176 className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${
Search across all of g1t, Explore, and a command palette177 each === type ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"
178 }`}
179 >
180 {TYPE_LABELS[each]}
181 {results && (
182 <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted">
183 {shortCount(results.counts[each])}
184 </span>
185 )}
186 </Link>
187 ))}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit188 </TabStrip>
Search across all of g1t, Explore, and a command palette189 )}
190
191 {results && (type === "code" || type === "repositories" || type === "issues" || type === "pulls") && (
192 <div className="mt-4 flex flex-wrap items-center gap-3">
193 {(type === "code" || type === "repositories") && (
194 <div className="w-56">
195 <Combobox
196 aria-label="Language"
197 value={language ?? "any"}
198 placeholder="Any language"
199 searchPlaceholder="Find a language…"
200 options={[
201 { value: "any", label: "Any language" },
202 ...languages.map((name) => ({ value: name, label: name })),
203 ]}
204 onValueChange={(value) => go(withQualifier(q, "language", value === "any" ? null : value, ["lang"]))}
205 />
206 </div>
207 )}
208 {(type === "issues" || type === "pulls") && (
209 <div className="flex items-center gap-1 rounded-lg border border-line bg-bg p-1">
210 {STATES[type].map((state) => (
211 <Pill
212 key={state.label}
213 to={searchHref(withIs(q, STATE_FAMILY, state.value), type)}
214 current={isValue(q, STATE_FAMILY) === state.value}
215 >
216 {state.label}
217 </Pill>
218 ))}
219 </div>
220 )}
221 {type === "code" && (
222 <p className="text-xs text-faint">
223 Narrow with <span className="font-mono text-muted">path:src/</span> or{" "}
224 <span className="font-mono text-muted">path:*.rs</span>.
225 </p>
226 )}
227 {(type === "issues" || type === "pulls") && (
228 <p className="text-xs text-faint">
229 Narrow with <span className="font-mono text-muted">author:</span> and{" "}
230 <span className="font-mono text-muted">label:</span>.
231 </p>
232 )}
233 </div>
234 )}
235
236 {results?.notes.map((note) => (
237 <p key={note} className="mt-4 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-muted">
238 {note}
239 </p>
240 ))}
241 {error && <p className="mt-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm">{error}</p>}
242
243 {!q && (
244 <div className="mt-8 grid gap-3 sm:grid-cols-2">
245 {QUALIFIERS.slice(0, 8).map((item) => (
246 <div key={item.example} className="rounded-lg border border-line bg-surface px-4 py-3">
247 <p className="font-mono text-sm">{item.example}</p>
248 <p className="mt-1 text-xs text-muted">{item.means}</p>
249 </div>
250 ))}
251 </div>
252 )}
253
254 {results && results.hits.length === 0 && (
255 <div className="mt-6">
256 <EmptyState title={`No ${TYPE_LABELS[type].toLowerCase()} match`}>
257 {SEARCH_TYPES.some((each) => each !== type && results.counts[each] > 0) ? (
258 <>
259 Try{" "}
260 {SEARCH_TYPES.filter((each) => each !== type && results.counts[each] > 0).map((each, index, list) => (
261 <span key={each}>
262 <Link to={searchHref(q, each)} className="text-accent hover:underline">
263 {TYPE_LABELS[each].toLowerCase()} ({shortCount(results.counts[each])})
264 </Link>
265 {index < list.length - 1 ? ", " : ""}
266 </span>
267 ))}
268 .
269 </>
270 ) : (
271 "Try fewer words, or a different qualifier."
272 )}
273 </EmptyState>
274 </div>
275 )}
276
277 {results && results.hits.length > 0 && (
278 <ol className={`mt-6 ${type === "code" ? "space-y-4" : "divide-y divide-line"}`}>
279 {results.hits.map((hit) => (
280 <li key={`${hit.kind}:${hit.url}`} className={type === "code" ? undefined : "py-4"}>
281 <SearchHitView hit={hit} />
282 </li>
283 ))}
284 </ol>
285 )}
286
287 {results && (results.page > 1 || results.more) && (
288 <nav aria-label="Pages" className="mt-8 flex items-center justify-between">
289 {results.page > 1 ? (
290 <Link
291 to={searchHref(q, type, results.page - 1)}
292 className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg"
293 >
294 <ChevronLeft size={14} />
295 Previous
296 </Link>
297 ) : (
298 <span />
299 )}
300 <span className="text-xs text-faint">Page {results.page}</span>
301 {results.more ? (
302 <Link
303 to={searchHref(q, type, results.page + 1)}
304 className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg"
305 >
306 Next
307 <ChevronRight size={14} />
308 </Link>
309 ) : (
310 <span />
311 )}
312 </nav>
313 )}
314 </main>
315 );
316}