Download ZIP from the Code button; a slimmer lifecycle panel; agent steps say what was done, not sandbox paths
- archive/<ref>.zip zips a branch, tag or commit for anyone who can see the repository, made on request (up to 10,000 files and 24 MB). - The lifecycle panel shows only while g1t is working: a spinner, the step, a five-part bar and the detail. Ready and queued are the merge box's to say. - 'The review is written to /work/review.json' reads 'Wrote its review.'; other /work paths read as the repository's.
12 files+314−600/12 viewed
| 71 | 71 | project you can see keeps that project's sidebar. A profile that does not | |
| 72 | 72 | exist says **No one on g1t goes by that name**, since profiles are public. | |
| 73 | 73 | ||
| 74 | + | ## Download a ZIP | |
| 75 | + | ||
| 76 | + | On a repository's **Files** page, **Code** → **Download ZIP** downloads the | |
| 77 | + | branch shown as one zip, its files in a folder named `<repo>-<branch>`. It | |
| 78 | + | works for anyone who can see the repository, and for any branch, tag or | |
| 79 | + | commit at `g1t.sh/<workspace>/<repo>/archive/<ref>.zip`. A ZIP holds the | |
| 80 | + | files, not the history; clone for that. A repository with more than 10,000 | |
| 81 | + | files or over 24 MB is too large to download this way, so clone it instead. | |
| 82 | + | ||
| 74 | 83 | ## Browsing without an account | |
| 75 | 84 | ||
| 76 | 85 | Public projects, Explore, Search and profiles are open to everyone, in the |
| 36 | 36 | AlertDialogTrigger, | |
| 37 | 37 | } from "./ui/alert-dialog"; | |
| 38 | 38 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 39 | + | import { shownStep } from "../lib/agent-step"; | |
| 39 | 40 | ||
| 40 | 41 | /** How often a page with something running asks again. */ | |
| 41 | 42 | export const LIVE_MS = 4000; | |
| 251 | 252 | <RunStatusBadge status={run.status} /> | |
| 252 | 253 | </div> | |
| 253 | 254 | {run.step && ( | |
| 254 | − | <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={run.step}> | |
| 255 | + | <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={shownStep(run.step)}> | |
| 255 | 256 | {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 256 | − | {run.step} | |
| 257 | + | {shownStep(run.step)} | |
| 257 | 258 | </p> | |
| 258 | 259 | )} | |
| 259 | 260 | {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>} | |
| 408 | 409 | <RunStatusBadge status={current.status} /> | |
| 409 | 410 | </div> | |
| 410 | 411 | {current.step && ( | |
| 411 | − | <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={current.step}> | |
| 412 | + | <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={shownStep(current.step)}> | |
| 412 | 413 | {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />} | |
| 413 | − | {current.step} | |
| 414 | + | {shownStep(current.step)} | |
| 414 | 415 | </p> | |
| 415 | 416 | )} | |
| 416 | 417 | {confidence && <ConfidenceLine confidence={confidence} />} | |
| 448 | 449 | if (!run) return null; | |
| 449 | 450 | return ( | |
| 450 | 451 | <span | |
| 451 | − | title={run.step ?? undefined} | |
| 452 | + | title={run.step ? shownStep(run.step) : undefined} | |
| 452 | 453 | className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged" | |
| 453 | 454 | > | |
| 454 | 455 | <Loader2 size={11} className="animate-spin" /> | |
| 488 | 489 | const run = data?.runs[0]; | |
| 489 | 490 | if (!run?.step) return null; | |
| 490 | 491 | return ( | |
| 491 | − | <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={run.step}> | |
| 492 | − | {RUN_KIND_LABEL[run.kind]}: {run.step} | |
| 492 | + | <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={shownStep(run.step)}> | |
| 493 | + | {RUN_KIND_LABEL[run.kind]}: {shownStep(run.step)} | |
| 493 | 494 | </span> | |
| 494 | 495 | ); | |
| 495 | 496 | } |
| 1 | − | import { Check, Hand } from "lucide-react"; | |
| 1 | + | import { Hand, Loader } from "lucide-react"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Lifecycle, Stage } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 65 | 65 | }; | |
| 66 | 66 | ||
| 67 | 67 | /** | |
| 68 | − | * Where a pull request made by g1t stands between "assigned" and | |
| 69 | − | * "ready to merge", and what is happening to it right now. | |
| 68 | + | * Where a pull request made by g1t stands while g1t is still seeing it | |
| 69 | + | * through: the step it is on, as a five-part bar, and what is happening now. | |
| 70 | + | * Once it is ready or queued the merge box says so, and this steps aside. | |
| 70 | 71 | */ | |
| 71 | 72 | export function LifecyclePanel({ lifecycle }: { lifecycle: Lifecycle }) { | |
| 72 | 73 | const { stage, detail, revisions } = lifecycle; | |
| 74 | + | if (stage === "ready" || stage === "queued") return null; | |
| 73 | 75 | if (stage === "needs_you") { | |
| 74 | 76 | return ( | |
| 75 | − | <div className="mt-4 rounded-xl border border-warn/40 bg-warn/5 px-4 py-3 text-sm"> | |
| 76 | − | <p className="flex items-center gap-2.5 font-medium"> | |
| 77 | − | <Hand size={16} className="shrink-0 text-warn" /> | |
| 78 | − | {TITLE[stage]} | |
| 79 | − | </p> | |
| 80 | − | <p className="mt-1 text-muted">{detail}</p> | |
| 77 | + | <div className="mt-4 flex gap-3 rounded-xl border border-warn/40 bg-warn/5 px-4 py-3 text-sm"> | |
| 78 | + | <Hand size={16} className="mt-0.5 shrink-0 text-warn" /> | |
| 79 | + | <div className="min-w-0"> | |
| 80 | + | <p className="font-medium">{TITLE[stage]}</p> | |
| 81 | + | <p className="mt-0.5 text-muted">{detail}</p> | |
| 82 | + | </div> | |
| 81 | 83 | </div> | |
| 82 | 84 | ); | |
| 83 | 85 | } | |
| 84 | 86 | const current = STEP_OF[stage]; | |
| 85 | − | const ready = stage === "ready"; | |
| 86 | 87 | return ( | |
| 87 | − | <div | |
| 88 | − | className={`mt-4 rounded-xl border px-4 py-3 text-sm ${ | |
| 89 | − | ready ? "border-accent/40 bg-accent/5" : "border-line bg-surface" | |
| 90 | − | }`} | |
| 91 | − | > | |
| 92 | − | <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 93 | − | <p className="font-medium">{TITLE[stage]}</p> | |
| 88 | + | <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3.5 text-sm"> | |
| 89 | + | <div className="flex items-center gap-2.5"> | |
| 90 | + | <Loader size={15} className="shrink-0 animate-spin text-accent motion-reduce:animate-none" /> | |
| 91 | + | <p className="font-medium"> | |
| 92 | + | {STAGE_LABEL[stage]} | |
| 93 | + | <span className="font-normal text-faint"> · step {current + 1} of {STEPS.length}</span> | |
| 94 | + | </p> | |
| 94 | 95 | {revisions > 0 && ( | |
| 95 | − | <p className="text-xs text-faint"> | |
| 96 | − | Revised {revisions === 1 ? "once" : `${revisions} times`} | |
| 97 | − | </p> | |
| 96 | + | <span className="ml-auto text-xs text-faint">Revised {revisions === 1 ? "once" : `${revisions} times`}</span> | |
| 98 | 97 | )} | |
| 99 | 98 | </div> | |
| 100 | − | <ol className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs"> | |
| 99 | + | <ol className="mt-3 grid grid-cols-5 gap-1.5" aria-label="Steps"> | |
| 101 | 100 | {STEPS.map((step, index) => { | |
| 102 | − | const done = index < current || ready; | |
| 103 | − | const active = index === current && !ready; | |
| 101 | + | const done = index < current; | |
| 102 | + | const active = index === current; | |
| 104 | 103 | return ( | |
| 105 | − | <li key={step} className="flex items-center gap-2"> | |
| 106 | − | {index > 0 && ( | |
| 107 | − | <span | |
| 108 | − | aria-hidden="true" | |
| 109 | − | className={`h-px w-4 sm:w-6 ${done || active ? "bg-accent-dim" : "bg-line"}`} | |
| 110 | − | /> | |
| 111 | − | )} | |
| 104 | + | <li key={step} aria-current={active ? "step" : undefined}> | |
| 112 | 105 | <span | |
| 113 | − | className={`flex items-center gap-1.5 ${ | |
| 114 | − | done ? "text-fg" : active ? "font-medium text-accent" : "text-faint" | |
| 106 | + | aria-hidden="true" | |
| 107 | + | className={`block h-1 rounded-full ${ | |
| 108 | + | done ? "bg-accent" : active ? "animate-pulse bg-accent/60 motion-reduce:animate-none" : "bg-line" | |
| 115 | 109 | }`} | |
| 116 | − | > | |
| 117 | − | {done ? ( | |
| 118 | − | <Check size={13} className="text-accent" /> | |
| 119 | − | ) : ( | |
| 120 | − | <span | |
| 121 | − | className={`size-1.5 rounded-full ${ | |
| 122 | − | active ? "animate-pulse bg-accent" : "bg-line-strong" | |
| 123 | − | }`} | |
| 124 | − | /> | |
| 125 | − | )} | |
| 110 | + | /> | |
| 111 | + | <span className={`mt-1.5 block truncate text-[0.6875rem] ${active ? "font-medium text-fg" : done ? "text-muted" : "text-faint"}`}> | |
| 126 | 112 | {step} | |
| 127 | 113 | </span> | |
| 128 | 114 | </li> | |
| 129 | 115 | ); | |
| 130 | 116 | })} | |
| 131 | 117 | </ol> | |
| 132 | − | <p className="mt-3 text-muted">{detail}</p> | |
| 118 | + | <p className="mt-2.5 text-muted">{detail}</p> | |
| 133 | 119 | </div> | |
| 134 | 120 | ); | |
| 135 | 121 | } |
| 1 | − | import { BookOpen, Check, ChevronDown, Code2, File, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal } from "lucide-react"; | |
| 1 | + | import { BookOpen, Check, ChevronDown, Code2, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal } from "lucide-react"; | |
| 2 | 2 | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Blame, BlobView as Blob, Branch, Commit, TreeView as Tree } from "@g1t/contracts"; | |
| 128 | 128 | ); | |
| 129 | 129 | } | |
| 130 | 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 }) { | |
| 131 | + | /** The one button for getting the code: clone over HTTPS or SSH, hand it to an agent, or download it. */ | |
| 132 | + | function CodeButton({ path, gitRef }: { path: string; gitRef: string }) { | |
| 133 | 133 | return ( | |
| 134 | 134 | <Popover> | |
| 135 | 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"> | |
| 137 | 137 | Code | |
| 138 | 138 | <ChevronDown size={13} /> | |
| 139 | 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} /> | |
| 140 | + | <PopoverContent align="end" className="w-96 p-0"> | |
| 141 | + | <div className="p-4"> | |
| 142 | + | <h2 className="mb-3 flex items-center gap-2 text-sm font-medium"> | |
| 143 | + | <SquareTerminal size={15} className="text-faint" /> | |
| 144 | + | Clone | |
| 145 | + | </h2> | |
| 146 | + | <CloneBox path={path} /> | |
| 147 | + | </div> | |
| 148 | + | <div className="border-t border-line p-1.5"> | |
| 149 | + | {/* A plain link: the browser downloads it, not the router. */} | |
| 150 | + | <a | |
| 151 | + | href={`/${path}/archive/${encodePath(gitRef)}.zip`} | |
| 152 | + | download | |
| 153 | + | className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-fg/90 transition-colors hover:bg-line hover:text-fg" | |
| 154 | + | > | |
| 155 | + | <FileArchive size={15} className="text-faint" /> | |
| 156 | + | Download ZIP | |
| 157 | + | </a> | |
| 158 | + | </div> | |
| 146 | 159 | </PopoverContent> | |
| 147 | 160 | </Popover> | |
| 148 | 161 | ); | |
| 179 | 192 | </span> | |
| 180 | 193 | )} | |
| 181 | 194 | <SearchCode repo={full} /> | |
| 182 | − | <CodeButton path={full} /> | |
| 195 | + | <CodeButton path={full} gitRef={gitRef} /> | |
| 183 | 196 | </> | |
| 184 | 197 | )} | |
| 185 | 198 | </div> |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { shownStep } from "./agent-step"; | |
| 5 | + | ||
| 6 | + | test("a step that hands back a file says what was done, not where", () => { | |
| 7 | + | assert.equal(shownStep("Said: Done. The review is written to `/work/review.json`."), "Wrote its review."); | |
| 8 | + | assert.equal(shownStep("Said: Plan saved to /work/plan.json"), "Wrote the plan."); | |
| 9 | + | assert.equal(shownStep("Said: I wrote /work/answer.md"), "Wrote its answer."); | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | test("paths in the checkout read as the repository's own", () => { | |
| 13 | + | assert.equal(shownStep("Editing `/work/repo/src/math.js`"), "Editing src/math.js"); | |
| 14 | + | assert.equal(shownStep("Running npm test"), "Running npm test"); | |
| 15 | + | }); |
| 1 | + | /** | |
| 2 | + | * An agent's latest step as the pages show it. The sandbox hands a review, | |
| 3 | + | * a plan or an answer back as a file under /work; an agent that says so | |
| 4 | + | * ("The review is written to `/work/review.json`") is saying it finished | |
| 5 | + | * that, not something anyone needs the path for. | |
| 6 | + | */ | |
| 7 | + | const HANDED_BACK: [RegExp, string][] = [ | |
| 8 | + | [/\/work\/review\.json/, "Wrote its review."], | |
| 9 | + | [/\/work\/plan\.json/, "Wrote the plan."], | |
| 10 | + | [/\/work\/answer\.md/, "Wrote its answer."], | |
| 11 | + | ]; | |
| 12 | + | ||
| 13 | + | export function shownStep(step: string): string { | |
| 14 | + | for (const [path, words] of HANDED_BACK) if (path.test(step)) return words; | |
| 15 | + | // Any other file of the checkout, as the repository names it. | |
| 16 | + | return step.replace(/`?\/work\/repo\/([^\s`]+)`?/g, "$1").replace(/`?\/work\/([^\s`]+)`?/g, "$1"); | |
| 17 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | import { inflateRawSync } from "node:zlib"; | |
| 4 | + | ||
| 5 | + | import { crc32, zip } from "./zip"; | |
| 6 | + | ||
| 7 | + | test("crc32 matches the standard check value", () => { | |
| 8 | + | assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926); | |
| 9 | + | }); | |
| 10 | + | ||
| 11 | + | test("a zip holds each file, deflated or stored, and reads back", async () => { | |
| 12 | + | const text = new TextEncoder().encode("hello ".repeat(200)); | |
| 13 | + | const tiny = new TextEncoder().encode("x"); | |
| 14 | + | const out = await zip([ | |
| 15 | + | { path: "repo-main/README.md", data: text }, | |
| 16 | + | { path: "repo-main/a.txt", data: tiny }, | |
| 17 | + | { path: "repo-main/empty", data: new Uint8Array() }, | |
| 18 | + | ]); | |
| 19 | + | const view = new DataView(out.buffer, out.byteOffset, out.byteLength); | |
| 20 | + | // The end record names three entries and where the directory starts. | |
| 21 | + | const end = out.length - 22; | |
| 22 | + | assert.equal(view.getUint32(end, true), 0x06054b50); | |
| 23 | + | assert.equal(view.getUint16(end + 10, true), 3); | |
| 24 | + | // Read each local entry back. | |
| 25 | + | let at = 0; | |
| 26 | + | const read: Record<string, string> = {}; | |
| 27 | + | for (let i = 0; i < 3; i++) { | |
| 28 | + | assert.equal(view.getUint32(at, true), 0x04034b50); | |
| 29 | + | const method = view.getUint16(at + 8, true); | |
| 30 | + | const size = view.getUint32(at + 18, true); | |
| 31 | + | const nameLength = view.getUint16(at + 26, true); | |
| 32 | + | const name = new TextDecoder().decode(out.subarray(at + 30, at + 30 + nameLength)); | |
| 33 | + | const body = out.subarray(at + 30 + nameLength, at + 30 + nameLength + size); | |
| 34 | + | read[name] = new TextDecoder().decode(method === 8 ? inflateRawSync(body) : body); | |
| 35 | + | at += 30 + nameLength + size; | |
| 36 | + | } | |
| 37 | + | assert.equal(read["repo-main/README.md"], "hello ".repeat(200)); | |
| 38 | + | assert.equal(read["repo-main/a.txt"], "x"); | |
| 39 | + | assert.equal(read["repo-main/empty"], ""); | |
| 40 | + | }); |
| 1 | + | /** | |
| 2 | + | * A zip archive of files, made in memory: each file deflated when that makes | |
| 3 | + | * it smaller, stored otherwise, with UTF-8 names. Enough for downloading a | |
| 4 | + | * commit; no zip64, so the archive and each file stay under 4 GB (the caller | |
| 5 | + | * caps them far below that). | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | const CRC_TABLE = (() => { | |
| 9 | + | const table = new Uint32Array(256); | |
| 10 | + | for (let n = 0; n < 256; n++) { | |
| 11 | + | let c = n; | |
| 12 | + | for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; | |
| 13 | + | table[n] = c >>> 0; | |
| 14 | + | } | |
| 15 | + | return table; | |
| 16 | + | })(); | |
| 17 | + | ||
| 18 | + | export function crc32(data: Uint8Array): number { | |
| 19 | + | let crc = 0xffffffff; | |
| 20 | + | for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8); | |
| 21 | + | return (crc ^ 0xffffffff) >>> 0; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | async function deflate(data: Uint8Array): Promise<Uint8Array> { | |
| 25 | + | const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream("deflate-raw")); | |
| 26 | + | return new Uint8Array(await new Response(stream).arrayBuffer()); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** The archive of `files`, in the order given. */ | |
| 30 | + | export async function zip(files: { path: string; data: Uint8Array }[]): Promise<Uint8Array<ArrayBuffer>> { | |
| 31 | + | const encoder = new TextEncoder(); | |
| 32 | + | const parts: Uint8Array[] = []; | |
| 33 | + | const central: Uint8Array[] = []; | |
| 34 | + | let offset = 0; | |
| 35 | + | for (const file of files) { | |
| 36 | + | const name = encoder.encode(file.path); | |
| 37 | + | const crc = crc32(file.data); | |
| 38 | + | const deflated = file.data.length > 0 ? await deflate(file.data) : file.data; | |
| 39 | + | const [method, body] = deflated.length < file.data.length ? [8, deflated] : [0, file.data]; | |
| 40 | + | const header = (central: boolean) => { | |
| 41 | + | const out = new Uint8Array((central ? 46 : 30) + name.length); | |
| 42 | + | const view = new DataView(out.buffer); | |
| 43 | + | let at = 0; | |
| 44 | + | const u16 = (v: number) => { | |
| 45 | + | view.setUint16(at, v, true); | |
| 46 | + | at += 2; | |
| 47 | + | }; | |
| 48 | + | const u32 = (v: number) => { | |
| 49 | + | view.setUint32(at, v >>> 0, true); | |
| 50 | + | at += 4; | |
| 51 | + | }; | |
| 52 | + | u32(central ? 0x02014b50 : 0x04034b50); | |
| 53 | + | if (central) u16(0x031e); // made by: Unix, 3.0 | |
| 54 | + | u16(20); // version needed | |
| 55 | + | u16(0x0800); // UTF-8 names | |
| 56 | + | u16(method); | |
| 57 | + | u16(0); // time | |
| 58 | + | u16(0x0021); // date: 1980-01-01 | |
| 59 | + | u32(crc); | |
| 60 | + | u32(body.length); | |
| 61 | + | u32(file.data.length); | |
| 62 | + | u16(name.length); | |
| 63 | + | u16(0); // extra | |
| 64 | + | if (central) { | |
| 65 | + | u16(0); // comment | |
| 66 | + | u16(0); // disk | |
| 67 | + | u16(0); // internal attributes | |
| 68 | + | u32(0o100644 << 16); | |
| 69 | + | u32(offset); | |
| 70 | + | } | |
| 71 | + | out.set(name, at); | |
| 72 | + | return out; | |
| 73 | + | }; | |
| 74 | + | const local = header(false); | |
| 75 | + | parts.push(local, body); | |
| 76 | + | central.push(header(true)); | |
| 77 | + | offset += local.length + body.length; | |
| 78 | + | } | |
| 79 | + | const centralSize = central.reduce((sum, part) => sum + part.length, 0); | |
| 80 | + | const end = new Uint8Array(22); | |
| 81 | + | const view = new DataView(end.buffer); | |
| 82 | + | view.setUint32(0, 0x06054b50, true); | |
| 83 | + | view.setUint16(8, files.length, true); | |
| 84 | + | view.setUint16(10, files.length, true); | |
| 85 | + | view.setUint32(12, centralSize, true); | |
| 86 | + | view.setUint32(16, offset, true); | |
| 87 | + | const all = [...parts, ...central, end]; | |
| 88 | + | const out = new Uint8Array(all.reduce((sum, part) => sum + part.length, 0)); | |
| 89 | + | let at = 0; | |
| 90 | + | for (const part of all) { | |
| 91 | + | out.set(part, at); | |
| 92 | + | at += part.length; | |
| 93 | + | } | |
| 94 | + | return out; | |
| 95 | + | } |
| 102 | 102 | route("code", "routes/repo/code.tsx"), | |
| 103 | 103 | route("tree/:ref/*", "routes/repo/tree.tsx"), | |
| 104 | 104 | route("blob/:ref/*", "routes/repo/blob.tsx"), | |
| 105 | + | route("archive/*", "routes/repo/archive.ts"), | |
| 105 | 106 | route("commits", "routes/repo/commits.tsx"), | |
| 106 | 107 | route("commit/:hash", "routes/repo/commit.tsx"), | |
| 107 | 108 | route("issues", "routes/repo/issues.tsx"), |
| 1 | + | /** | |
| 2 | + | * Download ZIP: a branch, tag or commit of a repository as one zip, its | |
| 3 | + | * files under `<repo>-<ref>/` as `git archive` would name them. For anyone | |
| 4 | + | * who can see the repository. Made on request and not kept, so it is capped: | |
| 5 | + | * a repository past the caps is cloned instead. | |
| 6 | + | */ | |
| 7 | + | import type { Route } from "./+types/archive"; | |
| 8 | + | import { repos } from "../../lib/services.server"; | |
| 9 | + | import { getViewer } from "../../lib/session.server"; | |
| 10 | + | import { zip } from "../../lib/zip"; | |
| 11 | + | ||
| 12 | + | /** The most files, and bytes in all, an archive is made of. */ | |
| 13 | + | const MAX_FILES = 10_000; | |
| 14 | + | // The bytes, their base64 and the zip are all held at once, in a Worker of 128 MB. | |
| 15 | + | const MAX_BYTES = 24 * 1024 * 1024; | |
| 16 | + | /** Blobs read per call to the repository. */ | |
| 17 | + | const PER_READ = 100; | |
| 18 | + | ||
| 19 | + | function refused(status: number, message: string): Response { | |
| 20 | + | return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } }); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 24 | + | const viewer = getViewer(context); | |
| 25 | + | const asked = params["*"] ?? ""; | |
| 26 | + | if (!asked.endsWith(".zip") || asked.length <= 4) return refused(404, "Ask for <branch, tag or commit>.zip."); | |
| 27 | + | const ref = asked.slice(0, -4); | |
| 28 | + | const repo = await repos.get({ namespace: params.owner, name: params.repo }, viewer).catch(() => null); | |
| 29 | + | if (!repo?.ok) return refused(404, "There is no such repository, or you cannot see it."); | |
| 30 | + | const listed = await repos.listFiles(repo.value.id, ref, MAX_FILES + 1).catch(() => null); | |
| 31 | + | if (!listed?.commit) return refused(404, `There is no branch, tag or commit named ${ref}.`); | |
| 32 | + | const clone = `git clone https://g1t.sh/${repo.value.namespace}/${repo.value.name}.git`; | |
| 33 | + | if (listed.truncated || listed.files.length > MAX_FILES) { | |
| 34 | + | return refused(413, `It has more than ${MAX_FILES.toLocaleString("en-US")} files, too many for a download. Clone it instead: ${clone}`); | |
| 35 | + | } | |
| 36 | + | const files = listed.files.filter((file): file is { path: string; hash: string } => file.hash != null); | |
| 37 | + | const unique = [...new Set(files.map((file) => file.hash))]; | |
| 38 | + | const bytes = new Map<string, Uint8Array>(); | |
| 39 | + | let total = 0; | |
| 40 | + | for (let at = 0; at < unique.length; at += PER_READ) { | |
| 41 | + | for (const blob of await repos.rawBlobs(repo.value.id, unique.slice(at, at + PER_READ), MAX_BYTES)) { | |
| 42 | + | total += blob.size; | |
| 43 | + | if (total > MAX_BYTES || (blob.data == null && blob.size > 0)) { | |
| 44 | + | return refused(413, `It is over ${MAX_BYTES / 1024 / 1024} MB, too large for a download. Clone it instead: ${clone}`); | |
| 45 | + | } | |
| 46 | + | bytes.set(blob.hash, Uint8Array.from(atob(blob.data ?? ""), (c) => c.charCodeAt(0))); | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | // A branch name can hold slashes; the folder and file name cannot. | |
| 50 | + | const label = `${repo.value.name}-${ref.replaceAll("/", "-")}`; | |
| 51 | + | const archive = await zip(files.map((file) => ({ path: `${label}/${file.path}`, data: bytes.get(file.hash) ?? new Uint8Array() }))); | |
| 52 | + | return new Response(archive, { | |
| 53 | + | headers: { | |
| 54 | + | "content-type": "application/zip", | |
| 55 | + | "content-disposition": `attachment; filename="${label}.zip"`, | |
| 56 | + | // A commit's files never change; a branch's do. | |
| 57 | + | "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=31536000, immutable" : "private, no-cache", | |
| 58 | + | }, | |
| 59 | + | }); | |
| 60 | + | } |
| 285 | 285 | gitAccess: (path, viewer, service) => | |
| 286 | 286 | call("git_access", { path, viewer, service }), | |
| 287 | 287 | branches: (path, viewer) => call("branches", { path, viewer }), | |
| 288 | + | listFiles: (repoId, ref, limit) => call("list_files", { repoId, ref, skipDirs: [], limit }), | |
| 289 | + | rawBlobs: (repoId, hashes, maxBytes) => call("raw_blobs", { repoId, hashes, maxBytes }), | |
| 288 | 290 | commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }), | |
| 289 | 291 | land: (sourceId, actor, branch) => call("land", { sourceId, actor, branch }), | |
| 290 | 292 | compare: (repoId, viewer, base, head) => call("compare", { repoId, viewer, base, head }), |
| 235 | 235 | branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>; | |
| 236 | 236 | ||
| 237 | 237 | /** | |
| 238 | + | * Every file at `ref` (the default branch when null), at most `limit` | |
| 239 | + | * (10,000 at most). No viewer: check access first. | |
| 240 | + | */ | |
| 241 | + | listFiles(repoId: string, ref: string | null, limit: number): Promise<FileList>; | |
| 242 | + | ||
| 243 | + | /** Blobs' bytes as standard base64, at most 100; `data` is null for one missing or over `maxBytes`. No viewer. */ | |
| 244 | + | rawBlobs(repoId: string, hashes: string[], maxBytes: number): Promise<RawBlob[]>; | |
| 245 | + | ||
| 246 | + | /** | |
| 238 | 247 | * Writes one file on a new branch made from the default branch's head, as | |
| 239 | 248 | * one commit by `actor`, for a change g1t proposes on their behalf (a | |
| 240 | 249 | * starter workflow). Refused unless they may push, when the branch exists, | |
| 282 | 291 | /** A branch and the commit it points to. */ | |
| 283 | 292 | export type Branch = { name: string; hash: string }; | |
| 284 | 293 | ||
| 294 | + | /** Files at a commit, and whether there were more than were listed. */ | |
| 295 | + | export type FileList = { commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean }; | |
| 296 | + | ||
| 297 | + | /** One blob's bytes, standard base64; null when missing or too large. */ | |
| 298 | + | export type RawBlob = { hash: string; size: number; data: string | null }; | |
| 299 | + | ||
| 285 | 300 | /** | |
| 286 | 301 | * What came of bringing a pull request up to date with the default branch | |
| 287 | 302 | * without a sandbox. `needs_agent` pushed nothing: the runner's `update` |