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.

g1t.sh
537 linesCodeBlameRaw
1import {
2 AlertTriangle,
3 BarChart3,
4 BookOpen,
5 Bot,
6 CalendarDays,
7 ChevronRight,
8 Cloud,
9 Code2,
10 FolderOpen,
11 Headset,
12 LayoutGrid,
13 ListChecks,
14 MessagesSquare,
15 Palette,
16 Search,
17 ShieldCheck,
18 Siren,
19 Webhook,
20} from "lucide-react";
21import { type ReactNode, useState } from "react";
22import { Link, useSearchParams } from "react-router";
23
24import { type AiProvider, AI_PROVIDERS, type Provider, PROVIDERS } from "@g1t/contracts";
25import { type ConnectorScope, type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
26
27import { AI_PROVIDER_LOOKS, isEndpoint } from "../lib/ai-providers";
28import { type BrandIcon, brandIcon } from "../lib/brand-icons";
29import { CONTACT } from "../lib/legal";
30import {
31 type CategoryFilter,
32 type ConnectedState,
33 arrange,
34 askHref,
35 categoriesWith,
36 categoryCounts,
37 categoryFilter,
38 categoryTitle,
39 groupByCategory,
40 monogram,
41 tileColor,
42} from "../lib/connectors";
43import { cn } from "../lib/cn";
44import { providerPage } from "../lib/integration-sections";
45import { Badge } from "./ui/badge";
46import { Button } from "./ui/button";
47import { Hint } from "./ui/hint";
48import { Input, InputAddon, InputGroup } from "./ui/input";
49import { Card } from "./ui/card";
50
51const CATEGORY_ICONS: Record<CategoryFilter, ReactNode> = {
52 all: <LayoutGrid size={15} />,
53 code: <Code2 size={15} />,
54 issues: <ListChecks size={15} />,
55 chat: <MessagesSquare size={15} />,
56 calendar: <CalendarDays size={15} />,
57 docs: <BookOpen size={15} />,
58 monitoring: <Siren size={15} />,
59 cloud: <Cloud size={15} />,
60 ai: <Bot size={15} />,
61 data: <BarChart3 size={15} />,
62 crm: <Headset size={15} />,
63 design: <Palette size={15} />,
64 security: <ShieldCheck size={15} />,
65 files: <FolderOpen size={15} />,
66};
67
68const PROVIDER_COLORS: Record<Provider, string> = {
69 sentry: "#a78bfa",
70 datadog: "#a26ce6",
71 webhook: "#5eead4",
72 jira: "#5b8def",
73 linear: "#8b8ff0",
74};
75
76/**
77 * A mark's tile: a brand's icon, or letters or an icon, on a wash of
78 * `color`. With no colour (a brand drawn in black) it is drawn in the text
79 * colour on a neutral wash, so it reads in both themes.
80 */
81function MarkTile({ color, size, children }: { color: string | null; size: number; children: ReactNode }) {
82 const tint = color ?? "var(--color-fg)";
83 return (
84 <span
85 aria-hidden="true"
86 className="inline-flex shrink-0 items-center justify-center font-semibold tracking-tight"
87 style={{
88 width: size,
89 height: size,
90 borderRadius: size * 0.28,
91 fontSize: size * (typeof children === "string" && children.length > 1 ? 0.34 : 0.42),
92 background: `color-mix(in srgb, ${tint} ${color ? 16 : 8}%, transparent)`,
93 // Toward the text colour, so the mark reads on the tile in both themes.
94 color: color ? `color-mix(in srgb, ${color} 60%, var(--color-fg))` : "var(--color-fg)",
95 boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tint} ${color ? 30 : 14}%, transparent)`,
96 }}
97 >
98 {children}
99 </span>
100 );
101}
102
103/** A brand's icon, from Simple Icons, in the tile's colour. */
104function BrandGlyph({ icon, size }: { icon: BrandIcon; size: number }) {
105 return (
106 <svg viewBox="0 0 24 24" width={size * 0.5} height={size * 0.5} fill="currentColor">
107 <path d={icon.path} />
108 </svg>
109 );
110}
111
112/** A provider's brand icon, by its name, when Simple Icons has one. */
113function brandMark(name: string, size: number): ReactNode {
114 const brand = brandIcon(name.replace(/_/g, "-"));
115 if (!brand) return null;
116 return (
117 <MarkTile color={brand.color} size={size}>
118 <BrandGlyph icon={brand} size={size} />
119 </MarkTile>
120 );
121}
122
123/** An integration's mark: its brand's icon, the webhook icon, or its initials in its colour. */
124export function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
125 if (provider === "webhook") {
126 return (
127 <MarkTile color={PROVIDER_COLORS.webhook} size={size}>
128 <Webhook size={size * 0.5} />
129 </MarkTile>
130 );
131 }
132 return (
133 brandMark(provider, size) ?? (
134 <MarkTile color={PROVIDER_COLORS[provider]} size={size}>
135 {monogram(PROVIDERS[provider].label)}
136 </MarkTile>
137 )
138 );
139}
140
141/** An AI provider's mark: its brand's icon, an icon for an endpoint, or its initials in its colour. */
142export function AiProviderMark({ provider, size = 32 }: { provider: AiProvider; size?: number }) {
143 const look = AI_PROVIDER_LOOKS[provider];
144 return (
145 brandMark(provider, size) ?? (
146 <MarkTile color={look.color} size={size}>
147 {isEndpoint(provider) ? <Bot size={size * 0.5} /> : look.mark || monogram(AI_PROVIDERS[provider].label)}
148 </MarkTile>
149 )
150 );
151}
152
153/** One provider to choose, as a compact tile that opens its form. */
154function ProviderTile({ to, mark, label, blurb, active }: { to: string; mark: ReactNode; label: string; blurb: string; active: boolean }) {
155 return (
156 <Link
157 to={to}
158 preventScrollReset
159 className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${
160 active ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
161 }`}
162 >
163 {mark}
164 <span className="min-w-0 grow">
165 <span className="block truncate text-sm font-medium">{label}</span>
166 <Hint label={blurb}>
167 <span className="block truncate text-xs text-muted">{blurb}</span>
168 </Hint>
169 </span>
170 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
171 </Link>
172 );
173}
174
175/** Integrations to choose from, as compact tiles that open their form on their section's page. */
176export function ProviderTiles({
177 providers,
178 slug,
179 adding,
180 blurb,
181}: {
182 providers: Provider[];
183 slug: string;
184 adding: Provider | null;
185 blurb: (provider: Provider) => string;
186}) {
187 return (
188 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
189 {providers.map((provider) => (
190 <ProviderTile
191 key={provider}
192 to={`/${slug}/-/${providerPage(PROVIDERS[provider].kind)}?add=${provider}#add`}
193 mark={<ProviderMark provider={provider} size={28} />}
194 label={PROVIDERS[provider].label}
195 blurb={blurb(provider)}
196 active={adding === provider}
197 />
198 ))}
199 </div>
200 );
201}
202
203/** AI providers to choose from, as compact tiles that open their form on the workspace's AI page. */
204export function AiProviderTiles({ providers, slug, adding }: { providers: AiProvider[]; slug: string; adding: AiProvider | null }) {
205 return (
206 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
207 {providers.map((provider) => (
208 <ProviderTile
209 key={provider}
210 to={`/${slug}/-/ai?add=${provider}#add`}
211 mark={<AiProviderMark provider={provider} size={28} />}
212 label={AI_PROVIDERS[provider].label}
213 blurb={AI_PROVIDER_LOOKS[provider].blurb}
214 active={adding === provider}
215 />
216 ))}
217 </div>
218 );
219}
220
221/** A connector's tile: its brand's icon, its provider's mark, or its letters on a steady colour. */
222export function ConnectorMark({ view, size = 36 }: { view: Pick<ConnectorView, "id" | "name" | "provider">; size?: number }) {
223 const brand = brandIcon(view.id);
224 if (brand) {
225 return (
226 <MarkTile color={brand.color} size={size}>
227 <BrandGlyph icon={brand} size={size} />
228 </MarkTile>
229 );
230 }
231 if (view.provider) return <ProviderMark provider={view.provider} size={size} />;
232 // Outgoing webhooks have no brand: the same webhook mark as incoming ones.
233 if (view.id === "webhooks") return <ProviderMark provider="webhook" size={size} />;
234 return (
235 <MarkTile color={tileColor(view.id)} size={size}>
236 {monogram(view.name)}
237 </MarkTile>
238 );
239}
240
241export type ConnectorDirectoryProps = {
242 scope: ConnectorScope;
243 views: ConnectorView[];
244 /** What is connected here, by connector id. */
245 connected: Record<string, ConnectedState>;
246 /** The workspace's slug on its page; null on your own. */
247 workspace: string | null;
248 /** Whether the viewer can connect things here (an owner, or anyone for themselves). */
249 canManage: boolean;
250 /** Category navigation as a sidebar beside the cards (wide pages), or as chips above them. */
251 nav?: "sidebar" | "chips";
252};
253
254/**
255 * The integrations directory: categories to browse, a search, and every
256 * connector as a card, sorted into Connected, Available and Soon.
257 */
258export function ConnectorDirectory({ scope, views, connected, workspace, canManage, nav = "sidebar" }: ConnectorDirectoryProps) {
259 const [params, setParams] = useSearchParams();
260 const category = categoryFilter(params.get("category"));
261 const [query, setQuery] = useState("");
262 const counts = categoryCounts(views);
263 const categories = categoriesWith(views);
264 const found = arrange(views, { category, query, connected: new Set(Object.keys(connected)) });
265 const searching = query.trim().length > 0;
266 const grouped = category === "all" && !searching;
267 const empty = found.connected.length + found.available.length + found.soon.length === 0;
268
269 const choose = (next: CategoryFilter) => {
270 setParams(
271 (current) => {
272 const updated = new URLSearchParams(current);
273 if (next === "all") updated.delete("category");
274 else updated.set("category", next);
275 return updated;
276 },
277 { replace: true, preventScrollReset: true },
278 );
279 setQuery("");
280 };
281
282 const options: { id: CategoryFilter; title: string }[] = [{ id: "all", title: "All" }, ...categories];
283 const card = (view: ConnectorView) => (
284 <ConnectorCard
285 key={view.id}
286 view={view}
287 scope={scope}
288 state={connected[view.id] ?? null}
289 workspace={workspace}
290 canManage={canManage}
291 showCategory={category === "all" || searching}
292 />
293 );
294
295 return (
296 <div className={cn(nav === "sidebar" && "md:grid md:grid-cols-[12rem_minmax(0,1fr)] md:gap-8")}>
297 {/* Categories: a list beside the cards on a wide page, chips that scroll sideways otherwise. */}
298 <nav aria-label="Categories" className={cn(nav === "sidebar" && "md:sticky md:top-20 md:self-start")}>
299 <ul
300 className={cn(
301 "-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 scrollbar-none sm:mx-0 sm:px-0",
302 nav === "sidebar" && "md:mx-0 md:flex-col md:gap-px md:overflow-visible md:px-0",
303 )}
304 >
305 {options.map((option) => {
306 const current = option.id === category;
307 return (
308 <li key={option.id} className="shrink-0">
309 <button
310 type="button"
311 onClick={() => choose(option.id)}
312 aria-current={current ? "page" : undefined}
313 className={cn(
314 "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors",
315 nav === "sidebar" && "md:w-full md:rounded-md md:border-transparent md:px-2.5",
316 current
317 ? "border-accent/40 bg-accent/10 text-fg md:bg-raised"
318 : "border-line text-muted hover:border-line-strong hover:text-fg md:hover:bg-surface",
319 )}
320 >
321 <span className={cn("hidden text-faint", nav === "sidebar" && "md:inline-flex", current && "text-accent")}>
322 {CATEGORY_ICONS[option.id]}
323 </span>
324 <span className="grow text-left">{option.title}</span>
325 <span className="text-xs text-faint tabular-nums">{counts[option.id]}</span>
326 </button>
327 </li>
328 );
329 })}
330 </ul>
331 </nav>
332
333 <div className="mt-5 min-w-0 md:mt-0">
334 <InputGroup>
335 <InputAddon className="pr-0">
336 <Search size={15} />
337 </InputAddon>
338 <Input
339 type="search"
340 value={query}
341 onChange={(event) => setQuery(event.target.value)}
342 placeholder={category === "all" ? "Search integrations" : `Search all integrations, not just ${categoryTitle(category)}`}
343 aria-label="Search integrations"
344 />
345 </InputGroup>
346
347 {empty && (
348 <Card asChild tone="plain" className="mt-8 border-dashed px-4 py-8 text-center text-sm text-muted">
349 <p>
350 Nothing matches “{query.trim()}”.{" "}
351 <a
352 href={askHref({ connector: { id: "other", name: query.trim() || "something else" }, scope, workspace, address: CONTACT.support })}
353 className="text-accent hover:underline"
354 >
355 Ask for it
356 </a>
357 , and say what you would use it for.
358 </p>
359 </Card>
360 )}
361
362 {found.connected.length > 0 && (
363 <Group title="Connected" count={found.connected.length}>
364 <Cards>{found.connected.map(card)}</Cards>
365 </Group>
366 )}
367 {found.available.length > 0 && (
368 <Group
369 title="Available"
370 count={found.available.length}
371 about={
372 scope === "workspace"
373 ? canManage
374 ? "Ready to connect now."
375 : `Ready now. An owner of ${workspace} connects them.`
376 : "Ready to connect now."
377 }
378 >
379 <Cards>{found.available.map(card)}</Cards>
380 </Group>
381 )}
382 {found.soon.length > 0 && (
383 <Group title="Soon" count={found.soon.length} about="Being built. Ask for the ones you need, and they come sooner.">
384 {grouped ? (
385 <div className="space-y-6">
386 {groupByCategory(found.soon).map((group) => (
387 <div key={group.id}>
388 <p className="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-faint uppercase">
389 {CATEGORY_ICONS[group.id]}
390 {group.title}
391 </p>
392 <Cards>{group.views.map((view) => <ConnectorCard key={view.id} view={view} scope={scope} state={null} workspace={workspace} canManage={canManage} showCategory={false} />)}</Cards>
393 </div>
394 ))}
395 </div>
396 ) : (
397 <Cards>{found.soon.map(card)}</Cards>
398 )}
399 </Group>
400 )}
401 </div>
402 </div>
403 );
404}
405
406function Group({ title, count, about, children }: { title: string; count: number; about?: string; children: ReactNode }) {
407 return (
408 <section className="mt-8">
409 <h2 className="flex items-baseline gap-2 text-sm font-medium">
410 {title}
411 <span className="text-xs font-normal text-faint tabular-nums">{count}</span>
412 </h2>
413 {about && <p className="mt-0.5 text-xs text-muted">{about}</p>}
414 <div className="mt-3">{children}</div>
415 </section>
416 );
417}
418
419function Cards({ children }: { children: ReactNode }) {
420 return <ul className="grid gap-3 sm:grid-cols-2">{children}</ul>;
421}
422
423function ConnectorCard({
424 view,
425 scope,
426 state,
427 workspace,
428 canManage,
429 showCategory,
430}: {
431 view: ConnectorView;
432 scope: ConnectorScope;
433 state: ConnectedState | null;
434 workspace: string | null;
435 canManage: boolean;
436 showCategory: boolean;
437}) {
438 const soon = view.status === "soon";
439 const setup = view.href ? connectorPath(view.href, workspace ?? "") : null;
440 const to = state?.manage ?? setup;
441 const also =
442 view.alsoIn === "personal"
443 ? "Each person can also connect their own, under their settings."
444 : view.alsoIn === "workspace"
445 ? "A workspace owner can also connect it for everyone."
446 : null;
447 return (
448 <li
449 className={cn(
450 "relative flex flex-col rounded-xl border p-4 transition-colors",
451 soon ? "border-line/70 bg-surface/40" : "border-line bg-surface",
452 !soon && to && "hover:border-line-strong",
453 )}
454 >
455 <div className="flex items-start gap-3">
456 <span className={cn(soon && "opacity-70 grayscale-35")}>
457 <ConnectorMark view={view} />
458 </span>
459 <div className="min-w-0 grow">
460 <div className="flex items-center gap-2">
461 {!soon && to ? (
462 // The whole card opens it: the link stretches over the card.
463 <Link to={to} className="truncate text-sm font-medium after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none">
464 {view.name}
465 </Link>
466 ) : (
467 <p className={cn("truncate text-sm font-medium", soon && "text-fg/80")}>{view.name}</p>
468 )}
469 {state ? (
470 state.problem ? (
471 <Hint label={state.problem}>
472 <Badge tone="warn" className="relative z-10" tabIndex={0}>
473 <AlertTriangle size={11} /> Needs attention
474 </Badge>
475 </Hint>
476 ) : (
477 <Badge tone="success">Connected</Badge>
478 )
479 ) : soon ? (
480 <Badge>Soon</Badge>
481 ) : null}
482 </div>
483 {showCategory && <p className="text-xs text-faint">{categoryTitle(view.category)}</p>}
484 </div>
485 </div>
486 <p className={cn("mt-2.5 text-sm", soon ? "text-muted/90" : "text-muted")}>{view.description}</p>
487 {state?.detail && <p className="mt-1.5 truncate text-xs text-faint">{state.detail}</p>}
488 {view.capabilities.length > 0 && (
489 <ul className="mt-3 flex flex-wrap gap-1.5">
490 {view.capabilities.map((capability) => (
491 <li key={capability} className="rounded-md bg-raised px-1.5 py-0.5 text-[0.6875rem] text-muted">
492 {capability}
493 </li>
494 ))}
495 </ul>
496 )}
497 <div className="mt-auto flex items-end justify-between gap-3 pt-3">
498 <p className="text-[0.6875rem] text-faint">{also}</p>
499 {soon ? (
500 <Button asChild variant="outline" size="xs" className="relative z-10">
501 <a href={askHref({ connector: view, scope, workspace, address: CONTACT.support })}>Ask for this</a>
502 </Button>
503 ) : to ? (
504 <span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-accent">
505 {state ? (canManage ? "Manage" : "View") : canManage ? "Set up" : "View"}
506 <ChevronRight size={13} />
507 </span>
508 ) : null}
509 </div>
510 </li>
511 );
512}
513
514/** A short line saying who a page's connections are for, and where the other kind lives. */
515export function ScopeNote({ scope, workspace }: { scope: ConnectorScope; workspace: string | null }) {
516 return (
517 <Card asChild tone="plain" className="mb-6 bg-surface/60 px-4 py-3 text-sm text-muted">
518 <p>
519 {scope === "workspace" ? (
520 <>
521 Connected here, a tool works <span className="text-fg">for everyone in {workspace}</span>.
522 Your own accounts, such as your calendar, are under{" "}
523 <Link to="/settings/integrations" className="text-accent hover:underline">
524 your integrations
525 </Link>
526 .
527 </>
528 ) : (
529 <>
530 Connected here, a tool works <span className="text-fg">just for you, in every workspace</span> you belong to. Tools
531 the whole team shares are connected by a workspace owner, under the workspace's Integrations.
532 </>
533 )}
534 </p>
535 </Card>
536 );
537}