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.

156 linesCodeBlameRaw
1/**
2 * Integrations in the Marketplace, by who builds them: every connector is
3 * g1t's own (Official), so that section holds the ones connected, the ones
4 * a workspace can connect today, any this g1t can't connect (with why),
5 * those each person connects for themselves, and the ones the catalog
6 * lists as coming; Verified, Community and Internal are sections too,
7 * empty and saying so. Search, and filters by tier and availability, kept
8 * in the address. Each workspace one is set up on its own page under
9 * Workspace → Integrations; owners connect, anyone else asks.
10 */
11import { Search } from "lucide-react";
12import { useState } from "react";
13import { Link, useSearchParams } from "react-router";
14
15import { CONNECTORS, connectorView, connectorsFor } from "@g1t/contracts/connectors";
16
17import type { Route } from "./+types/integrations";
18import { IntegrationCard, IntegrationRow, ListingFilterBar, ListingLegend, SectionHead, TierBadge, TierEmpty, filterCounts } from "../../../components/marketplace";
19import { type IntegrationListing, comingListings, integrationListings, integrationMatches, passes, personalListings, readFilters, tiersShown } from "../../../lib/marketplace";
20import { loadConnected } from "../../../lib/marketplace.server";
21import { requireUser, roleIn } from "../../../lib/session.server";
22import { useMarketplace } from "./layout";
23import { Card } from "../../../components/ui/card";
24
25export async function loader({ params, context, request }: Route.LoaderArgs) {
26 const viewer = requireUser(context, request);
27 const slug = params.owner.toLowerCase();
28 if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
29 return await loadConnected(slug, viewer);
30}
31
32const HEADINGS = { official: "From g1t", verified: "From verified publishers", community: "From the community", internal: "Built in this workspace" } as const;
33
34export default function MarketplaceIntegrations({ loaderData }: Route.ComponentProps) {
35 const { slug, owner, username, requests } = useMarketplace();
36 const [query, setQuery] = useState("");
37 const [params] = useSearchParams();
38 const filters = readFilters(params);
39 const views = connectorsFor("workspace");
40 const personalViews = CONNECTORS.map((c) => connectorView(c, "personal")).filter((view) => view != null);
41 const workspace = integrationListings(views, loaderData.connected, requests?.requests ?? [], username, slug, loaderData.unavailable);
42 // What each person connects for themselves, today; and what is coming, for either.
43 const yours = personalListings(personalViews, slug, loaderData.unavailable);
44 const coming = comingListings(
45 views,
46 personalViews.filter((view) => !CONNECTORS.find((c) => c.id === view.id)?.scopes.includes("workspace")),
47 slug,
48 );
49 const everything = [...workspace, ...yours, ...coming];
50 const wanted = query.trim().toLowerCase();
51 const keep = (listing: IntegrationListing) => integrationMatches(listing, query) && passes(listing, filters);
52 // A search or an availability filter can empty a tier; the tier filter alone only picks one.
53 const filtered = wanted !== "" || filters.availability !== "all";
54 // A plain function, not a component made each render: that would remount the cards (and close a request's dialog).
55 const grid = (items: IntegrationListing[]) => (
56 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
57 {items.map((listing) => (
58 <IntegrationCard key={`${listing.scope}:${listing.ref}`} listing={listing} slug={slug} owner={owner} />
59 ))}
60 </div>
61 );
62 return (
63 <div className="space-y-10">
64 <div className="space-y-4">
65 <div className="flex flex-wrap items-center justify-between gap-3">
66 <Card asChild radius="lg" className="flex h-9 w-full max-w-sm items-center gap-2 px-2.5 text-sm focus-within:border-line-strong">
67 <label>
68 <Search size={15} className="shrink-0 text-faint" />
69 <input
70 value={query}
71 onChange={(event) => setQuery(event.target.value)}
72 placeholder="Find an integration"
73 aria-label="Find an integration"
74 autoComplete="off"
75 className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
76 />
77 </label>
78 </Card>
79 <Link to={`/${slug}/-/integrations`} className="text-[0.8125rem] text-muted hover:text-fg">
80 Workspace integrations settings
81 </Link>
82 </div>
83 <ListingFilterBar kind="integration" filters={filters} counts={filterCounts(everything)} />
84 </div>
85
86 {tiersShown(filters).map((tier) => {
87 const connected = workspace.filter((l) => l.tier === tier && l.availability === "added" && keep(l));
88 const available = workspace.filter((l) => l.tier === tier && l.availability === "available" && keep(l));
89 const blocked = workspace.filter((l) => l.tier === tier && l.availability === "unavailable" && keep(l));
90 const own = yours.filter((l) => l.tier === tier && keep(l));
91 const soon = coming.filter((l) => l.tier === tier && keep(l));
92 const count = connected.length + available.length + blocked.length + own.length + soon.length;
93 return (
94 <section key={tier} aria-labelledby={`tier-${tier}`} className="space-y-8">
95 <SectionHead
96 id={`tier-${tier}`}
97 title={
98 <>
99 <TierBadge tier={tier} />
100 {HEADINGS[tier]}
101 </>
102 }
103 aside={`${count}`}
104 >
105 {tier === "official" && count > 0 ? "Every connector in the catalog is built and supported by g1t. Each says what it lets agents do." : null}
106 </SectionHead>
107 {count === 0 && <TierEmpty tier={tier} kind="integration" filtered={filtered} />}
108 {connected.length > 0 && (
109 <div>
110 <SectionHead sub id={`${tier}-connected`} title="Connected" aside={`${connected.length}`} />
111 {grid(connected)}
112 </div>
113 )}
114 {available.length > 0 && (
115 <div>
116 <SectionHead sub id={`${tier}-available`} title="Available to connect" aside={`${available.length}`}>
117 Connected once for the whole workspace by an owner.
118 </SectionHead>
119 {grid(available)}
120 </div>
121 )}
122 {blocked.length > 0 && (
123 <div>
124 <SectionHead sub id={`${tier}-unavailable`} title="Not available here" aside={`${blocked.length}`}>
125 This g1t lacks something these need. Each says what.
126 </SectionHead>
127 {grid(blocked)}
128 </div>
129 )}
130 {own.length > 0 && (
131 <div>
132 <SectionHead sub id={`${tier}-yours`} title="Connected by each person" aside={`${own.length}`}>
133 Each person connects these for themselves, and agents use them only when that person asks.
134 </SectionHead>
135 {grid(own)}
136 </div>
137 )}
138 {soon.length > 0 && (
139 <div>
140 <SectionHead sub id={`${tier}-soon`} title="Soon" aside={`${soon.length}`}>
141 Planned, not built. Nobody can connect these yet.
142 </SectionHead>
143 <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
144 {soon.map((listing) => (
145 <IntegrationRow key={listing.ref} listing={listing} />
146 ))}
147 </ul>
148 </div>
149 )}
150 </section>
151 );
152 })}
153 <ListingLegend kind="integration" />
154 </div>
155 );
156}