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.

1,385 linesCodeBlameRaw
1import {
2 Activity as ActivityIcon,
3 ArrowRight,
4 ArrowUpRight,
5 BookOpen,
6 Plus,
7 Brain,
8 Globe,
9 Tag,
10 Code2,
11 GitBranch,
12 GitCommitHorizontal,
13 GitMerge,
14 Hand,
15 HeartPulse,
16 Lock,
17 Package as PackageGlyph,
18 Pin,
19 Rocket,
20 RotateCw,
21} from "lucide-react";
22import { waitUntil } from "cloudflare:workers";
23import { isbot } from "isbot";
24import { type ReactNode, Suspense } from "react";
25import { Await, Form, Link } from "react-router";
26
27import {
28 type G1tEvent,
29 type Memory,
30 type PackageSummary,
31 type Project,
32 type Pull,
33 workOwner,
34} from "@g1t/contracts";
35
36import type { Route } from "./+types/overview";
37import { InlineMarkdown } from "../../components/inline-markdown";
38import { DeployLink, host, StatusDot } from "../../components/deploy";
39import { CheckBadge } from "../../components/checks";
40import { useRefreshWhile } from "../../lib/refresh";
41import { ActivityFeed, DeployStrip, Meter, NeedsList, Panel, Quiet, Unavailable, percent } from "../../components/mission";
42import { type ActiveBranch, ActiveBranches } from "../../components/branches";
43import { PackageIcon } from "../../components/package-icon";
44import { Hint } from "../../components/ui/hint";
45import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton";
46import { ProductionShot } from "../../components/production-shot";
47import { GithubLinkStrip } from "../../components/github";
48import { distinctFacts } from "../../lib/memory-facts";
49import { githubApp } from "../../lib/github.server";
50import { ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
51import { Avatar } from "../../components/ui/avatar";
52import { Card } from "../../components/ui/card";
53import { ChangeSize, PersonLink } from "../../components/work";
54import {
55 type ActivityItem,
56 type Need,
57 type PipelineStage,
58 PIPELINE,
59 TIME,
60 actorIds,
61 ageBuckets,
62 firstPassRate,
63 groupActivity,
64 nameActor,
65 passRate,
66 pipelineStage,
67 PROJECT_FEED_EVENT_TYPES,
68 projectFeed,
69 queuedNumbers,
70 rankNeeds,
71 runHealth,
72} from "../../lib/mission";
73import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
74import {
75 PUBLISH_GUIDES,
76 bare,
77 kindLabel,
78 linksToShow,
79 latestTag,
80 libraryPackages,
81 packageName,
82 packagePath,
83 projectChanges,
84 projectPlan,
85 publishGuide,
86} from "../../lib/project-kind";
87import { AboutEditor, KindMenu, LinkList } from "../../components/project-about";
88import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns } from "../../components/project-head";
89import { actions, agents, deployments, events as eventLog, identity, packages, projects, repos, work } from "../../lib/services.server";
90import { madeByG1t } from "../../lib/opened-by";
91import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
92import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server";
93import { shotVersion } from "./production-screenshot";
94import { DeploymentsPanel } from "../../components/deployments-panel";
95import { reportedProduction } from "../../lib/deployments";
96import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image";
97import { cloneUrl, useAddresses } from "../../lib/addresses";
98import { readBranches } from "../../lib/branches.server";
99import { commitChecksFor } from "../../lib/commit-checks.server";
100import { showCommits } from "../../lib/commit-people.server";
101import { CommitAvatars, CommitNames } from "../../components/commit-person";
102import { CommitChecksBadge } from "../../components/commit-checks";
103import { repoPages } from "../../lib/repo-live";
104import { useRepoLive } from "../../lib/use-repo-live";
105
106const MAX_LANDED = 6;
107/** Branches read for the Active branches list, and shown. */
108const BRANCHES_READ = 10;
109const BRANCHES_SHOWN = 5;
110/** A library's packages shown on its overview; the rest are a link away. */
111const PACKAGES_SHOWN = 3;
112/** The default branch's latest commits listed. */
113const COMMITS_SHOWN = 5;
114/** How long Active branches may take before the section links to Branches instead. */
115const BRANCHES_WAIT_MS = 3_500;
116/**
117 * The same for a crawler, which gets the page only once everything in it
118 * has settled (entry.server.tsx): past this, it gets the link to Branches.
119 */
120const BRANCHES_WAIT_CRAWLER_MS = 700;
121
122/**
123 * The overview streams: the layout's header and tabs (one repository
124 * lookup, one project) go out at once with a skeleton below them, and
125 * everything here, seventeen calls across six services, follows in the
126 * same response as it settles. Crawlers wait for all of it
127 * (entry.server.tsx). Before, the first byte waited on the slowest of them.
128 */
129export function loader({ params, context, request }: Route.LoaderArgs) {
130 const userAgent = request.headers.get("user-agent");
131 return { overview: overviewData({ params, context }, Boolean(userAgent && isbot(userAgent))) };
132}
133
134async function overviewData({ params, context }: Pick<Route.LoaderArgs, "params" | "context">, crawler: boolean) {
135 const viewer = getViewer(context);
136 const path = { namespace: params.owner, name: params.repo };
137 const ref = { workspace: params.owner, slug: params.repo };
138 const now = Date.now();
139 // A section whose service fails shows its own empty state; the page stays up.
140 const soft = <T,>(promise: Promise<T> | null): Promise<T | null> =>
141 promise ? promise.catch((error) => (console.warn("overview:", error), null)) : Promise.resolve(null);
142 // People with a role of their own here see its running parts: deployments,
143 // memory; everyone who can read it sees the rest. Not awaited: only
144 // those sections wait on the repository lookup, which the layout makes in
145 // this request too.
146 const accessP = accessTo(context, params);
147 const memberP = accessP.then((access) => access.insider);
148 const forMembers = <T,>(start: () => Promise<T>): Promise<T | null> =>
149 memberP.then((member) => (member ? soft(start()) : null));
150 const repoP = soft(repoFor(context, params));
151 const projectP = soft(projectFor(context, params));
152 // A library or a tool shows its packages where an app shows production.
153 // Deployments from anywhere (g1t.page, g1t Actions, the API), by environment, for anyone who can read it.
154 const environmentsP = projectP.then((found) =>
155 soft(deployments.environments(found?.ok && found.value.source.kind === "hosted" ? found.value.source.repo : path, viewer)),
156 );
157 const planP = projectP.then((project) => (project?.ok ? projectPlan(project.value) : null));
158 const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null));
159 const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null));
160 // Recent workflow runs, for Health when no pull request's checks have finished lately.
161 const workflowRunsP = soft(actions.runs(path, viewer, { limit: 30 }));
162 // Only the kinds the feed shows: the newest events are mostly session
163 // steps and merge checks, which would otherwise crowd out everything.
164 const eventsP = repoP.then((repo) =>
165 repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, types: [...PROJECT_FEED_EVENT_TYPES], limit: 150 })) : null,
166 );
167 const minePullsP = repoP.then((repo) =>
168 repo?.ok && viewer
169 ? soft(work.listActivePulls(viewer)).then((list) => (list ?? []).filter((item) => item.pull.repoId === repo.value.id))
170 : null,
171 );
172 const openP = soft(work.listPulls(path, viewer, "open"));
173 // Where its code came from on GitHub, for members.
174 const githubP = repoP.then((repo) => (repo?.ok ? forMembers(() => githubApp.link(repo.value.id)) : null));
175 const listP = forMembers(() => deployments.list(ref, viewer));
176 // Active branches: the newest few besides the default, with how far each
177 // has moved. Branches with an open pull request are read first.
178 const branchesP = Promise.all([repoP, soft(repos.branches(path, viewer)), openP, listP]).then(
179 async ([repo, branchList, pulls, deploys]): Promise<{ main: string; total: number; shown: ActiveBranch[] } | null> => {
180 if (!repo?.ok || !branchList?.ok) return null;
181 const main = repo.value.defaultBranch;
182 if (branchList.value.every((branch) => branch.name === main)) return { main, total: 0, shown: [] };
183 const read = await readBranches(
184 path,
185 viewer,
186 {
187 defaultBranch: main,
188 branches: branchList.value,
189 pulls: pulls?.ok ? pulls.value : [],
190 previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [],
191 },
192 BRANCHES_READ,
193 );
194 return { main, total: read.total, shown: read.shown.slice(0, BRANCHES_SHOWN) };
195 },
196 );
197 // Active branches walk history when a branch or the default branch has
198 // moved: streamed, so the rest shows first. Bounded well inside the
199 // response's stream timeout (entry.server.tsx): a promise still pending
200 // when the stream ends never settles in the browser, and its skeleton
201 // would stay. The walk finishes after the page if it must (waitUntil),
202 // so repos keeps the answer and the next view has it. Crawlers, which
203 // wait for the whole page, wait for it only briefly.
204 waitUntil(branchesP.then(() => undefined, () => undefined));
205 const branches = Promise.race([
206 branchesP.catch(() => null),
207 new Promise<"slow">((resolve) => setTimeout(() => resolve("slow"), crawler ? BRANCHES_WAIT_CRAWLER_MS : BRANCHES_WAIT_MS)),
208 ]);
209 const [{ insider: member, can }, project, settings, list, open, closed, log, counts, queue, issues, memories, recent, mine, packageList, tagList] = await Promise.all([
210 accessP,
211 projectP,
212 forMembers(() => deployments.settings(ref, viewer)),
213 listP,
214 openP,
215 soft(work.listPulls(path, viewer, "closed")),
216 // With their people, in one identity call.
217 soft(repos.log(path, viewer, null, COMMITS_SHOWN).then(async (found) => (found.ok ? { ok: true as const, value: await showCommits(found.value) } : found))),
218 soft(countsFor(context, params)),
219 soft(work.queue(path, viewer)),
220 soft(work.listIssues(path, viewer, { state: "open" })),
221 forMembers(() => agents.listMemories(viewer, params.owner, path)),
222 eventsP,
223 minePullsP,
224 packagesP,
225 // The latest release, for About.
226 soft(repos.tags(path, viewer)),
227 ]);
228 const workflowRuns = await workflowRunsP;
229 const ok = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
230
231 const openPulls = ok(open) ?? [];
232 const builds = ok(list)?.deployments ?? [];
233 const landed = (ok(closed) ?? [])
234 .filter((pull) => pull.status === "merged")
235 .sort((a, b) => Date.parse(b.mergedAt ?? b.updatedAt) - Date.parse(a.mergedAt ?? a.updatedAt));
236 const queued = queuedNumbers(ok(queue)?.active ?? []);
237 const lifecycleOf = new Map((mine ?? []).map((item) => [item.pull.number, item.lifecycle]));
238
239 // --- The pipeline -----------------------------------------------------------
240 type Card = {
241 number: number;
242 title: string;
243 agent: string;
244 status: Pull["status"];
245 checkStatus: Pull["checkStatus"];
246 needsYou: boolean;
247 at: string;
248 };
249 const columns: Record<PipelineStage, Card[]> = { working: [], checking: [], reviewing: [], queue: [], landed: [] };
250 for (const pull of openPulls.slice(0, 40)) {
251 columns[pipelineStage(pull, undefined, queued, lifecycleOf.get(pull.number))].push({
252 number: pull.number,
253 title: pull.title,
254 agent: pull.agent,
255 status: pull.status,
256 checkStatus: pull.checkStatus,
257 needsYou: lifecycleOf.get(pull.number)?.stage === "needs_you",
258 at: pull.updatedAt,
259 });
260 }
261 for (const pull of landed.filter((p) => now - Date.parse(p.mergedAt ?? p.updatedAt) < 3 * TIME.DAY).slice(0, 5)) {
262 columns.landed.push({
263 number: pull.number,
264 title: pull.title,
265 agent: pull.agent,
266 status: pull.status,
267 checkStatus: pull.checkStatus,
268 needsYou: false,
269 at: pull.mergedAt ?? pull.updatedAt,
270 });
271 }
272
273 // --- Needs you ---------------------------------------------------------------
274 const base = `/${params.owner}/${params.repo}`;
275 const needs: Need[] = [];
276 const latestProduction = builds.find((build) => build.kind === "production") ?? null;
277 if (latestProduction?.status === "failed") {
278 needs.push({
279 key: "deploy",
280 kind: "deploy",
281 title: "The production build failed",
282 detail: latestProduction.error ?? "Production still serves the build before it.",
283 to: `${base}/deployments/${latestProduction.id}`,
284 action: "See the build",
285 at: Date.parse(latestProduction.finishedAt ?? latestProduction.createdAt),
286 where: latestProduction.commit.slice(0, 7),
287 });
288 }
289 const me = viewer?.username.toLowerCase();
290 for (const pull of openPulls) {
291 const to = `${base}/pull/${pull.number}`;
292 const where = `#${pull.number}`;
293 const lifecycle = lifecycleOf.get(pull.number);
294 if (lifecycle?.stage === "needs_you") {
295 const conflict = /conflict/i.test(lifecycle.detail);
296 needs.push({ key: `pull:${pull.number}`, kind: conflict ? "conflict" : "stalled", title: pull.title, detail: lifecycle.detail, to, action: conflict ? "Resolve" : "Decide", at: Date.parse(pull.updatedAt), where });
297 } else if (lifecycle?.stage === "ready") {
298 needs.push({ key: `pull:${pull.number}`, kind: "ready", title: pull.title, detail: "Ready to land when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
299 }
300 // Never your own, nor one g1t made for you; whoever it is for asked.
301 const owner = workOwner(pull).username;
302 if (me && pull.status === "open" && owner.toLowerCase() !== me && pull.reviewers.some((name) => name.toLowerCase() === me)) {
303 needs.push({ key: `review:${pull.number}`, kind: "review", title: pull.title, detail: `${owner} asked for your review.`, to, action: "Review", at: Date.parse(pull.updatedAt), where });
304 }
305 if (member && pull.status === "open" && pull.checkStatus === "failed" && !lifecycle) {
306 needs.push({ key: `checks:${pull.number}`, kind: "checks", title: pull.title, detail: "Its checks failed.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
307 }
308 }
309 // --- Activity and health -------------------------------------------------------
310 const eventList: G1tEvent[] = recent ?? [];
311 const logged: ActivityItem[] = projectFeed(eventList, path);
312 // The log names people by account id: their usernames, in one lookup.
313 const ids = actorIds(logged.map((item) => item.actor));
314 const names = ids.length > 0 ? await soft(identity.usernames(ids)) : {};
315 const items = logged.map((item) => ({ ...item, actor: nameActor(item.actor, names) }));
316 const checkEvents = eventList.flatMap((event) =>
317 event.type === "checks.completed" ? [{ repo: "", number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
318 );
319 const memoryList = ok(memories)?.project ?? [];
320 // Pinned first, then the newest; a fact remembered twice, once.
321 const knows = distinctFacts([...memoryList.filter((m) => m.pinned), ...memoryList.filter((m) => !m.pinned).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))]).slice(0, 5);
322 const openIssues = ok(issues);
323
324 // --- Where it runs ------------------------------------------------------------
325 const commitNow = ok(log)?.[0] ?? null;
326 // Production deployed somewhere other than g1t.page (by g1t Actions, or
327 // reported through the API): the slot an app deployed elsewhere shows.
328 const environments = await environmentsP;
329 const environmentList = environments?.ok ? environments.value : null;
330 const deployment: ExternalDeployment | null = reportedProduction(environmentList?.environments ?? []);
331 // An app that has not said where it runs, but reports production from
332 // elsewhere, runs elsewhere.
333 const found = ok(project);
334 const projectValue = found && deployment && found.kind === "app" && found.runs == null ? { ...found, runs: "elsewhere" as const } : found;
335 const isLibrary = projectValue != null && projectPlan(projectValue) === "release";
336 const published = libraryPackages(ok(packageList) ?? []);
337
338 // The checks on the latest commit and each active branch's head, in
339 // one call once the branches are read: streamed in beside them.
340 const checks = branches.then((found) =>
341 commitChecksFor(path, viewer, [commitNow?.hash, ...(found && found !== "slow" ? found.shown : []).map((branch) => branch.commit?.hash)]),
342 );
343 const github = await githubP;
344 return {
345 member,
346 can,
347 github,
348 project: projectValue,
349 settings: ok(settings),
350 builds: builds.slice(0, 30),
351 live: ok(list)?.live ?? [],
352 commit: ok(log)?.[0] ?? null,
353 commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author, coAuthors: one.coAuthors, at: one.authoredAt })),
354 release: latestTag(ok(tagList) ?? []),
355 // Production as reported from outside g1t, for an app deployed elsewhere.
356 deployment,
357 // Its screenshot's address on the site (production-elsewhere.ts), when it has one to show.
358 elsewhereShot: elsewhereShot(projectValue, deployment, base),
359 // Every environment's latest deployment, for the Deployments panel.
360 environments: environmentList,
361 // About's languages and contributors, filled in by the files' About work.
362 languages: null as { name: string; share: number }[] | null,
363 contributors: null as { username: string }[] | null,
364 open: ok(counts) ?? { issues: openIssues?.length ?? 0, pulls: openPulls.length },
365 pullsLoaded: open?.ok ?? false,
366 columns,
367 needs: rankNeeds(needs),
368 landed: landed.slice(0, MAX_LANDED),
369 groups: groupActivity(items).slice(0, 30),
370 eventsLoaded: recent != null,
371 health: {
372 // A pull request's checks when any finished lately; otherwise the
373 // project's workflow runs, for one that ships straight to its branch.
374 ...(checkEvents.length > 0 || !workflowRuns?.ok
375 ? { passRate: passRate(checkEvents.map((c) => c.passed)), firstPass: firstPassRate(checkEvents), checkRuns: checkEvents.length }
376 : runHealth(workflowRuns.value)),
377 ages: openIssues ? ageBuckets(openIssues.map((issue) => issue.createdAt), now) : null,
378 },
379 knows: knows as Memory[],
380 memoryCount: memoryList.length,
381 memoriesLoaded: memories?.ok ?? false,
382 library: isLibrary
383 ? { packages: published.slice(0, PACKAGES_SHOWN), total: published.length, loaded: packageList?.ok ?? false }
384 : null,
385 branches,
386 checks,
387 };
388}
389
390export async function action({ request, params, context }: Route.ActionArgs) {
391 assertSameOrigin(request);
392 const user = requireUser(context, request);
393 const form = await request.formData().catch(() => null);
394 const intent = form?.get("intent");
395 // What it is, where it runs, its description and links: its settings.
396 if (form && (intent === "kind" || intent === "about")) {
397 const refused = await refusal(context, params, "manage_settings");
398 if (refused) return { error: refused };
399 const saved = await projects.update(user, params.owner, params.repo, projectChanges(form));
400 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
401 }
402 // Deploying is compute.
403 const refused = await refusal(context, params, "run");
404 if (refused) return { error: refused };
405 const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null);
406 return started.ok ? { notice: "Production is building." } : { error: started.error.message };
407}
408
409type Loaded = Awaited<ReturnType<typeof overviewData>>;
410type Card = Loaded["columns"]["working"][number];
411
412const STAGE_TONE: Record<PipelineStage, string> = {
413 working: "bg-accent",
414 checking: "bg-info",
415 reviewing: "bg-warn",
416 queue: "bg-success-dim",
417 landed: "bg-success",
418};
419
420function PipelineCard({ card, base, stage }: { card: Card; base: string; stage: PipelineStage }) {
421 return (
422 <li>
423 <Link
424 to={`${base}/pull/${card.number}`}
425 prefetch="intent"
426 className={`block rounded-lg border bg-bg/60 p-2.5 transition-colors hover:border-line-strong hover:bg-raised ${
427 card.needsYou ? "border-warn/50" : "border-line"
428 }`}
429 >
430 <span className="line-clamp-2 text-[0.8125rem] leading-snug font-medium">{card.title}</span>
431 <span className="mt-1.5 flex items-center gap-1.5 text-[0.6875rem] text-muted">
432 <Avatar name={card.agent} size={14} />
433 <span className="font-mono text-faint">#{card.number}</span>
434 <span className="grow" />
435 {card.needsYou ? (
436 <Hint label="Stopped: a person decides what happens next">
437 <span className="inline-flex items-center gap-1 text-warn">
438 <Hand size={11} /> you
439 <span className="sr-only">: stopped, a person decides what happens next</span>
440 </span>
441 </Hint>
442 ) : stage === "landed" ? (
443 <TimeAgo at={card.at} />
444 ) : (
445 <CheckBadge status={card.checkStatus} />
446 )}
447 </span>
448 </Link>
449 </li>
450 );
451}
452
453/** Open pull requests moving through Working → Checking → Reviewing → Queue → Landed. */
454function Pipeline({ columns, base }: { columns: Loaded["columns"]; base: string }) {
455 return (
456 <div className="relative -mx-4 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
457 {/* Each stage as tall as what is in it: an empty one stays short
458 instead of stretching to the busiest. */}
459 <ol className="grid min-w-176 grid-cols-5 items-start gap-2">
460 {PIPELINE.map(({ stage, label }, index) => {
461 const cards = columns[stage];
462 return (
463 <Card asChild key={stage} className="flex min-w-0 flex-col p-2">
464 <li>
465 <p className="flex items-center gap-2 px-1 pt-0.5 pb-2 text-xs font-medium text-muted">
466 <span className={`size-1.5 rounded-full ${STAGE_TONE[stage]} ${cards.length && stage !== "landed" ? "animate-pulse" : ""}`} />
467 {label}
468 <span className="tabular-nums text-faint">{cards.length}</span>
469 {index < PIPELINE.length - 1 && <ArrowRight size={11} className="ml-auto text-line-strong" />}
470 </p>
471 {cards.length === 0 ? (
472 <p className="rounded-lg border border-dashed border-line/70 px-2 py-3 text-center text-[0.6875rem] text-faint">Empty</p>
473 ) : (
474 <ul className="space-y-1.5">
475 {cards.slice(0, 6).map((card) => (
476 <PipelineCard key={card.number} card={card} base={base} stage={stage} />
477 ))}
478 {cards.length > 6 && (
479 <li className="px-1 text-[0.6875rem] text-muted">
480 <Link to={`${base}/pulls`} className="hover:text-fg">
481 {cards.length - 6} more
482 </Link>
483 </li>
484 )}
485 </ul>
486 )}
487 </li>
488 </Card>
489 );
490 })}
491 </ol>
492 </div>
493 );
494}
495
496/**
497 * Where an app's overview shows production, a library's shows what it
498 * publishes: each package with its latest version and how to install it,
499 * or, before there is one, how to publish a first version.
500 */
501function LibraryHead({
502 library,
503 project,
504 base,
505 workspace,
506}: {
507 library: { packages: PackageSummary[]; total: number; loaded: boolean };
508 project: Project | null;
509 base: string;
510 workspace: string;
511}) {
512 const guide = publishGuide(project?.ecosystem ?? null);
513 const name = project?.name ?? base.split("/").pop();
514 return (
515 <div className="p-5 sm:p-6">
516 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
517 <PackageGlyph size={13} className="text-accent" />
518 Packages
519 </p>
520 {library.packages.length > 0 ? (
521 <>
522 <ul className="mt-3 space-y-4">
523 {library.packages.map((pkg) => {
524 const commands = installCommands(pkg, pkg.latest, "you");
525 return (
526 <li key={pkg.id} className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:gap-5">
527 <Link to={packagePath(pkg)} className="group flex min-w-0 items-center gap-3 lg:w-72 lg:shrink-0">
528 <PackageIcon ecosystem={pkg.ecosystem} size={32} />
529 <span className="min-w-0">
530 <span className="block truncate font-mono text-[0.9375rem] font-medium group-hover:text-accent">{packageName(pkg)}</span>
531 <span className="block text-xs text-muted">
532 {ECOSYSTEM_LABEL[pkg.ecosystem]} · {pkg.latest ?? "no versions yet"}
533 {pkg.versions > 1 && ` · ${pkg.versions} versions`}
534 </span>
535 </span>
536 </Link>
537 <div className="min-w-0 grow space-y-1.5">
538 {/* The workspace's registry first, or the tool installs a package of the same name from elsewhere. */}
539 {commands.registry && <CopyLine prompt text={commands.registry} />}
540 <CopyLine prompt text={commands.install} />
541 </div>
542 </li>
543 );
544 })}
545 </ul>
546 {library.total > library.packages.length && (
547 <Link to={`/${workspace}/-/packages`} className="mt-3 inline-block text-xs text-muted hover:text-fg">
548 All {library.total} packages
549 </Link>
550 )}
551 </>
552 ) : (
553 <>
554 <p className="mt-2 text-lg font-medium">{library.loaded ? "Not published yet" : "Packages could not be loaded just now"}</p>
555 {guide ? (
556 <>
557 <p className="mt-1 max-w-lg text-sm text-muted">
558 Publish a first version of {name} to the workspace's {guide.label} registry, for others to install
559 {guide.start ? ":" : "."}
560 </p>
561 {guide.start && (
562 <div className="mt-3 max-w-lg">
563 <CopyLine prompt text={guide.start} />
564 </div>
565 )}
566 <a href={guide.guide} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
567 Publishing to {guide.label} <ArrowUpRight size={13} />
568 </a>
569 </>
570 ) : (
571 <>
572 <p className="mt-1 max-w-lg text-sm text-muted">Publish a first version of {name} to one of the workspace's registries:</p>
573 <p className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm">
574 {PUBLISH_GUIDES.map((one) => (
575 <a key={one.label} href={one.guide} className="inline-flex items-center gap-1 text-accent hover:underline">
576 {one.label} <ArrowUpRight size={13} />
577 </a>
578 ))}
579 </p>
580 </>
581 )}
582 </>
583 )}
584 </div>
585 );
586}
587
588/**
589 * Where the overview asks for the screenshot of production deployed
590 * elsewhere, with `?v=` naming the reported deploy or the day; null when
591 * there is none to show (lib/production-image.ts `externalTarget`).
592 */
593function elsewhereShot(project: Project | null | undefined, deployment: ExternalDeployment | null, base: string): string | null {
594 const target = project ? externalTarget(project, deployment, Date.now()) : null;
595 return target ? `${base}/production-elsewhere.jpg?v=${externalVersion(target)}` : null;
596}
597
598function Stat({ label, value, to }: { label: string; value: ReactNode; to?: string }) {
599 const body = (
600 <>
601 <span className="block text-[0.6875rem] text-faint">{label}</span>
602 <span className="mt-0.5 block truncate text-[0.8125rem] font-medium">{value}</span>
603 </>
604 );
605 return to ? (
606 <Link to={to} className="min-w-0 px-5 py-3 transition-colors hover:bg-raised/50">
607 {body}
608 </Link>
609 ) : (
610 <div className="min-w-0 px-5 py-3">{body}</div>
611 );
612}
613
614export default function ProjectOverview({ loaderData, actionData, params }: Route.ComponentProps) {
615 return (
616 <Suspense fallback={<OverviewSkeleton />}>
617 <Await resolve={loaderData.overview} errorElement={<OverviewFailed />}>
618 {(overview) => (
619 // Faded in over its outline, not switched for it.
620 <div className="animate-fade-in">
621 <Overview loaderData={overview} actionData={actionData} params={params} />
622 </div>
623 )}
624 </Await>
625 </Suspense>
626 );
627}
628
629/** The overview's sections as they will sit, while they stream in. */
630function OverviewSkeleton() {
631 return (
632 <Loading className="space-y-8" aria-label="Loading the overview">
633 {/* Production: its screenshot beside where it is live, then the facts under them. */}
634 <section aria-hidden="true" className="overflow-hidden rounded-2xl border border-line bg-surface">
635 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
636 <Skeleton className="aspect-[16/10] w-full shrink-0 rounded-lg sm:w-72" />
637 <div className="min-w-0 grow">
638 <Skeleton className="h-3 w-24" />
639 <Skeleton className="mt-3 h-5 w-72 max-w-full" />
640 <Skeleton className="mt-3 h-3 w-48 max-w-full" />
641 </div>
642 </div>
643 <div className="grid grid-cols-2 gap-px border-t border-line bg-line sm:grid-cols-4">
644 {Array.from({ length: 4 }, (_, index) => (
645 <div key={index} className="bg-surface px-5 py-4">
646 <Skeleton className="h-3 w-16" />
647 <Skeleton className="mt-2.5 h-4 w-28" />
648 </div>
649 ))}
650 </div>
651 </section>
652 {/* Right now: the work, column by column. */}
653 <div aria-hidden="true" className="grid grid-cols-2 gap-2 sm:grid-cols-5">
654 {Array.from({ length: 5 }, (_, index) => (
655 <Card key={index} className="p-3">
656 <Skeleton className="h-3 w-20" />
657 <Skeleton className="mt-3 h-14 rounded-lg" />
658 </Card>
659 ))}
660 </div>
661 <div className="grid gap-6 lg:grid-cols-2">
662 <section className="rounded-2xl border border-line bg-surface">
663 <SkeletonRows rows={4} />
664 </section>
665 <section className="rounded-2xl border border-line bg-surface">
666 <SkeletonRows rows={4} />
667 </section>
668 </div>
669 </Loading>
670 );
671}
672
673function OverviewFailed() {
674 return (
675 <p className="rounded-2xl border border-line bg-surface p-6 text-sm text-muted">
676 The overview could not be loaded just now. Reload the page to try again.
677 </p>
678 );
679}
680
681function Overview({
682 loaderData,
683 actionData,
684 params,
685}: {
686 loaderData: Loaded;
687 actionData: Route.ComponentProps["actionData"];
688 params: Route.ComponentProps["params"];
689}) {
690 const { member, project, settings, builds, live, commit, open, columns, needs, landed, groups, health, knows, branches, library, checks } =
691 loaderData;
692 const base = `/${params.owner}/${params.repo}`;
693 const production = live.find((app) => app.kind === "production") ?? null;
694 // The project's own domain, once it is active, is where production is visited.
695 const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null;
696 const previews = live.filter((app) => app.kind === "preview");
697 const latestProduction = builds.find((build) => build.kind === "production") ?? null;
698 const plan = project ? projectPlan(project) : "production";
699 const canChange = member && loaderData.can.manage_settings;
700 // Its homepage and docs beside what it is, unless the card below already links there.
701 const headUrl =
702 plan === "production"
703 ? (productionUrl ?? settings?.productionUrl)
704 : plan === "elsewhere"
705 ? (loaderData.deployment?.url ?? project?.productionUrl)
706 : plan === "docs"
707 ? (project?.links.docs ?? project?.productionUrl ?? project?.links.homepage)
708 : plan === "other"
709 ? (project?.links.homepage ?? project?.links.docs)
710 : null;
711 const stripLinks = project ? linksToShow({ ...project.links, custom: [] }, [headUrl]) : [];
712 const addresses = useAddresses();
713 // The production card's picture: an uploaded image, served from the
714 // usercontent origin, and where someone who may change it adds one.
715 const upload = project?.productionImage
716 ? { url: productionImageUrl(project.productionImage.file, addresses.usercontent), preferred: project.productionImage.preferred }
717 : null;
718 const addImage = canChange ? `${base}/settings#production-image` : null;
719 const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null;
720 const source = project?.source.kind === "hosted" ? project.source : null;
721 const moving = columns.working.length + columns.checking.length > 0;
722 useRefreshWhile(moving);
723 // Pushes, checks, releases and deployments show as they happen.
724 useRepoLive({ match: repoPages.files() });
725 const ages = health.ages;
726 const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1;
727
728 return (
729 <div className="space-y-8">
730 {loaderData.github && <GithubLinkStrip link={loaderData.github} />}
731 {/* What the project is, running, and where its code is. */}
732 <section className="overflow-hidden rounded-2xl border border-line bg-surface">
733 {project && (
734 <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-line px-5 py-2.5 text-xs sm:px-6">
735 <KindMenu project={project} label={kindLabel(project)} canChange={canChange} />
736 {stripLinks.map((link) => (
737 <DeployLink key={link.key} href={link.url} className="inline-flex min-w-0 items-center gap-1.5 text-muted hover:text-fg">
738 {link.type === "docs" ? <BookOpen size={12} className="shrink-0 text-faint" /> : <Globe size={12} className="shrink-0 text-faint" />}
739 <span className="truncate">{link.type === "docs" ? `Docs · ${bare(link.url)}` : link.label}</span>
740 </DeployLink>
741 ))}
742 </div>
743 )}
744 {plan === "elsewhere" && project ? (
745 <ElsewhereHead
746 project={project}
747 base={base}
748 canChange={canChange}
749 canDeploy={member && loaderData.can.manage_integrations}
750 deployment={loaderData.deployment}
751 commit={commit}
752 checks={commit ? <CommitChecksBadge checks={checks} sha={commit.hash} className="size-5" /> : undefined}
753 shot={
754 <ProductionShot
755 sources={cardSources(loaderData.elsewhereShot, upload)}
756 href={elsewhereUrl}
757 label={elsewhereUrl ? host(elsewhereUrl).replace(/\/$/, "") : ""}
758 addImage={addImage}
759 className="w-full shrink-0 sm:w-60 lg:w-72"
760 />
761 }
762 />
763 ) : plan === "unknown" && project ? (
764 <WhereItRuns project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} />
765 ) : plan === "docs" && project ? (
766 <DocsHead project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} />
767 ) : plan === "other" && project ? (
768 <OtherHead project={project} base={base} canChange={canChange} />
769 ) : library ? (
770 <LibraryHead library={library} project={project} base={base} workspace={params.owner} />
771 ) : (
772 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
773 {/* Always a picture: the screenshot (members, like the deployments), the uploaded image, or the frame. */}
774 <ProductionShot
775 sources={cardSources(member && production ? `${base}/production.jpg?v=${shotVersion(production)}` : null, upload)}
776 href={production ? (productionUrl ?? production.url) : null}
777 label={production ? host(productionUrl ?? production.url) : settings?.productionUrl ? host(settings.productionUrl) : ""}
778 note={!production && settings?.enabled ? "A screenshot appears after the next deploy" : null}
779 addImage={addImage}
780 className="w-full shrink-0 sm:w-60 lg:w-72"
781 />
782 <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4">
783 <div className="min-w-0">
784 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
785 <Rocket size={13} className="text-accent" />
786 Production
787 </p>
788 {production ? (
789 <>
790 <DeployLink
791 href={productionUrl ?? production.url}
792 className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent"
793 >
794 {host(productionUrl ?? production.url)}
795 <ArrowUpRight size={16} className="shrink-0 text-faint" />
796 </DeployLink>
797 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
798 {latestProduction && <StatusDot status={latestProduction.status} label={latestProduction.status === "ready" ? "Live" : undefined} />}
799 <span className="inline-flex items-center gap-1 font-mono">
800 <GitCommitHorizontal size={13} className="text-faint" />
801 {production.commit.slice(0, 7)}
802 </span>
803 <span>
804 deployed <TimeAgo at={production.deployedAt} />
805 </span>
806 {settings?.primaryDomain && <span className="text-faint">also at {host(production.url)}</span>} </p>
807 </>
808 ) : settings?.enabled ? (
809 <>
810 <p className="mt-2 font-mono text-lg text-muted">{host(settings.productionUrl)}</p>
811 <p className="mt-1.5 text-xs text-muted">
812 {latestProduction ? (
813 <StatusDot status={latestProduction.status} label={latestProduction.status === "failed" ? "The last build failed" : undefined} />
814 ) : (
815 "Not deployed yet. Push to the default branch, or deploy it now."
816 )}
817 </p>
818 </>
819 ) : (
820 <>
821 <p className="mt-2 text-lg font-medium">Deployments are off</p>
822 <p className="mt-1 max-w-lg text-sm text-muted">
823 Nothing builds or runs until you turn them on. Then {project?.name ?? params.repo} goes up on g1t.page:
824 production from the default branch, and a live preview for every pull request. It runs only while
825 someone visits.
826 </p>
827 </>
828 )}
829 </div>
830 {member && (
831 <div className="flex shrink-0 items-center gap-2">
832 {production && (
833 <ButtonLink to={productionUrl ?? production.url} variant="accent" reloadDocument target="_blank" rel="noopener noreferrer">
834 Visit
835 <ArrowUpRight size={14} />
836 </ButtonLink>
837 )}
838 {settings?.enabled ? (
839 loaderData.can.run && <Form method="post">
840 <Hint label="Build production again from the default branch">
841 <SubmitButton variant="outline" name="intent" value="redeploy" pending="Redeploying…">
842 <RotateCw size={14} />
843 Redeploy
844 </SubmitButton>
845 </Hint>
846 </Form>
847 ) : loaderData.can.manage_integrations && (
848 <ButtonLink to={`${base}/settings/deployments`} variant="accent">
849 <Rocket size={14} />
850 Turn on deployments
851 </ButtonLink>
852 )}
853 </div>
854 )}
855 </div>
856 </div>
857 )}
858 {actionData && (
859 <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-success"}`}>
860 {"error" in actionData ? actionData.error : actionData.notice}
861 </p>
862 )}
863 <div className="grid grid-cols-2 divide-line border-t border-line text-sm sm:grid-cols-4 sm:divide-x">
864 <Stat
865 label="Source"
866 to={`${base}/code`}
867 value={
868 source ? (
869 <span className="inline-flex items-center gap-1.5 font-mono">
870 {project?.private ? <Lock size={12} className="text-faint" /> : <Code2 size={12} className="text-faint" />}
871 {source.repo.name}
872 {source.rootDir ? `/${source.rootDir}` : ""}
873 </span>
874 ) : (
875 "—"
876 )
877 }
878 />
879 <Stat
880 label="Default branch"
881 to={`${base}/commits`}
882 value={
883 <span className="inline-flex items-center gap-1.5 font-mono">
884 <GitBranch size={12} className="text-faint" />
885 {source?.defaultBranch ?? "main"}
886 </span>
887 }
888 />
889 <Stat
890 label="Latest commit"
891 value={
892 commit ? (
893 <span className="flex min-w-0 items-center gap-1">
894 <Hint label={commit.message.split("\n")[0]}>
895 <Link to={`${base}/commit/${commit.hash}`} className="min-w-0 truncate hover:text-accent">
896 <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "}
897 <span className="font-normal text-muted">
898 <TimeAgo at={commit.authoredAt} />
899 </span>
900 </Link>
901 </Hint>
902 {/* A button, so beside the link rather than in it. */}
903 <CommitChecksBadge checks={checks} sha={commit.hash} className="size-5" />
904 </span>
905 ) : (
906 "No commits yet"
907 )
908 }
909 />
910 <Stat
911 label={member && settings?.enabled && !library ? "Last deploy" : "Open"}
912 to={member && settings?.enabled && !library ? `${base}/deployments` : `${base}/pulls`}
913 value={
914 member && settings?.enabled && !library ? (
915 latestProduction ? (
916 <span className="inline-flex items-center gap-2">
917 <StatusDot status={latestProduction.status} />
918 <span className="font-normal text-muted">
919 <TimeAgo at={latestProduction.createdAt} />
920 </span>
921 </span>
922 ) : (
923 "None yet"
924 )
925 ) : (
926 `${open.pulls} pull requests · ${open.issues} issues`
927 )
928 }
929 />
930 </div>
931 {commit && (
932 <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6">
933 <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" />
934 {commit.message.split("\n")[0]}{" "}
935 <span className="text-faint">
936 · <CommitNames commit={commit} className="hover:text-fg" />
937 </span>
938 </p>
939 )}
940 </section>
941
942 <Panel
943 title="Right now"
944 icon={<span className={`block size-2 rounded-full ${moving ? "animate-pulse bg-accent" : "bg-line-strong"}`} />}
945 all={{ to: `${base}/pulls`, label: "Pull requests" }}
946 >
947 {!loaderData.pullsLoaded ? (
948 <Unavailable what="Pull requests" />
949 ) : Object.values(columns).every((c) => c.length === 0) ? (
950 <Quiet
951 action={
952 member ? (
953 <Link to={`${base}/issues/new`} className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg hover:bg-fg-hover">
954 <Plus size={12} /> New issue
955 </Link>
956 ) : null
957 }
958 >
959 Nothing is moving. Open an issue, or push a branch and open a pull request.
960 </Quiet>
961 ) : (
962 <Pipeline columns={columns} base={base} />
963 )}
964 </Panel>
965
966 <div className="grid gap-x-8 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
967 <div className="min-w-0 space-y-8">
968 {needs.length > 0 && (
969 <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}>
970 <NeedsList needs={needs} limit={5} />
971 </Panel>
972 )}
973
974 <Suspense
975 fallback={
976 <Panel title="Active branches" icon={<GitBranch size={14} />}>
977 <div aria-busy="true">
978 <SkeletonRows rows={3} rowClassName="h-12" />
979 </div>
980 </Panel>
981 }
982 >
983 <Await
984 resolve={branches}
985 errorElement={
986 <Panel title="Active branches" icon={<GitBranch size={14} />}>
987 <Unavailable what="Branches" />
988 </Panel>
989 }
990 >
991 {(branches) => (
992 <Panel
993 title="Active branches"
994 icon={<GitBranch size={14} />}
995 count={branches && branches !== "slow" ? branches.total || null : null}
996 all={{ to: `${base}/branches`, label: "All branches" }}
997 >
998 {branches === "slow" ? (
999 <Quiet>
1000 Its branches are taking a while to compare.{" "}
1001 <Link to={`${base}/branches`} className="text-accent hover:underline">
1002 See them on Branches
1003 </Link>
1004 .
1005 </Quiet>
1006 ) : !branches ? (
1007 <Unavailable what="Branches" />
1008 ) : branches.shown.length === 0 ? (
1009 <Quiet>
1010 Only {branches.main} so far. Branches pushed here show with how far each has moved from {branches.main}, and
1011 its pull request and preview.
1012 </Quiet>
1013 ) : (
1014 <>
1015 <ActiveBranches branches={branches.shown} base={base} main={branches.main} checks={checks} />
1016 {branches.total > branches.shown.length && (
1017 <p className="mt-2 px-1 text-xs text-faint">
1018 The {branches.shown.length} most recently changed of {branches.total} branches.
1019 </p>
1020 )}
1021 </>
1022 )}
1023 </Panel>
1024 )}
1025 </Await>
1026 </Suspense>
1027
1028 <Panel title="Recent changes" icon={<GitMerge size={14} />} all={{ to: `${base}/pulls?state=closed`, label: "All landed" }}>
1029 {landed.length === 0 ? (
1030 <Quiet>Nothing has landed yet. Merged pull requests show here with who made them and why.</Quiet>
1031 ) : (
1032 <Card asChild divided className="overflow-hidden">
1033 <ul>
1034 {landed.map((pull) => {
1035 const byAgent = pull.runtime === "hosted" || madeByG1t(pull);
1036 return (
1037 <li key={pull.id} className="flex items-start gap-3 px-4 py-3">
1038 <GitMerge size={15} className="mt-0.5 shrink-0 text-merged" />
1039 <span className="min-w-0 grow">
1040 <Link to={`${base}/pull/${pull.number}`} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent">
1041 {pull.title}
1042 </Link>
1043 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
1044 <span className="font-mono text-faint">#{pull.number}</span>
1045 <span>·</span>
1046 {/* The avatar is whoever the line names first: the
1047 agent that made it, or the person who wrote it
1048 with their own tools. */}
1049 <span className="inline-flex items-center gap-1">
1050 {pull.requestedBy ? (
1051 <>
1052 <Avatar name={pull.author.username} size={13} />
1053 made by <PersonLink name={pull.author.username} className="hover:text-fg" /> for{" "}
1054 <PersonLink name={pull.requestedBy.username} className="hover:text-fg" />
1055 </>
1056 ) : byAgent ? (
1057 <>
1058 <Avatar name={pull.agent} size={13} />
1059 made by {pull.agent}
1060 </>
1061 ) : (
1062 <>
1063 <Avatar name={pull.author.username} size={13} />
1064 by <PersonLink name={pull.author.username} className="hover:text-fg" />
1065 </>
1066 )}
1067 </span>
1068 {pull.issue != null && (
1069 <>
1070 <span>· for</span>
1071 <Link to={`${base}/issues/${pull.issue}`} className="hover:text-fg">
1072 #{pull.issue}
1073 </Link>
1074 </>
1075 )}
1076 {pull.mergedBy && (
1077 <span>
1078 · landed by <PersonLink name={pull.mergedBy} className="hover:text-fg" />
1079 </span>
1080 )}
1081 <span>
1082 · <TimeAgo at={pull.mergedAt ?? pull.updatedAt} />
1083 </span>
1084 </span>
1085 </span>
1086 <span className="hidden shrink-0 text-xs text-faint sm:block">
1087 <ChangeSize files={pull.files} />
1088 </span>
1089 {byAgent && (
1090 <Link to={`${base}/sessions/${pull.number}`} className="shrink-0 text-xs text-muted hover:text-fg">
1091 Session
1092 </Link>
1093 )}
1094 </li>
1095 );
1096 })}
1097 </ul>
1098 </Card>
1099 )}
1100 </Panel>
1101
1102 <LatestCommits commits={loaderData.commits} base={base} branch={source?.defaultBranch ?? "main"} />
1103
1104 <Panel title="Activity" icon={<ActivityIcon size={14} />}>
1105 {loaderData.eventsLoaded ? (
1106 <ActivityFeed groups={groups} showRepo={false} limit={8} empty="Nothing has happened here yet." />
1107 ) : (
1108 <Unavailable what="Activity" />
1109 )}
1110 </Panel>
1111
1112 {previews.length > 0 && (
1113 <Panel title="Previews" icon={<GitBranch size={14} />} count={previews.length} all={{ to: `${base}/deployments`, label: "Deployments" }}>
1114 <Card asChild divided className="overflow-hidden">
1115 <ul>
1116 {previews.map((app) => (
1117 <li key={app.url} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1118 <span className="min-w-0 grow">
1119 <DeployLink href={app.url} className="block truncate font-mono text-[0.8125rem] hover:text-accent">
1120 {host(app.url)}
1121 </DeployLink>
1122 <span className="text-xs text-muted">
1123 {app.branch}
1124 {app.number != null && (
1125 <>
1126 {" · "}
1127 <Link to={`${base}/pull/${app.number}`} className="hover:underline">
1128 #{app.number}
1129 </Link>
1130 </>
1131 )}
1132 </span>
1133 </span>
1134 <span className="shrink-0 text-xs text-faint">
1135 <TimeAgo at={app.deployedAt} />
1136 </span>
1137 </li>
1138 ))}
1139 </ul>
1140 </Card>
1141 </Panel>
1142 )}
1143 </div>
1144
1145 <aside className="space-y-4 text-sm">
1146 {/* Deployments panel (deployments-panel.tsx): each environment's latest deployment. */}
1147 <DeploymentsPanel base={base} summary={loaderData.environments} className="rounded-xl border border-line bg-surface p-5" />
1148 {project && <AboutCard project={project} canChange={canChange} base={base} loaderData={loaderData} />}
1149
1150 {member && (
1151 <Card asChild className="p-5">
1152 <section>
1153 <div className="flex items-center justify-between">
1154 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
1155 <Brain size={14} className="text-accent" />
1156 What this project remembers
1157 </h2>
1158 <Link to={`${base}/memory`} className="text-xs text-muted hover:text-fg">
1159 Memory
1160 </Link>
1161 </div>
1162 {!loaderData.memoriesLoaded ? (
1163 <p className="mt-3 text-xs text-muted">Memory could not be loaded just now.</p>
1164 ) : knows.length === 0 ? (
1165 <p className="mt-3 text-xs leading-5 text-muted">
1166 Nothing yet. People and coding agents note conventions, decisions and traps here, and agents read them with recall.
1167 </p>
1168 ) : (
1169 <ul className="mt-3 space-y-2.5">
1170 {knows.map((memory) => (
1171 <li key={memory.id} className="text-xs leading-5">
1172 <p className="line-clamp-3 text-fg-soft">
1173 {memory.pinned && <Pin size={11} className="mr-1 inline text-accent" />}
1174 <InlineMarkdown text={memory.text} />
1175 </p>
1176 <p className="text-faint">
1177 {memory.kind} · {memory.createdBy} · <TimeAgo at={memory.createdAt} />
1178 </p>
1179 </li>
1180 ))}
1181 {loaderData.memoryCount > knows.length && (
1182 <li>
1183 <Link to={`${base}/memory`} className="text-xs text-muted hover:text-fg">
1184 {loaderData.memoryCount - knows.length} more
1185 </Link>
1186 </li>
1187 )}
1188 </ul>
1189 )}
1190 </section>
1191 </Card>
1192 )}
1193
1194 <Card asChild className="p-5">
1195 <section>
1196 <h2 className="flex items-center gap-1.5 text-sm font-semibold">
1197 <HeartPulse size={14} className="text-faint" />
1198 Health
1199 </h2>
1200 <div className="mt-4 space-y-4 text-xs">
1201 <div>
1202 <div className="flex items-baseline justify-between">
1203 <span className="text-muted">Checks passing</span>
1204 <span className="font-medium tabular-nums">{percent(health.passRate)}</span>
1205 </div>
1206 <div className="mt-1.5">
1207 <Meter value={health.passRate} tone={health.passRate != null && health.passRate < 0.7 ? "bg-warn" : "bg-success"} />
1208 </div>
1209 <p className="mt-1 text-faint">
1210 {health.checkRuns
1211 ? `${health.checkRuns} recent runs · ${percent(health.firstPass.rate)} pass on the first try`
1212 : "No checks have run recently."}
1213 </p>
1214 </div>
1215 {member && (plan === "production" || builds.length > 0) && (
1216 <div>
1217 <div className="flex items-baseline justify-between">
1218 <span className="text-muted">Deploys</span>
1219 <Link to={`${base}/deployments`} className="text-faint hover:text-fg">
1220 {builds.length ? `last ${Math.min(builds.length, 20)}` : "none yet"}
1221 </Link>
1222 </div>
1223 <div className="mt-1.5">
1224 <DeployStrip builds={builds} base={base} />
1225 </div>
1226 </div>
1227 )}
1228 <div>
1229 <div className="flex items-baseline justify-between">
1230 <span className="text-muted">Open issues by age</span>
1231 <Link to={`${base}/issues`} className="font-medium tabular-nums hover:text-accent">
1232 {open.issues}
1233 </Link>
1234 </div>
1235 {ages ? (
1236 <ul className="mt-2 space-y-1.5">
1237 {ages.map((bucket) => (
1238 <li key={bucket.label} className="grid grid-cols-[6.5rem_1fr_1.5rem] items-center gap-2">
1239 <span className="text-faint">{bucket.label}</span>
1240 <span className="block h-1.5 overflow-hidden rounded-full bg-line">
1241 <span
1242 className={`block h-full rounded-full ${bucket.label === "Older" ? "bg-warn" : "bg-fg-soft/60"}`}
1243 style={{ width: `${(bucket.count / oldest) * 100}%` }}
1244 />
1245 </span>
1246 <span className="text-right tabular-nums text-muted">{bucket.count}</span>
1247 </li>
1248 ))}
1249 </ul>
1250 ) : (
1251 <p className="mt-1 text-faint">Issues could not be loaded just now.</p>
1252 )}
1253 </div>
1254 </div>
1255 </section>
1256 </Card>
1257
1258 {source && <CloneCard addresses={addresses} path={`${source.repo.namespace}/${source.repo.name}`} />}
1259 </aside>
1260 </div>
1261 </div>
1262 );
1263}
1264
1265/** The newest commits on the default branch, with who made them. */
1266function LatestCommits({ commits, base, branch }: { commits: Loaded["commits"]; base: string; branch: string }) {
1267 if (commits.length === 0) return null;
1268 return (
1269 <Panel title={`Latest on ${branch}`} icon={<GitCommitHorizontal size={14} />} all={{ to: `${base}/commits`, label: "All commits" }}>
1270 <Card asChild divided className="overflow-hidden">
1271 <ul>
1272 {commits.map((one) => (
1273 <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1274 <CommitAvatars commit={one} size={18} max={2} />
1275 <span className="min-w-0 grow">
1276 <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent">
1277 {one.message}
1278 </Link>
1279 <span className="text-xs text-muted">
1280 <CommitNames commit={one} className="hover:text-fg" />
1281 </span>
1282 </span>
1283 <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block">
1284 {one.hash.slice(0, 7)}
1285 </Link>
1286 <span className="w-16 shrink-0 text-right text-xs text-faint">
1287 <TimeAgo at={one.at} />
1288 </span>
1289 </li>
1290 ))}
1291 </ul>
1292 </Card>
1293 </Panel>
1294 );
1295}
1296
1297/** What the project is: its description, links, kind, latest release, languages and contributors. */
1298function AboutCard({
1299 project,
1300 canChange,
1301 base,
1302 loaderData,
1303}: {
1304 project: NonNullable<Loaded["project"]>;
1305 canChange: boolean;
1306 base: string;
1307 loaderData: Pick<Loaded, "release" | "languages" | "contributors">;
1308}) {
1309 return (
1310 <Card asChild className="p-5">
1311 <section>
1312 <div className="flex items-center justify-between">
1313 <h2 className="text-sm font-semibold">About</h2>
1314 {canChange && <AboutEditor project={project} />}
1315 </div>
1316 <p className={`mt-2.5 text-sm ${project.description ? "text-fg-soft" : "text-faint"}`}>{project.description ?? "No description."}</p>
1317 <LinkList links={project.links} className="mt-3" />
1318 <ul className="mt-4 space-y-2 text-xs text-muted">
1319 <li className="flex items-center gap-2">
1320 {project.kind === "library" || project.kind === "tool" ? (
1321 <PackageGlyph size={13} className="shrink-0 text-faint" />
1322 ) : project.kind === "docs" ? (
1323 <BookOpen size={13} className="shrink-0 text-faint" />
1324 ) : (
1325 <Rocket size={13} className="shrink-0 text-faint" />
1326 )}
1327 <Hint label={project.kindReason.detail}>
1328 <span>{kindLabel(project)}</span>
1329 </Hint>
1330 </li>
1331 {loaderData.release && (
1332 <li className="flex items-center gap-2">
1333 <Tag size={13} className="shrink-0 text-faint" />
1334 <Link to={`${base}/tags`} className="hover:text-fg">
1335 <span className="font-mono text-fg-soft">{loaderData.release.name}</span>
1336 {loaderData.release.at && (
1337 <span className="text-faint">
1338 {" · "}
1339 <TimeAgo at={loaderData.release.at} />
1340 </span>
1341 )}
1342 </Link>
1343 </li>
1344 )}
1345 {loaderData.languages && loaderData.languages.length > 0 && (
1346 <li className="flex items-center gap-2">
1347 <Code2 size={13} className="shrink-0 text-faint" />
1348 <span>
1349 {loaderData.languages
1350 .slice(0, 3)
1351 .map((language) => `${language.name} ${Math.round(language.share * 100)}%`)
1352 .join(" · ")}
1353 </span>
1354 </li>
1355 )}
1356 </ul>
1357 {loaderData.contributors && loaderData.contributors.length > 0 && (
1358 <div className="mt-4 flex flex-wrap gap-1">
1359 {loaderData.contributors.slice(0, 12).map((person) => (
1360 <Hint key={person.username} label={person.username}>
1361 <Link to={`/${person.username}`}>
1362 <Avatar name={person.username} size={22} />
1363 </Link>
1364 </Hint>
1365 ))}
1366 </div>
1367 )}
1368 </section>
1369 </Card>
1370 );
1371}
1372
1373/** The clone command, with the address this g1t serves. */
1374function CloneCard({ addresses, path }: { addresses: ReturnType<typeof useAddresses>; path: string }) {
1375 return (
1376 <Card asChild className="p-5">
1377 <section>
1378 <h2 className="text-sm font-semibold">Clone</h2>
1379 <div className="mt-3">
1380 <CopyLine breakAtSlashes text={`git clone ${cloneUrl(addresses, path)}`} />
1381 </div>
1382 </section>
1383 </Card>
1384 );
1385}