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/routes/workspace/overview.tsx

202 lines8,146 bytesCodeBlame
1import { ArrowRight, KeyRound } from "lucide-react";
2import { Link } from "react-router";
3
4import type { Route } from "./+types/overview";
5import { RepoList } from "../../components/repo-list";
6import { Avatar, ButtonLink, CopyLine, Pill, TimeAgo } from "../../components/ui";
7import { PullIcon } from "../../components/work";
8import { identity, repos as reposApi, work } from "../../lib/services.server";
9import { getViewer, roleIn } from "../../lib/session.server";
10
11/** Repositories whose open issues and pull requests are counted. */
12const MAX_COUNTED = 30;
13/** Repositories whose pull requests are listed under "In progress". */
14const MAX_LISTED = 8;
15const MAX_PULLS = 8;
16const MAX_FACES = 8;
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const viewer = getViewer(context);
20 const slug = params.owner.toLowerCase();
21 const role = roleIn(viewer, slug);
22 const [repos, members] = await Promise.all([
23 reposApi.list(viewer, { namespace: slug }),
24 role ? identity.listMembers(slug, viewer) : null,
25 ]);
26
27 const counted = repos.slice(0, MAX_COUNTED);
28 const counts = await Promise.all(
29 counted.map((repo) => work.counts({ namespace: repo.namespace, name: repo.name }, viewer)),
30 );
31 const open: Record<string, { issues: number; pulls: number }> = {};
32 counted.forEach((repo, i) => {
33 const result = counts[i];
34 if (result.ok) open[repo.id] = result.value;
35 });
36
37 const busy = counted.filter((repo) => (open[repo.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED);
38 const lists = await Promise.all(
39 busy.map((repo) =>
40 work.listPulls({ namespace: repo.namespace, name: repo.name }, viewer, "open"),
41 ),
42 );
43 const pulls = lists
44 .flatMap((result, i) =>
45 result.ok ? result.value.map((pull) => ({ pull, repo: busy[i].name })) : [],
46 )
47 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
48 .slice(0, MAX_PULLS);
49
50 return { slug, role, repos, open, pulls, members: members?.ok ? members.value : null };
51}
52
53function Stat({ value, label }: { value: number; label: string }) {
54 return (
55 <div className="rounded-xl border border-line bg-surface px-4 py-3">
56 <p className="text-xl font-semibold tabular-nums tracking-tight">{value}</p>
57 <p className="text-xs text-muted">{label}</p>
58 </div>
59 );
60}
61
62/** What a member sees in a workspace that has no repositories yet. */
63function GetStarted({ slug }: { slug: string }) {
64 return (
65 <div className="rounded-xl border border-line bg-surface p-6">
66 <h2 className="font-medium">Add the first repository</h2>
67 <p className="mt-1.5 max-w-xl text-sm text-muted">
68 A repository holds code, the issues that say what should change, and
69 the pull requests agents and people make for them.
70 </p>
71 <div className="mt-5 flex flex-wrap gap-3">
72 <ButtonLink to={`/new?workspace=${slug}`}>Create or import a repository</ButtonLink>
73 </div>
74 <p className="mt-6 text-sm text-muted">Or push one you already have:</p>
75 <div className="mt-2 max-w-xl">
76 <CopyLine prompt text={`git push https://g1t.sh/${slug}/my-project.git main`} />
77 </div>
78 </div>
79 );
80}
81
82export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
83 const { slug, role, repos, open, pulls, members } = loaderData;
84 const totals = Object.values(open).reduce(
85 (sum, counts) => ({
86 issues: sum.issues + counts.issues,
87 pulls: sum.pulls + counts.pulls,
88 }),
89 { issues: 0, pulls: 0 },
90 );
91 return (
92 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
93 <div className="min-w-0 space-y-10">
94 {repos.length === 0 && role ? (
95 <GetStarted slug={slug} />
96 ) : (
97 <>
98 <div className="grid grid-cols-3 gap-3">
99 <Stat
100 value={repos.length}
101 label={repos.length === 1 ? "Repository" : "Repositories"}
102 />
103 <Stat value={totals.issues} label="Open issues" />
104 <Stat value={totals.pulls} label="Pull requests in progress" />
105 </div>
106
107 {pulls.length > 0 && (
108 <section>
109 <h2 className="text-sm font-medium text-muted">In progress</h2>
110 <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
111 {pulls.map(({ pull, repo }) => (
112 <li key={pull.id}>
113 <Link
114 prefetch="intent"
115 to={`/${slug}/${repo}/pull/${pull.number}`}
116 className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
117 >
118 <PullIcon status={pull.status} />
119 <span className="min-w-0 grow">
120 <span className="block truncate font-medium">{pull.title}</span>
121 <span className="font-mono text-xs text-muted">
122 {repo}#{pull.number}
123 {pull.issue != null && ` · for #${pull.issue}`} ·{" "}
124 {pull.author.username}
125 </span>
126 </span>
127 <span className="hidden shrink-0 text-xs text-muted sm:block">
128 {pull.status === "draft" ? "Being worked on" : "Ready for review"}
129 </span>
130 <span className="w-14 shrink-0 text-right text-xs text-faint">
131 <TimeAgo at={pull.updatedAt} />
132 </span>
133 </Link>
134 </li>
135 ))}
136 </ul>
137 </section>
138 )}
139
140 <section>
141 <h2 className="text-sm font-medium text-muted">Repositories</h2>
142 <RepoList repos={repos} open={open} />
143 </section>
144 </>
145 )}
146 </div>
147
148 <aside className="space-y-6">
149 {members ? (
150 <section>
151 <div className="flex items-center justify-between">
152 <h2 className="text-sm font-medium">People</h2>
153 <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
154 {role === "owner" ? "Manage" : "See all"}
155 </Link>
156 </div>
157 <ul className="mt-3 space-y-1.5">
158 {members.slice(0, MAX_FACES).map((member) => (
159 <li key={member.username} className="flex items-center gap-2 text-sm">
160 <Avatar name={member.username} />
161 <span className="grow truncate font-mono">{member.username}</span>
162 {member.role === "owner" && <Pill>owner</Pill>}
163 </li>
164 ))}
165 </ul>
166 {members.length > MAX_FACES && (
167 <p className="mt-2 text-xs text-faint">
168 and {members.length - MAX_FACES} more
169 </p>
170 )}
171 </section>
172 ) : (
173 <p className="text-sm text-muted">
174 Members of this workspace create repositories here and merge pull
175 requests into them. Anyone can open an issue or a pull request on
176 a public repository.
177 </p>
178 )}
179
180 {role && (
181 <section className="rounded-xl border border-line bg-surface p-5">
182 <h2 className="flex items-center gap-2 font-medium">
183 <KeyRound size={15} className="text-faint" />
184 Automate without a service account
185 </h2>
186 <p className="mt-1.5 text-sm text-muted">
187 A workspace has access tokens of its own for CI, integrations
188 and agents. They act as the workspace, not as a person.
189 </p>
190 <Link
191 prefetch="intent"
192 to={`/${slug}/-/tokens`}
193 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
194 >
195 Access tokens <ArrowRight size={13} />
196 </Link>
197 </section>
198 )}
199 </aside>
200 </div>
201 );
202}