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

222 lines7,999 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 { BookMarked, Building2, CircleDot, CircleCheck, CircleSlash, FileCode2, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Lock, User } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link } from "react-router";
4
5import type { Segment, SiteHit } from "@g1t/contracts";
6
7import { Avatar, TimeAgo } from "./ui";
8import { Badge } from "./ui/badge";
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights9import { PersonLink } from "./work";
Search across all of g1t, Explore, and a command palette10
11/** Text with the parts that matched the query marked. */
12export function Highlighted({ parts, className }: { parts: Segment[]; className?: string }) {
13 return (
14 <span className={className}>
15 {parts.map((part, index) =>
16 part.highlight ? (
17 <mark key={index} className="rounded-[3px] bg-accent/20 px-px text-fg">
18 {part.text}
19 </mark>
20 ) : (
21 <span key={index}>{part.text}</span>
22 ),
23 )}
24 </span>
25 );
26}
27
28function stateIcon(hit: SiteHit): ReactNode {
29 if (hit.kind === "issue") {
30 return hit.state === "open" ? (
31 <CircleDot size={15} className="text-accent" aria-label="Open" />
32 ) : (
33 <CircleCheck size={15} className="text-merged" aria-label="Closed" />
34 );
35 }
36 switch (hit.state) {
37 case "merged":
38 return <GitMerge size={15} className="text-merged" aria-label="Merged" />;
39 case "closed":
40 return <GitPullRequestClosed size={15} className="text-danger" aria-label="Closed" />;
41 case "draft":
42 return <GitPullRequestDraft size={15} className="text-faint" aria-label="Draft" />;
43 default:
44 return <GitPullRequest size={15} className="text-accent" aria-label="Open" />;
45 }
46}
47
48/** The icon for a kind of result, for lists that mix them. */
49export function hitIcon(hit: SiteHit, size = 15): ReactNode {
50 switch (hit.kind) {
51 case "repository":
52 return hit.private ? <Lock size={size} /> : <BookMarked size={size} />;
53 case "code":
54 return <FileCode2 size={size} />;
55 case "issue":
56 return hit.state === "open" ? <CircleDot size={size} /> : <CircleSlash size={size} />;
57 case "pull":
58 return <GitPullRequest size={size} />;
59 case "user":
60 return <User size={size} />;
61 case "workspace":
62 return <Building2 size={size} />;
63 }
64}
65
66function RepoLink({ repo }: { repo: string }) {
67 return (
68 <Link to={`/${repo}`} className="font-mono text-xs text-muted hover:text-fg">
69 {repo}
70 </Link>
71 );
72}
73
74function Repository({ hit }: { hit: SiteHit }) {
75 const [owner, name] = (hit.repo ?? hit.title).split("/");
76 return (
77 <div>
78 <div className="flex flex-wrap items-center gap-2">
79 <span className="text-faint">{hitIcon(hit)}</span>
80 <Link to={hit.url} className="font-mono text-[0.9375rem] hover:underline">
81 <span className="text-muted">{owner}/</span>
82 <span className="font-semibold text-fg">{name}</span>
83 </Link>
84 {hit.private && <Badge>private</Badge>}
85 </div>
86 {hit.snippet.length > 0 && <Highlighted parts={hit.snippet} className="mt-1.5 block text-sm text-muted" />}
87 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-faint">
88 {hit.language && <span className="font-mono">{hit.language}</span>}
89 {hit.topics.slice(0, 6).map((topic) => (
90 <Link
91 key={topic}
92 to={`/explore?topic=${encodeURIComponent(topic)}`}
93 className="rounded-full bg-raised px-2 py-px text-muted ring-1 ring-line hover:text-fg"
94 >
95 {topic}
96 </Link>
97 ))}
98 {hit.updatedAt && (
99 <span>
100 Updated <TimeAgo at={hit.updatedAt} />
101 </span>
102 )}
103 </div>
104 </div>
105 );
106}
107
108function Code({ hit }: { hit: SiteHit }) {
109 const base = hit.url.split("#")[0];
110 return (
111 <div className="overflow-hidden rounded-lg border border-line">
112 <div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-line bg-surface px-3 py-2">
113 {hit.repo && <RepoLink repo={hit.repo} />}
114 <span className="text-line-strong">/</span>
115 <Link to={hit.url} className="min-w-0 truncate font-mono text-sm font-medium hover:underline">
116 {hit.path}
117 </Link>
118 {hit.private && <Badge>private</Badge>}
119 {hit.language && <span className="ml-auto font-mono text-xs text-faint">{hit.language}</span>}
120 </div>
121 {hit.lines.length > 0 && (
122 <table className="w-full table-fixed border-collapse font-mono text-[0.8125rem] leading-6">
123 <tbody>
124 {hit.lines.map((line, index) => {
125 const gap = index > 0 && line.number !== hit.lines[index - 1]!.number + 1;
126 return (
127 <tr key={line.number} className={gap ? "border-t border-dashed border-line" : undefined}>
128 <td className="w-14 select-none pr-3 text-right align-top">
129 <Link to={`${base}#L${line.number}`} className="text-faint hover:text-fg">
130 {line.number}
131 </Link>
132 </td>
133 <td className="overflow-hidden pr-3 whitespace-pre text-ellipsis text-fg/90">
134 <Highlighted parts={line.parts} />
135 </td>
136 </tr>
137 );
138 })}
139 </tbody>
140 </table>
141 )}
142 </div>
143 );
144}
145
146function Item({ hit }: { hit: SiteHit }) {
147 return (
148 <div className="flex gap-3">
149 <span className="mt-0.5 shrink-0">{stateIcon(hit)}</span>
150 <div className="min-w-0 grow">
151 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
152 {hit.repo && <RepoLink repo={hit.repo} />}
153 <span className="font-mono text-xs text-faint">#{hit.number}</span>
154 {hit.private && <Badge>private</Badge>}
155 </div>
156 <Link to={hit.url} className="mt-0.5 block font-medium hover:underline">
157 {hit.title}
158 </Link>
159 {hit.snippet.length > 0 && <Highlighted parts={hit.snippet} className="mt-1 block text-sm text-muted" />}
160 <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
161 {hit.author && (
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights162 <span>
163 <PersonLink name={hit.author} className="hover:text-fg" />
164 {hit.requestedBy && (
165 <>
166 {" "}
167 for{" "}
168 <PersonLink name={hit.requestedBy} className="hover:text-fg" />
169 </>
170 )}
171 </span>
Search across all of g1t, Explore, and a command palette172 )}
173 {hit.labels.map((label) => (
174 <span key={label} className="rounded-full px-2 py-px text-muted ring-1 ring-line">
175 {label}
176 </span>
177 ))}
178 {hit.updatedAt && (
179 <span>
180 Updated <TimeAgo at={hit.updatedAt} />
181 </span>
182 )}
183 </div>
184 </div>
185 </div>
186 );
187}
188
189function Person({ hit }: { hit: SiteHit }) {
190 const workspace = hit.kind === "workspace";
191 return (
192 <div className="flex items-start gap-3">
193 <Avatar name={hit.slug ?? hit.title} image={hit.avatar} size={36} square={workspace} />
194 <div className="min-w-0">
195 <div className="flex flex-wrap items-center gap-2">
196 <Link to={hit.url} className="font-medium hover:underline">
197 {hit.title}
198 </Link>
199 <span className="font-mono text-xs text-faint">{hit.slug}</span>
200 <Badge>{workspace ? "workspace" : "person"}</Badge>
201 </div>
202 {hit.snippet.length > 0 && <Highlighted parts={hit.snippet} className="mt-1 block text-sm text-muted" />}
203 </div>
204 </div>
205 );
206}
207
208/** One result, drawn for its kind. */
209export function SearchHitView({ hit }: { hit: SiteHit }) {
210 switch (hit.kind) {
211 case "repository":
212 return <Repository hit={hit} />;
213 case "code":
214 return <Code hit={hit} />;
215 case "issue":
216 case "pull":
217 return <Item hit={hit} />;
218 case "user":
219 case "workspace":
220 return <Person hit={hit} />;
221 }
222}