flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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