Vectrum
Sign In

Build Vectrum with the MetalUI "Soft Hardware" theme

Adds the Vectrum app (repositories, code browsing, editing, issues,
settings, SSH and HTTPS git) and styles the whole UI with MetalUI
(@unlocalhosted/metalui): bone and graphite colorways, Sidebar, Menu,
CommandPalette search, Tabs, Switcher, Select, Popover, Dialog,
AlertDialog, DropZone, Attachment, Field, Textarea, EmptyState, Toasts
and MetalUI icons. Pieces without a MetalUI component are drawn with its
theme utilities (materials, wells, engraved labels, type roles).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent 11bb49ccommitaecb85c

Showing 131 changed files with 11757 additions and 111 deletions.

+3−0.gitignoreView File
@@ -39,3 +39,6 @@ yarn-error.log*
3939 # typescript
4040 *.tsbuildinfo
4141 next-env.d.ts
42+
43+ # git repositories and issue data
44+ /data/
+78−20README.mdView File
@@ -1,36 +1,94 @@
1- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
1+ # Vectrum
22
3- ## Getting Started
3+ [vectrum.cc](https://vectrum.cc) · A self-hosted, multi-user GitHub built with Next.js. Repositories are real bare
4+ git repositories on disk, and you push to them with plain git over **SSH** or
5+ **HTTPS**.
46
5- First, run the development server:
7+ ## Features
8+
9+ - **Accounts**: sign up, sign in, profiles, password change (scrypt-hashed passwords, database-backed sessions)
10+ - **Repositories**: public or private, rename, delete, optional README on creation
11+ - **Collaborators**: give other users push access to a repository
12+ - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
13+ - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens
14+ - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored
15+ - **Web editor**: CodeMirror with syntax highlighting and markdown preview. Create, edit, rename, upload and delete files, committing to the current branch or a new one
16+ - **Code browsing**: file tree, highlighting (Shiki), rendered READMEs (sanitized HTML), raw files, image previews
17+ - **History**: commit log per branch or file, commit diffs, branches overview, recent-activity dashboard
18+ - **Issues as a Linear-style project tracker**, per repository:
19+ - Workflow statuses (Backlog → Todo → In Progress → In Review → Done / Canceled), priorities, assignees, labels, estimates and due dates
20+ - List view grouped by status, priority or assignee, and a kanban board with drag and drop
21+ - Filters (assignee, priority, label, "My issues"), scopes (All / Active / Backlog) and orderings, kept in the URL so views can be shared
22+ - Quick-create dialog and keyboard shortcuts: `C` new issue, `J`/`K` move, `Enter` open, `S`/`P`/`A`/`L` set status, priority, assignee, labels
23+ - Inline editing of every property, with an activity feed of changes next to the markdown comments
24+ - Anyone who can read a repository can open issues and comment, but only collaborators and the owner can change an issue (title, description, status and all other properties). Only an issue's creator and the repository owner can delete it. Labels are managed under *Settings → Labels* or created from the label menu
25+ - Light and dark mode
26+
27+ ## Getting started
28+
29+ Requires Node.js 22.5+ (for the built-in `node:sqlite`) and `git` on the `PATH`.
630
731 ```bash
8- npm run dev
9- # or
10- yarn dev
11- # or
12- pnpm dev
13- # or
32+ bun install
1433 bun dev
1534 ```
1635
17- Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
36+ Open http://localhost:3000, sign up, and create a repository.
37+
38+ ### Pushing over SSH
39+
40+ 1. Add your public key (`~/.ssh/id_ed25519.pub`) under **Settings → SSH keys**.
41+ 2. Check the connection: `ssh -T -p 2222 git@localhost`
42+ 3. Push:
43+ ```bash
44+ git remote add origin ssh://git@localhost:2222/<you>/<repo>.git
45+ git push -u origin main
46+ ```
47+
48+ ### Pushing over HTTPS
1849
19- You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
50+ Generate a token under **Settings → API tokens** and use it as the password
51+ when git asks (any username works):
2052
21- This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
53+ ```bash
54+ git clone http://localhost:3000/<you>/<repo>.git
55+ # Username: <you> Password: vec_…
56+ ```
57+
58+ Anonymous users can clone public repositories without a token.
2259
23- ## Learn More
60+ ## Configuration
2461
25- To learn more about Next.js, take a look at the following resources:
62+ | Variable | Default | Purpose |
63+ | ----------------- | ----------- | --------------------------------------------------------- |
64+ | `DATA_ROOT` | `./data` | Where repositories, the database and the SSH host key live |
65+ | `SSH_PORT` | `2222` | Port of the built-in SSH server |
66+ | `SSH_LISTEN_HOST` | `0.0.0.0` | Interface the SSH server binds to |
67+ | `SSH_HOST` | request host | Host name shown in SSH clone URLs, e.g. `vectrum.cc` |
68+ | `SSH_DISABLED` | — | Set to `1` to not start the SSH server |
2669
27- - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28- - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
70+ Data layout:
71+
72+ ```
73+ data/
74+ app.db users, sessions, keys, tokens, repos, issues (SQLite)
75+ ssh_host_ed25519_key generated on first start
76+ repos/<owner>/<repo>.git bare git repositories
77+ ```
2978
30- You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
79+ ## Security notes
3180
32- ## Deploy on Vercel
81+ - Serve the web UI over HTTPS if it's reachable beyond localhost. Session cookies become `Secure` when `X-Forwarded-Proto: https` is set.
82+ - Private repositories show a 404 page to anyone without access, so their existence isn't revealed.
83+ - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin.
3384
34- The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
85+ ## Layout
3586
36- Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
87+ - `lib/db.ts`: SQLite schema and connection
88+ - `lib/users.ts`, `lib/auth.ts`: accounts, password hashing, sessions
89+ - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators
90+ - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/`
91+ - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens
92+ - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories
93+ - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`)
94+ - `app/actions/*`: server actions, each re-checking the session and permissions
+111−0app/[owner]/[repo]/blob/[...segments]/page.tsxaddedView File
@@ -0,0 +1,111 @@
1+ import Link from "next/link";
2+ import { notFound, redirect } from "next/navigation";
3+ import { BranchSelect } from "@/components/branch-select";
4+ import { Breadcrumbs } from "@/components/breadcrumbs";
5+ import { CopyButton } from "@/components/clone-button";
6+ import { CodeView } from "@/components/code-view";
7+ import { FileActions } from "@/components/editor-file-actions";
8+ import { LatestCommitBar } from "@/components/file-table";
9+ import { Markdown } from "@/components/markdown";
10+ import { Box, Segmented, segmentClass } from "@/components/ui";
11+ import { lastCommit, listBranches, objectType, readBlob } from "@/lib/git";
12+ import { encodePath, formatBytes } from "@/lib/format";
13+ import { languageFor } from "@/lib/languages";
14+ import { getRefAndPath, getRepo } from "@/lib/repo";
15+ import { IMAGE_TYPES } from "@/lib/mime";
16+
17+ export default async function BlobPage({ params, searchParams }: PageProps<"/[owner]/[repo]/blob/[...segments]">) {
18+ const { owner, repo: repoParam, segments } = await params;
19+ const { plain } = await searchParams;
20+ const repo = await getRepo(owner, repoParam);
21+ const { ref, path } = await getRefAndPath(repo, segments);
22+ const type = await objectType(repo.dir, ref, path);
23+ if (type === "tree") redirect(`${repo.base}/tree/${encodePath(ref)}/${encodePath(path)}`);
24+ if (type !== "blob") notFound();
25+
26+ const [blob, commit, branches] = await Promise.all([
27+ readBlob(repo.dir, ref, path),
28+ lastCommit(repo.dir, ref, path),
29+ listBranches(repo.dir),
30+ ]);
31+ const filename = path.split("/").pop()!;
32+ const ext = filename.split(".").pop()!.toLowerCase();
33+ const rawHref = `${repo.base}/raw/${encodePath(ref)}/${encodePath(path)}`;
34+ const text = blob.binary || blob.tooLarge ? null : blob.content.toString("utf8");
35+ const isMarkdown = /\.(md|markdown)$/i.test(filename);
36+ const showRendered = isMarkdown && plain !== "1";
37+ const lines = text === null ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
38+ const editable = repo.canWrite && branches.some((b) => b.name === ref);
39+ const selfHref = `${repo.base}/blob/${encodePath(ref)}/${encodePath(path)}`;
40+ const language = languageFor(filename)?.name;
41+ const meta = [text !== null && `${lines} line${lines === 1 ? "" : "s"}`, formatBytes(blob.size), language].filter(Boolean).join(" · ");
42+
43+ let content;
44+ if (ext in IMAGE_TYPES) {
45+ content = (
46+ <div className="p-3">
47+ <div className="well flex justify-center rounded-plate bg-[repeating-conic-gradient(var(--mu-s-lo)_0_25%,var(--mu-s-hi)_0_50%)] bg-[length:20px_20px] p-8">
48+ {/* eslint-disable-next-line @next/next/no-img-element */}
49+ <img src={rawHref} alt={filename} className="max-w-full rounded-row shadow-lift" />
50+ </div>
51+ </div>
52+ );
53+ } else if (text === null) {
54+ content = (
55+ <p className="px-6 py-12 text-center type-body text-ink2">
56+ {blob.tooLarge ? "This file is too large to display." : "Binary file not shown."}{" "}
57+ <Link href={rawHref} prefetch={false} className="text-ink underline decoration-rule underline-offset-2 hover:decoration-current">
58+ View raw
59+ </Link>
60+ </p>
61+ );
62+ } else if (showRendered) {
63+ const dir = path.split("/").slice(0, -1).join("/");
64+ content = (
65+ <div className="px-6 py-8 md:px-10">
66+ <Markdown context={{ base: repo.base, gitRef: ref, dir }}>{text}</Markdown>
67+ </div>
68+ );
69+ } else {
70+ content = <CodeView code={text} filename={filename} />;
71+ }
72+
73+ return (
74+ <div>
75+ <div className="mb-4 flex flex-wrap items-center gap-3">
76+ <BranchSelect base={repo.base} kind="blob" current={ref} branches={branches.map((b) => b.name)} path={path} />
77+ <Breadcrumbs base={repo.base} name={repo.name} gitRef={ref} path={path} />
78+ <CopyButton text={path} label="Copy path" />
79+ </div>
80+ {commit && (
81+ <div className="mb-4">
82+ <LatestCommitBar repo={repo} commit={commit} gitRef={ref} path={path} />
83+ </div>
84+ )}
85+ <Box className="overflow-hidden">
86+ <div className="rule-b flex min-h-12 flex-wrap items-center gap-3 px-3 py-2">
87+ {isMarkdown && (
88+ <Segmented>
89+ <Link href={selfHref} className={segmentClass(showRendered)} aria-current={showRendered ? "page" : undefined}>
90+ Preview
91+ </Link>
92+ <Link href={`${selfHref}?plain=1`} className={segmentClass(!showRendered)} aria-current={showRendered ? undefined : "page"}>
93+ Code
94+ </Link>
95+ </Segmented>
96+ )}
97+ <span className="px-1 type-readout text-ink2 tabular-nums">{meta}</span>
98+ <div className="ml-auto">
99+ <FileActions
100+ rawHref={rawHref}
101+ text={text}
102+ editHref={editable && text !== null ? `${repo.base}/edit/${encodePath(ref)}/${encodePath(path)}` : undefined}
103+ deleteHref={editable ? `${repo.base}/delete/${encodePath(ref)}/${encodePath(path)}` : undefined}
104+ />
105+ </div>
106+ </div>
107+ {content}
108+ </Box>
109+ </div>
110+ );
111+ }
+57−0app/[owner]/[repo]/branches/page.tsxaddedView File
@@ -0,0 +1,57 @@
1+ import Link from "next/link";
2+ import type { Metadata } from "next";
3+ import { BranchIcon, HistoryIcon } from "@/components/icons";
4+ import { liftRowClass, plateHeadingClass } from "@/components/repo-rows";
5+ import { Blankslate, Box, ButtonLink, Label, PageTitle, RefChip } from "@/components/ui";
6+ import { listBranches } from "@/lib/git";
7+ import { encodePath, timeAgo } from "@/lib/format";
8+ import { getRepo } from "@/lib/repo";
9+
10+ export const metadata: Metadata = { title: "Branches" };
11+
12+ export default async function BranchesPage({ params }: PageProps<"/[owner]/[repo]/branches">) {
13+ const { owner, repo: repoParam } = await params;
14+ const repo = await getRepo(owner, repoParam);
15+ const branches = await listBranches(repo.dir);
16+
17+ return (
18+ <div>
19+ <PageTitle title="Branches" />
20+ <Box className="p-1.5">
21+ <h2 className={plateHeadingClass}>
22+ All branches <span className="type-readout text-ink3 [text-shadow:none]">{branches.length}</span>
23+ </h2>
24+ {branches.length === 0 && (
25+ <Blankslate icon={<BranchIcon />} title="No branches yet">
26+ Push to this repository to create one.
27+ </Blankslate>
28+ )}
29+ <ul>
30+ {branches.map((b) => (
31+ <li key={b.name} className={`${liftRowClass} flex min-h-13 items-center gap-3 px-3 py-2`}>
32+ <span className="icon-tile size-8 shrink-0 rounded-row">
33+ <BranchIcon width={15} height={15} />
34+ </span>
35+ <Link href={`${repo.base}/tree/${encodePath(b.name)}`} className="shrink-0 rounded-[7px] transition-opacity hover:opacity-80 focus-visible:focus-ring">
36+ <RefChip>{b.name}</RefChip>
37+ </Link>
38+ {b.name === repo.defaultBranch && <Label tone="accent">default</Label>}
39+ <span className="min-w-0 truncate type-ui text-ink2">{b.subject}</span>
40+ <span className="ml-auto hidden shrink-0 type-meta text-ink3 sm:inline">
41+ Updated <time dateTime={new Date(b.date).toISOString()} className="type-readout">{timeAgo(b.date)}</time>
42+ </span>
43+ <ButtonLink
44+ href={`${repo.base}/commits/${encodePath(b.name)}`}
45+ variant="invisible"
46+ icon={<HistoryIcon width={14} height={14} />}
47+ className="shrink-0 max-sm:ml-auto"
48+ >
49+ Commits
50+ </ButtonLink>
51+ </li>
52+ ))}
53+ </ul>
54+ </Box>
55+ </div>
56+ );
57+ }
+165−0app/[owner]/[repo]/commit/[sha]/page.tsxaddedView File
@@ -0,0 +1,165 @@
1+ import { notFound } from "next/navigation";
2+ import { ShaChip } from "@/components/file-table";
3+ import { CodeIcon, FileIcon } from "@/components/icons";
4+ import { Avatar, Box, ButtonLink, Label, RefChip } from "@/components/ui";
5+ import { diffCommit, getCommit, type FileDiff } from "@/lib/git";
6+ import { encodePath, shortSha, timeAgo } from "@/lib/format";
7+ import { getRepo } from "@/lib/repo";
8+
9+ const MAX_RENDERED_LINES = 3000;
10+
11+ const lineStyles = {
12+ add: { row: "bg-[var(--diff-add)]", num: "bg-[var(--diff-add-num)]", sign: "+" },
13+ del: { row: "bg-[var(--diff-del)]", num: "bg-[var(--diff-del-num)]", sign: "-" },
14+ ctx: { row: "", num: "", sign: " " },
15+ };
16+
17+ /** The change as five lamps: green for the added share, red for the deleted, dark when nothing changed. */
18+ function DiffStat({ additions, deletions }: { additions: number; deletions: number }) {
19+ const total = additions + deletions;
20+ const greens = total ? Math.round((additions / total) * 5) : 0;
21+ return (
22+ <span className="flex shrink-0 items-center gap-2 type-readout">
23+ <span className="text-success-emphasis">+{additions}</span>
24+ <span className="text-danger">−{deletions}</span>
25+ <span aria-hidden className="flex gap-[2px]">
26+ {Array.from({ length: 5 }, (_, i) => (
27+ <span
28+ key={i}
29+ className={`size-2 rounded-[2.5px] ${i < greens ? "bg-success-emphasis" : i < (total ? 5 : 0) ? "bg-danger" : "material-well"}`}
30+ />
31+ ))}
32+ </span>
33+ </span>
34+ );
35+ }
36+
37+ function FileDiffView({ file, base, sha, budget }: { file: FileDiff; base: string; sha: string; budget: number }) {
38+ const name = file.status === "renamed" ? `${file.oldPath} → ${file.newPath}` : file.newPath;
39+ const truncated = file.lines.length > budget;
40+ return (
41+ <section id={encodeURIComponent(file.newPath)} aria-label={name} className="mb-4 scroll-mt-20">
42+ <Box className="overflow-clip">
43+ <div className="rule-b sticky top-14 z-10 flex min-h-11 items-center gap-3 bg-[var(--mu-frost-strong)] py-1.5 pr-2 pl-4 backdrop-blur-md">
44+ <DiffStat additions={file.additions} deletions={file.deletions} />
45+ <span className="min-w-0 truncate font-mono text-[11.5px] font-medium text-ink">{name}</span>
46+ {file.status !== "modified" && (
47+ <Label tone={file.status === "added" ? "success" : file.status === "deleted" ? "danger" : "default"}>{file.status}</Label>
48+ )}
49+ {file.status !== "deleted" && (
50+ <ButtonLink
51+ href={`${base}/blob/${sha}/${encodePath(file.newPath)}`}
52+ variant="invisible"
53+ icon={<FileIcon width={14} height={14} />}
54+ className="ml-auto shrink-0"
55+ >
56+ View File
57+ </ButtonLink>
58+ )}
59+ </div>
60+ {file.binary ? (
61+ <p className="p-6 text-center type-ui text-ink3">Binary file not shown.</p>
62+ ) : file.lines.length === 0 ? (
63+ <p className="p-6 text-center type-ui text-ink3">File mode or name changed only.</p>
64+ ) : truncated ? (
65+ <p className="p-6 text-center type-ui text-ink3">Large diffs are not rendered by default.</p>
66+ ) : (
67+ <div className="overflow-x-auto">
68+ <table className="w-full border-collapse font-mono text-[11.5px] leading-[22px] text-ink">
69+ <tbody>
70+ {file.lines.map((line, i) =>
71+ line.kind === "hunk" ? (
72+ <tr key={i} className="bg-[var(--diff-hunk)] text-ink3">
73+ <td colSpan={3} className="px-4 py-1.5">
74+ {line.text}
75+ </td>
76+ </tr>
77+ ) : (
78+ <tr key={i} className={lineStyles[line.kind].row}>
79+ <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}>
80+ {line.oldNo}
81+ </td>
82+ <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}>
83+ {line.newNo}
84+ </td>
85+ <td className="px-2 whitespace-pre">
86+ <span className="select-none text-ink3">{lineStyles[line.kind].sign} </span>
87+ {line.text}
88+ </td>
89+ </tr>
90+ ),
91+ )}
92+ </tbody>
93+ </table>
94+ </div>
95+ )}
96+ </Box>
97+ </section>
98+ );
99+ }
100+
101+ export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/commit/[sha]">) {
102+ const { owner, repo: repoParam, sha } = await params;
103+ const repo = await getRepo(owner, repoParam);
104+ const commit = await getCommit(repo.dir, sha);
105+ if (!commit) notFound();
106+ const files = await diffCommit(repo.dir, commit);
107+ const additions = files.reduce((n, f) => n + f.additions, 0);
108+ const deletions = files.reduce((n, f) => n + f.deletions, 0);
109+
110+ // Each file may render only the diff lines left over from the files above it.
111+ const budgets = files.map((_, i) => MAX_RENDERED_LINES - files.slice(0, i).reduce((n, f) => n + f.lines.length, 0));
112+
113+ return (
114+ <div>
115+ <Box className="mb-6 overflow-hidden">
116+ <div className="flex flex-wrap items-start gap-4 p-6">
117+ <div className="min-w-0 flex-1">
118+ <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">{commit.subject}</h1>
119+ {commit.body && <pre className="mt-3 font-mono text-[11.5px] leading-relaxed whitespace-pre-wrap text-ink2">{commit.body}</pre>}
120+ </div>
121+ <ButtonLink href={`${repo.base}/tree/${commit.sha}`} icon={<CodeIcon />}>
122+ Browse Files
123+ </ButtonLink>
124+ </div>
125+ <div className="rule-t flex flex-wrap items-center gap-2 px-6 py-3 type-ui">
126+ <Avatar name={commit.authorName} size={22} />
127+ <span className="font-medium text-ink" title={commit.authorEmail}>
128+ {commit.authorName}
129+ </span>
130+ <span className="text-ink2">
131+ committed <time dateTime={new Date(commit.date).toISOString()}>{timeAgo(commit.date)}</time>
132+ </span>
133+ <span className="ml-auto flex flex-wrap items-center gap-1.5 type-meta text-ink3">
134+ {commit.parents.length > 0 && (
135+ <>
136+ <span className="type-label engraved">
137+ {commit.parents.length} parent{commit.parents.length > 1 && "s"}
138+ </span>
139+ {commit.parents.map((p) => (
140+ <ShaChip key={p} sha={p} href={`${repo.base}/commit/${p}`} className="mr-1.5" />
141+ ))}
142+ </>
143+ )}
144+ <span className="type-label engraved">commit</span>
145+ <RefChip>{shortSha(commit.sha)}</RefChip>
146+ </span>
147+ </div>
148+ </Box>
149+
150+ <p className="mb-4 px-1 type-ui text-ink2">
151+ Showing <strong className="font-semibold text-ink">{files.length} changed file{files.length === 1 ? "" : "s"}</strong> with{" "}
152+ <strong className="font-semibold text-success-emphasis">{additions} addition{additions === 1 ? "" : "s"}</strong> and{" "}
153+ <strong className="font-semibold text-danger">{deletions} deletion{deletions === 1 ? "" : "s"}</strong>.
154+ </p>
155+
156+ {commit.parents.length > 1 && (
157+ <p className="mb-4 px-1 type-meta text-ink3">Merge commit — showing changes against the first parent.</p>
158+ )}
159+
160+ {files.map((file, i) => (
161+ <FileDiffView key={file.newPath} file={file} base={repo.base} sha={commit.sha} budget={budgets[i]} />
162+ ))}
163+ </div>
164+ );
165+ }
+117−0app/[owner]/[repo]/commits/[...segments]/page.tsxaddedView File
@@ -0,0 +1,117 @@
1+ import Link from "next/link";
2+ import { Tooltip } from "@unlocalhosted/metalui";
3+ import { BranchSelect } from "@/components/branch-select";
4+ import { CopyButton } from "@/components/clone-button";
5+ import { ShaChip } from "@/components/file-table";
6+ import { ChevronLeftIcon, ChevronRightIcon, CodeIcon, CommitIcon, HistoryIcon } from "@/components/icons";
7+ import { ghostIconLinkClass, liftRowClass } from "@/components/repo-rows";
8+ import { Avatar, Blankslate, Box, ButtonLink, RefChip } from "@/components/ui";
9+ import { listBranches, log, type Commit } from "@/lib/git";
10+ import { encodePath, formatDate, timeAgo } from "@/lib/format";
11+ import { getRefAndPath, getRepo } from "@/lib/repo";
12+
13+ const PAGE_SIZE = 35;
14+
15+ function groupByDay(commits: Commit[]) {
16+ const groups = new Map<string, Commit[]>();
17+ for (const c of commits) {
18+ const day = formatDate(c.date);
19+ groups.set(day, [...(groups.get(day) ?? []), c]);
20+ }
21+ return [...groups];
22+ }
23+
24+ export default async function CommitsPage({ params, searchParams }: PageProps<"/[owner]/[repo]/commits/[...segments]">) {
25+ const { owner, repo: repoParam, segments } = await params;
26+ const page = Math.max(1, Number((await searchParams).page) || 1);
27+ const repo = await getRepo(owner, repoParam);
28+ const { ref, path } = await getRefAndPath(repo, segments);
29+
30+ const [commits, branches] = await Promise.all([
31+ log(repo.dir, ref, { path, limit: PAGE_SIZE + 1, skip: (page - 1) * PAGE_SIZE }),
32+ listBranches(repo.dir),
33+ ]);
34+ const hasNext = commits.length > PAGE_SIZE;
35+ const selfHref = `${repo.base}/commits/${encodePath(ref)}${path ? `/${encodePath(path)}` : ""}`;
36+
37+ return (
38+ <div>
39+ <div className="mb-6 flex flex-wrap items-center gap-3">
40+ <h1 className="mr-2 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Commits</h1>
41+ <BranchSelect base={repo.base} kind="commits" current={ref} branches={branches.map((b) => b.name)} path={path} />
42+ {path && (
43+ <span className="flex items-center gap-1.5 type-ui text-ink2">
44+ History for <RefChip>{path}</RefChip>
45+ </span>
46+ )}
47+ </div>
48+
49+ {commits.length === 0 && (
50+ <Box>
51+ <Blankslate icon={<HistoryIcon />} title="No commits found" />
52+ </Box>
53+ )}
54+
55+ <div className="space-y-6">
56+ {groupByDay(commits.slice(0, PAGE_SIZE)).map(([day, dayCommits]) => (
57+ <section key={day}>
58+ <h2 className="mb-2 flex items-center gap-2 px-3 type-label engraved">
59+ <CommitIcon width={13} height={13} className="text-ink3" />
60+ {day}
61+ </h2>
62+ <Box className="p-1.5">
63+ <ul>
64+ {dayCommits.map((c) => (
65+ <li key={c.sha} className={`${liftRowClass} flex items-center gap-3.5 px-3 py-2.5`}>
66+ <Avatar name={c.authorName} size={32} className="hidden sm:inline-flex" />
67+ <div className="min-w-0 flex-1">
68+ <Link
69+ href={`${repo.base}/commit/${c.sha}`}
70+ className="block truncate type-title text-ink decoration-ink3 underline-offset-4 hover:underline"
71+ >
72+ {c.subject}
73+ </Link>
74+ <div className="mt-0.5 flex items-center gap-1 type-meta text-ink3">
75+ <span className="font-medium text-ink2">{c.authorName}</span> committed{" "}
76+ <time dateTime={new Date(c.date).toISOString()}>{timeAgo(c.date)}</time>
77+ </div>
78+ </div>
79+ <div className="flex shrink-0 items-center gap-1">
80+ <ShaChip sha={c.sha} href={`${repo.base}/commit/${c.sha}`} className="mr-1" />
81+ <CopyButton text={c.sha} label="Copy full SHA" />
82+ <Tooltip label="Browse files">
83+ <Link
84+ href={`${repo.base}/tree/${c.sha}`}
85+ aria-label="Browse the repository at this point in the history"
86+ className={ghostIconLinkClass}
87+ >
88+ <CodeIcon />
89+ </Link>
90+ </Tooltip>
91+ </div>
92+ </li>
93+ ))}
94+ </ul>
95+ </Box>
96+ </section>
97+ ))}
98+ </div>
99+
100+ {(page > 1 || hasNext) && (
101+ <div className="mt-8 flex justify-center gap-2">
102+ {page > 1 ? (
103+ <ButtonLink href={`${selfHref}?page=${page - 1}`} icon={<ChevronLeftIcon />}>
104+ Newer
105+ </ButtonLink>
106+ ) : null}
107+ {hasNext ? (
108+ <ButtonLink href={`${selfHref}?page=${page + 1}`}>
109+ Older
110+ <ChevronRightIcon />
111+ </ButtonLink>
112+ ) : null}
113+ </div>
114+ )}
115+ </div>
116+ );
117+ }
+16−0app/[owner]/[repo]/commits/page.tsxaddedView File
@@ -0,0 +1,16 @@
1+ import { redirect } from "next/navigation";
2+ import { HistoryIcon } from "@/components/icons";
3+ import { Blankslate, Box } from "@/components/ui";
4+ import { encodePath } from "@/lib/format";
5+ import { getRepo } from "@/lib/repo";
6+
7+ export default async function CommitsIndex({ params }: PageProps<"/[owner]/[repo]/commits">) {
8+ const { owner, repo: repoParam } = await params;
9+ const repo = await getRepo(owner, repoParam);
10+ if (repo.defaultBranch) redirect(`${repo.base}/commits/${encodePath(repo.defaultBranch)}`);
11+ return (
12+ <Box>
13+ <Blankslate icon={<HistoryIcon />} title="No commits yet">This repository is empty.</Blankslate>
14+ </Box>
15+ );
16+ }
+26−0app/[owner]/[repo]/delete/[...segments]/page.tsxaddedView File
@@ -0,0 +1,26 @@
1+ import type { Metadata } from "next";
2+ import { notFound } from "next/navigation";
3+ import { deleteFileAction } from "@/app/actions/files";
4+ import { DeleteFileForm } from "@/components/delete-file-form";
5+ import { objectType, revParse } from "@/lib/git";
6+ import { encodePath } from "@/lib/format";
7+ import { getBranchAndPath, getWritableRepo } from "@/lib/repo";
8+
9+ export const metadata: Metadata = { title: "Delete file" };
10+
11+ export default async function DeleteFilePage({ params }: PageProps<"/[owner]/[repo]/delete/[...segments]">) {
12+ const { owner, repo: repoParam, segments } = await params;
13+ const repo = await getWritableRepo(owner, repoParam);
14+ const { branch, path } = await getBranchAndPath(repo, segments);
15+ if (!path || (await objectType(repo.dir, branch, path)) !== "blob") notFound();
16+
17+ return (
18+ <DeleteFileForm
19+ action={deleteFileAction.bind(null, repo.owner, repo.name)}
20+ branch={branch}
21+ baseSha={(await revParse(repo.dir, `refs/heads/${branch}`))!}
22+ path={path}
23+ cancelHref={`${repo.base}/blob/${encodePath(branch)}/${encodePath(path)}`}
24+ />
25+ );
26+ }
+37−0app/[owner]/[repo]/edit/[...segments]/page.tsxaddedView File
@@ -0,0 +1,37 @@
1+ import type { Metadata } from "next";
2+ import { notFound } from "next/navigation";
3+ import { saveFileAction } from "@/app/actions/files";
4+ import { FileEditor } from "@/components/file-editor";
5+ import { FileIcon } from "@/components/icons";
6+ import { Blankslate } from "@/components/ui";
7+ import { objectType, readBlob, revParse } from "@/lib/git";
8+ import { encodePath } from "@/lib/format";
9+ import { getBranchAndPath, getWritableRepo } from "@/lib/repo";
10+
11+ export const metadata: Metadata = { title: "Editing" };
12+
13+ export default async function EditPage({ params }: PageProps<"/[owner]/[repo]/edit/[...segments]">) {
14+ const { owner, repo: repoParam, segments } = await params;
15+ const repo = await getWritableRepo(owner, repoParam);
16+ const { branch, path } = await getBranchAndPath(repo, segments);
17+ if (!path || (await objectType(repo.dir, branch, path)) !== "blob") notFound();
18+
19+ const blob = await readBlob(repo.dir, branch, path);
20+ if (blob.binary || blob.tooLarge) {
21+ return <Blankslate icon={<FileIcon />} title="This file can’t be edited in the browser">Binary and very large files can only be changed with git.</Blankslate>;
22+ }
23+ const baseSha = (await revParse(repo.dir, `refs/heads/${branch}`))!;
24+
25+ return (
26+ <FileEditor
27+ action={saveFileAction.bind(null, repo.owner, repo.name)}
28+ repoName={repo.name}
29+ branch={branch}
30+ baseSha={baseSha}
31+ originalPath={path}
32+ initialPath={path}
33+ initialContent={blob.content.toString("utf8")}
34+ cancelHref={`${repo.base}/blob/${encodePath(branch)}/${encodePath(path)}`}
35+ />
36+ );
37+ }
+6−0app/[owner]/[repo]/git-receive-pack/route.tsaddedView File
@@ -0,0 +1,6 @@
1+ import { handleServiceRpc } from "@/lib/git-http";
2+
3+ export async function POST(request: Request, ctx: RouteContext<"/[owner]/[repo]/git-receive-pack">) {
4+ const { owner, repo } = await ctx.params;
5+ return handleServiceRpc(request, owner, repo, "git-receive-pack");
6+ }
+6−0app/[owner]/[repo]/git-upload-pack/route.tsaddedView File
@@ -0,0 +1,6 @@
1+ import { handleServiceRpc } from "@/lib/git-http";
2+
3+ export async function POST(request: Request, ctx: RouteContext<"/[owner]/[repo]/git-upload-pack">) {
4+ const { owner, repo } = await ctx.params;
5+ return handleServiceRpc(request, owner, repo, "git-upload-pack");
6+ }
+22−0app/[owner]/[repo]/image/route.tsaddedView File
@@ -0,0 +1,22 @@
1+ import { getViewer } from "@/lib/auth";
2+ import { normalizeRepoName } from "@/lib/git";
3+ import { findRepo, getRepoImage, repoAccess } from "@/lib/repos";
4+
5+ /** The repository's uploaded image. URLs carry `?v=<upload time>`, so responses can be cached for good. */
6+ export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/image">) {
7+ const { owner, repo: repoParam } = await ctx.params;
8+ const repo = findRepo(owner, normalizeRepoName(repoParam));
9+ const viewer = await getViewer();
10+ if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 });
11+
12+ const image = getRepoImage(repo.id);
13+ if (!image) return new Response("Not found", { status: 404 });
14+ return new Response(new Uint8Array(image.data), {
15+ headers: {
16+ "Content-Type": image.contentType,
17+ "X-Content-Type-Options": "nosniff",
18+ "Cache-Control": `${repo.visibility === "private" ? "private" : "public"}, max-age=31536000, immutable`,
19+ "Content-Security-Policy": "default-src 'none'; sandbox",
20+ },
21+ });
22+ }
+6−0app/[owner]/[repo]/info/refs/route.tsaddedView File
@@ -0,0 +1,6 @@
1+ import { handleInfoRefs } from "@/lib/git-http";
2+
3+ export async function GET(request: Request, ctx: RouteContext<"/[owner]/[repo]/info/refs">) {
4+ const { owner, repo } = await ctx.params;
5+ return handleInfoRefs(request, owner, repo);
6+ }
+227−0app/[owner]/[repo]/issues/[number]/page.tsxaddedView File
@@ -0,0 +1,227 @@
1+ import Link from "next/link";
2+ import { notFound } from "next/navigation";
3+ import type { ReactNode } from "react";
4+ import { commentAction, deleteIssueAction } from "@/app/actions/issues";
5+ import { Surface, Textarea } from "@unlocalhosted/metalui";
6+ import { ConfirmButton } from "@/components/confirm-button";
7+ import { ArrowRightIcon, CommentIcon, TrashIcon } from "@/components/icons";
8+ import { IssueDetail } from "@/components/issues/issue-detail";
9+ import { PriorityIcon, StatusIcon } from "@/components/issues/issue-icons";
10+ import { TrackerProvider } from "@/components/issues/tracker-context";
11+ import { Markdown } from "@/components/markdown";
12+ import { Avatar, Button, ButtonLink, Label } from "@/components/ui";
13+ import { formatDate, timeAgo } from "@/lib/format";
14+ import { STATUS_IDS, formatDay, priorityLabel, statusLabel, type Status } from "@/lib/issue-meta";
15+ import { getIssue, listComments, listEvents, type IssueEvent } from "@/lib/issues";
16+ import { getRepo } from "@/lib/repo";
17+ import { trackerProps } from "@/lib/tracker";
18+
19+ export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) {
20+ const { owner, repo: repoParam, number } = await params;
21+ const repo = await getRepo(owner, repoParam);
22+ const issue = getIssue(repo.id, Number(number));
23+ return { title: issue ? `${issue.title} · Issue #${issue.number}` : "Issue" };
24+ }
25+
26+ function CommentBox({ author, createdAt, body, isAuthor }: { author: string; createdAt: number; body: string; isAuthor?: boolean }) {
27+ return (
28+ <div className="flex gap-3">
29+ <Link href={`/${author}`} className="shrink-0">
30+ <Avatar name={author} size={32} />
31+ </Link>
32+ <Surface material="raise-lite" radius="plate" className="min-w-0 flex-1">
33+ <div className="rule-b flex h-10 items-center gap-2 px-4">
34+ <Link href={`/${author}`} className="type-ui text-ink hover:underline">
35+ {author}
36+ </Link>
37+ <span className="type-label engraved" title={formatDate(createdAt)}>
38+ commented {timeAgo(createdAt)}
39+ </span>
40+ {isAuthor && (
41+ <span className="ml-auto">
42+ <Label tone="accent">Author</Label>
43+ </span>
44+ )}
45+ </div>
46+ <div className="px-5 py-4">
47+ <Markdown>{body}</Markdown>
48+ </div>
49+ </Surface>
50+ </div>
51+ );
52+ }
53+
54+ /** A point on the activity rail: a small sunk well on the page, holding the event's glyph or an unlit dot. */
55+ function TimelineDot({ children }: { children?: ReactNode }) {
56+ return (
57+ <span className="well flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full -ml-[3px]">
58+ {children ?? <span className="h-1.5 w-1.5 rounded-full bg-ink3" />}
59+ </span>
60+ );
61+ }
62+
63+ const isStatus = (value: string | null): value is Status => !!value && (STATUS_IDS as string[]).includes(value);
64+
65+ function StatusName({ value }: { value: string | null }) {
66+ if (!isStatus(value)) return <strong className="font-medium text-ink">{value ?? "none"}</strong>;
67+ return (
68+ <strong className="inline-flex items-center gap-1 align-middle font-medium text-ink">
69+ <StatusIcon status={value} size={12} /> {statusLabel(value)}
70+ </strong>
71+ );
72+ }
73+
74+ /** One line in the activity feed describing a property change. */
75+ function describe(e: IssueEvent): { icon: ReactNode; text: ReactNode } {
76+ const strong = (s: ReactNode) => <strong className="font-medium text-ink">{s}</strong>;
77+ switch (e.field) {
78+ case "status":
79+ return {
80+ icon: isStatus(e.newValue) ? <StatusIcon status={e.newValue} size={12} /> : null,
81+ text: (
82+ <>
83+ changed status from <StatusName value={e.oldValue} /> to <StatusName value={e.newValue} />
84+ </>
85+ ),
86+ };
87+ case "priority":
88+ return {
89+ icon: <PriorityIcon priority={Number(e.newValue)} size={12} />,
90+ text: e.newValue === "0" ? <>removed the priority</> : <>set priority to {strong(priorityLabel(Number(e.newValue)))}</>,
91+ };
92+ case "assignee":
93+ return {
94+ icon: <Avatar name={e.newValue ?? e.oldValue ?? "?"} size={14} />,
95+ text: !e.newValue ? (
96+ <>unassigned {strong(e.oldValue)}</>
97+ ) : e.newValue === e.actor && !e.oldValue ? (
98+ <>self-assigned the issue</>
99+ ) : e.oldValue ? (
100+ <>
101+ reassigned from {strong(e.oldValue)} to {strong(e.newValue)}
102+ </>
103+ ) : (
104+ <>assigned {strong(e.newValue)}</>
105+ ),
106+ };
107+ case "label":
108+ return { icon: null, text: e.newValue ? <>added label {strong(e.newValue)}</> : <>removed label {strong(e.oldValue)}</> };
109+ case "estimate":
110+ return { icon: null, text: e.newValue ? <>set the estimate to {strong(`${e.newValue} points`)}</> : <>removed the estimate</> };
111+ case "due_date":
112+ return { icon: null, text: e.newValue ? <>set the due date to {strong(formatDay(e.newValue, true))}</> : <>removed the due date</> };
113+ case "title":
114+ return { icon: null, text: <>renamed this from {strong(`“${e.oldValue}”`)}</> };
115+ default:
116+ return { icon: null, text: <>changed {e.field}</> };
117+ }
118+ }
119+
120+ export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) {
121+ const { owner, repo: repoParam, number } = await params;
122+ const repo = await getRepo(owner, repoParam);
123+ const issue = getIssue(repo.id, Number(number));
124+ if (!issue) notFound();
125+ const viewer = repo.viewer;
126+ const action = commentAction.bind(null, repo.owner, repo.name, issue.number);
127+
128+ // Comments and property changes, oldest first.
129+ const activity = [
130+ ...listComments(issue.id).map((c) => ({ kind: "comment" as const, at: c.createdAt, comment: c })),
131+ ...listEvents(issue.id).map((e) => ({ kind: "event" as const, at: e.createdAt, event: e })),
132+ ].sort((a, b) => a.at - b.at);
133+
134+ return (
135+ <div className="mx-auto max-w-6xl">
136+ <TrackerProvider {...trackerProps(repo)}>
137+ <IssueDetail
138+ issue={issue}
139+ description={<Markdown>{issue.body}</Markdown>}
140+ sidebarFooter={
141+ <div className="space-y-2">
142+ <p className="flex items-center gap-1.5">
143+ <Avatar name={issue.author} size={16} />
144+ <Link href={`/${issue.author}`} className="font-medium text-ink hover:underline">
145+ {issue.author}
146+ </Link>
147+ opened {timeAgo(issue.createdAt)}
148+ </p>
149+ <p title={formatDate(issue.updatedAt)}>Updated {timeAgo(issue.updatedAt)}</p>
150+ {(repo.isAdmin || viewer?.id === issue.authorId) && (
151+ <div className="pt-1.5">
152+ <ConfirmButton
153+ action={deleteIssueAction.bind(null, repo.owner, repo.name, issue.number)}
154+ message={`Delete issue #${issue.number}? This can't be undone.`}
155+ >
156+ <TrashIcon /> Delete issue
157+ </ConfirmButton>
158+ </div>
159+ )}
160+ </div>
161+ }
162+ >
163+ <h2 className="rule-t mb-4 pt-6 type-label engraved">Activity</h2>
164+ <div className="relative space-y-4 before:absolute before:top-2 before:bottom-2 before:left-4 before:w-px before:bg-line">
165+ <p className="relative flex items-center gap-2 pl-2.5 type-body text-ink2">
166+ <TimelineDot />
167+ <strong className="font-medium text-ink">{issue.author}</strong> created the issue · {timeAgo(issue.createdAt)}
168+ </p>
169+ {activity.map((item) => {
170+ if (item.kind === "comment") {
171+ const c = item.comment;
172+ return (
173+ <div key={`c${c.id}`} className="relative">
174+ <CommentBox author={c.author} createdAt={c.createdAt} body={c.body} isAuthor={c.author === issue.author} />
175+ </div>
176+ );
177+ }
178+ const { icon, text } = describe(item.event);
179+ return (
180+ <p key={`e${item.event.id}`} className="relative flex flex-wrap items-center gap-x-1.5 gap-y-1 pl-2.5 type-body text-ink2">
181+ <TimelineDot>{icon}</TimelineDot>
182+ <strong className="font-medium text-ink">{item.event.actor}</strong> {text} · {timeAgo(item.event.createdAt)}
183+ </p>
184+ );
185+ })}
186+
187+ <div className="relative flex gap-3 pt-2">
188+ {viewer ? (
189+ <>
190+ <Avatar name={viewer.username} size={32} />
191+ <Surface material="raise-lite" radius="plate" className="min-w-0 flex-1 p-3">
192+ <form action={action} className="space-y-3">
193+ <Textarea
194+ name="body"
195+ minRows={4}
196+ maxRows={16}
197+ required
198+ aria-label="Comment"
199+ placeholder="Leave a comment… Markdown is supported."
200+ />
201+ <div className="flex justify-end">
202+ <Button type="submit" variant="primary" icon={<CommentIcon />}>
203+ Comment
204+ </Button>
205+ </div>
206+ </form>
207+ </Surface>
208+ </>
209+ ) : (
210+ <Surface material="raise-lite" radius="plate" className="flex w-full flex-wrap items-center justify-between gap-3 p-5">
211+ <span className="type-body text-ink2">Sign in to join the conversation.</span>
212+ <ButtonLink
213+ href={`/login?next=${encodeURIComponent(`${repo.base}/issues/${issue.number}`)}`}
214+ variant="primary"
215+ icon={<ArrowRightIcon />}
216+ >
217+ Sign in to comment
218+ </ButtonLink>
219+ </Surface>
220+ )}
221+ </div>
222+ </div>
223+ </IssueDetail>
224+ </TrackerProvider>
225+ </div>
226+ );
227+ }
+16−0app/[owner]/[repo]/issues/new/form.tsxaddedView File
@@ -0,0 +1,16 @@
1+ "use client";
2+
3+ import { useRouter } from "next/navigation";
4+ import { IssueComposer } from "@/components/issues/issue-composer";
5+ import { useTracker } from "@/components/issues/tracker-context";
6+ import { Box } from "@/components/ui";
7+
8+ export function NewIssueForm() {
9+ const router = useRouter();
10+ const { base } = useTracker();
11+ return (
12+ <Box>
13+ <IssueComposer onCreated={(number) => router.push(`${base}/issues/${number}`)} onCancel={() => router.push(`${base}/issues`)} />
14+ </Box>
15+ );
16+ }
+23−0app/[owner]/[repo]/issues/new/page.tsxaddedView File
@@ -0,0 +1,23 @@
1+ import type { Metadata } from "next";
2+ import { TrackerProvider } from "@/components/issues/tracker-context";
3+ import { PageTitle } from "@/components/ui";
4+ import { requireViewer } from "@/lib/auth";
5+ import { getRepo } from "@/lib/repo";
6+ import { trackerProps } from "@/lib/tracker";
7+ import { NewIssueForm } from "./form";
8+
9+ export const metadata: Metadata = { title: "New issue" };
10+
11+ export default async function NewIssuePage({ params }: PageProps<"/[owner]/[repo]/issues/new">) {
12+ const { owner, repo: repoParam } = await params;
13+ const repo = await getRepo(owner, repoParam);
14+ await requireViewer(`${repo.base}/issues/new`);
15+ return (
16+ <div className="max-w-3xl">
17+ <PageTitle title="Create new issue" />
18+ <TrackerProvider {...trackerProps(repo)}>
19+ <NewIssueForm />
20+ </TrackerProvider>
21+ </div>
22+ );
23+ }
+20−0app/[owner]/[repo]/issues/page.tsxaddedView File
@@ -0,0 +1,20 @@
1+ import type { Metadata } from "next";
2+ import { IssueTracker } from "@/components/issues/issue-tracker";
3+ import { TrackerProvider } from "@/components/issues/tracker-context";
4+ import { parseView } from "@/components/issues/view";
5+ import { listIssues } from "@/lib/issues";
6+ import { getRepo } from "@/lib/repo";
7+ import { trackerProps } from "@/lib/tracker";
8+
9+ export const metadata: Metadata = { title: "Issues" };
10+
11+ export default async function IssuesPage({ params, searchParams }: PageProps<"/[owner]/[repo]/issues">) {
12+ const { owner, repo: repoParam } = await params;
13+ const repo = await getRepo(owner, repoParam);
14+
15+ return (
16+ <TrackerProvider {...trackerProps(repo)}>
17+ <IssueTracker issues={listIssues(repo.id)} initialView={parseView(await searchParams)} />
18+ </TrackerProvider>
19+ );
20+ }
+46−0app/[owner]/[repo]/layout.tsxaddedView File
@@ -0,0 +1,46 @@
1+ import Link from "next/link";
2+ import { RepoTabs } from "@/components/repo-tabs";
3+ import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui";
4+ import { openIssueCount } from "@/lib/issues";
5+ import { getRepo } from "@/lib/repo";
6+
7+ export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) {
8+ const { owner, repo } = await params;
9+ const r = await getRepo(owner, repo);
10+ return {
11+ title: { default: `${r.owner}/${r.name}`, template: `%s · ${r.owner}/${r.name}` },
12+ description: r.description || undefined,
13+ };
14+ }
15+
16+ export default async function RepoLayout({ params, children }: LayoutProps<"/[owner]/[repo]">) {
17+ const { owner, repo: repoParam } = await params;
18+ const repo = await getRepo(owner, repoParam);
19+
20+ return (
21+ <>
22+ <div className="rule-b">
23+ <div className="flex min-h-12 flex-wrap items-center gap-x-6 gap-y-1.5 px-4 py-1.5 md:px-5 xl:grid xl:grid-cols-[1fr_auto_1fr]">
24+ <div className="flex min-w-0 items-center gap-2">
25+ <Link href={repo.base} aria-label={`${repo.owner}/${repo.name}`} className="shrink-0 rounded-key transition-opacity hover:opacity-80 focus-visible:focus-ring">
26+ <RepoAvatar repo={repo} size={22} />
27+ </Link>
28+ <Link href={`/${repo.owner}`} className="hidden shrink-0 text-[15px] text-ink2 transition-colors hover:text-ink sm:inline">
29+ {repo.owner}
30+ </Link>
31+ <span aria-hidden className="hidden text-[15px] text-ink3 sm:inline">
32+ /
33+ </span>
34+ <Link href={repo.base} className="truncate text-[15px] font-semibold tracking-[-0.01em] text-ink transition-opacity hover:opacity-80">
35+ {repo.name}
36+ </Link>
37+ <VisibilityLabel visibility={repo.visibility} />
38+ {repo.access === "write" && <Label tone="accent">Collaborator</Label>}
39+ </div>
40+ <RepoTabs base={repo.base} openIssues={openIssueCount(repo.id)} showSettings={repo.isAdmin} />
41+ </div>
42+ </div>
43+ <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div>
44+ </>
45+ );
46+ }
+30−0app/[owner]/[repo]/new/[...segments]/page.tsxaddedView File
@@ -0,0 +1,30 @@
1+ import type { Metadata } from "next";
2+ import { notFound } from "next/navigation";
3+ import { saveFileAction } from "@/app/actions/files";
4+ import { FileEditor } from "@/components/file-editor";
5+ import { objectType, revParse } from "@/lib/git";
6+ import { encodePath } from "@/lib/format";
7+ import { getBranchAndPath, getWritableRepo } from "@/lib/repo";
8+
9+ export const metadata: Metadata = { title: "New file" };
10+
11+ export default async function NewFilePage({ params }: PageProps<"/[owner]/[repo]/new/[...segments]">) {
12+ const { owner, repo: repoParam, segments } = await params;
13+ const repo = await getWritableRepo(owner, repoParam);
14+ const { branch, path: dir, exists } = await getBranchAndPath(repo, segments);
15+ if (exists && dir && (await objectType(repo.dir, branch, dir)) !== "tree") notFound();
16+ const baseSha = exists ? ((await revParse(repo.dir, `refs/heads/${branch}`)) ?? "") : "";
17+
18+ return (
19+ <FileEditor
20+ action={saveFileAction.bind(null, repo.owner, repo.name)}
21+ repoName={repo.name}
22+ branch={branch}
23+ baseSha={baseSha}
24+ originalPath=""
25+ initialPath={dir ? `${dir}/` : ""}
26+ initialContent=""
27+ cancelHref={exists ? `${repo.base}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}` : repo.base}
28+ />
29+ );
30+ }
+204−0app/[owner]/[repo]/page.tsxaddedView File
@@ -0,0 +1,204 @@
1+ import Link from "next/link";
2+ import { AvatarGroup } from "@unlocalhosted/metalui";
3+ import { CloneUrlTabs, CopyButton } from "@/components/clone-button";
4+ import {
5+ BranchIcon,
6+ CalendarIcon,
7+ HistoryIcon,
8+ IssueOpenedIcon,
9+ PeopleIcon,
10+ PlusIcon,
11+ SparkleIcon,
12+ TagIcon,
13+ UploadIcon,
14+ } from "@/components/icons";
15+ import { TreeView } from "@/components/tree-view";
16+ import { Avatar, Blankslate, Box, ButtonLink } from "@/components/ui";
17+ import { encodePath, formatDate } from "@/lib/format";
18+ import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git";
19+ import { openIssueCount } from "@/lib/issues";
20+ import { languageBreakdown, type LanguageShare } from "@/lib/languages";
21+ import { cloneUrls, getRepo, type Repo } from "@/lib/repo";
22+ import { listCollaborators } from "@/lib/repos";
23+
24+ function CommandBlock({ title, commands }: { title: string; commands: string }) {
25+ return (
26+ <div className="rule-t px-6 py-5">
27+ <h3 className="mb-3 type-title text-ink">{title}</h3>
28+ <div className="flex items-start gap-2">
29+ <pre className="well flex-1 overflow-x-auto rounded-plate px-4 py-3 font-mono text-[11.5px] leading-6 text-ink">{commands}</pre>
30+ <CopyButton text={commands} label="Copy commands" />
31+ </div>
32+ </div>
33+ );
34+ }
35+
36+ async function QuickSetup({ repo }: { repo: Repo }) {
37+ const urls = await cloneUrls(repo);
38+ if (!repo.canWrite) {
39+ return (
40+ <Box>
41+ <Blankslate icon={<SparkleIcon />} title="This repository is empty">
42+ The owner hasn’t pushed anything yet.
43+ </Blankslate>
44+ </Box>
45+ );
46+ }
47+ return (
48+ <Box className="overflow-hidden">
49+ <div className="p-6">
50+ <h3 className="type-content font-semibold text-ink">Let’s get this repository started</h3>
51+ <p className="mt-1 mb-5 type-lead text-ink2">Add a file in the browser, or push from your machine.</p>
52+ <div className="mb-6 flex flex-wrap gap-2">
53+ <ButtonLink href={`${repo.base}/new/main`} variant="primary" icon={<PlusIcon />}>
54+ Create New File
55+ </ButtonLink>
56+ <ButtonLink href={`${repo.base}/upload/main`} icon={<UploadIcon />}>
57+ Upload Files
58+ </ButtonLink>
59+ </div>
60+ <div className="max-w-xl">
61+ <CloneUrlTabs urls={urls} />
62+ </div>
63+ </div>
64+ <CommandBlock
65+ title="…or create a new repository on the command line"
66+ commands={`echo "# ${repo.name}" >> README.md\ngit init\ngit add README.md\ngit commit -m "first commit"\ngit branch -M main\ngit remote add origin ${urls.ssh}\ngit push -u origin main`}
67+ />
68+ <CommandBlock
69+ title="…or push an existing repository from the command line"
70+ commands={`git remote add origin ${urls.ssh}\ngit branch -M main\ngit push -u origin main`}
71+ />
72+ </Box>
73+ );
74+ }
75+
76+ /** An engraved heading over a sidebar group, with an optional readout at its right. */
77+ function SectionLabel({ children, count }: { children: string; count?: number }) {
78+ return (
79+ <h2 className="mb-2 flex items-center justify-between type-label engraved">
80+ {children}
81+ {count !== undefined && <span className="type-readout text-ink3 [text-shadow:none]">{count}</span>}
82+ </h2>
83+ );
84+ }
85+
86+ function Stat({ href, icon: Icon, label, value }: { href: string; icon: typeof BranchIcon; label: string; value: number }) {
87+ return (
88+ <li>
89+ <Link
90+ href={href}
91+ className="group -mx-2 flex h-8 items-center gap-2.5 rounded-row px-2 type-ui text-ink outline-none hover:recipe-row-list-hover focus-visible:focus-ring"
92+ >
93+ <Icon width={14} height={14} className="text-ink3 transition-colors group-hover:text-ink2" />
94+ {label}
95+ <span className="ml-auto type-readout text-ink2">{value}</span>
96+ </Link>
97+ </li>
98+ );
99+ }
100+
101+ function Languages({ languages }: { languages: LanguageShare[] }) {
102+ // Tiny shares are unreadable in the bar and the legend; fold them into "Other".
103+ const shown = languages.filter((l) => l.percent >= 0.1).slice(0, 8);
104+ const other = 100 - shown.reduce((sum, l) => sum + l.percent, 0);
105+ const items = other >= 0.1 ? [...shown, { name: "Other", color: "var(--mu-ink3)", percent: other }] : shown;
106+ return (
107+ <section>
108+ <SectionLabel>Languages</SectionLabel>
109+ {/* A sunk track, each language a lit segment in it. */}
110+ <div
111+ role="img"
112+ aria-label={items.map((l) => `${l.name} ${l.percent.toFixed(1)}%`).join(", ")}
113+ className="material-well flex h-3 gap-[2px] overflow-hidden rounded-pill p-[3px]"
114+ >
115+ {items.map((l) => (
116+ <span
117+ key={l.name}
118+ className="min-w-[3px] rounded-pill shadow-[inset_0_1px_0_rgb(255_255_255/0.25)]"
119+ style={{ width: `${l.percent}%`, background: l.color }}
120+ title={`${l.name} ${l.percent.toFixed(1)}%`}
121+ />
122+ ))}
123+ </div>
124+ <ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 type-ui">
125+ {items.map((l) => (
126+ <li key={l.name} className="flex min-w-0 items-center gap-2">
127+ <span aria-hidden className="size-1.5 shrink-0 rounded-full" style={{ background: l.color }} />
128+ <span className="truncate text-ink">{l.name}</span>
129+ <span className="type-readout text-ink3">{l.percent.toFixed(1)}%</span>
130+ </li>
131+ ))}
132+ </ul>
133+ </section>
134+ );
135+ }
136+
137+ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) {
138+ const { owner, repo: repoParam } = await params;
139+ const repo = await getRepo(owner, repoParam);
140+ const ref = repo.defaultBranch;
141+ const [branches, tags, commits, files, contributors] = await Promise.all([
142+ listBranches(repo.dir),
143+ listTags(repo.dir),
144+ ref ? commitCount(repo.dir, ref) : 0,
145+ ref ? listFilesWithSizes(repo.dir, ref) : [],
146+ ref ? listContributors(repo.dir, ref) : [],
147+ ]);
148+ const collaborators = listCollaborators(repo.id);
149+ const languages = languageBreakdown(files);
150+ const historyHref = ref ? `${repo.base}/commits/${encodePath(ref)}` : `${repo.base}/commits`;
151+
152+ return (
153+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_260px] xl:gap-10">
154+ <div className="min-w-0">
155+ {ref ? <TreeView repo={repo} gitRef={ref} path="" /> : <QuickSetup repo={repo} />}
156+ </div>
157+ <aside className="flex flex-col gap-7">
158+ <section>
159+ <SectionLabel>About</SectionLabel>
160+ <p className={`type-body ${repo.description ? "text-ink" : "text-ink3 italic"}`}>
161+ {repo.description || "No description provided."}
162+ </p>
163+ <p className="mt-2 flex items-center gap-1.5 type-meta text-ink3">
164+ <CalendarIcon width={12} height={12} /> Created {formatDate(repo.createdAt)}
165+ </p>
166+ </section>
167+
168+ <section>
169+ <SectionLabel>Stats</SectionLabel>
170+ <ul>
171+ <Stat href={historyHref} icon={HistoryIcon} label="Commits" value={commits} />
172+ <Stat href={`${repo.base}/branches`} icon={BranchIcon} label="Branches" value={branches.length} />
173+ <Stat href={`${repo.base}/branches`} icon={TagIcon} label="Tags" value={tags.length} />
174+ <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={openIssueCount(repo.id)} />
175+ <Stat
176+ href={repo.isAdmin ? `${repo.base}/settings/access` : `/${repo.owner}`}
177+ icon={PeopleIcon}
178+ label="Collaborators"
179+ value={collaborators.length}
180+ />
181+ </ul>
182+ </section>
183+
184+ {languages.length > 0 && <Languages languages={languages} />}
185+
186+ <section>
187+ <SectionLabel count={contributors.length || 1}>Contributors</SectionLabel>
188+ {contributors.length ? (
189+ <AvatarGroup
190+ aria-label="Contributors"
191+ people={contributors.slice(0, 24).map((c) => ({ name: c.name }))}
192+ max={8}
193+ size="regular"
194+ />
195+ ) : (
196+ <Link href={`/${repo.owner}`} title={repo.owner} className="inline-flex rounded-full focus-visible:focus-ring">
197+ <Avatar name={repo.owner} size={32} />
198+ </Link>
199+ )}
200+ </section>
201+ </aside>
202+ </div>
203+ );
204+ }
+29−0app/[owner]/[repo]/raw/[...segments]/route.tsaddedView File
@@ -0,0 +1,29 @@
1+ import { getViewer } from "@/lib/auth";
2+ import { normalizeRepoName, objectType, readBlob, resolveRefAndPath } from "@/lib/git";
3+ import { contentTypeFor } from "@/lib/mime";
4+ import { findRepo, repoAccess } from "@/lib/repos";
5+
6+ export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/raw/[...segments]">) {
7+ const { owner, repo: repoParam, segments } = await ctx.params;
8+ const repo = findRepo(owner, normalizeRepoName(repoParam));
9+ const viewer = await getViewer();
10+ if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 });
11+
12+ const resolved = await resolveRefAndPath(repo.dir, segments);
13+ if (!resolved || (await objectType(repo.dir, resolved.ref, resolved.path)) !== "blob") {
14+ return new Response("Not found", { status: 404 });
15+ }
16+ const blob = await readBlob(repo.dir, resolved.ref, resolved.path);
17+ if (blob.tooLarge) return new Response("File too large", { status: 413 });
18+
19+ const filename = resolved.path.split("/").pop()!;
20+ return new Response(new Uint8Array(blob.content), {
21+ headers: {
22+ "Content-Type": contentTypeFor(filename, blob.binary),
23+ "X-Content-Type-Options": "nosniff",
24+ "Cache-Control": repo.visibility === "private" ? "private, no-store" : "no-cache",
25+ // Sandbox the response so things like scripted SVGs can't run in our origin.
26+ "Content-Security-Policy": "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
27+ },
28+ });
29+ }
+32−0app/[owner]/[repo]/settings/access/form.tsxaddedView File
@@ -0,0 +1,32 @@
1+ "use client";
2+
3+ import { useActionState } from "react";
4+ import type { FormState } from "@/app/actions/types";
5+ import { PersonIcon, PlusIcon } from "@/components/icons";
6+ import { TextField } from "@/components/settings-fields";
7+ import { PlateBody } from "@/components/settings-plate";
8+ import { Alert, Button } from "@/components/ui";
9+
10+ export function AddCollaboratorForm({ action }: { action: (prev: FormState, formData: FormData) => Promise<FormState> }) {
11+ const [state, formAction, pending] = useActionState(action, undefined);
12+ return (
13+ <form action={formAction}>
14+ <PlateBody>
15+ <div className="flex max-w-xl flex-wrap items-end gap-2">
16+ <TextField
17+ className="min-w-60 flex-1"
18+ label="Username"
19+ icon={<PersonIcon />}
20+ name="username"
21+ required
22+ />
23+ <Button variant="primary" icon={<PlusIcon />} disabled={pending}>
24+ {pending ? "Adding…" : "Add Collaborator"}
25+ </Button>
26+ </div>
27+ {state?.error && <Alert>{state.error}</Alert>}
28+ {state?.success && <Alert kind="success">{state.success}</Alert>}
29+ </PlateBody>
30+ </form>
31+ );
32+ }
+79−0app/[owner]/[repo]/settings/access/page.tsxaddedView File
@@ -0,0 +1,79 @@
1+ import type { Metadata } from "next";
2+ import Link from "next/link";
3+ import { Label as MuLabel } from "@unlocalhosted/metalui";
4+ import { addCollaboratorAction, removeCollaboratorAction } from "@/app/actions/repo";
5+ import { ConfirmButton } from "@/components/confirm-button";
6+ import { PeopleIcon } from "@/components/icons";
7+ import { SettingsSection } from "@/components/metal";
8+ import { ListRow, PlateSection } from "@/components/settings-plate";
9+ import { Avatar, Blankslate, Label, PageTitle } from "@/components/ui";
10+ import { timeAgo } from "@/lib/format";
11+ import { getRepo } from "@/lib/repo";
12+ import { listCollaborators } from "@/lib/repos";
13+ import { AddCollaboratorForm } from "./form";
14+
15+ export const metadata: Metadata = { title: "Collaborators" };
16+
17+ export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/settings/access">) {
18+ const { owner, repo: repoParam } = await params;
19+ const repo = await getRepo(owner, repoParam);
20+ const collaborators = listCollaborators(repo.id);
21+
22+ return (
23+ <div>
24+ <PageTitle title="Collaborators" description="Collaborators can see this repository, push to it and edit files in the browser." />
25+ <div className="flex flex-col gap-7">
26+ <SettingsSection title={`People with access · ${collaborators.length + 1}`}>
27+ <ListRow
28+ lead={<Avatar name={repo.owner} size={32} />}
29+ title={
30+ <>
31+ <Link href={`/${repo.owner}`} className="truncate hover:underline">
32+ {repo.owner}
33+ </Link>
34+ <Label tone="accent">Owner</Label>
35+ </>
36+ }
37+ />
38+ {collaborators.map((c) => (
39+ <ListRow
40+ key={c.id}
41+ lead={<Avatar name={c.username} size={32} />}
42+ title={
43+ <>
44+ <Link href={`/${c.username}`} className="truncate hover:underline">
45+ {c.username}
46+ </Link>
47+ <Label>Write</Label>
48+ </>
49+ }
50+ meta={
51+ <MuLabel variant="engraved">
52+ {c.name && (
53+ <>
54+ <b>{c.name}</b> ·{" "}
55+ </>
56+ )}
57+ Added <b>{timeAgo(c.addedAt)}</b>
58+ </MuLabel>
59+ }
60+ >
61+ <ConfirmButton
62+ action={removeCollaboratorAction.bind(null, repo.owner, repo.name, c.id)}
63+ message={`Remove ${c.username} from ${repo.owner}/${repo.name}?`}
64+ variant="default"
65+ >
66+ Remove
67+ </ConfirmButton>
68+ </ListRow>
69+ ))}
70+ {collaborators.length === 0 && <Blankslate icon={<PeopleIcon />} title="You haven’t invited any collaborators yet" />}
71+ </SettingsSection>
72+
73+ <PlateSection title="Add a collaborator">
74+ <AddCollaboratorForm action={addCollaboratorAction.bind(null, repo.owner, repo.name)} />
75+ </PlateSection>
76+ </div>
77+ </div>
78+ );
79+ }
+185−0app/[owner]/[repo]/settings/forms.tsxaddedView File
@@ -0,0 +1,185 @@
1+ "use client";
2+
3+ import { useActionState, useEffect, useState } from "react";
4+ import type { FormState } from "@/app/actions/types";
5+ import { AlertIcon, GlobeIcon, LockIcon, PencilIcon, TrashIcon, UndoIcon, UploadIcon } from "@/components/icons";
6+ import { ChoiceCards, TextField } from "@/components/settings-fields";
7+ import { PlateBody, PlateFooter } from "@/components/settings-plate";
8+ import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui";
9+
10+ type Action = (prev: FormState, formData: FormData) => Promise<FormState>;
11+
12+ export function ImageForm({
13+ action,
14+ resetAction,
15+ repo,
16+ }: {
17+ action: Action;
18+ resetAction: () => Promise<void>;
19+ repo: RepoImageRef;
20+ }) {
21+ const [preview, setPreview] = useState<string | null>(null);
22+ const [state, formAction, pending] = useActionState(async (prev: FormState, formData: FormData) => {
23+ const result = await action(prev, formData);
24+ if (result?.success) setPreview(null);
25+ return result;
26+ }, undefined);
27+ useEffect(() => () => {
28+ if (preview) URL.revokeObjectURL(preview);
29+ }, [preview]);
30+
31+ return (
32+ <PlateBody>
33+ <div className="flex flex-wrap items-start gap-5">
34+ <RepoAvatar repo={repo} src={preview ?? undefined} size={72} />
35+ <div className="flex min-w-60 flex-1 flex-col gap-3">
36+ <p className="type-body text-ink2">
37+ Shown next to the repository everywhere. Until you upload one, it’s the owner’s avatar. PNG, JPEG, GIF or WebP, up to 1&nbsp;MB.
38+ </p>
39+ <div className="flex flex-wrap items-center gap-2">
40+ <form action={formAction} className="flex items-center gap-2">
41+ {/* The file input stays a real input; its label is drawn as MetalUI's standard cap. */}
42+ <label className={buttonClass("default", "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-green-deep has-focus-visible:outline-solid")}>
43+ <UploadIcon /> {repo.imageVersion ? "Replace Image" : "Upload Image"}
44+ <input
45+ type="file"
46+ name="image"
47+ accept="image/png,image/jpeg,image/gif,image/webp"
48+ required
49+ className="sr-only"
50+ onChange={(e) => {
51+ const file = e.target.files?.[0];
52+ setPreview(file ? URL.createObjectURL(file) : null);
53+ }}
54+ />
55+ </label>
56+ {preview && (
57+ <Button variant="primary" disabled={pending}>
58+ {pending ? "Saving…" : "Save Image"}
59+ </Button>
60+ )}
61+ </form>
62+ {repo.imageVersion && !preview && (
63+ <form action={resetAction}>
64+ <Button variant="invisible" icon={<UndoIcon />}>
65+ Reset to Owner’s Avatar
66+ </Button>
67+ </form>
68+ )}
69+ </div>
70+ {state?.error && <Alert>{state.error}</Alert>}
71+ {state?.success && !preview && <Alert kind="success">{state.success}</Alert>}
72+ </div>
73+ </div>
74+ </PlateBody>
75+ );
76+ }
77+
78+ export function RenameForm({ action, name }: { action: Action; name: string }) {
79+ const [state, formAction, pending] = useActionState(action, undefined);
80+ return (
81+ <form action={formAction}>
82+ <PlateBody>
83+ <div className="flex max-w-xl flex-wrap items-end gap-2">
84+ <TextField className="min-w-60 flex-1" label="Repository name" name="name" defaultValue={name} required />
85+ <Button icon={<PencilIcon />} disabled={pending}>
86+ Rename
87+ </Button>
88+ </div>
89+ {state?.error && <Alert>{state.error}</Alert>}
90+ </PlateBody>
91+ </form>
92+ );
93+ }
94+
95+ export function GeneralForm({
96+ action,
97+ description,
98+ visibility,
99+ }: {
100+ action: Action;
101+ description: string;
102+ visibility: "public" | "private";
103+ }) {
104+ const [state, formAction, pending] = useActionState(action, undefined);
105+ return (
106+ <form action={formAction}>
107+ <PlateBody className="max-w-xl">
108+ <TextField label="Description" name="description" defaultValue={description} maxLength={350} />
109+ <div className="flex flex-col gap-1.5">
110+ <span id="repo-visibility" className="type-ui text-ink">
111+ Visibility
112+ </span>
113+ <ChoiceCards
114+ name="visibility"
115+ aria-labelledby="repo-visibility"
116+ defaultValue={visibility}
117+ columns={1}
118+ choices={[
119+ { value: "public", icon: <GlobeIcon />, title: "Public", description: "Anyone can see and clone this repository." },
120+ { value: "private", icon: <LockIcon />, title: "Private", description: "Only you and collaborators can see it." },
121+ ]}
122+ />
123+ </div>
124+ {state?.error && <Alert>{state.error}</Alert>}
125+ {state?.success && <Alert kind="success">{state.success}</Alert>}
126+ </PlateBody>
127+ <PlateFooter>
128+ <Button variant="primary" disabled={pending}>
129+ {pending ? "Saving…" : "Save Changes"}
130+ </Button>
131+ </PlateFooter>
132+ </form>
133+ );
134+ }
135+
136+ /** Deleting the repository: one red cap opens the type-to-confirm form, whose red cap does it. */
137+ export function DangerZone({ action, fullName }: { action: Action; fullName: string }) {
138+ const [state, formAction, pending] = useActionState(action, undefined);
139+ const [confirm, setConfirm] = useState("");
140+ const [open, setOpen] = useState(false);
141+ return (
142+ <>
143+ <div className="flex min-h-[52px] flex-wrap items-center gap-4 px-6 py-4">
144+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
145+ <p className="type-title text-ink">Delete this repository</p>
146+ <p className="type-body text-ink2">Once you delete a repository, there is no going back. Please be certain.</p>
147+ </div>
148+ {!open && (
149+ <Button type="button" variant="danger" icon={<TrashIcon />} onClick={() => setOpen(true)}>
150+ Delete Repository
151+ </Button>
152+ )}
153+ </div>
154+ {open && (
155+ <form action={formAction}>
156+ <PlateBody className="pt-0">
157+ <p className="flex items-start gap-2 type-body text-red">
158+ <AlertIcon className="mt-px shrink-0" />
159+ <span>
160+ This will permanently delete <strong>{fullName}</strong>, its history, issues and access settings.
161+ </span>
162+ </p>
163+ <TextField
164+ className="max-w-xl"
165+ label={`To confirm, type “${fullName}” below:`}
166+ name="confirm"
167+ value={confirm}
168+ onChange={(e) => setConfirm(e.target.value)}
169+ autoFocus
170+ />
171+ {state?.error && <Alert>{state.error}</Alert>}
172+ </PlateBody>
173+ <PlateFooter>
174+ <Button type="button" onClick={() => setOpen(false)}>
175+ Cancel
176+ </Button>
177+ <Button variant="danger" icon={<TrashIcon />} disabled={confirm !== fullName || pending}>
178+ {pending ? "Deleting…" : "I Understand, Delete This Repository"}
179+ </Button>
180+ </PlateFooter>
181+ </form>
182+ )}
183+ </>
184+ );
185+ }
+68−0app/[owner]/[repo]/settings/labels/form.tsxaddedView File
@@ -0,0 +1,68 @@
1+ "use client";
2+
3+ import { useActionState, useState } from "react";
4+ import type { FormState } from "@/app/actions/types";
5+ import { PlusIcon } from "@/components/icons";
6+ import { LabelChip } from "@/components/issues/issue-icons";
7+ import { TextField } from "@/components/settings-fields";
8+ import { PlateBody } from "@/components/settings-plate";
9+ import { Alert, Button } from "@/components/ui";
10+ import { LABEL_COLORS } from "@/lib/issue-meta";
11+
12+ export function NewLabelForm({ action }: { action: (prev: FormState, formData: FormData) => Promise<FormState> }) {
13+ const [state, formAction, pending] = useActionState(action, undefined);
14+ const [name, setName] = useState("");
15+ const [color, setColor] = useState<string>(LABEL_COLORS[1]);
16+ return (
17+ <form action={formAction}>
18+ <PlateBody>
19+ <div className="flex max-w-xl flex-wrap items-end gap-2">
20+ <TextField
21+ className="min-w-60 flex-1"
22+ label="Name"
23+ name="name"
24+ required
25+ maxLength={50}
26+ value={name}
27+ onChange={(e) => setName(e.target.value)}
28+ placeholder="e.g. bug"
29+ />
30+ <Button variant="primary" icon={<PlusIcon />} disabled={pending}>
31+ {pending ? "Creating…" : "Create Label"}
32+ </Button>
33+ </div>
34+ <div className="flex flex-wrap items-center gap-x-6 gap-y-3">
35+ <fieldset className="flex flex-col gap-1.5">
36+ <legend className="mb-1.5 type-ui text-ink">Color</legend>
37+ {/* Colour keys: each a small raised disc; the chosen one is ringed in the focus green (intent). */}
38+ <div className="flex items-center gap-2 rounded-pill recipe-well-track p-1.5">
39+ {LABEL_COLORS.map((c) => (
40+ <label key={c} className="cursor-pointer" title={c}>
41+ <input
42+ type="radio"
43+ name="color"
44+ value={c}
45+ checked={color === c}
46+ onChange={() => setColor(c)}
47+ aria-label={c}
48+ className="peer sr-only"
49+ />
50+ <span
51+ className="block size-5 rounded-full shadow-[var(--mu-raise-sm)] transition-[outline-color] duration-150 outline-2 outline-offset-2 outline-transparent outline-solid peer-checked:outline-green-deep peer-focus-visible:outline-green-deep/60"
52+ style={{ background: c }}
53+ />
54+ </label>
55+ ))}
56+ </div>
57+ </fieldset>
58+ <div className="flex flex-col gap-1.5">
59+ <span className="type-label engraved">Preview</span>
60+ <LabelChip name={name.trim() || "label"} color={color} />
61+ </div>
62+ </div>
63+ {state?.error && <Alert>{state.error}</Alert>}
64+ {state?.success && <Alert kind="success">{state.success}</Alert>}
65+ </PlateBody>
66+ </form>
67+ );
68+ }
+69−0app/[owner]/[repo]/settings/labels/page.tsxaddedView File
@@ -0,0 +1,69 @@
1+ import type { Metadata } from "next";
2+ import Link from "next/link";
3+ import { createLabelFormAction, deleteLabelAction } from "@/app/actions/issues";
4+ import { ConfirmButton } from "@/components/confirm-button";
5+ import { TagIcon } from "@/components/icons";
6+ import { LabelChip } from "@/components/issues/issue-icons";
7+ import { SettingsSection } from "@/components/metal";
8+ import { PlateSection } from "@/components/settings-plate";
9+ import { Blankslate, PageTitle } from "@/components/ui";
10+ import { labelUsage, listLabels } from "@/lib/issues";
11+ import { getRepo } from "@/lib/repo";
12+ import { NewLabelForm } from "./form";
13+
14+ export const metadata: Metadata = { title: "Labels" };
15+
16+ export default async function LabelsPage({ params }: PageProps<"/[owner]/[repo]/settings/labels">) {
17+ const { owner, repo: repoParam } = await params;
18+ const repo = await getRepo(owner, repoParam);
19+ const labels = listLabels(repo.id);
20+ const usage = labelUsage(repo.id);
21+
22+ return (
23+ <div>
24+ <PageTitle
25+ title="Labels"
26+ description="Labels categorize issues. Collaborators can also create them right from an issue’s label menu."
27+ />
28+ <div className="flex flex-col gap-7">
29+ {labels.length === 0 ? (
30+ <PlateSection title="Labels">
31+ <Blankslate icon={<TagIcon />} title="No labels yet">
32+ Create labels like bug, feature or design to filter and group issues.
33+ </Blankslate>
34+ </PlateSection>
35+ ) : (
36+ <SettingsSection title={`Labels · ${labels.length}`}>
37+ {labels.map((label) => {
38+ const count = usage.get(label.id) ?? 0;
39+ return (
40+ <div key={label.id} className="flex min-h-[52px] items-center gap-3 px-[18px] py-2.5">
41+ <LabelChip name={label.name} color={label.color} />
42+ <Link
43+ href={`${repo.base}/issues?label=${label.id}`}
44+ className="rounded-key px-1 type-label engraved hover:text-ink focus-visible:focus-ring"
45+ >
46+ {count} issue{count === 1 ? "" : "s"}
47+ </Link>
48+ <div className="ml-auto">
49+ <ConfirmButton
50+ action={deleteLabelAction.bind(null, repo.owner, repo.name, label.id)}
51+ message={`Delete the label "${label.name}"? It will be removed from ${count} issue${count === 1 ? "" : "s"}.`}
52+ variant="default"
53+ >
54+ Delete Label
55+ </ConfirmButton>
56+ </div>
57+ </div>
58+ );
59+ })}
60+ </SettingsSection>
61+ )}
62+
63+ <PlateSection title="New label">
64+ <NewLabelForm action={createLabelFormAction.bind(null, repo.owner, repo.name)} />
65+ </PlateSection>
66+ </div>
67+ </div>
68+ );
69+ }
+23−0app/[owner]/[repo]/settings/layout.tsxaddedView File
@@ -0,0 +1,23 @@
1+ import { forbidden } from "next/navigation";
2+ import { SettingsNav } from "@/components/settings-nav";
3+ import { GearIcon, PeopleIcon, TagIcon } from "@/components/icons";
4+ import { getRepo } from "@/lib/repo";
5+
6+ export default async function RepoSettingsLayout({ params, children }: LayoutProps<"/[owner]/[repo]/settings">) {
7+ const { owner, repo: repoParam } = await params;
8+ const repo = await getRepo(owner, repoParam);
9+ if (!repo.isAdmin) forbidden();
10+ return (
11+ <div className="grid gap-8 md:grid-cols-[200px_1fr]">
12+ <SettingsNav
13+ title="Repository"
14+ items={[
15+ { href: `${repo.base}/settings`, label: "General", icon: <GearIcon /> },
16+ { href: `${repo.base}/settings/access`, label: "Collaborators", icon: <PeopleIcon /> },
17+ { href: `${repo.base}/settings/labels`, label: "Labels", icon: <TagIcon /> },
18+ ]}
19+ />
20+ <div className="min-w-0">{children}</div>
21+ </div>
22+ );
23+ }
+40−0app/[owner]/[repo]/settings/page.tsxaddedView File
@@ -0,0 +1,40 @@
1+ import type { Metadata } from "next";
2+ import { deleteRepoAction, removeRepoImageAction, renameRepoAction, updateRepoAction, updateRepoImageAction } from "@/app/actions/repo";
3+ import { PlateSection } from "@/components/settings-plate";
4+ import { PageTitle } from "@/components/ui";
5+ import { getRepo } from "@/lib/repo";
6+ import { DangerZone, GeneralForm, ImageForm, RenameForm } from "./forms";
7+
8+ export const metadata: Metadata = { title: "Settings" };
9+
10+ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings">) {
11+ const { owner, repo: repoParam } = await params;
12+ const repo = await getRepo(owner, repoParam);
13+ return (
14+ <div>
15+ <PageTitle title="General" />
16+ <div className="flex flex-col gap-7">
17+ <PlateSection title="Name">
18+ <RenameForm action={renameRepoAction.bind(null, repo.owner, repo.name)} name={repo.name} />
19+ </PlateSection>
20+ <PlateSection title="Repository image">
21+ <ImageForm
22+ action={updateRepoImageAction.bind(null, repo.owner, repo.name)}
23+ resetAction={removeRepoImageAction.bind(null, repo.owner, repo.name)}
24+ repo={{ owner: repo.owner, name: repo.name, imageVersion: repo.imageVersion }}
25+ />
26+ </PlateSection>
27+ <PlateSection title="Details">
28+ <GeneralForm
29+ action={updateRepoAction.bind(null, repo.owner, repo.name)}
30+ description={repo.description}
31+ visibility={repo.visibility}
32+ />
33+ </PlateSection>
34+ <PlateSection title="Danger zone">
35+ <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} />
36+ </PlateSection>
37+ </div>
38+ </div>
39+ );
40+ }
+15−0app/[owner]/[repo]/tree/[...segments]/page.tsxaddedView File
@@ -0,0 +1,15 @@
1+ import { notFound, redirect } from "next/navigation";
2+ import { TreeView } from "@/components/tree-view";
3+ import { objectType } from "@/lib/git";
4+ import { encodePath } from "@/lib/format";
5+ import { getRefAndPath, getRepo } from "@/lib/repo";
6+
7+ export default async function TreePage({ params }: PageProps<"/[owner]/[repo]/tree/[...segments]">) {
8+ const { owner, repo: repoParam, segments } = await params;
9+ const repo = await getRepo(owner, repoParam);
10+ const { ref, path } = await getRefAndPath(repo, segments);
11+ const type = await objectType(repo.dir, ref, path);
12+ if (type === "blob") redirect(`${repo.base}/blob/${encodePath(ref)}/${encodePath(path)}`);
13+ if (type !== "tree") notFound();
14+ return <TreeView repo={repo} gitRef={ref} path={path} />;
15+ }
+28−0app/[owner]/[repo]/upload/[...segments]/page.tsxaddedView File
@@ -0,0 +1,28 @@
1+ import type { Metadata } from "next";
2+ import { notFound } from "next/navigation";
3+ import { uploadFilesAction } from "@/app/actions/files";
4+ import { UploadForm } from "@/components/upload-form";
5+ import { objectType, revParse } from "@/lib/git";
6+ import { encodePath } from "@/lib/format";
7+ import { getBranchAndPath, getWritableRepo } from "@/lib/repo";
8+
9+ export const metadata: Metadata = { title: "Upload files" };
10+
11+ export default async function UploadPage({ params }: PageProps<"/[owner]/[repo]/upload/[...segments]">) {
12+ const { owner, repo: repoParam, segments } = await params;
13+ const repo = await getWritableRepo(owner, repoParam);
14+ const { branch, path: dir, exists } = await getBranchAndPath(repo, segments);
15+ if (exists && dir && (await objectType(repo.dir, branch, dir)) !== "tree") notFound();
16+ const baseSha = exists ? ((await revParse(repo.dir, `refs/heads/${branch}`)) ?? "") : "";
17+
18+ return (
19+ <UploadForm
20+ action={uploadFilesAction.bind(null, repo.owner, repo.name)}
21+ repoName={repo.name}
22+ branch={branch}
23+ baseSha={baseSha}
24+ dir={dir}
25+ cancelHref={exists ? `${repo.base}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}` : repo.base}
26+ />
27+ );
28+ }
+92−0app/[owner]/page.tsxaddedView File
@@ -0,0 +1,92 @@
1+ import { notFound } from "next/navigation";
2+ import { Field } from "@unlocalhosted/metalui";
3+ import { CalendarIcon, PencilIcon, PlusIcon, RepoIcon, SearchIcon } from "@/components/icons";
4+ import { FieldIcon, FieldInput } from "@/components/metal";
5+ import { RepoList } from "@/components/repo-list";
6+ import { Avatar, Blankslate, Box, ButtonLink, Counter } from "@/components/ui";
7+ import { getViewer } from "@/lib/auth";
8+ import { formatDate } from "@/lib/format";
9+ import { withActivity } from "@/lib/listing";
10+ import { listReposByOwner } from "@/lib/repos";
11+ import { getUserByUsername } from "@/lib/users";
12+
13+ export async function generateMetadata({ params }: PageProps<"/[owner]">) {
14+ const user = getUserByUsername((await params).owner);
15+ return { title: user ? (user.name ? `${user.username} (${user.name})` : user.username) : "Not found" };
16+ }
17+
18+ const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink";
19+
20+ export default async function ProfilePage({ params, searchParams }: PageProps<"/[owner]">) {
21+ const user = getUserByUsername((await params).owner);
22+ if (!user) notFound();
23+ const q = String((await searchParams).q ?? "").trim().toLowerCase();
24+ const viewer = await getViewer();
25+ const all = await withActivity(listReposByOwner(user.id, viewer?.id ?? null));
26+ const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all;
27+ const isSelf = viewer?.id === user.id;
28+ const publicCount = all.filter((r) => r.visibility === "public").length;
29+
30+ return (
31+ <div className="mx-auto grid w-full max-w-[1280px] gap-8 px-4 py-10 md:grid-cols-[300px_1fr] md:px-6">
32+ <aside>
33+ <Box className="p-6 md:sticky md:top-20">
34+ <Avatar name={user.username} size={88} />
35+ <h1 className="mt-5">
36+ {user.name && <span className={`block ${heading}`}>{user.name}</span>}
37+ <span className={`block ${user.name ? "mt-1 type-readout text-ink3" : heading}`}>@{user.username}</span>
38+ </h1>
39+ {user.bio && <p className="mt-3 type-body text-ink2">{user.bio}</p>}
40+ <dl className="mt-5 grid grid-cols-2 gap-2">
41+ <div className="well flex flex-col-reverse gap-1 rounded-row px-3 py-2.5">
42+ <dt className="type-label engraved">Repositories</dt>
43+ <dd className="type-stat text-ink">{all.length}</dd>
44+ </div>
45+ <div className="well flex flex-col-reverse gap-1 rounded-row px-3 py-2.5">
46+ <dt className="type-label engraved">Public</dt>
47+ <dd className="type-stat text-ink">{publicCount}</dd>
48+ </div>
49+ </dl>
50+ <p className="mt-4 flex items-center gap-2 type-meta text-ink3">
51+ <CalendarIcon width={14} height={14} /> Joined {formatDate(user.createdAt)}
52+ </p>
53+ {isSelf && (
54+ <ButtonLink href="/settings/profile" icon={<PencilIcon />} className="mt-5 w-full">
55+ Edit Profile
56+ </ButtonLink>
57+ )}
58+ </Box>
59+ </aside>
60+
61+ <main className="min-w-0">
62+ <div className="mb-5 flex flex-wrap items-center gap-3">
63+ <h2 className="flex items-center gap-2 type-display text-ink">
64+ <RepoIcon className="text-ink2" /> Repositories <Counter>{all.length}</Counter>
65+ </h2>
66+ <div className="ml-auto flex w-full items-center gap-2 sm:w-auto">
67+ <form role="search" className="flex-1 sm:w-72">
68+ <Field size="regular">
69+ <FieldIcon>
70+ <SearchIcon />
71+ </FieldIcon>
72+ <FieldInput name="q" defaultValue={q} placeholder="Find a repository…" aria-label="Find a repository" />
73+ </Field>
74+ </form>
75+ {isSelf && (
76+ <ButtonLink href="/new" variant="primary" icon={<PlusIcon />}>
77+ New
78+ </ButtonLink>
79+ )}
80+ </div>
81+ </div>
82+ {repos.length > 0 ? (
83+ <RepoList repos={repos} showOwner={false} />
84+ ) : (
85+ <Box>
86+ <Blankslate icon={<RepoIcon />} title={q ? "No matching repositories" : `${user.username} doesn’t have any repositories yet.`} />
87+ </Box>
88+ )}
89+ </main>
90+ </div>
91+ );
92+ }
+34−0app/actions/auth.tsaddedView File
@@ -0,0 +1,34 @@
1+ "use server";
2+
3+ import { redirect } from "next/navigation";
4+ import { endSession, safeNext, startSession } from "@/lib/auth";
5+ import { authenticateUser, createUser, UserError } from "@/lib/users";
6+ import type { FormState } from "./types";
7+
8+ export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> {
9+ try {
10+ const user = await createUser({
11+ username: String(formData.get("username") ?? ""),
12+ email: String(formData.get("email") ?? ""),
13+ password: String(formData.get("password") ?? ""),
14+ name: String(formData.get("name") ?? ""),
15+ });
16+ await startSession(user.id);
17+ } catch (e) {
18+ if (e instanceof UserError) return { error: e.message };
19+ throw e;
20+ }
21+ redirect(safeNext(formData.get("next")));
22+ }
23+
24+ export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
25+ const user = await authenticateUser(String(formData.get("login") ?? ""), String(formData.get("password") ?? ""));
26+ if (!user) return { error: "Incorrect username or password." };
27+ await startSession(user.id);
28+ redirect(safeNext(formData.get("next")));
29+ }
30+
31+ export async function logoutAction() {
32+ await endSession();
33+ redirect("/");
34+ }
+143−0app/actions/files.tsaddedView File
@@ -0,0 +1,143 @@
1+ "use server";
2+
3+ import { redirect } from "next/navigation";
4+ import { git, GitError, objectType, revParse } from "@/lib/git";
5+ import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write";
6+ import { encodePath } from "@/lib/format";
7+ import { markPushed, type RepoRecord } from "@/lib/repos";
8+ import type { User } from "@/lib/users";
9+ import { ActionError, repoForAction } from "./guard";
10+ import type { FormState } from "./types";
11+
12+ const str = (formData: FormData, key: string) => String(formData.get(key) ?? "").trim();
13+
14+ /** Blob sha of a path at a commit, or null if it doesn't exist there. */
15+ async function blobAt(dir: string, commit: string, path: string) {
16+ return (await git(dir, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]).catch(() => "")).trim() || null;
17+ }
18+
19+ /**
20+ * Commits `changes` from the web UI, either directly to `branch` or to a new
21+ * branch, and returns the branch that received the commit. `touched` lists the
22+ * paths whose content the user saw; if any changed on the branch since the page
23+ * was loaded (`baseSha`), the commit is refused instead of overwriting them.
24+ */
25+ async function commitFromForm(
26+ repo: RepoRecord,
27+ viewer: User,
28+ formData: FormData,
29+ changes: FileChange[],
30+ touched: string[],
31+ defaultMessage: string,
32+ ) {
33+ const branch = str(formData, "branch");
34+ const baseSha = str(formData, "baseSha");
35+ const head = await revParse(repo.dir, `refs/heads/${branch}`);
36+
37+ if (baseSha) {
38+ if (!head) throw new ActionError(`Branch ${branch} no longer exists.`);
39+ if (head !== baseSha) {
40+ for (const path of touched) {
41+ if ((await blobAt(repo.dir, baseSha, path)) !== (await blobAt(repo.dir, head, path))) {
42+ throw new ActionError(`${path} was changed on ${branch} since you started editing. Reload to get the latest version.`);
43+ }
44+ }
45+ }
46+ } else if (head) {
47+ throw new ActionError(`Branch ${branch} was created by someone else in the meantime. Reload and try again.`);
48+ }
49+
50+ const summary = str(formData, "message") || defaultMessage;
51+ const description = str(formData, "description");
52+ const message = `${summary}${description ? `\n\n${description}` : ""}\n`;
53+ const author = { name: viewer.name || viewer.username, email: viewer.email };
54+
55+ let target = branch;
56+ if (formData.get("target") === "branch") {
57+ target = str(formData, "newBranch");
58+ if (!(await isValidBranchName(repo.dir, target))) throw new ActionError(`${target || "That"} is not a valid branch name.`);
59+ await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author });
60+ } else {
61+ await commitFiles(repo.dir, { branch, parent: head, changes, message, author });
62+ }
63+ markPushed(repo.id);
64+ return target;
65+ }
66+
67+ async function handle(fn: () => Promise<string>): Promise<FormState> {
68+ let destination: string;
69+ try {
70+ destination = await fn();
71+ } catch (e) {
72+ if (e instanceof ActionError || e instanceof GitError) return { error: e.message };
73+ throw e;
74+ }
75+ redirect(destination);
76+ }
77+
78+ export async function saveFileAction(owner: string, name: string, _prev: FormState, formData: FormData) {
79+ return handle(async () => {
80+ const { repo, viewer } = await repoForAction(owner, name, "write");
81+ const path = cleanRepoPath(str(formData, "path"));
82+ if (!path) throw new ActionError("That file name is invalid.");
83+ const originalPath = str(formData, "originalPath");
84+ const content = Buffer.from(String(formData.get("content") ?? ""));
85+ const head = await revParse(repo.dir, `refs/heads/${str(formData, "branch")}`);
86+
87+ // Refuse to silently overwrite a different existing file.
88+ if (path !== originalPath && head && (await objectType(repo.dir, head, path))) {
89+ throw new ActionError(`A file named ${path} already exists.`);
90+ }
91+ const changes: FileChange[] = [{ path, content }];
92+ if (originalPath && originalPath !== path) changes.unshift({ path: originalPath, delete: true });
93+
94+ const filename = path.split("/").pop()!;
95+ const verb = !originalPath ? "Create" : originalPath !== path ? "Rename" : "Update";
96+ const branch = await commitFromForm(repo, viewer, formData, changes, originalPath ? [originalPath] : [], `${verb} ${filename}`);
97+ return `/${repo.owner}/${repo.name}/blob/${encodePath(branch)}/${encodePath(path)}`;
98+ });
99+ }
100+
101+ export async function deleteFileAction(owner: string, name: string, _prev: FormState, formData: FormData) {
102+ return handle(async () => {
103+ const { repo, viewer } = await repoForAction(owner, name, "write");
104+ const path = cleanRepoPath(str(formData, "path"));
105+ if (!path) throw new ActionError("That file name is invalid.");
106+ const branch = await commitFromForm(repo, viewer, formData, [{ path, delete: true }], [path], `Delete ${path.split("/").pop()}`);
107+
108+ // Go to the closest directory that still exists.
109+ const parts = path.split("/").slice(0, -1);
110+ while (parts.length && (await objectType(repo.dir, `refs/heads/${branch}`, parts.join("/"))) !== "tree") parts.pop();
111+ const suffix = parts.length ? `/${encodePath(parts.join("/"))}` : "";
112+ const hasFiles = await revParse(repo.dir, `refs/heads/${branch}`);
113+ return hasFiles ? `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${suffix}` : `/${repo.owner}/${repo.name}`;
114+ });
115+ }
116+
117+ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
118+
119+ export async function uploadFilesAction(owner: string, name: string, _prev: FormState, formData: FormData) {
120+ return handle(async () => {
121+ const { repo, viewer } = await repoForAction(owner, name, "write");
122+ const dir = str(formData, "dir");
123+ const files = formData.getAll("files").filter((f): f is File => f instanceof File && f.size > 0);
124+ if (files.length === 0) throw new ActionError("Choose at least one file to upload.");
125+
126+ const changes: FileChange[] = [];
127+ for (const file of files) {
128+ if (file.size > MAX_UPLOAD_BYTES) throw new ActionError(`${file.name} is larger than 10 MB.`);
129+ const path = cleanRepoPath(dir ? `${dir}/${file.name}` : file.name);
130+ if (!path) throw new ActionError(`${file.name} is not a valid file name.`);
131+ changes.push({ path, content: Buffer.from(await file.arrayBuffer()) });
132+ }
133+ const branch = await commitFromForm(
134+ repo,
135+ viewer,
136+ formData,
137+ changes,
138+ changes.map((c) => c.path),
139+ files.length === 1 ? `Add ${files[0].name}` : `Add ${files.length} files`,
140+ );
141+ return `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}`;
142+ });
143+ }
+22−0app/actions/guard.tsaddedView File
@@ -0,0 +1,22 @@
1+ import "server-only";
2+ import { getViewer } from "@/lib/auth";
3+ import { canWrite, findRepo, repoAccess, type Access } from "@/lib/repos";
4+
5+ export class ActionError extends Error {}
6+
7+ export async function viewerOrThrow() {
8+ const viewer = await getViewer();
9+ if (!viewer) throw new ActionError("You must be signed in to do that.");
10+ return viewer;
11+ }
12+
13+ /** Loads a repository for a server action and checks the viewer's access level. */
14+ export async function repoForAction(owner: string, name: string, need: "read" | "write" | "admin") {
15+ const viewer = await viewerOrThrow();
16+ const repo = findRepo(owner, name);
17+ const access: Access = repo ? repoAccess(repo, viewer.id) : null;
18+ if (!repo || !access) throw new ActionError("Repository not found.");
19+ if (need === "write" && !canWrite(access)) throw new ActionError("You don't have push access to this repository.");
20+ if (need === "admin" && access !== "admin") throw new ActionError("Only the owner can change these settings.");
21+ return { repo, viewer, access };
22+ }
+176−0app/actions/issues.tsaddedView File
@@ -0,0 +1,176 @@
1+ "use server";
2+
3+ import { revalidatePath } from "next/cache";
4+ import { redirect } from "next/navigation";
5+ import {
6+ ESTIMATES,
7+ STATUS_IDS,
8+ isValidDay,
9+ type ActionResult,
10+ type IssueLabel,
11+ type IssuePatch,
12+ type NewIssueInput,
13+ } from "@/lib/issue-meta";
14+ import {
15+ addComment,
16+ createIssue,
17+ createLabel,
18+ deleteIssue,
19+ deleteLabel,
20+ getIssue,
21+ LabelError,
22+ listLabels,
23+ listMembers,
24+ updateIssue,
25+ } from "@/lib/issues";
26+ import { canWrite, type RepoRecord } from "@/lib/repos";
27+ import { ActionError, repoForAction } from "./guard";
28+ import type { FormState } from "./types";
29+
30+ const MAX_BODY = 65_536;
31+
32+ function revalidateIssues(repo: RepoRecord) {
33+ revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
34+ }
35+
36+ /**
37+ * Validates untrusted issue properties from the browser. Only the fields that
38+ * are present are checked; anything invalid throws.
39+ */
40+ function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch {
41+ const out: IssuePatch = {};
42+ if (patch.title !== undefined) {
43+ const title = String(patch.title).trim().slice(0, 256);
44+ if (!title) throw new ActionError("Title can't be blank.");
45+ out.title = title;
46+ }
47+ if (patch.body !== undefined) out.body = String(patch.body).trim().slice(0, MAX_BODY);
48+ if (patch.status !== undefined) {
49+ if (!STATUS_IDS.includes(patch.status)) throw new ActionError("Unknown status.");
50+ out.status = patch.status;
51+ }
52+ if (patch.priority !== undefined) {
53+ if (!Number.isInteger(patch.priority) || patch.priority < 0 || patch.priority > 4) throw new ActionError("Unknown priority.");
54+ out.priority = patch.priority;
55+ }
56+ if (patch.assigneeId !== undefined) {
57+ if (patch.assigneeId !== null && !listMembers(repo.id, repo.ownerId).some((m) => m.id === patch.assigneeId)) {
58+ throw new ActionError("Only the owner and collaborators can be assigned.");
59+ }
60+ out.assigneeId = patch.assigneeId;
61+ }
62+ if (patch.estimate !== undefined) {
63+ if (patch.estimate !== null && !(ESTIMATES as readonly number[]).includes(patch.estimate)) throw new ActionError("Unknown estimate.");
64+ out.estimate = patch.estimate;
65+ }
66+ if (patch.dueDate !== undefined) {
67+ if (patch.dueDate !== null && !isValidDay(String(patch.dueDate))) throw new ActionError("Invalid due date.");
68+ out.dueDate = patch.dueDate;
69+ }
70+ if (patch.labelIds !== undefined) {
71+ if (!Array.isArray(patch.labelIds)) throw new ActionError("Invalid labels.");
72+ const known = new Set(listLabels(repo.id).map((l) => l.id));
73+ const ids = [...new Set(patch.labelIds)];
74+ if (!ids.every((id) => known.has(id))) throw new ActionError("Unknown label.");
75+ out.labelIds = ids;
76+ }
77+ if (patch.sortOrder !== undefined) {
78+ if (typeof patch.sortOrder !== "number" || !Number.isFinite(patch.sortOrder)) throw new ActionError("Invalid order.");
79+ out.sortOrder = patch.sortOrder;
80+ }
81+ return out;
82+ }
83+
84+ // Permissions: anyone who can read a repository may open issues and comment.
85+ // Changing an issue in any way (title, description, status, triage, order) needs
86+ // push access. Deleting is reserved for the issue's creator and the repository owner.
87+
88+ export async function createIssueAction(
89+ owner: string,
90+ name: string,
91+ input: NewIssueInput,
92+ ): Promise<ActionResult & { number?: number }> {
93+ try {
94+ const { repo, viewer, access } = await repoForAction(owner, name, "read");
95+ const { title, body, ...properties } = validate(repo, { ...input, sortOrder: undefined });
96+ if (!title) return { error: "Title can't be blank." };
97+ const number = createIssue(repo.id, viewer.id, {
98+ title,
99+ body: body ?? "",
100+ ...(canWrite(access) ? properties : {}),
101+ });
102+ revalidateIssues(repo);
103+ return { number };
104+ } catch (e) {
105+ if (e instanceof ActionError) return { error: e.message };
106+ throw e;
107+ }
108+ }
109+
110+ export async function updateIssueAction(owner: string, name: string, number: number, patch: IssuePatch): Promise<ActionResult> {
111+ try {
112+ const { repo, viewer, access } = await repoForAction(owner, name, "read");
113+ const issue = getIssue(repo.id, number);
114+ if (!issue) return { error: "Issue not found." };
115+ if (!canWrite(access)) return { error: "Only collaborators can change issues. You can still comment." };
116+ const clean = validate(repo, patch);
117+ updateIssue(issue, clean, viewer.id);
118+ revalidateIssues(repo);
119+ return {};
120+ } catch (e) {
121+ if (e instanceof ActionError) return { error: e.message };
122+ throw e;
123+ }
124+ }
125+
126+ export async function commentAction(owner: string, name: string, number: number, formData: FormData) {
127+ const { repo, viewer } = await repoForAction(owner, name, "read");
128+ const issue = getIssue(repo.id, number);
129+ if (!issue) throw new ActionError("Issue not found");
130+ const body = String(formData.get("body") ?? "").trim().slice(0, MAX_BODY);
131+ if (body) addComment(issue.id, { body, authorId: viewer.id });
132+ revalidateIssues(repo);
133+ }
134+
135+ export async function deleteIssueAction(owner: string, name: string, number: number) {
136+ const { repo, viewer, access } = await repoForAction(owner, name, "read");
137+ const issue = getIssue(repo.id, number);
138+ if (!issue) throw new ActionError("Issue not found.");
139+ if (issue.authorId !== viewer.id && access !== "admin") {
140+ throw new ActionError("Only the issue's creator and the repository owner can delete it.");
141+ }
142+ deleteIssue(issue.id);
143+ revalidateIssues(repo);
144+ redirect(`/${repo.owner}/${repo.name}/issues`);
145+ }
146+
147+ export async function createLabelAction(
148+ owner: string,
149+ name: string,
150+ input: { name: string; color: string },
151+ ): Promise<ActionResult & { label?: IssueLabel }> {
152+ try {
153+ const { repo } = await repoForAction(owner, name, "write");
154+ const label = createLabel(repo.id, { name: String(input.name ?? ""), color: String(input.color ?? "") });
155+ revalidateIssues(repo);
156+ return { label };
157+ } catch (e) {
158+ if (e instanceof ActionError || e instanceof LabelError) return { error: e.message };
159+ throw e;
160+ }
161+ }
162+
163+ /** For the labels settings form. */
164+ export async function createLabelFormAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> {
165+ const result = await createLabelAction(owner, name, {
166+ name: String(formData.get("name") ?? ""),
167+ color: String(formData.get("color") ?? ""),
168+ });
169+ return result.error ? { error: result.error } : { success: `Created label "${result.label!.name}".` };
170+ }
171+
172+ export async function deleteLabelAction(owner: string, name: string, labelId: number) {
173+ const { repo } = await repoForAction(owner, name, "write");
174+ deleteLabel(repo.id, labelId);
175+ revalidateIssues(repo);
176+ }
+114−0app/actions/repo.tsaddedView File
@@ -0,0 +1,114 @@
1+ "use server";
2+
3+ import { revalidatePath } from "next/cache";
4+ import { redirect } from "next/navigation";
5+ import {
6+ addCollaborator,
7+ createRepo,
8+ deleteRepo,
9+ removeCollaborator,
10+ removeRepoImage,
11+ renameRepo,
12+ RepoError,
13+ setRepoImage,
14+ updateRepo,
15+ type Visibility,
16+ } from "@/lib/repos";
17+ import { ActionError, repoForAction, viewerOrThrow } from "./guard";
18+ import type { FormState } from "./types";
19+
20+ const visibilityOf = (v: FormDataEntryValue | null): Visibility => (v === "private" ? "private" : "public");
21+
22+ async function attempt(fn: () => Promise<FormState | void> | FormState | void): Promise<FormState> {
23+ try {
24+ return (await fn()) ?? undefined;
25+ } catch (e) {
26+ if (e instanceof RepoError || e instanceof ActionError) return { error: e.message };
27+ throw e;
28+ }
29+ }
30+
31+ export async function createRepoAction(_prev: FormState, formData: FormData): Promise<FormState> {
32+ let path = "";
33+ const result = await attempt(async () => {
34+ const viewer = await viewerOrThrow();
35+ const repo = await createRepo(viewer, {
36+ name: String(formData.get("name") ?? ""),
37+ description: String(formData.get("description") ?? ""),
38+ visibility: visibilityOf(formData.get("visibility")),
39+ readme: formData.get("readme") === "on",
40+ });
41+ path = `/${repo.owner}/${repo.name}`;
42+ });
43+ if (result?.error) return result;
44+ redirect(path);
45+ }
46+
47+ export async function updateRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
48+ return attempt(async () => {
49+ const { repo } = await repoForAction(owner, name, "admin");
50+ updateRepo(repo.id, {
51+ description: String(formData.get("description") ?? ""),
52+ visibility: visibilityOf(formData.get("visibility")),
53+ });
54+ revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
55+ return { success: "Settings saved." };
56+ });
57+ }
58+
59+ export async function updateRepoImageAction(owner: string, name: string, _prev: FormState, formData: FormData) {
60+ return attempt(async () => {
61+ const { repo } = await repoForAction(owner, name, "admin");
62+ const file = formData.get("image");
63+ if (!(file instanceof File)) throw new RepoError("Choose an image to upload.");
64+ setRepoImage(repo.id, new Uint8Array(await file.arrayBuffer()));
65+ // The image shows up in sidebars and lists everywhere, not just on this repository's pages.
66+ revalidatePath("/", "layout");
67+ return { success: "Repository image updated." };
68+ });
69+ }
70+
71+ export async function removeRepoImageAction(owner: string, name: string) {
72+ const { repo } = await repoForAction(owner, name, "admin");
73+ removeRepoImage(repo.id);
74+ revalidatePath("/", "layout");
75+ }
76+
77+ export async function renameRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
78+ let path = "";
79+ const result = await attempt(async () => {
80+ const { repo } = await repoForAction(owner, name, "admin");
81+ const renamed = await renameRepo(repo, String(formData.get("name") ?? ""));
82+ path = `/${renamed.owner}/${renamed.name}/settings`;
83+ });
84+ if (result?.error) return result;
85+ redirect(path);
86+ }
87+
88+ export async function deleteRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
89+ const result = await attempt(async () => {
90+ const { repo } = await repoForAction(owner, name, "admin");
91+ if (formData.get("confirm") !== `${repo.owner}/${repo.name}`) {
92+ return { error: `Type ${repo.owner}/${repo.name} to confirm.` };
93+ }
94+ await deleteRepo(repo);
95+ });
96+ if (result?.error) return result;
97+ redirect("/");
98+ }
99+
100+ export async function addCollaboratorAction(owner: string, name: string, _prev: FormState, formData: FormData) {
101+ return attempt(async () => {
102+ const { repo } = await repoForAction(owner, name, "admin");
103+ const username = String(formData.get("username") ?? "").trim();
104+ addCollaborator(repo, username);
105+ revalidatePath(`/${repo.owner}/${repo.name}/settings`);
106+ return { success: `${username} can now push to this repository.` };
107+ });
108+ }
109+
110+ export async function removeCollaboratorAction(owner: string, name: string, userId: number) {
111+ const { repo } = await repoForAction(owner, name, "admin");
112+ removeCollaborator(repo.id, userId);
113+ revalidatePath(`/${repo.owner}/${repo.name}/settings`);
114+ }
+91−0app/actions/settings.tsaddedView File
@@ -0,0 +1,91 @@
1+ "use server";
2+
3+ import { revalidatePath } from "next/cache";
4+ import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys";
5+ import { canWrite, findRepoById, repoAccess } from "@/lib/repos";
6+ import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens";
7+ import { setPassword, updateProfile, UserError, validatePassword, verifyUserPassword } from "@/lib/users";
8+ import { viewerOrThrow } from "./guard";
9+ import type { FormState } from "./types";
10+
11+ export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> {
12+ const viewer = await viewerOrThrow();
13+ try {
14+ updateProfile(viewer.id, {
15+ name: String(formData.get("name") ?? ""),
16+ email: String(formData.get("email") ?? ""),
17+ bio: String(formData.get("bio") ?? ""),
18+ });
19+ } catch (e) {
20+ if (e instanceof UserError) return { error: e.message };
21+ throw e;
22+ }
23+ revalidatePath("/", "layout");
24+ return { success: "Profile updated." };
25+ }
26+
27+ export async function changePasswordAction(_prev: FormState, formData: FormData): Promise<FormState> {
28+ const viewer = await viewerOrThrow();
29+ const current = String(formData.get("current") ?? "");
30+ const next = String(formData.get("password") ?? "");
31+ if (!(await verifyUserPassword(viewer.id, current))) return { error: "Your current password is incorrect." };
32+ if (next !== formData.get("confirm")) return { error: "The new passwords don't match." };
33+ const problem = validatePassword(next);
34+ if (problem) return { error: problem };
35+ await setPassword(viewer.id, next);
36+ return { success: "Password changed." };
37+ }
38+
39+ export async function addSshKeyAction(_prev: FormState, formData: FormData): Promise<FormState> {
40+ const viewer = await viewerOrThrow();
41+ try {
42+ addSshKey(viewer.id, String(formData.get("title") ?? ""), String(formData.get("key") ?? ""));
43+ } catch (e) {
44+ if (e instanceof KeyError) return { error: e.message };
45+ throw e;
46+ }
47+ revalidatePath("/settings/keys");
48+ return { success: "SSH key added." };
49+ }
50+
51+ export async function deleteSshKeyAction(keyId: number) {
52+ const viewer = await viewerOrThrow();
53+ deleteSshKey(viewer.id, keyId);
54+ revalidatePath("/settings/keys");
55+ }
56+
57+ export async function createTokenAction(_prev: FormState, formData: FormData): Promise<FormState> {
58+ const viewer = await viewerOrThrow();
59+ const permission: TokenPermission = formData.get("permission") === "read" ? "read" : "write";
60+ const expiry = String(formData.get("expiry") ?? "30");
61+ let repoIds: number[] | null = null;
62+ if (formData.get("scope") === "selected") {
63+ repoIds = formData
64+ .getAll("repos")
65+ .map(Number)
66+ .filter((id) => {
67+ const repo = findRepoById(id);
68+ const access = repo ? repoAccess(repo, viewer.id) : null;
69+ return access && (permission === "read" || canWrite(access));
70+ });
71+ }
72+ try {
73+ const token = createToken(viewer.id, {
74+ name: String(formData.get("name") ?? ""),
75+ permission,
76+ expiresInDays: expiry === "never" ? null : Number(expiry) || 30,
77+ repoIds,
78+ });
79+ revalidatePath("/settings/tokens");
80+ return { token };
81+ } catch (e) {
82+ if (e instanceof TokenError) return { error: e.message };
83+ throw e;
84+ }
85+ }
86+
87+ export async function deleteTokenAction(tokenId: number) {
88+ const viewer = await viewerOrThrow();
89+ deleteToken(viewer.id, tokenId);
90+ revalidatePath("/settings/tokens");
91+ }
+1−0app/actions/types.tsaddedView File
@@ -0,0 +1 @@
1+ export type FormState = { error?: string; success?: string; token?: string } | undefined;
+0−0app/apple-icon.pngaddedView File

Binary file not shown.

+54−0app/explore/page.tsxaddedView File
@@ -0,0 +1,54 @@
1+ import type { Metadata } from "next";
2+ import { Field } from "@unlocalhosted/metalui";
3+ import { CompassIcon, SearchIcon } from "@/components/icons";
4+ import { FieldIcon, FieldInput } from "@/components/metal";
5+ import { RepoList } from "@/components/repo-list";
6+ import { Blankslate, Box, Counter } from "@/components/ui";
7+ import { getViewer } from "@/lib/auth";
8+ import { withActivity } from "@/lib/listing";
9+ import { searchRepos } from "@/lib/repos";
10+
11+ export const metadata: Metadata = { title: "Explore" };
12+
13+ export default async function ExplorePage({ searchParams }: PageProps<"/explore">) {
14+ const q = String((await searchParams).q ?? "").trim();
15+ const viewer = await getViewer();
16+ const repos = await withActivity(searchRepos(q, viewer?.id ?? null));
17+
18+ return (
19+ <main className="w-full">
20+ <section className="rule-b">
21+ <div className="mx-auto max-w-5xl px-4 pt-16 pb-14 text-center">
22+ <span className="icon-tile mx-auto size-14 rounded-[18px]">
23+ <CompassIcon width={24} height={24} />
24+ </span>
25+ <p className="mt-6 type-label engraved">Public repositories</p>
26+ <h1 className="mt-2 text-[36px] leading-[1.05] font-semibold tracking-[-0.04em] text-ink">Explore Vectrum</h1>
27+ <p className="mt-2 type-lead text-ink2">Discover repositories hosted here.</p>
28+ <form role="search" className="mx-auto mt-8 max-w-xl text-left">
29+ <Field size="large" className="focus-within:focus-ring-flush">
30+ <FieldIcon>
31+ <SearchIcon />
32+ </FieldIcon>
33+ <FieldInput name="q" defaultValue={q} placeholder="Search by name, owner or description" aria-label="Search repositories" />
34+ </Field>
35+ </form>
36+ </div>
37+ </section>
38+ <div className="mx-auto max-w-5xl px-4 py-10">
39+ <h2 className="mb-4 flex items-center gap-2 type-display text-ink">
40+ {q ? <>Results for “{q}”</> : "Recently updated"} <Counter>{repos.length}</Counter>
41+ </h2>
42+ {repos.length ? (
43+ <RepoList repos={repos} />
44+ ) : (
45+ <Box>
46+ <Blankslate icon={<SearchIcon />} title="No repositories found">
47+ {q ? "Try a different search." : "Nobody has created a public repository yet."}
48+ </Blankslate>
49+ </Box>
50+ )}
51+ </div>
52+ </main>
53+ );
54+ }
+0−0app/favicon.icoView File

Binary file not shown.

+19−0app/forbidden.tsxaddedView File
@@ -0,0 +1,19 @@
1+ import { StatusPage } from "@/components/home-status";
2+ import { HomeIcon } from "@/components/icons";
3+ import { ButtonLink } from "@/components/ui";
4+
5+ export default function Forbidden() {
6+ return (
7+ <StatusPage
8+ code="403"
9+ label="Forbidden"
10+ title="You don’t have access to this page"
11+ description="Ask the repository owner to add you as a collaborator."
12+ actions={
13+ <ButtonLink href="/" icon={<HomeIcon />}>
14+ Go Home
15+ </ButtonLink>
16+ }
17+ />
18+ );
19+ }
+318−14app/globals.cssView File
@@ -1,26 +1,330 @@
11 @import "tailwindcss";
2+ /* MetalUI's Tailwind theme: its type roles (type-ui, type-label…), inks (text-ink, text-ink2…), materials
3+ (material-raised, material-well, material-cap…) and radii, for the parts of the app MetalUI has no
4+ component for. The components' own CSS comes from @unlocalhosted/metalui/styles.css (see layout). */
5+ @import "@unlocalhosted/metalui/theme.css";
26
7+ /*
8+ * Soft Hardware: bone (light) and graphite (dark) colorways from MetalUI. The app's older token names
9+ * are kept as aliases of MetalUI's so every surface follows the colorway:
10+ * sheet / canvas / subtle / inset → surface lo → hi, and the sunk well
11+ * fg / muted / dim → ink, ink2, ink3
12+ * line → the engraved rule
13+ * Green is intent (focus, selection, live), never a call to action; red is destructive only.
14+ *
15+ * The colorway follows the system unless <html data-mu-colorway="bone|graphite"> overrides it.
16+ */
317 :root {
4- --background: #ffffff;
5- --foreground: #171717;
6- }
18+ --mu-sans: var(--font-geist), "Geist", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
19+ --mu-mono: var(--font-geist-mono), "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
20+ --mu-pixel: var(--font-doto), "Martian Mono", ui-monospace, monospace;
721
8- @theme inline {
9- --color-background: var(--background);
10- --color-foreground: var(--foreground);
11- --font-sans: var(--font-geist-sans);
12- --font-mono: var(--font-geist-mono);
22+ --page: var(--mu-page);
23+ --sheet: var(--mu-s);
24+ --canvas: var(--mu-s-hi);
25+ --canvas-subtle: var(--mu-s-lo);
26+ --canvas-inset: var(--mu-well-top);
27+ --header: var(--mu-page);
28+ --border: var(--mu-rule);
29+ --border-muted: color-mix(in srgb, var(--mu-rule) 70%, transparent);
30+ --fg: var(--mu-ink);
31+ --fg-muted: var(--mu-ink2);
32+ --fg-dim: var(--mu-ink3);
33+ --accent: var(--mu-ink);
34+ --accent-strong: var(--mu-ink);
35+ --accent-2: var(--mu-cue-url-ink);
36+ --accent-3: var(--mu-ink2);
37+ --success-emphasis: #2B7A52;
38+ --done: var(--mu-tint-iris);
39+ --danger: var(--mu-red);
40+ --attention: #9A6A12;
41+ --diff-add: rgba(63, 185, 122, .10);
42+ --diff-add-num: rgba(63, 185, 122, .18);
43+ --diff-del: rgba(216, 69, 59, .08);
44+ --diff-del-num: rgba(216, 69, 59, .15);
45+ --diff-hunk: rgba(40, 38, 32, .04);
1346 }
1447
48+ [data-mu-colorway="graphite"] {
49+ --page: #19191B;
50+ --success-emphasis: var(--mu-green);
51+ --attention: var(--mu-warning);
52+ --diff-hunk: rgba(255, 255, 255, .04);
53+ }
1554 @media (prefers-color-scheme: dark) {
16- :root {
17- --background: #0a0a0a;
18- --foreground: #ededed;
55+ :root:not([data-mu-colorway="bone"]) {
56+ --page: #19191B;
57+ --success-emphasis: var(--mu-green);
58+ --attention: var(--mu-warning);
59+ --diff-hunk: rgba(255, 255, 255, .04);
60+ }
61+ :root:not([data-mu-colorway="bone"]) .shiki,
62+ :root:not([data-mu-colorway="bone"]) .shiki span {
63+ color: var(--shiki-dark) !important;
64+ background-color: transparent !important;
1965 }
2066 }
67+ [data-mu-colorway="graphite"] .shiki,
68+ [data-mu-colorway="graphite"] .shiki span {
69+ color: var(--shiki-dark) !important;
70+ background-color: transparent !important;
71+ }
72+
73+ /* Tailwind's `dark:` variant follows the colorway. */
74+ @custom-variant dark {
75+ &:where([data-mu-colorway="graphite"], [data-mu-colorway="graphite"] *) {
76+ @slot;
77+ }
78+ @media (prefers-color-scheme: dark) {
79+ &:where(:root:not([data-mu-colorway="bone"]), :root:not([data-mu-colorway="bone"]) *) {
80+ @slot;
81+ }
82+ }
83+ }
84+
85+ /* App aliases. `page`, `green`, `success` and `red` come from MetalUI's theme as they are. */
86+ @theme inline {
87+ --color-sheet: var(--sheet);
88+ --color-canvas: var(--canvas);
89+ --color-subtle: var(--canvas-subtle);
90+ --color-inset: var(--canvas-inset);
91+ --color-header: var(--header);
92+ --color-line: var(--border);
93+ --color-line-muted: var(--border-muted);
94+ --color-fg: var(--fg);
95+ --color-muted: var(--fg-muted);
96+ --color-dim: var(--fg-dim);
97+ --color-accent: var(--accent);
98+ --color-accent-strong: var(--accent-strong);
99+ --color-accent-2: var(--accent-2);
100+ --color-accent-3: var(--accent-3);
101+ --color-success-emphasis: var(--success-emphasis);
102+ --color-done: var(--done);
103+ --color-danger: var(--danger);
104+ --color-attention: var(--attention);
105+ --font-sans: var(--mu-sans);
106+ --font-mono: var(--mu-mono);
107+ --shadow-card: var(--mu-raise-sm);
108+ --shadow-lift: var(--mu-raise-lite);
109+ --shadow-float: var(--mu-raise);
110+ --animate-pop: pop 180ms var(--mu-ease-slide);
111+ --animate-fade-up: fade-up 500ms var(--mu-ease-slide) both;
112+ }
113+
114+ @keyframes pop {
115+ from { opacity: 0; transform: translateY(-6px) scale(0.985); }
116+ to { opacity: 1; transform: none; }
117+ }
118+ @keyframes fade-up {
119+ from { opacity: 0; transform: translateY(6px); }
120+ to { opacity: 1; transform: none; }
121+ }
21122
22123 body {
23- background: var(--background);
24- color: var(--foreground);
25- font-family: Arial, Helvetica, sans-serif;
124+ background: var(--page);
125+ color: var(--mu-ink);
126+ font-family: var(--mu-sans);
127+ }
128+
129+ @layer components {
130+ /* The engraved label role: uppercase mono with a lip, e.g. "PULL REQUEST · #8" */
131+ .mono-label {
132+ font: 400 9.5px/12px var(--mu-mono);
133+ text-transform: uppercase;
134+ letter-spacing: 0.11em;
135+ color: var(--mu-engrave);
136+ text-shadow: var(--mu-lip-shadow);
137+ }
138+ /* A section heading, engraved above its plate: "ABOUT" */
139+ .section-label {
140+ font: 400 9.5px/12px var(--mu-mono);
141+ text-transform: uppercase;
142+ letter-spacing: 0.11em;
143+ color: var(--mu-engrave);
144+ text-shadow: var(--mu-lip-shadow);
145+ }
146+ /* A raised plate in the colorway (Surface material="raise-lite"), for server-rendered blocks. */
147+ .plate {
148+ background: linear-gradient(var(--mu-s-hi), var(--mu-s));
149+ box-shadow: var(--mu-raise-lite);
150+ }
151+ /* A sunk well (the field look): inputs, code, quiet insets. */
152+ .well {
153+ background: linear-gradient(var(--mu-well-top), var(--mu-well-bot));
154+ box-shadow: var(--mu-well);
155+ }
156+ /* An engraved hairline with its lip. */
157+ .rule-b { border-bottom: 1px solid var(--mu-rule); box-shadow: 0 1px 0 var(--mu-rule-lip); }
158+ .rule-t { border-top: 1px solid var(--mu-rule); box-shadow: inset 0 1px 0 var(--mu-rule-lip); }
159+ }
160+
161+ ::selection {
162+ background: color-mix(in srgb, var(--mu-green) 35%, transparent);
163+ }
164+
165+ :focus-visible {
166+ outline: 2px solid var(--mu-focus);
167+ outline-offset: 2px;
168+ }
169+
170+ /* Menus and dialogs rise in on the surface spring */
171+ details[open] > .menu-panel { animation: var(--animate-pop); }
172+ dialog[open] { animation: var(--animate-pop); }
173+ dialog::backdrop { background: var(--mu-scrim); backdrop-filter: blur(2px); }
174+
175+ /* Code viewer: shiki output with line numbers */
176+ .code-view .shiki {
177+ background: transparent !important;
178+ padding: 12px 0;
179+ overflow-x: auto;
180+ font-size: 12.5px;
181+ line-height: 21px;
182+ counter-reset: line;
183+ }
184+ .code-view .shiki code {
185+ display: block;
186+ min-width: max-content;
187+ font-family: var(--mu-mono);
188+ }
189+ .code-view .line {
190+ display: inline-block;
191+ width: 100%;
192+ padding-right: 24px;
193+ }
194+ .code-view .line:hover {
195+ background: var(--mu-menu-row-hover);
196+ }
197+ .code-view .line::before {
198+ counter-increment: line;
199+ content: counter(line);
200+ display: inline-block;
201+ width: 60px;
202+ padding-right: 20px;
203+ margin-right: 4px;
204+ text-align: right;
205+ color: var(--mu-syn-line);
206+ user-select: none;
207+ }
208+ .code-view .line:hover::before { color: var(--mu-ink2); }
209+
210+ /* Rendered markdown (README, issues) */
211+ .markdown {
212+ font-size: 14px;
213+ line-height: 1.7;
214+ letter-spacing: -0.005em;
215+ overflow-wrap: break-word;
216+ }
217+ .markdown > *:first-child { margin-top: 0; }
218+ .markdown > *:last-child { margin-bottom: 0; }
219+ .markdown p, .markdown ul, .markdown ol, .markdown blockquote, .markdown pre, .markdown table {
220+ margin: 0 0 16px;
221+ }
222+ .markdown h1, .markdown h2, .markdown h3, .markdown h4, .markdown h5, .markdown h6 {
223+ margin: 26px 0 10px;
224+ font-weight: 600;
225+ line-height: 1.25;
226+ letter-spacing: -0.02em;
227+ }
228+ .markdown h1 { font-size: 1.6em; }
229+ .markdown h2 { font-size: 1.3em; }
230+ .markdown h3 { font-size: 1.1em; }
231+ .markdown h4 { font-size: 1em; }
232+ .markdown a {
233+ color: inherit;
234+ text-decoration: underline;
235+ text-decoration-thickness: 1px;
236+ text-decoration-color: color-mix(in srgb, currentColor 30%, transparent);
237+ text-underline-offset: 3px;
238+ transition: text-decoration-color .16s;
239+ }
240+ .markdown a:hover { text-decoration-color: currentColor; }
241+ .markdown ul { list-style: disc; padding-left: 1.6em; }
242+ .markdown ol { list-style: decimal; padding-left: 1.6em; }
243+ .markdown li::marker { color: var(--mu-ink3); }
244+ .markdown li + li { margin-top: .3em; }
245+ .markdown li > input[type="checkbox"] { margin-right: .5em; accent-color: var(--mu-green-deep); }
246+ .markdown blockquote {
247+ padding: .6em 1em;
248+ color: var(--mu-ink2);
249+ background: linear-gradient(var(--mu-well-top), var(--mu-well-bot));
250+ box-shadow: var(--mu-well);
251+ border-radius: 12px;
252+ }
253+ .markdown code {
254+ padding: .15em .45em;
255+ font-size: 85%;
256+ background: linear-gradient(var(--mu-well-top), var(--mu-well-bot));
257+ box-shadow: var(--mu-well);
258+ border-radius: 6px;
259+ font-family: var(--mu-mono);
260+ }
261+ .markdown pre {
262+ padding: 14px 16px;
263+ overflow: auto;
264+ font-size: 85%;
265+ line-height: 1.7;
266+ background: linear-gradient(var(--mu-well-top), var(--mu-well-bot));
267+ box-shadow: var(--mu-well);
268+ border-radius: 14px;
269+ }
270+ .markdown pre code { padding: 0; background: transparent; box-shadow: none; font-size: 100%; }
271+ .markdown table {
272+ border-collapse: separate;
273+ border-spacing: 0;
274+ display: block;
275+ overflow: auto;
276+ }
277+ .markdown th, .markdown td { padding: 9px 14px; border-bottom: 1px solid var(--mu-rule); }
278+ .markdown tr:last-child td { border-bottom: 0; }
279+ .markdown th {
280+ font: 400 9.5px/12px var(--mu-mono);
281+ text-transform: uppercase;
282+ letter-spacing: 0.11em;
283+ color: var(--mu-engrave);
284+ text-align: left;
285+ }
286+ .markdown img { max-width: 100%; display: inline; border-radius: 10px; }
287+ .markdown hr { height: 1px; margin: 32px 0; background: var(--mu-rule); border: 0; box-shadow: 0 1px 0 var(--mu-rule-lip); }
288+
289+ /* A quiet light from above, behind marketing-style headers */
290+ .aurora {
291+ background: radial-gradient(40rem 18rem at 50% -4rem, color-mix(in srgb, var(--mu-s-hi) 90%, transparent), transparent 70%);
292+ }
293+ /* Hairline grid that fades out towards the edges, like a cutting mat */
294+ .dot-grid {
295+ background-image:
296+ linear-gradient(to right, color-mix(in srgb, var(--mu-rule) 55%, transparent) 1px, transparent 1px),
297+ linear-gradient(to bottom, color-mix(in srgb, var(--mu-rule) 55%, transparent) 1px, transparent 1px);
298+ background-size: 48px 48px;
299+ mask-image: radial-gradient(ellipse 60% 55% at 50% 0%, black 30%, transparent 75%);
300+ }
301+ .text-gradient {
302+ background: linear-gradient(180deg, var(--mu-ink) 20%, color-mix(in srgb, var(--mu-ink) 50%, transparent) 110%);
303+ -webkit-background-clip: text;
304+ background-clip: text;
305+ color: transparent;
306+ }
307+ /* A raised plate for highlighted cards */
308+ .ring-gradient {
309+ background: linear-gradient(var(--mu-s-hi), var(--mu-s));
310+ box-shadow: var(--mu-raise);
311+ }
312+ /* A glyph sunk in a well, like the empty state's */
313+ .icon-tile {
314+ display: flex;
315+ align-items: center;
316+ justify-content: center;
317+ background: linear-gradient(var(--mu-well-top), var(--mu-well-bot));
318+ box-shadow: var(--mu-well);
319+ color: var(--mu-ink3);
320+ }
321+
322+ /* CodeMirror editor sizing */
323+ .cm-theme, .cm-editor { height: 100%; }
324+ .cm-editor { font-size: 12.5px; }
325+ .cm-editor .cm-scroller { font-family: var(--mu-mono); line-height: 21px; }
326+ .cm-editor.cm-focused { outline: none !important; }
327+
328+ @media (prefers-reduced-motion: reduce) {
329+ *, *::before, *::after { animation: none !important; transition: none !important; }
26330 }
+4−0app/icon.svgaddedView File
@@ -0,0 +1,4 @@
1+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <style>path { fill: #171717 } @media (prefers-color-scheme: dark) { path { fill: #dcdcdc } }</style>
3+ <path fill-rule="evenodd" d="M2 13a8.5 8.5 0 1 0 17 0a8.5 8.5 0 1 0-17 0ZM11.54 7.69Q10 4 14 4H26Q30 4 28.46 7.69L21.54 24.31Q20 28 18.46 24.31Z"/>
4+ </svg>
+80−8app/layout.tsxView File

Large diffs are not rendered by default.

+38−0app/login/page.tsxaddedView File

Large diffs are not rendered by default.

+83−0app/new/form.tsxaddedView File

Large diffs are not rendered by default.

+24−0app/new/page.tsxaddedView File

Large diffs are not rendered by default.

+24−0app/not-found.tsxaddedView File

Large diffs are not rendered by default.

+243−61app/page.tsxView File

Large diffs are not rendered by default.

+43−0app/settings/keys/form.tsxaddedView File

Large diffs are not rendered by default.

+113−0app/settings/keys/page.tsxaddedView File

Large diffs are not rendered by default.

+30−0app/settings/layout.tsxaddedView File

Large diffs are not rendered by default.

+5−0app/settings/page.tsxaddedView File

Large diffs are not rendered by default.

+77−0app/settings/profile/forms.tsxaddedView File

Large diffs are not rendered by default.

+25−0app/settings/profile/page.tsxaddedView File

Large diffs are not rendered by default.

+126−0app/settings/tokens/form.tsxaddedView File

Large diffs are not rendered by default.

+115−0app/settings/tokens/page.tsxaddedView File

Large diffs are not rendered by default.

+54−0app/signup/page.tsxaddedView File

Large diffs are not rendered by default.

+427−4bun.lockView File

Large diffs are not rendered by default.

+59−0components/auth-forms.tsxaddedView File

Large diffs are not rendered by default.

+46−0components/branch-select.tsxaddedView File

Large diffs are not rendered by default.

+27−0components/brand.tsxaddedView File

Large diffs are not rendered by default.

+34−0components/breadcrumbs.tsxaddedView File

Large diffs are not rendered by default.

+111−0components/clone-button.tsxaddedView File

Large diffs are not rendered by default.

+122−0components/code-view.tsxaddedView File

Large diffs are not rendered by default.

+129−0components/commit-dialog.tsxaddedView File

Large diffs are not rendered by default.

+80−0components/confirm-button.tsxaddedView File

Large diffs are not rendered by default.

+65−0components/delete-file-form.tsxaddedView File

Large diffs are not rendered by default.

+37−0components/doc-tabs.tsxaddedView File

Large diffs are not rendered by default.

+67−0components/dropdown.tsxaddedView File

Large diffs are not rendered by default.

+49−0components/editor-file-actions.tsxaddedView File

Large diffs are not rendered by default.

+57−0components/editor-theme.tsaddedView File

Large diffs are not rendered by default.

+199−0components/file-editor.tsxaddedView File

Large diffs are not rendered by default.

+133−0components/file-table.tsxaddedView File

Large diffs are not rendered by default.

+62−0components/header.tsxaddedView File

Large diffs are not rendered by default.

+65−0components/home-status.tsxaddedView File

Large diffs are not rendered by default.

+55−0components/home-terminal.tsxaddedView File

Large diffs are not rendered by default.

+269−0components/icons.tsxaddedView File

Large diffs are not rendered by default.

+172−0components/issues/issue-composer.tsxaddedView File

Large diffs are not rendered by default.

+217−0components/issues/issue-detail.tsxaddedView File

Large diffs are not rendered by default.

+145−0components/issues/issue-icons.tsxaddedView File

Large diffs are not rendered by default.

+806−0components/issues/issue-tracker.tsxaddedView File

Large diffs are not rendered by default.

+256−0components/issues/picker.tsxaddedView File

Large diffs are not rendered by default.

+227−0components/issues/properties.tsxaddedView File

Large diffs are not rendered by default.

+70−0components/issues/tracker-context.tsxaddedView File

Large diffs are not rendered by default.

+67−0components/issues/view.tsaddedView File

Large diffs are not rendered by default.

+21−0components/markdown-preview.tsxaddedView File

Large diffs are not rendered by default.

+39−0components/markdown.tsxaddedView File

Large diffs are not rendered by default.

+91−0components/metal.tsxaddedView File

Large diffs are not rendered by default.

+28−0components/nav-links.tsxaddedView File

Large diffs are not rendered by default.

+13−0components/providers.tsxaddedView File

Large diffs are not rendered by default.

+32−0components/repo-add-file-menu.tsxaddedView File

Large diffs are not rendered by default.

+50−0components/repo-filter-list.tsxaddedView File

Large diffs are not rendered by default.

+46−0components/repo-list.tsxaddedView File

Large diffs are not rendered by default.

+16−0components/repo-rows.tsaddedView File

Large diffs are not rendered by default.

+54−0components/repo-tabs.tsxaddedView File

Large diffs are not rendered by default.

+93−0components/search-box.tsxaddedView File

Large diffs are not rendered by default.

+147−0components/settings-fields.tsxaddedView File

Large diffs are not rendered by default.

+24−0components/settings-nav.tsxaddedView File

Large diffs are not rendered by default.

+63−0components/settings-plate.tsxaddedView File

Large diffs are not rendered by default.

+145−0components/sidebar-nav.tsxaddedView File

Large diffs are not rendered by default.

+8−0components/sliding-indicator.tsaddedView File

Large diffs are not rendered by default.

+58−0components/theme-toggle.tsxaddedView File

Large diffs are not rendered by default.

+71−0components/tree-view.tsxaddedView File

Large diffs are not rendered by default.

+328−0components/ui.tsxaddedView File

Large diffs are not rendered by default.

+138−0components/upload-form.tsxaddedView File

Large diffs are not rendered by default.

+8−0instrumentation.tsaddedView File

Large diffs are not rendered by default.

+62−0lib/auth.tsaddedView File

Large diffs are not rendered by default.

+179−0lib/db.tsaddedView File

Large diffs are not rendered by default.

+37−0lib/format.tsaddedView File

Large diffs are not rendered by default.

+134−0lib/git-http.tsaddedView File

Large diffs are not rendered by default.

+96−0lib/git-write.tsaddedView File

Large diffs are not rendered by default.

+383−0lib/git.tsaddedView File

Large diffs are not rendered by default.

+127−0lib/issue-meta.tsaddedView File

Large diffs are not rendered by default.

+300−0lib/issues.tsaddedView File

Large diffs are not rendered by default.

+96−0lib/keys.tsaddedView File

Large diffs are not rendered by default.

+91−0lib/languages.tsaddedView File

Large diffs are not rendered by default.

+29−0lib/listing.tsaddedView File

Large diffs are not rendered by default.

+19−0lib/mime.tsaddedView File

Large diffs are not rendered by default.

+4−0lib/paths.tsaddedView File

Large diffs are not rendered by default.

+71−0lib/repo.tsaddedView File

Large diffs are not rendered by default.

+241−0lib/repos.tsaddedView File

Large diffs are not rendered by default.

+19−0lib/ssh-config.tsaddedView File

Large diffs are not rendered by default.

+116−0lib/ssh-server.tsaddedView File

Large diffs are not rendered by default.

+6−0lib/theme.tsaddedView File

Large diffs are not rendered by default.

+123−0lib/tokens.tsaddedView File

Large diffs are not rendered by default.

+17−0lib/tracker.tsaddedView File

Large diffs are not rendered by default.

+122−0lib/users.tsaddedView File

Large diffs are not rendered by default.

+9−1next.config.tsView File

Large diffs are not rendered by default.

+18−3package.jsonView File

Large diffs are not rendered by default.