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