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