Commit

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.

syntaqxcommitted Parent2ef440eBrowse files
12 files+314−600/12 viewed
+9−0
7171 project you can see keeps that project's sidebar. A profile that does not
7272 exist says **No one on g1t goes by that name**, since profiles are public.
7373
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+
7483 ## Browsing without an account
7584
7685 Public projects, Explore, Search and profiles are open to everyone, in the
+8−7
3636 AlertDialogTrigger,
3737 } from "./ui/alert-dialog";
3838 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
39+import { shownStep } from "../lib/agent-step";
3940
4041 /** How often a page with something running asks again. */
4142 export const LIVE_MS = 4000;
251252 <RunStatusBadge status={run.status} />
252253 </div>
253254 {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)}>
255256 {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)}
257258 </p>
258259 )}
259260 {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>}
408409 <RunStatusBadge status={current.status} />
409410 </div>
410411 {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)}>
412413 {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)}
414415 </p>
415416 )}
416417 {confidence && <ConfidenceLine confidence={confidence} />}
448449 if (!run) return null;
449450 return (
450451 <span
451− title={run.step ?? undefined}
452+ title={run.step ? shownStep(run.step) : undefined}
452453 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"
453454 >
454455 <Loader2 size={11} className="animate-spin" />
488489 const run = data?.runs[0];
489490 if (!run?.step) return null;
490491 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)}
493494 </span>
494495 );
495496 }
+29−43
1−import { Check, Hand } from "lucide-react";
1+import { Hand, Loader } from "lucide-react";
22
33 import type { Lifecycle, Stage } from "@g1t/contracts";
44
6565 };
6666
6767 /**
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.
7071 */
7172 export function LifecyclePanel({ lifecycle }: { lifecycle: Lifecycle }) {
7273 const { stage, detail, revisions } = lifecycle;
74+ if (stage === "ready" || stage === "queued") return null;
7375 if (stage === "needs_you") {
7476 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>
8183 </div>
8284 );
8385 }
8486 const current = STEP_OF[stage];
85− const ready = stage === "ready";
8687 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>
9495 {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>
9897 )}
9998 </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">
101100 {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;
104103 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}>
112105 <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"
115109 }`}
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"}`}>
126112 {step}
127113 </span>
128114 </li>
129115 );
130116 })}
131117 </ol>
132− <p className="mt-3 text-muted">{detail}</p>
118+ <p className="mt-2.5 text-muted">{detail}</p>
133119 </div>
134120 );
135121 }
+23−10
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";
22 import { Form, Link } from "react-router";
33
44 import type { Blame, BlobView as Blob, Branch, Commit, TreeView as Tree } from "@g1t/contracts";
128128 );
129129 }
130130
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 }) {
133133 return (
134134 <Popover>
135135 <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">
137137 Code
138138 <ChevronDown size={13} />
139139 </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>
146159 </PopoverContent>
147160 </Popover>
148161 );
179192 </span>
180193 )}
181194 <SearchCode repo={full} />
182− <CodeButton path={full} />
195+ <CodeButton path={full} gitRef={gitRef} />
183196 </>
184197 )}
185198 </div>
+15−0
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+});
+17−0
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+}
+40−0
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+});
+95−0
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+}
+1−0
102102 route("code", "routes/repo/code.tsx"),
103103 route("tree/:ref/*", "routes/repo/tree.tsx"),
104104 route("blob/:ref/*", "routes/repo/blob.tsx"),
105+ route("archive/*", "routes/repo/archive.ts"),
105106 route("commits", "routes/repo/commits.tsx"),
106107 route("commit/:hash", "routes/repo/commit.tsx"),
107108 route("issues", "routes/repo/issues.tsx"),
+60−0
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+}
+2−0
285285 gitAccess: (path, viewer, service) =>
286286 call("git_access", { path, viewer, service }),
287287 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 }),
288290 commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }),
289291 land: (sourceId, actor, branch) => call("land", { sourceId, actor, branch }),
290292 compare: (repoId, viewer, base, head) => call("compare", { repoId, viewer, base, head }),
+15−0
235235 branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>;
236236
237237 /**
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+ /**
238247 * Writes one file on a new branch made from the default branch's head, as
239248 * one commit by `actor`, for a change g1t proposes on their behalf (a
240249 * starter workflow). Refused unless they may push, when the branch exists,
282291 /** A branch and the commit it points to. */
283292 export type Branch = { name: string; hash: string };
284293
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+
285300 /**
286301 * What came of bringing a pull request up to date with the default branch
287302 * without a sandbox. `needs_agent` pushed nothing: the runner's `update`