Skip to content

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

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