Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

4 commits

8 files+226−1040/8 viewed
+159−34
1−import { BookOpen, File, Folder, FolderGit2 } from "lucide-react";
2−import { Link } from "react-router";
1+import { BookOpen, Check, ChevronDown, Code2, File, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal } from "lucide-react";
2+import { Form, Link } from "react-router";
33
4−import type { Blame, BlobView as Blob, Commit, TreeView as Tree } from "@g1t/contracts";
4+import type { Blame, BlobView as Blob, Branch, Commit, TreeView as Tree } from "@g1t/contracts";
55
66 import { BlameView } from "./blame-view";
77 import { CodeLines } from "./code-lines";
99 import { AgentSetup } from "./agent-setup";
1010 import { CloneBox } from "./clone-box";
1111 import { Markdown } from "./markdown";
12−import { Avatar, CopyLine, TimeAgo } from "./ui";
12+import { Topics } from "./topics";
13+import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui";
14+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
15+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
1316
1417 function encodePath(path: string): string {
1518 return path.split("/").map(encodeURIComponent).join("/");
2932 const segments = path.split("/").filter(Boolean);
3033 if (segments.length === 0) return null;
3134 return (
32− <p className="mb-4 font-mono text-sm text-muted">
35+ <p className="font-mono text-sm text-muted">
3336 <Link to={`${base}/tree/${gitRef}`} className="text-accent hover:underline">
3437 {repo}
3538 </Link>
5659 );
5760 }
5861
59−/** The bar above a file listing describing the latest commit. */
60−function CommitBar({ commit, gitRef }: { commit: Commit; gitRef: string }) {
62+/** The bar above a file listing: the latest commit, and the way to the history. */
63+function CommitBar({ commit, base }: { commit: Commit; base: string }) {
6164 return (
6265 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-sm">
6366 <Avatar name={commit.author.name} />
6467 <span className="shrink-0 font-medium">{commit.author.name}</span>
65− <span className="truncate text-muted">{commit.message.split("\n")[0]}</span>
66− <span className="ml-auto flex shrink-0 items-center gap-3 text-xs text-faint">
67− <span className="rounded-full border border-line px-2 py-0.5 font-mono text-accent">
68− {gitRef}
69− </span>
70− <span className="font-mono">{commit.hash.slice(0, 7)}</span>
68+ <Link to={`${base}/commit/${commit.hash}`} className="truncate text-muted hover:text-fg hover:underline">
69+ {commit.message.split("\n")[0]}
70+ </Link>
71+ <span className="ml-auto flex shrink-0 items-center gap-4 text-xs text-faint">
72+ <Link to={`${base}/commit/${commit.hash}`} className="font-mono hover:text-fg">
73+ {commit.hash.slice(0, 7)}
74+ </Link>
7175 <TimeAgo at={commit.authoredAt} />
76+ <Link to={`${base}/commits`} className="inline-flex items-center gap-1.5 font-medium text-muted hover:text-fg">
77+ <History size={14} />
78+ History
79+ </Link>
7280 </span>
7381 </div>
7482 );
7583 }
7684
77−export function TreeView({ tree }: { tree: Tree }) {
85+const BAR_BUTTON =
86+ "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line bg-surface px-3 text-sm transition-colors hover:border-line-strong hover:bg-raised data-[state=open]:border-line-strong";
87+
88+/** Which branch is shown, and the others to switch to, at the same path. */
89+function BranchMenu({ base, gitRef, path, branches }: { base: string; gitRef: string; path: string; branches: Branch[] }) {
90+ const rest = path ? `/${encodePath(path)}` : "";
91+ return (
92+ <DropdownMenu>
93+ <DropdownMenuTrigger className={`${BAR_BUTTON} max-w-56 font-medium`}>
94+ <GitBranch size={14} className="text-faint" />
95+ <span className="truncate font-mono text-[0.8125rem]">{gitRef}</span>
96+ <ChevronDown size={13} className="text-faint" />
97+ </DropdownMenuTrigger>
98+ <DropdownMenuContent align="start" className="max-h-80 w-64 overflow-y-auto">
99+ <DropdownMenuLabel>Switch branches</DropdownMenuLabel>
100+ {branches.map((branch) => (
101+ <DropdownMenuItem key={branch.name} asChild>
102+ <Link to={`${base}/tree/${encodePath(branch.name)}${rest}`}>
103+ <Check className={branch.name === gitRef ? "" : "invisible"} />
104+ <span className="truncate font-mono text-[0.8125rem]">{branch.name}</span>
105+ </Link>
106+ </DropdownMenuItem>
107+ ))}
108+ </DropdownMenuContent>
109+ </DropdownMenu>
110+ );
111+}
112+
113+/** Searches this repository's code on the search page, where it can be widened to all of g1t. */
114+function SearchCode({ repo }: { repo: string }) {
115+ return (
116+ <Form action="/search" role="search" className="relative ml-auto w-full min-w-40 grow sm:w-64 sm:grow-0">
117+ <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
118+ <input
119+ name="q"
120+ {...notACredential()}
121+ placeholder="Search code"
122+ aria-label={`Search ${repo}`}
123+ className="h-8 w-full rounded-md border border-line bg-surface pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
124+ />
125+ <input type="hidden" name="repo" value={repo} />
126+ <input type="hidden" name="type" value="code" />
127+ </Form>
128+ );
129+}
130+
131+/** The one button for getting the code: clone over HTTPS or SSH, or hand it to an agent. */
132+function CodeButton({ path }: { path: string }) {
133+ return (
134+ <Popover>
135+ <PopoverTrigger className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent/90">
136+ <Code2 size={15} />
137+ Code
138+ <ChevronDown size={13} />
139+ </PopoverTrigger>
140+ <PopoverContent align="end" className="w-96 p-4">
141+ <h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
142+ <SquareTerminal size={15} className="text-faint" />
143+ Clone
144+ </h2>
145+ <CloneBox path={path} />
146+ </PopoverContent>
147+ </Popover>
148+ );
149+}
150+
151+/** Above the files: the branch and, at the root, how many there are, search and Code; below it, where you are. */
152+function CodeBar({
153+ base,
154+ repo,
155+ gitRef,
156+ path,
157+ branches,
158+}: {
159+ base: string;
160+ repo: { namespace: string; name: string };
161+ gitRef: string;
162+ path: string;
163+ branches: Branch[] | null;
164+}) {
165+ const full = `${repo.namespace}/${repo.name}`;
166+ const list = branches && branches.length > 0 ? branches : [{ name: gitRef, hash: "" }];
167+ return (
168+ <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
169+ <BranchMenu base={base} gitRef={gitRef} path={path} branches={list} />
170+ {path ? (
171+ <Breadcrumbs base={base} repo={repo.name} gitRef={gitRef} path={path} />
172+ ) : (
173+ <>
174+ {branches && (
175+ <span className="inline-flex items-center gap-1.5 text-sm text-muted">
176+ <GitBranch size={14} className="text-faint" />
177+ <span className="font-medium text-fg">{branches.length}</span>
178+ {branches.length === 1 ? "branch" : "branches"}
179+ </span>
180+ )}
181+ <SearchCode repo={full} />
182+ <CodeButton path={full} />
183+ </>
184+ )}
185+ </div>
186+ );
187+}
188+
189+export function TreeView({ tree, branches = null }: { tree: Tree; branches?: Branch[] | null }) {
78190 const { repo, ref, path, head, entries, readme } = tree;
79191 const base = `/${repo.namespace}/${repo.name}`;
80192 const prefix = path ? `${encodePath(path)}/` : "";
104216 }
105217
106218 return (
107− <div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
219+ <div className={path ? "" : "grid gap-8 lg:grid-cols-[1fr_17rem]"}>
108220 <div className="min-w-0">
109− <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
221+ <CodeBar base={base} repo={repo} gitRef={ref} path={path} branches={branches} />
110222 <div className="overflow-hidden rounded-xl border border-line">
111− <CommitBar commit={head} gitRef={ref} />
223+ <CommitBar commit={head} base={base} />
112224 <ul className="divide-y divide-line text-sm">
113225 {entries.map((entry) => {
114226 const isTree = entry.kind === "tree";
132244 </div>
133245
134246 {readme?.text != null && (
135− <section className="mt-6 overflow-hidden rounded-xl border border-line">
247+ <section id="readme" className="mt-6 scroll-mt-20 overflow-hidden rounded-xl border border-line">
136248 <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium">
137249 <BookOpen size={15} className="text-faint" />
138250 {readme.name}
154266 </div>
155267
156268 {!path && (
157− <aside className="space-y-6">
158− <section>
159− <h2 className="text-sm font-medium">Clone</h2>
160− <div className="mt-2">
161− <CloneBox path={`${repo.namespace}/${repo.name}`} />
162− </div>
163− </section>
164− <section>
165− <h2 className="text-sm font-medium">About</h2>
166− <p className="mt-2 text-sm text-muted">
167− {repo.description ?? "No description."}
168− </p>
169− <p className="mt-3 text-xs text-faint">
170− Created <TimeAgo at={repo.createdAt} />
171− </p>
172− </section>
269+ <aside>
270+ <h2 className="text-base font-semibold">About</h2>
271+ <p className="mt-2.5 text-sm text-fg-soft">{repo.description ?? "No description."}</p>
272+ <Topics topics={repo.topics} className="mt-3" />
273+ <ul className="mt-4 space-y-2.5 text-sm text-muted">
274+ {readme && (
275+ <li>
276+ <a href="#readme" className="inline-flex items-center gap-2 hover:text-fg">
277+ <BookOpen size={15} className="text-faint" />
278+ Readme
279+ </a>
280+ </li>
281+ )}
282+ <li>
283+ <Link to={`${base}/commits`} className="inline-flex items-center gap-2 hover:text-fg">
284+ <History size={15} className="text-faint" />
285+ Commits
286+ </Link>
287+ </li>
288+ {branches && (
289+ <li className="flex items-center gap-2">
290+ <GitBranch size={15} className="text-faint" />
291+ {branches.length} {branches.length === 1 ? "branch" : "branches"}
292+ </li>
293+ )}
294+ </ul>
295+ <p className="mt-4 text-xs text-faint">
296+ Created <TimeAgo at={repo.createdAt} />
297+ </p>
173298 </aside>
174299 )}
175300 </div>
+12−2
12231223 domains: "Domains",
12241224 guardrails: "Guardrails",
12251225 audit: "Audit log",
1226− tree: "Code",
1227− blob: "Code",
1226+ tree: "Files",
1227+ blob: "Files",
1228+ agents: "Agents",
1229+ context: "Context",
1230+ memory: "Memory",
1231+ security: "Security",
1232+ packages: "Packages",
1233+ runners: "Runners",
1234+ workflows: "Workflows",
1235+ observability: "Observability",
1236+ insights: "Insights",
1237+ sessions: "Sessions",
12281238 };
12291239
12301240 /** Where the page is, as a trail of links: workspace / repository / section. */
+19−0
1+import { Link } from "react-router";
2+
3+/** A repository's topics, each a link to Explore filtered by it. */
4+export function Topics({ topics, className = "mt-2" }: { topics: string[] | undefined; className?: string }) {
5+ if (!topics?.length) return null;
6+ return (
7+ <div className={`${className} flex flex-wrap gap-1.5`}>
8+ {topics.map((topic) => (
9+ <Link
10+ key={topic}
11+ to={`/explore?topic=${encodeURIComponent(topic)}`}
12+ className="rounded-full bg-accent/10 px-2 py-px text-xs text-accent ring-1 ring-accent/30 transition-colors hover:bg-accent/20"
13+ >
14+ {topic}
15+ </Link>
16+ ))}
17+ </div>
18+ );
19+}
+8−33
1−import { Search } from "lucide-react";
2−import { Form } from "react-router";
3−
41 import type { Route } from "./+types/code";
52 import { TreeView } from "../../components/repo-view";
6−import { notACredential } from "../../components/ui";
73 import { page } from "../../lib/meta";
84 import { repos } from "../../lib/services.server";
95 import { getViewer, unwrap } from "../../lib/session.server";
1410
1511 export async function loader({ params, context }: Route.LoaderArgs) {
1612 const path = { namespace: params.owner, name: params.repo };
17− return unwrap(await repos.tree(path, getViewer(context), null, ""));
18−}
19−
20−/**
21− * Searches this repository's code: the search page adds `repo:` for it, so
22− * the results can be widened to all of g1t from there.
23− */
24−function SearchThisRepository({ repo }: { repo: string }) {
25− return (
26− <Form action="/search" role="search" className="relative mb-4 max-w-md">
27− <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
28− <input
29− name="q"
30− {...notACredential()}
31− placeholder="Search this repository's code"
32− aria-label={`Search ${repo}`}
33− className="h-9 w-full rounded-md border border-line bg-surface pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
34− />
35− <input type="hidden" name="repo" value={repo} />
36− <input type="hidden" name="type" value="code" />
37− </Form>
38− );
13+ const viewer = getViewer(context);
14+ const [tree, branches] = await Promise.all([
15+ repos.tree(path, viewer, null, ""),
16+ // For the branch menu; the page still shows without it.
17+ repos.branches(path, viewer).catch(() => null),
18+ ]);
19+ return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null };
3920 }
4021
4122 export default function Code({ loaderData }: Route.ComponentProps) {
42− const repo = `${loaderData.repo.namespace}/${loaderData.repo.name}`;
43− return (
44− <>
45− {loaderData.head && <SearchThisRepository repo={repo} />}
46− <TreeView tree={loaderData} />
47− </>
48− );
23+ return <TreeView tree={loaderData.tree} branches={loaderData.branches} />;
4924 }
+7−19
44 import type { Project } from "@g1t/contracts";
55
66 import type { Route } from "./+types/layout";
7+import { Topics } from "../../components/topics";
78 import { page } from "../../lib/meta";
89 import { type Tab as PageTab, tabsFor } from "../../lib/project-nav";
910 import { Pill } from "../../components/ui";
7475 }
7576
7677 /** A repository's topics, each a way into Explore. */
77−function Topics({ topics }: { topics: string[] | undefined }) {
78− if (!topics?.length) return null;
79− return (
80− <div className="mt-2 flex flex-wrap gap-1.5">
81− {topics.map((topic) => (
82− <Link
83− key={topic}
84− to={`/explore?topic=${encodeURIComponent(topic)}`}
85− className="rounded-full bg-accent/10 px-2 py-px text-xs text-accent ring-1 ring-accent/30 transition-colors hover:bg-accent/20"
86− >
87− {topic}
88− </Link>
89− ))}
90− </div>
91− );
92−}
93−
9478 function Header({ project, isPrivate, archived, namespace, name, description, large }: {
9579 project: Project | null;
9680 isPrivate: boolean;
171155 const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description;
172156 const { pathname } = useLocation();
173157 const tabs = tabsFor(pathname.slice(base.length + 1), member, access.can);
158+ // The files' own About says what it is and its topics, as the one place.
159+ const filesPage = /^(code|tree)(\/|$)/.test(pathname.slice(base.length + 1));
174160 // Everyone, signed in or not, finds the project's pages in the sidebar;
175161 // the page shows its name, and the views of the page it is on as tabs.
176162 return (
183169 archived={Boolean(repo.archivedAt)}
184170 namespace={repo.namespace}
185171 name={repo.name}
186− description={description}
172+ // The files' own About says it there, as the one place.
173+ description={filesPage ? null : description}
187174 />
188− <Topics topics={repo.topics} />
175+ {/* On the files' pages, About shows them. */}
176+ {!filesPage && <Topics topics={repo.topics} />}
189177 {tabs && (
190178 <div className="mt-3">
191179 <PageTabs base={base} tabs={tabs} />
+7−3
1313 export async function loader({ params, context, request }: Route.LoaderArgs) {
1414 const path = { namespace: params.owner, name: params.repo };
1515 const viewer = getViewer(context);
16− const tree = await repos.tree(path, viewer, params.ref, params["*"] ?? "");
16+ const [tree, branches] = await Promise.all([
17+ repos.tree(path, viewer, params.ref, params["*"] ?? ""),
18+ // For the branch menu; the page still shows without it.
19+ repos.branches(path, viewer).catch(() => null),
20+ ]);
1721 // A branch that was renamed: the same folder on its new name.
1822 if (!tree.ok && tree.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
19− return unwrap(tree);
23+ return { tree: unwrap(tree), branches: branches?.ok ? branches.value : null };
2024 }
2125
2226 export default function Tree({ loaderData }: Route.ComponentProps) {
23− return <TreeView tree={loaderData} />;
27+ return <TreeView tree={loaderData.tree} branches={loaderData.branches} />;
2428 }
+4−0
8787 title: "Self-hosted runners",
8888 about: "Your own machines, which run workflow jobs, and if you choose agents' work, for free. They connect out to g1t; nothing reaches in.",
8989 },
90+ security: {
91+ title: "Security",
92+ about: "Open alerts in every project: secrets found in pushes and history, and vulnerable dependencies. Each project's Security page has the details and the security update g1t opened for each.",
93+ },
9094 integrations: {
9195 title: "Integrations",
9296 about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
+10−13
1−import { ChevronRight, ShieldCheck } from "lucide-react";
1+import { ChevronRight } from "lucide-react";
22 import { Link, data } from "react-router";
33
44 import { SEVERITIES, type SeverityCounts } from "@g1t/contracts";
88 import { SeverityCountsGrid, SeverityCountsInline } from "../../components/security";
99 import { TimeAgo } from "../../components/ui";
1010 import { Badge } from "../../components/ui/badge";
11−import { security } from "../../lib/services.server";
11+import { repos, security } from "../../lib/services.server";
1212 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
1313
1414 export function meta({ params, ...args }: Route.MetaArgs) {
1818 export async function loader({ params, context }: Route.LoaderArgs) {
1919 const viewer = getViewer(context);
2020 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21− const projects = unwrap(await security.workspace(params.owner, viewer));
21+ const [scanned, current] = await Promise.all([
22+ security.workspace(params.owner, viewer),
23+ repos.list(viewer, { namespace: params.owner.toLowerCase() }),
24+ ]);
25+ // Only repositories that are still there: a deleted one's alerts stay
26+ // with it for its 30 days, but not on this page.
27+ const live = new Set(current.map((repo) => repo.id));
28+ const projects = unwrap(scanned).filter((project) => live.has(project.repoId));
2229 const total = Object.fromEntries(SEVERITIES.map((severity) => [severity, 0])) as SeverityCounts;
2330 for (const project of projects) {
2431 for (const severity of SEVERITIES) total[severity] += project.counts[severity];
3542 const { projects, total } = loaderData;
3643 return (
3744 <div className="space-y-6">
38− <div>
39− <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
40− <ShieldCheck size={19} className="text-accent" />
41− Security across projects
42− </h2>
43− <p className="mt-1.5 max-w-2xl text-sm text-muted">
44− Open alerts in every project of {params.owner}: secrets found in pushes and history, and vulnerable
45− dependencies. Each project's Security page has the details and the security update g1t opened for each.
46− </p>
47− </div>
4845 <div>
4946 <SeverityCountsGrid counts={total} />
5047 <p className="mt-2 text-xs text-faint">