Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
4 commits
- Security matches the workspace's other pages and leaves out deleted repositories; every section has a name in the trailChase Pierce91a7e0f
- Topics sit in About on the files pages, under the descriptionChase Pierce5a58561
- Files pages say Files in the trail and keep the description in About onlyChase Pierce8b7fa02
- The code page reads like a forge: a branch menu, search and a Code button above the filesChase Pierce1b3058d
8 files+226−1040/8 viewed
| 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"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 5 | ||
| 6 | 6 | import { BlameView } from "./blame-view"; | |
| 7 | 7 | import { CodeLines } from "./code-lines"; | |
| 9 | 9 | import { AgentSetup } from "./agent-setup"; | |
| 10 | 10 | import { CloneBox } from "./clone-box"; | |
| 11 | 11 | 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"; | |
| 13 | 16 | ||
| 14 | 17 | function encodePath(path: string): string { | |
| 15 | 18 | return path.split("/").map(encodeURIComponent).join("/"); | |
| 29 | 32 | const segments = path.split("/").filter(Boolean); | |
| 30 | 33 | if (segments.length === 0) return null; | |
| 31 | 34 | return ( | |
| 32 | − | <p className="mb-4 font-mono text-sm text-muted"> | |
| 35 | + | <p className="font-mono text-sm text-muted"> | |
| 33 | 36 | <Link to={`${base}/tree/${gitRef}`} className="text-accent hover:underline"> | |
| 34 | 37 | {repo} | |
| 35 | 38 | </Link> | |
| 56 | 59 | ); | |
| 57 | 60 | } | |
| 58 | 61 | ||
| 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 }) { | |
| 61 | 64 | return ( | |
| 62 | 65 | <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-sm"> | |
| 63 | 66 | <Avatar name={commit.author.name} /> | |
| 64 | 67 | <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> | |
| 71 | 75 | <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> | |
| 72 | 80 | </span> | |
| 73 | 81 | </div> | |
| 74 | 82 | ); | |
| 75 | 83 | } | |
| 76 | 84 | ||
| 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 }) { | |
| 78 | 190 | const { repo, ref, path, head, entries, readme } = tree; | |
| 79 | 191 | const base = `/${repo.namespace}/${repo.name}`; | |
| 80 | 192 | const prefix = path ? `${encodePath(path)}/` : ""; | |
| 104 | 216 | } | |
| 105 | 217 | ||
| 106 | 218 | return ( | |
| 107 | − | <div className="grid gap-6 lg:grid-cols-[1fr_18rem]"> | |
| 219 | + | <div className={path ? "" : "grid gap-8 lg:grid-cols-[1fr_17rem]"}> | |
| 108 | 220 | <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} /> | |
| 110 | 222 | <div className="overflow-hidden rounded-xl border border-line"> | |
| 111 | − | <CommitBar commit={head} gitRef={ref} /> | |
| 223 | + | <CommitBar commit={head} base={base} /> | |
| 112 | 224 | <ul className="divide-y divide-line text-sm"> | |
| 113 | 225 | {entries.map((entry) => { | |
| 114 | 226 | const isTree = entry.kind === "tree"; | |
| 132 | 244 | </div> | |
| 133 | 245 | ||
| 134 | 246 | {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"> | |
| 136 | 248 | <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium"> | |
| 137 | 249 | <BookOpen size={15} className="text-faint" /> | |
| 138 | 250 | {readme.name} | |
| 154 | 266 | </div> | |
| 155 | 267 | ||
| 156 | 268 | {!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> | |
| 173 | 298 | </aside> | |
| 174 | 299 | )} | |
| 175 | 300 | </div> |
| 1223 | 1223 | domains: "Domains", | |
| 1224 | 1224 | guardrails: "Guardrails", | |
| 1225 | 1225 | 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", | |
| 1228 | 1238 | }; | |
| 1229 | 1239 | ||
| 1230 | 1240 | /** Where the page is, as a trail of links: workspace / repository / section. */ |
| 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 | + | } |
| 1 | − | import { Search } from "lucide-react"; | |
| 2 | − | import { Form } from "react-router"; | |
| 3 | − | ||
| 4 | 1 | import type { Route } from "./+types/code"; | |
| 5 | 2 | import { TreeView } from "../../components/repo-view"; | |
| 6 | − | import { notACredential } from "../../components/ui"; | |
| 7 | 3 | import { page } from "../../lib/meta"; | |
| 8 | 4 | import { repos } from "../../lib/services.server"; | |
| 9 | 5 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 14 | 10 | ||
| 15 | 11 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 16 | 12 | 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 }; | |
| 39 | 20 | } | |
| 40 | 21 | ||
| 41 | 22 | 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} />; | |
| 49 | 24 | } |
| 4 | 4 | import type { Project } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/layout"; | |
| 7 | + | import { Topics } from "../../components/topics"; | |
| 7 | 8 | import { page } from "../../lib/meta"; | |
| 8 | 9 | import { type Tab as PageTab, tabsFor } from "../../lib/project-nav"; | |
| 9 | 10 | import { Pill } from "../../components/ui"; | |
| 74 | 75 | } | |
| 75 | 76 | ||
| 76 | 77 | /** 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 | − | ||
| 94 | 78 | function Header({ project, isPrivate, archived, namespace, name, description, large }: { | |
| 95 | 79 | project: Project | null; | |
| 96 | 80 | isPrivate: boolean; | |
| 171 | 155 | const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description; | |
| 172 | 156 | const { pathname } = useLocation(); | |
| 173 | 157 | 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)); | |
| 174 | 160 | // Everyone, signed in or not, finds the project's pages in the sidebar; | |
| 175 | 161 | // the page shows its name, and the views of the page it is on as tabs. | |
| 176 | 162 | return ( | |
| 183 | 169 | archived={Boolean(repo.archivedAt)} | |
| 184 | 170 | namespace={repo.namespace} | |
| 185 | 171 | name={repo.name} | |
| 186 | − | description={description} | |
| 172 | + | // The files' own About says it there, as the one place. | |
| 173 | + | description={filesPage ? null : description} | |
| 187 | 174 | /> | |
| 188 | − | <Topics topics={repo.topics} /> | |
| 175 | + | {/* On the files' pages, About shows them. */} | |
| 176 | + | {!filesPage && <Topics topics={repo.topics} />} | |
| 189 | 177 | {tabs && ( | |
| 190 | 178 | <div className="mt-3"> | |
| 191 | 179 | <PageTabs base={base} tabs={tabs} /> |
| 13 | 13 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 14 | 14 | const path = { namespace: params.owner, name: params.repo }; | |
| 15 | 15 | 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 | + | ]); | |
| 17 | 21 | // A branch that was renamed: the same folder on its new name. | |
| 18 | 22 | 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 }; | |
| 20 | 24 | } | |
| 21 | 25 | ||
| 22 | 26 | export default function Tree({ loaderData }: Route.ComponentProps) { | |
| 23 | − | return <TreeView tree={loaderData} />; | |
| 27 | + | return <TreeView tree={loaderData.tree} branches={loaderData.branches} />; | |
| 24 | 28 | } |
| 87 | 87 | title: "Self-hosted runners", | |
| 88 | 88 | about: "Your own machines, which run workflow jobs, and if you choose agents' work, for free. They connect out to g1t; nothing reaches in.", | |
| 89 | 89 | }, | |
| 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 | + | }, | |
| 90 | 94 | integrations: { | |
| 91 | 95 | title: "Integrations", | |
| 92 | 96 | about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.", |
| 1 | − | import { ChevronRight, ShieldCheck } from "lucide-react"; | |
| 1 | + | import { ChevronRight } from "lucide-react"; | |
| 2 | 2 | import { Link, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { SEVERITIES, type SeverityCounts } from "@g1t/contracts"; | |
| 8 | 8 | import { SeverityCountsGrid, SeverityCountsInline } from "../../components/security"; | |
| 9 | 9 | import { TimeAgo } from "../../components/ui"; | |
| 10 | 10 | import { Badge } from "../../components/ui/badge"; | |
| 11 | − | import { security } from "../../lib/services.server"; | |
| 11 | + | import { repos, security } from "../../lib/services.server"; | |
| 12 | 12 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | 13 | ||
| 14 | 14 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 18 | 18 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 19 | 19 | const viewer = getViewer(context); | |
| 20 | 20 | 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)); | |
| 22 | 29 | const total = Object.fromEntries(SEVERITIES.map((severity) => [severity, 0])) as SeverityCounts; | |
| 23 | 30 | for (const project of projects) { | |
| 24 | 31 | for (const severity of SEVERITIES) total[severity] += project.counts[severity]; | |
| 35 | 42 | const { projects, total } = loaderData; | |
| 36 | 43 | return ( | |
| 37 | 44 | <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> | |
| 48 | 45 | <div> | |
| 49 | 46 | <SeverityCountsGrid counts={total} /> | |
| 50 | 47 | <p className="mt-2 text-xs text-faint"> |