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/components/search.tsx

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