Vectrum
Sign In

Add an Android app

Vectrum for Android in mobile/, in Kotlin with Jetpack Compose and the website's MetalUI look:
home feed, explore, repositories and code, issues, pull requests (review and merge), commits,
workflow runs and releases. It talks to a new JSON API under /api/v1 (lib/app-api.ts) with the
website's session token as a bearer token. See docs/mobile.md.

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

Showing 74 changed files with 9696 additions and 4 deletions.

+1−0.dockerignoreView File
@@ -9,3 +9,4 @@ next-env.d.ts
99 Dockerfile
1010 docker-compose.yml
1111 .dockerignore
12+ mobile
+2−1README.mdView File
@@ -8,7 +8,7 @@ Everything they do is recorded as theirs and can be reverted in one click. Peopl
88 decide what reaches protected branches.
99
1010 Vectrum comes with issues, pull requests, forks, wikis, releases, a container
11- registry, and CI on self-hosted runners. See [all features](docs/features.md).
11+ registry, CI on self-hosted runners, and an Android app. See [all features](docs/features.md).
1212
1313 Website: [vectrum.cc](https://vectrum.cc)
1414
@@ -35,6 +35,7 @@ docker compose up -d --build
3535 - [Using Vectrum](docs/usage.md): git, container images, wikis, releases, forks
3636 - [Actions](docs/actions.md): workflows and runners
3737 - [Agents](docs/agents.md): MCP, the command line, events and webhooks
38+ - [Android app](docs/mobile.md): building it, and the API it uses
3839 - [Security](docs/security.md)
3940 - [Code layout](docs/layout.md)
4041
+108−0app/api/v1/[...path]/route.tsaddedView File
@@ -0,0 +1,108 @@
1+ import * as api from "@/lib/app-api";
2+ import { ApiError, apiViewer, errorResponse, json } from "@/lib/app-api";
3+
4+ // The mobile app's JSON API (lib/app-api.ts). Paths:
5+ // GET /api/v1/server { name, api, signups }: what the app's setup checks first
6+ // POST /api/v1/session sign in: { login, password } → { token, user }
7+ // POST /api/v1/signup { username, email, password, name } → { token, user }
8+ // GET /api/v1/user the signed-in person
9+ // GET /api/v1/home, /explore?q=, /users/<name>
10+ // GET /api/v1/repos/<owner>/<repo> and …/tree, /blob, /raw, /image, /commits, /commits/<sha>,
11+ // /branches, /events, /issues, /pulls, /runs, /releases
12+ // GET, PATCH …/issues/<n>; POST …/issues, …/issues/<n>/comments
13+ // GET, PATCH …/pulls/<n>; GET …/pulls/<n>/files; POST …/pulls/<n>/comments, /reviews, /merge
14+ // GET …/runs/<n>
15+
16+ type Ctx = RouteContext<"/api/v1/[...path]">;
17+
18+ const number = (value: string | undefined) => {
19+ if (!value || !/^[1-9]\d{0,8}$/.test(value)) throw new ApiError(404, "Not found.");
20+ return Number(value);
21+ };
22+
23+ async function handle(request: Request, ctx: Ctx) {
24+ try {
25+ const { path } = await ctx.params;
26+ const method = request.method;
27+ const params = new URL(request.url).searchParams;
28+ const [first, ...rest] = path;
29+
30+ if (rest.length === 0 && method === "GET" && first === "server") return json(api.serverInfo());
31+ if (rest.length === 0 && method === "POST" && first === "session") return json(await api.signIn(request));
32+ if (rest.length === 0 && method === "POST" && first === "signup") return json(await api.signUp(request), 201);
33+
34+ const viewer = await apiViewer(request);
35+ if (method === "GET" && rest.length === 0) {
36+ if (first === "user") {
37+ if (!viewer) throw new ApiError(401, "Sign in first.");
38+ return json({ user: viewer });
39+ }
40+ if (first === "home") return json(await api.home(viewer));
41+ if (first === "explore") return json(await api.explore(viewer, params.get("q") ?? ""));
42+ }
43+ if (first === "users" && rest.length === 1 && method === "GET") return json(await api.profile(viewer, rest[0]));
44+
45+ if (first === "repos" && rest.length >= 2) {
46+ const [owner, name, section, id, sub, ...more] = rest;
47+ if (more.length) throw new ApiError(404, "Not found.");
48+ const n = () => number(id);
49+ switch (`${method} ${section ?? ""}${id === undefined ? "" : " :id"}${sub === undefined ? "" : ` ${sub}`}`) {
50+ case "GET ":
51+ return json(await api.repository(viewer, owner, name));
52+ case "GET image":
53+ return await api.repoImage(viewer, owner, name);
54+ case "GET tree":
55+ return json(await api.tree(viewer, owner, name, params));
56+ case "GET blob":
57+ return json(await api.blob(viewer, owner, name, params));
58+ case "GET raw":
59+ return await api.raw(viewer, owner, name, params);
60+ case "GET commits":
61+ return json(await api.commits(viewer, owner, name, params));
62+ case "GET commits :id":
63+ return json(await api.commit(viewer, owner, name, id));
64+ case "GET branches":
65+ return json(await api.branches(viewer, owner, name));
66+ case "GET events":
67+ return json(await api.repoEvents(viewer, owner, name));
68+ case "GET issues":
69+ return json(await api.issues(viewer, owner, name));
70+ case "POST issues":
71+ return json(await api.openIssue(request, viewer, owner, name), 201);
72+ case "GET issues :id":
73+ return json(await api.issue(viewer, owner, name, n()));
74+ case "PATCH issues :id":
75+ return json(await api.changeIssue(request, viewer, owner, name, n()));
76+ case "POST issues :id comments":
77+ return json(await api.commentOnIssue(request, viewer, owner, name, n()));
78+ case "GET pulls":
79+ return json(await api.pulls(viewer, owner, name, params));
80+ case "GET pulls :id":
81+ return json(await api.pull(viewer, owner, name, n()));
82+ case "PATCH pulls :id":
83+ return json(await api.changePull(request, viewer, owner, name, n()));
84+ case "GET pulls :id files":
85+ return json(await api.pullFiles(viewer, owner, name, n()));
86+ case "POST pulls :id comments":
87+ return json(await api.commentOnPull(request, viewer, owner, name, n()));
88+ case "POST pulls :id reviews":
89+ return json(await api.reviewPull(request, viewer, owner, name, n()));
90+ case "POST pulls :id merge":
91+ return json(await api.mergePullRequest(request, viewer, owner, name, n()));
92+ case "GET runs":
93+ return json(await api.runs(viewer, owner, name, params));
94+ case "GET runs :id":
95+ return json(await api.run(viewer, owner, name, n()));
96+ case "GET releases":
97+ return json(await api.releases(viewer, owner, name));
98+ }
99+ }
100+ throw new ApiError(404, "Not found.");
101+ } catch (e) {
102+ return errorResponse(e);
103+ }
104+ }
105+
106+ export const GET = handle;
107+ export const POST = handle;
108+ export const PATCH = handle;
+1−0docs/features.mdView File
@@ -30,4 +30,5 @@
3030 - **Command line**: `vectrum`, one Node.js file served at `/vectrum.mjs`, works like GitHub's `gh` for people and agents in a shell, signs in with OAuth, and is a git credential helper so pushes count as the agent's
3131 - **Pages for agents**: every page is also Markdown (`Accept: text/markdown` or `?format=md`), and `/llms.txt` and `/<owner>/<repo>/llms.txt` describe the server and a repository (with its `AGENTS.md`/`CLAUDE.md`)
3232 - **Code search** in a repository's files, for people (the *Search code* box) and agents (`search_code`)
33+ - **Android app**: repositories, code, issues, pull requests (review and merge), commits, workflow runs and releases on your phone, in the website's look. See [mobile.md](mobile.md)
3334 - Light and dark mode
+1−0docs/layout.mdView File
@@ -18,3 +18,4 @@
1818 - `lib/releases.ts`, `lib/release-http.ts`: releases and their files, and the download, upload and source archive routes
1919 - `lib/workflows.ts`, `lib/ci.ts`, `lib/runs.ts`, `lib/runners.ts`: workflow parsing, starting runs on push, the job queue and runner registration; `app/api/runner/*` is the runner API and `public/vectrum-runner.mjs` the runner
2020 - `app/actions/*`: server actions, each re-checking the session and permissions
21+ - `lib/app-api.ts`: the JSON API of the Android app (`app/api/v1`); `mobile/`: the app itself (see [mobile.md](mobile.md))
+88−0docs/mobile.mdaddedView File
@@ -0,0 +1,88 @@
1+ # Android app
2+
3+ `mobile/` is Vectrum for Android, in Kotlin with Jetpack Compose. It looks like the website: MetalUI's
4+ bone and graphite colorways, raised plates and sunk wells, Geist and Geist Mono, engraved mono labels.
5+
6+ What it does:
7+
8+ - **Home**: a greeting, counts of your repositories, open issues and pull requests, your repositories, and the
9+ event feed of everything that happened in them (pushes, issues, reviews, workflow runs)
10+ - **Explore**: search the repositories you can see
11+ - **Repositories**: README, files and folders on any branch or tag, code with syntax highlighting and line
12+ numbers (or wrapped), images, Markdown previews, clone addresses
13+ - **Issues**: grouped by status like the website's list, with Active / Backlog / All / Closed scopes; open one,
14+ comment, and (with push access) change its status, priority, assignee and labels
15+ - **Pull requests**: conversation, reviews and line comment threads, commits, the diff of every file, checks
16+ and merge requirements; comment, approve or request changes, merge (merge commit or squash), close and reopen
17+ - **Commits**: history by day with CI status, and each commit's diff
18+ - **Actions**: workflow runs, their jobs and steps, and each step's log, live while it runs
19+ - **Releases**: notes and files to download
20+ - Light, dark or following the system, like the website's theme switch
21+
22+ ## Building
23+
24+ Needs JDK 17+ and the Android SDK (platform 37, build tools 36). With `ANDROID_HOME` set (or `sdk.dir` in
25+ `mobile/local.properties`):
26+
27+ ```bash
28+ cd mobile
29+ ./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
30+ ./gradlew installDebug # onto a connected device or emulator
31+ ./gradlew assembleRelease # minified, signed with the debug key; sign with your own key to publish
32+ ```
33+
34+ The app runs on Android 8.0 (API 26) and newer.
35+
36+ ## Setting up
37+
38+ The first time it opens, the app walks through three steps:
39+
40+ 1. **Welcome**: what Vectrum is and what the app does.
41+ 2. **Server**: *Vectrum Cloud* (vectrum.cc) or *Self-hosted*, with the server's address
42+ (`git.example.com`, `192.168.1.10:3000`). Plain HTTP is fine; without `http://` or `https://`, HTTPS is
43+ tried first, then HTTP. The app checks that the server is a Vectrum new enough for the app before going on.
44+ 3. **Account**: sign in with a username or email and password, or create an account when the server takes
45+ sign-ups (not with `SIGNUPS_DISABLED`).
46+
47+ After signing out, the app opens on the account step for the same server, with *Change* to pick another.
48+ From the Android emulator, the computer running `bun dev` is `http://10.0.2.2:3000`.
49+
50+ The app gets the same 30-day session token the website keeps in its cookie, and stores it in the app's private
51+ storage; *Sign Out* forgets it. Changing your password ends the session, as on the website. Plain `http://` is
52+ allowed for self-hosted servers on a local network; the sign-in screen warns before a password goes to a
53+ non-local server without HTTPS.
54+
55+ ## The API
56+
57+ The app talks to `/api/v1` (`app/api/v1/[...path]/route.ts`, `lib/app-api.ts`): JSON, with the session token
58+ as `Authorization: Bearer <token>`. It acts as the signed-in person and applies the website's permission
59+ checks, so the app can't do anything the website wouldn't let that person do: private repositories are "not
60+ found" to others, anyone who can read a repository may open issues and comment, and changing issues,
61+ approving, requesting changes and merging need push access and the base branch's rules.
62+
63+ | Request | |
64+ | --- | --- |
65+ | `GET /api/v1/server` | `{ name, api, signups }`: what the setup checks before signing in |
66+ | `POST /api/v1/session` | `{ login, password }` → `{ token, user }` |
67+ | `POST /api/v1/signup` | `{ username, email, password, name }` → `{ token, user }`, when sign-ups are open |
68+ | `GET /api/v1/user`, `/home`, `/explore?q=`, `/users/<name>` | |
69+ | `GET /api/v1/repos/<owner>/<repo>` | the repository, its README, root files, branches and counts |
70+ | `GET …/tree?ref=&path=`, `…/blob?ref=&path=`, `…/raw?ref=&path=`, `…/image` | |
71+ | `GET …/commits?ref=&page=`, `…/commits/<sha>`, `…/branches`, `…/events` | |
72+ | `GET …/issues`, `GET, PATCH …/issues/<n>`, `POST …/issues`, `POST …/issues/<n>/comments` | |
73+ | `GET …/pulls?state=`, `GET, PATCH …/pulls/<n>`, `GET …/pulls/<n>/files` | |
74+ | `POST …/pulls/<n>/comments`, `…/pulls/<n>/reviews`, `…/pulls/<n>/merge` | |
75+ | `GET …/runs?page=`, `…/runs/<n>`, `…/releases` | |
76+
77+ Errors are `{ "error": "…" }` with a 4xx status; 401 means the session ended.
78+
79+ ## Code layout
80+
81+ - `data/`: the API's models (`Models.kt`), the HTTP client (`Api.kt`) and the stored session (`Session.kt`)
82+ - `ui/theme/`: MetalUI's colorways (`Palette.kt`), type roles (`Type.kt`) and materials (`Materials.kt`:
83+ plates, wells and caps as stacks of shadows, from MetalUI's tokens)
84+ - `ui/icons/`: the website's glyphs as vectors
85+ - `ui/components/`: buttons, fields, the segmented control, pills and LEDs, avatars and identicons, Markdown,
86+ the code highlighter and viewer, diffs, sheets and toasts
87+ - `ui/screens/`: one file per area; `ui/App.kt` holds the navigation and the tab bar
88+ - `ui/Loader.kt`: screen data kept in view models, with pull to refresh and paging
+770−0lib/app-api.tsaddedView File
@@ -0,0 +1,770 @@
1+ import "server-only";
2+ import { person, type Actor } from "./actor";
3+ import { userForSession } from "./auth";
4+ import { describeEvent } from "./event-text";
5+ import { listEvents as listRepoEvents, type RepoEvent } from "./events";
6+ import { commitCount, defaultBranch, diffCommit, getCommit, GitError, listBranches, listTags, listTree, log, normalizeRepoName, objectType, readBlob, revParse, type Commit, type FileDiff } from "./git";
7+ import { cleanRepoPath } from "./git-write";
8+ import type { IssuePatch } from "./issue-meta";
9+ import {
10+ addComment,
11+ createIssue,
12+ getIssue,
13+ IssueError,
14+ LabelError,
15+ listComments,
16+ listEvents as listIssueEvents,
17+ listIssues,
18+ listLabels,
19+ listMembers,
20+ openIssueCount,
21+ updateIssue,
22+ validateIssuePatch,
23+ } from "./issues";
24+ import { recentActivity, withActivity } from "./listing";
25+ import { IMAGE_TYPES } from "./mime";
26+ import { ProtectionError } from "./protection";
27+ import {
28+ addPullComment,
29+ countOpenPulls,
30+ getPull,
31+ headLabel,
32+ listPullComments,
33+ listPullEvents,
34+ listPulls,
35+ listReviews,
36+ mergePull,
37+ pullCommits,
38+ pullDiff,
39+ PullError,
40+ pullStatus,
41+ submitReview,
42+ threadsOf,
43+ updatePull,
44+ type PullRequest,
45+ type PullState,
46+ type Review,
47+ } from "./pulls";
48+ import { countReleases, listReleases, releaseTitle } from "./releases";
49+ import { cloneUrls } from "./repo";
50+ import { canWrite, findRepo, getRepoImage, listReposByOwner, listReposForUser, repoAccess, searchRepos, visibleParent, type Access, type RepoRecord } from "./repos";
51+ import { serverOrigin } from "./request-origin";
52+ import { commitStatuses, getRun, listJobs, listRuns, listSteps, readJobLogs, type Run } from "./runs";
53+ import { SIGNUPS_ENABLED } from "./signups";
54+ import { authenticateUser, createUser, getUserByUsername, UserError, type User } from "./users";
55+
56+ /*
57+ * The JSON API of the Vectrum mobile app (mobile/), under /api/v1. The app signs in with a username
58+ * and password and gets the same session token the web UI keeps in its cookie; it sends it as a
59+ * bearer token. Everything here acts as that person, with the web UI's permission checks (see
60+ * app/actions/*), so the app can do nothing the website wouldn't let them do.
61+ */
62+
63+ export class ApiError extends Error {
64+ constructor(
65+ readonly status: number,
66+ message: string,
67+ ) {
68+ super(message);
69+ }
70+ }
71+
72+ const isUserError = (e: unknown) =>
73+ e instanceof IssueError || e instanceof LabelError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError;
74+
75+ /** Turns what a handler threw into a JSON answer: user errors become 4xx, anything else is rethrown. */
76+ export function errorResponse(e: unknown) {
77+ if (e instanceof ApiError) return json({ error: e.message }, e.status);
78+ if (isUserError(e)) return json({ error: (e as Error).message }, 422);
79+ throw e;
80+ }
81+
82+ export const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
83+
84+ // ---------------------------------------------------------------------------
85+ // Who is asking
86+
87+ /** The signed-in person, null without credentials. A bearer token that isn't a valid session is a 401. */
88+ export async function apiViewer(request: Request): Promise<User | null> {
89+ const header = request.headers.get("authorization") ?? "";
90+ if (!/^Bearer\s+/i.test(header)) return null;
91+ const user = await userForSession(header.replace(/^Bearer\s+/i, "").trim());
92+ if (!user) throw new ApiError(401, "Your session has expired. Sign in again.");
93+ return user;
94+ }
95+
96+ function requireUser(viewer: User | null) {
97+ if (!viewer) throw new ApiError(401, "Sign in to do that.");
98+ return viewer;
99+ }
100+
101+ const MAX_BODY = 65_536;
102+
103+ /** Largest request body read: issue and comment bodies are capped at 64 KB, so this leaves room for the rest. */
104+ const MAX_REQUEST_BYTES = 256 * 1024;
105+
106+ async function readJson(request: Request): Promise<Record<string, unknown>> {
107+ if (Number(request.headers.get("content-length") ?? 0) > MAX_REQUEST_BYTES) throw new ApiError(413, "That request is too large.");
108+ // Read with a cap too: the length header is optional.
109+ const reader = request.body?.getReader();
110+ const chunks: Uint8Array[] = [];
111+ let size = 0;
112+ for (;;) {
113+ const part = reader ? await reader.read() : { done: true as const, value: undefined };
114+ if (part.done) break;
115+ size += part.value.byteLength;
116+ if (size > MAX_REQUEST_BYTES) {
117+ await reader?.cancel();
118+ throw new ApiError(413, "That request is too large.");
119+ }
120+ chunks.push(part.value);
121+ }
122+ const raw = Buffer.concat(chunks).toString("utf8");
123+ const body = (() => {
124+ try {
125+ return JSON.parse(raw) as unknown;
126+ } catch {
127+ return null;
128+ }
129+ })();
130+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new ApiError(400, "Expected a JSON object.");
131+ return body as Record<string, unknown>;
132+ }
133+
134+ const text = (v: unknown, max = MAX_BODY) => (typeof v === "string" ? v.slice(0, max) : "");
135+
136+ /** What the app's setup shows about a server before signing in: that it's a Vectrum, and whether it takes sign-ups. */
137+ export function serverInfo() {
138+ return { name: "Vectrum", api: 1, signups: SIGNUPS_ENABLED };
139+ }
140+
141+ /** Creates an account, as the website's sign-up form does, and signs it in. */
142+ export async function signUp(request: Request) {
143+ if (!SIGNUPS_ENABLED) throw new ApiError(403, "Sign-ups are closed on this server. Ask the person who runs it for an account.");
144+ const body = await readJson(request);
145+ const password = text(body.password, 200);
146+ try {
147+ const user = await createUser({ username: text(body.username, 100), email: text(body.email, 200), password, name: text(body.name, 100) });
148+ const auth = await authenticateUser(user.email, password);
149+ if (!auth) throw new ApiError(500, "The account was created, but signing in failed. Try signing in.");
150+ return { token: auth.token, user: userJson(auth.user) };
151+ } catch (e) {
152+ if (e instanceof UserError) throw new ApiError(422, e.message);
153+ throw e;
154+ }
155+ }
156+
157+ export async function signIn(request: Request) {
158+ const body = await readJson(request);
159+ const auth = await authenticateUser(text(body.login, 200), text(body.password, 200));
160+ if (!auth) throw new ApiError(401, "Incorrect username or password.");
161+ return { token: auth.token, user: userJson(auth.user) };
162+ }
163+
164+ // ---------------------------------------------------------------------------
165+ // Shapes
166+
167+ const userJson = (u: User) => ({ id: u.id, username: u.username, name: u.name, email: u.email, bio: u.bio, createdAt: u.createdAt });
168+
169+ type Listed = RepoRecord & { updatedAt?: number; empty?: boolean; parent?: RepoRecord["forkOf"] };
170+
171+ function repoJson(r: Listed) {
172+ return {
173+ id: r.id,
174+ owner: r.owner,
175+ name: r.name,
176+ description: r.description,
177+ visibility: r.visibility,
178+ // The repository a fork came from, when the viewer may see it.
179+ forkOf: r.parent ? { owner: r.parent.owner, name: r.parent.name } : null,
180+ isFork: !!r.forkOf,
181+ hasImage: r.imageVersion !== null,
182+ imageVersion: r.imageVersion,
183+ createdAt: r.createdAt,
184+ updatedAt: r.updatedAt ?? r.pushedAt ?? r.createdAt,
185+ empty: r.empty ?? false,
186+ };
187+ }
188+
189+ const commitJson = (c: Commit) => ({
190+ sha: c.sha,
191+ parents: c.parents,
192+ authorName: c.authorName,
193+ authorEmail: c.authorEmail,
194+ committerName: c.committerName,
195+ date: c.date,
196+ subject: c.subject,
197+ body: c.body,
198+ // Commits an agent made for its person name the agent as committer.
199+ agent: /^agent\+/.test(c.committerEmail) ? c.committerName : null,
200+ });
201+
202+ /** Longest diff the app gets: enough to read on a phone, without sending megabytes. */
203+ const MAX_DIFF_LINES = 8000;
204+ const MAX_FILE_LINES = 1500;
205+
206+ function diffJson(files: FileDiff[]) {
207+ let budget = MAX_DIFF_LINES;
208+ return files.map((f) => {
209+ const keep = Math.max(0, Math.min(f.lines.length, MAX_FILE_LINES, budget));
210+ budget -= keep;
211+ return {
212+ oldPath: f.oldPath,
213+ newPath: f.newPath,
214+ status: f.status,
215+ binary: f.binary,
216+ additions: f.additions,
217+ deletions: f.deletions,
218+ truncated: keep < f.lines.length,
219+ lines: f.lines.slice(0, keep).map((l) => ({ kind: l.kind, text: l.text, oldNo: l.oldNo ?? null, newNo: l.newNo ?? null })),
220+ };
221+ });
222+ }
223+
224+ const pullJson = (p: PullRequest) => ({
225+ number: p.number,
226+ title: p.title,
227+ body: p.body,
228+ author: p.author,
229+ agent: p.agent?.name ?? null,
230+ head: p.head,
231+ headLabel: headLabel(p),
232+ base: p.base,
233+ headSha: p.headSha,
234+ state: p.state,
235+ draft: p.draft,
236+ mergeSha: p.mergeSha,
237+ mergeMethod: p.mergeMethod,
238+ mergedBy: p.mergedBy,
239+ linkedIssues: p.linkedIssues,
240+ commentCount: p.commentCount,
241+ createdAt: p.createdAt,
242+ updatedAt: p.updatedAt,
243+ closedAt: p.closedAt,
244+ mergedAt: p.mergedAt,
245+ });
246+
247+ const runJson = (r: Run) => ({
248+ number: r.number,
249+ workflowName: r.workflowName,
250+ workflowPath: r.workflowPath,
251+ event: r.event,
252+ ref: r.ref.replace(/^refs\/(heads|tags)\//, ""),
253+ sha: r.sha,
254+ title: r.title,
255+ actor: r.actor,
256+ agent: r.agentName,
257+ status: r.status,
258+ error: r.error,
259+ createdAt: r.createdAt,
260+ startedAt: r.startedAt,
261+ finishedAt: r.finishedAt,
262+ });
263+
264+ function eventJson(e: RepoEvent, repo: RepoRecord | undefined) {
265+ const number = /^#(\d+)$/.exec(e.subject)?.[1];
266+ const p = e.payload as Record<string, unknown>;
267+ const isPull = !!p.pull_request || e.kind.startsWith("pull_request");
268+ const run = (p.workflow_run ?? {}) as Record<string, unknown>;
269+ return {
270+ id: e.id,
271+ kind: e.kind,
272+ action: e.action,
273+ actor: e.actor,
274+ agent: e.agent?.name ?? null,
275+ text: describeEvent(e),
276+ createdAt: e.createdAt,
277+ repo: repo ? { owner: repo.owner, name: repo.name } : null,
278+ // What the event is about, for the app to open it.
279+ target: number
280+ ? { type: isPull ? "pull" : "issue", number: Number(number) }
281+ : e.kind === "push" && typeof p.after === "string" && p.after && !p.deleted
282+ ? { type: "commit", sha: p.after }
283+ : e.kind === "workflow_run" && typeof run.number === "number"
284+ ? { type: "run", number: run.number }
285+ : null,
286+ };
287+ }
288+
289+ // ---------------------------------------------------------------------------
290+ // Home, explore, people
291+
292+ export async function home(viewer: User | null) {
293+ const user = requireUser(viewer);
294+ const mine = await withActivity(await listReposForUser(user.id), user.id);
295+ const byId = new Map(mine.map((r) => [r.id, r]));
296+ const [events, activity, discover] = await Promise.all([
297+ listRepoEvents({ repoIds: mine.map((r) => r.id), limit: 40 }),
298+ recentActivity(mine, 10),
299+ searchRepos("", user.id, 30).then((rs) => withActivity(rs.filter((r) => r.ownerId !== user.id && r.visibility === "public").slice(0, 6), user.id)),
300+ ]);
301+ const counts = await Promise.all(mine.slice(0, 50).map(async (r) => ({ issues: await openIssueCount(r.id), pulls: await countOpenPulls(r.id) })));
302+ return {
303+ user: userJson(user),
304+ repos: mine.map(repoJson),
305+ openIssues: counts.reduce((n, c) => n + c.issues, 0),
306+ openPulls: counts.reduce((n, c) => n + c.pulls, 0),
307+ events: events.reverse().map((e) => eventJson(e, byId.get(e.repoId))),
308+ commits: activity.map(({ repo, commit }) => ({ repo: { owner: repo.owner, name: repo.name }, commit: commitJson(commit) })),
309+ discover: discover.map(repoJson),
310+ };
311+ }
312+
313+ export async function explore(viewer: User | null, query: string) {
314+ const repos = await withActivity(await searchRepos(query.slice(0, 100), viewer?.id ?? null, 50), viewer?.id);
315+ return { repos: repos.map(repoJson) };
316+ }
317+
318+ export async function profile(viewer: User | null, username: string) {
319+ const user = await getUserByUsername(username);
320+ if (!user) throw new ApiError(404, "There's no such user.");
321+ const repos = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id);
322+ return { user: { ...userJson(user), email: viewer?.id === user.id ? user.email : "" }, repos: repos.map(repoJson) };
323+ }
324+
325+ // ---------------------------------------------------------------------------
326+ // Repositories
327+
328+ type Opened = { repo: RepoRecord; access: Exclude<Access, null> };
329+
330+ async function openRepo(viewer: User | null, owner: string, name: string, need: "read" | "write" = "read"): Promise<Opened> {
331+ const repo = await findRepo(owner, normalizeRepoName(name));
332+ const access = repo ? await repoAccess(repo, viewer?.id) : null;
333+ // Repositories the viewer can't see are "not found", so private ones don't reveal they exist.
334+ if (!repo || !access) throw new ApiError(404, "There's no such repository, or you can't see it.");
335+ if (need === "write" && !canWrite(access)) throw new ApiError(403, "You don't have push access to this repository.");
336+ return { repo, access };
337+ }
338+
339+ /** A branch, tag or commit the app named, or the default branch. */
340+ async function resolveRef(repo: RepoRecord, ref: string | null) {
341+ const name = ref || (await defaultBranch(repo.dir));
342+ if (!name) return null;
343+ // Like the website: branch and tag names, commit SHAs and HEAD, not git's revision syntax (main^{/text}, :/text, @{1}).
344+ if (name.length > 255 || name.startsWith("-") || /[\s~^:?*[\\]|\.\.|@\{/.test(name)) throw new ApiError(400, "That isn't a valid branch, tag or commit.");
345+ const sha =
346+ name === "HEAD"
347+ ? await revParse(repo.dir, "HEAD")
348+ : ((await revParse(repo.dir, `refs/heads/${name}`)) ??
349+ (await revParse(repo.dir, `refs/tags/${name}`)) ??
350+ (/^[0-9a-f]{4,40}$/i.test(name) ? await revParse(repo.dir, name) : null));
351+ if (!sha) throw new ApiError(404, `There's no branch, tag or commit named ${name}.`);
352+ return { ref: name, sha };
353+ }
354+
355+ function cleanPath(input: string | null) {
356+ const trimmed = (input ?? "").replace(/^\/+|\/+$/g, "");
357+ if (!trimmed) return "";
358+ const path = cleanRepoPath(trimmed);
359+ if (!path) throw new ApiError(400, "That isn't a valid path.");
360+ return path;
361+ }
362+
363+ const README = /^readme(\.md|\.markdown|\.txt)?$/i;
364+
365+ export async function repository(viewer: User | null, owner: string, name: string) {
366+ const { repo, access } = await openRepo(viewer, owner, name);
367+ const branch = await defaultBranch(repo.dir);
368+ const [parent, urls, branches, tags, issues, pulls, releases] = await Promise.all([
369+ visibleParent(repo, viewer?.id),
370+ cloneUrls(repo),
371+ listBranches(repo.dir),
372+ listTags(repo.dir),
373+ openIssueCount(repo.id),
374+ countOpenPulls(repo.id),
375+ countReleases(repo.id),
376+ ]);
377+ let readme: { path: string; text: string } | null = null;
378+ let entries: Awaited<ReturnType<typeof listTree>> = [];
379+ let lastCommit: Commit | null = null;
380+ let commits = 0;
381+ if (branch) {
382+ const ref = `refs/heads/${branch}`;
383+ [entries, lastCommit, commits] = await Promise.all([listTree(repo.dir, ref, ""), log(repo.dir, ref, { limit: 1 }).then((c) => c[0] ?? null), commitCount(repo.dir, ref)]);
384+ const file = entries.find((e) => e.type === "blob" && README.test(e.name));
385+ if (file) {
386+ const blob = await readBlob(repo.dir, ref, file.path);
387+ if (!blob.binary && !blob.tooLarge) readme = { path: file.path, text: blob.content.toString("utf8").slice(0, 200_000) };
388+ }
389+ }
390+ return {
391+ repo: repoJson({ ...repo, parent, updatedAt: lastCommit?.date ?? repo.pushedAt ?? repo.createdAt, empty: !branch }),
392+ access,
393+ canWrite: canWrite(access),
394+ defaultBranch: branch,
395+ branches: branches.map((b) => b.name),
396+ tags: tags.slice(0, 100),
397+ cloneUrls: urls,
398+ counts: { issues, pulls, releases, commits, branches: branches.length },
399+ lastCommit: lastCommit && commitJson(lastCommit),
400+ entries: entries.map(entryJson),
401+ readme,
402+ wikiEnabled: repo.wikiEnabled,
403+ };
404+ }
405+
406+ const entryJson = (e: Awaited<ReturnType<typeof listTree>>[number]) => ({ name: e.name, path: e.path, type: e.type, size: e.size });
407+
408+ export async function repoImage(viewer: User | null, owner: string, name: string) {
409+ const { repo } = await openRepo(viewer, owner, name);
410+ const image = await getRepoImage(repo.id);
411+ if (!image) throw new ApiError(404, "This repository has no image.");
412+ return new Response(new Uint8Array(image.data), {
413+ headers: { "Content-Type": image.contentType, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable" },
414+ });
415+ }
416+
417+ export async function tree(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
418+ const { repo } = await openRepo(viewer, owner, name);
419+ const resolved = await resolveRef(repo, params.get("ref"));
420+ if (!resolved) return { ref: null, path: "", entries: [] };
421+ const path = cleanPath(params.get("path"));
422+ if (path && (await objectType(repo.dir, resolved.sha, path)) !== "tree") throw new ApiError(404, "There's no such folder.");
423+ return { ref: resolved.ref, path, entries: (await listTree(repo.dir, resolved.sha, path)).map(entryJson) };
424+ }
425+
426+ /** Largest file the app shows as text. */
427+ const MAX_TEXT_BYTES = 1024 * 1024;
428+
429+ export async function blob(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
430+ const { repo } = await openRepo(viewer, owner, name);
431+ const resolved = await resolveRef(repo, params.get("ref"));
432+ const path = cleanPath(params.get("path"));
433+ if (!resolved || !path || (await objectType(repo.dir, resolved.sha, path)) !== "blob") throw new ApiError(404, "There's no such file.");
434+ const file = await readBlob(repo.dir, resolved.sha, path);
435+ const tooLarge = file.tooLarge || file.size > MAX_TEXT_BYTES;
436+ const showText = !file.binary && !tooLarge;
437+ const last = await log(repo.dir, resolved.sha, { path, limit: 1 });
438+ return {
439+ ref: resolved.ref,
440+ path,
441+ size: file.size,
442+ binary: file.binary,
443+ tooLarge,
444+ image: isRasterImage(path) && !file.tooLarge,
445+ text: showText ? file.content.toString("utf8") : null,
446+ lastCommit: last[0] ? commitJson(last[0]) : null,
447+ };
448+ }
449+
450+ /** Images the app shows as pictures. SVGs are left out: they can carry scripts, and Android can't draw them. */
451+ const imageType = (path: string) => {
452+ const type = IMAGE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""];
453+ return type && type !== "image/svg+xml" ? type : null;
454+ };
455+ const isRasterImage = (path: string) => imageType(path) !== null;
456+
457+ /** A file's bytes, for images in the code browser and READMEs. */
458+ export async function raw(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
459+ const { repo } = await openRepo(viewer, owner, name);
460+ const resolved = await resolveRef(repo, params.get("ref"));
461+ const path = cleanPath(params.get("path"));
462+ if (!resolved || !path || (await objectType(repo.dir, resolved.sha, path)) !== "blob") throw new ApiError(404, "There's no such file.");
463+ const file = await readBlob(repo.dir, resolved.sha, path);
464+ if (file.tooLarge) throw new ApiError(413, "This file is too large to show.");
465+ // Only raster images are sent as what they are; anything else is plain bytes, so nothing scripted runs.
466+ return new Response(new Uint8Array(file.content), {
467+ headers: {
468+ "Content-Type": imageType(path) ?? "application/octet-stream",
469+ "X-Content-Type-Options": "nosniff",
470+ "Content-Security-Policy": "default-src 'none'; sandbox",
471+ "Cache-Control": "private, no-cache",
472+ },
473+ });
474+ }
475+
476+ export async function commits(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
477+ const { repo } = await openRepo(viewer, owner, name);
478+ const resolved = await resolveRef(repo, params.get("ref"));
479+ if (!resolved) return { ref: null, commits: [], hasMore: false };
480+ const path = cleanPath(params.get("path"));
481+ const page = Math.max(1, Math.min(1000, Number(params.get("page")) || 1));
482+ const perPage = 30;
483+ const list = await log(repo.dir, resolved.sha, { path: path || undefined, limit: perPage + 1, skip: (page - 1) * perPage });
484+ const shown = list.slice(0, perPage);
485+ const statuses = await commitStatuses(repo.id, shown.map((c) => c.sha));
486+ return {
487+ ref: resolved.ref,
488+ commits: shown.map((c) => ({ ...commitJson(c), status: statuses.get(c.sha) ?? null })),
489+ hasMore: list.length > perPage,
490+ };
491+ }
492+
493+ export async function commit(viewer: User | null, owner: string, name: string, sha: string) {
494+ const { repo } = await openRepo(viewer, owner, name);
495+ const found = await getCommit(repo.dir, sha);
496+ if (!found) throw new ApiError(404, "There's no such commit.");
497+ const files = await diffCommit(repo.dir, found);
498+ return { commit: commitJson(found), files: diffJson(files) };
499+ }
500+
501+ export async function branches(viewer: User | null, owner: string, name: string) {
502+ const { repo } = await openRepo(viewer, owner, name);
503+ const [list, branch] = await Promise.all([listBranches(repo.dir), defaultBranch(repo.dir)]);
504+ return {
505+ defaultBranch: branch,
506+ branches: list.map((b) => ({ name: b.name, sha: b.sha, date: b.date, subject: b.subject, isDefault: b.name === branch })),
507+ };
508+ }
509+
510+ export async function repoEvents(viewer: User | null, owner: string, name: string) {
511+ const { repo } = await openRepo(viewer, owner, name);
512+ const events = await listRepoEvents({ repoIds: [repo.id], limit: 50 });
513+ return { events: events.reverse().map((e) => eventJson(e, repo)) };
514+ }
515+
516+ // ---------------------------------------------------------------------------
517+ // Issues
518+ // Anyone who can read a repository may open issues and comment; changing one needs push access.
519+
520+ export async function issues(viewer: User | null, owner: string, name: string) {
521+ const { repo, access } = await openRepo(viewer, owner, name);
522+ const [list, labels, members] = await Promise.all([listIssues(repo.id), listLabels(repo.id), listMembers(repo.id, repo.ownerId)]);
523+ return { canWrite: canWrite(access), issues: list, labels, members };
524+ }
525+
526+ export async function issue(viewer: User | null, owner: string, name: string, number: number) {
527+ const { repo, access } = await openRepo(viewer, owner, name);
528+ const found = await getIssue(repo.id, number);
529+ if (!found) throw new ApiError(404, "There's no such issue.");
530+ const [comments, events, labels, members] = await Promise.all([listComments(found.id), listIssueEvents(found.id), listLabels(repo.id), listMembers(repo.id, repo.ownerId)]);
531+ return { canWrite: canWrite(access), issue: found, comments, events, labels, members };
532+ }
533+
534+ export async function openIssue(request: Request, viewer: User | null, owner: string, name: string) {
535+ const user = requireUser(viewer);
536+ const { repo, access } = await openRepo(user, owner, name);
537+ const body = await readJson(request);
538+ const input = issuePatchFrom(body);
539+ const { title, body: description, ...properties } = await validateIssuePatch(repo, { ...input, title: text(body.title, 300), body: text(body.body), sortOrder: undefined });
540+ if (!title) throw new ApiError(422, "Title can't be blank.");
541+ // People without push access can open issues, but not triage them.
542+ const number = await createIssue(repo, person(user.id), { title, body: description ?? "", ...(canWrite(access) ? properties : {}) });
543+ return { number };
544+ }
545+
546+ /** The issue fields a request changes. A field of the wrong type is refused, never read as "clear it". */
547+ function issuePatchFrom(body: Record<string, unknown>): IssuePatch {
548+ const patch: IssuePatch = {};
549+ const wrong = (field: string): never => {
550+ throw new ApiError(422, `${field} has the wrong type.`);
551+ };
552+ const has = (field: string) => field in body && body[field] !== undefined;
553+ if (has("title")) patch.title = typeof body.title === "string" ? body.title.slice(0, 300) : wrong("title");
554+ if (has("body")) patch.body = typeof body.body === "string" ? body.body.slice(0, MAX_BODY) : wrong("body");
555+ if (has("status")) patch.status = typeof body.status === "string" ? (body.status as IssuePatch["status"]) : wrong("status");
556+ if (has("priority")) patch.priority = typeof body.priority === "number" ? body.priority : wrong("priority");
557+ if (has("assigneeId")) patch.assigneeId = body.assigneeId === null || typeof body.assigneeId === "string" ? body.assigneeId : wrong("assigneeId");
558+ if (has("estimate")) patch.estimate = body.estimate === null || typeof body.estimate === "number" ? body.estimate : wrong("estimate");
559+ if (has("dueDate")) patch.dueDate = body.dueDate === null || typeof body.dueDate === "string" ? body.dueDate : wrong("dueDate");
560+ if (has("labelIds")) {
561+ const ids = body.labelIds;
562+ patch.labelIds = Array.isArray(ids) && ids.every((id) => typeof id === "string") ? (ids as string[]) : wrong("labelIds");
563+ }
564+ return patch;
565+ }
566+
567+ export async function changeIssue(request: Request, viewer: User | null, owner: string, name: string, number: number) {
568+ const user = requireUser(viewer);
569+ const { repo, access } = await openRepo(user, owner, name);
570+ const found = await getIssue(repo.id, number);
571+ if (!found) throw new ApiError(404, "There's no such issue.");
572+ if (!canWrite(access)) throw new ApiError(403, "Only collaborators can change issues. You can still comment.");
573+ const patch = await validateIssuePatch(repo, issuePatchFrom(await readJson(request)));
574+ if (patch.title !== undefined && !patch.title.trim()) throw new ApiError(422, "Title can't be blank.");
575+ await updateIssue(repo, found, patch, person(user.id));
576+ return issue(user, owner, name, number);
577+ }
578+
579+ export async function commentOnIssue(request: Request, viewer: User | null, owner: string, name: string, number: number) {
580+ const user = requireUser(viewer);
581+ const { repo } = await openRepo(user, owner, name);
582+ const found = await getIssue(repo.id, number);
583+ if (!found) throw new ApiError(404, "There's no such issue.");
584+ const body = text((await readJson(request)).body).trim();
585+ if (!body) throw new ApiError(422, "Write something first.");
586+ await addComment(repo, found, person(user.id), body);
587+ return issue(user, owner, name, number);
588+ }
589+
590+ // ---------------------------------------------------------------------------
591+ // Pull requests
592+ // As on the website: anyone who can read may comment; the author and collaborators may close and
593+ // reopen; approving, requesting changes and merging need push access (and the base branch's rules).
594+
595+ export async function pulls(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
596+ const { repo, access } = await openRepo(viewer, owner, name);
597+ const state = params.get("state");
598+ const valid: (PullState | "all")[] = ["open", "closed", "merged", "all"];
599+ const { pulls: list } = await listPulls(repo.id, { state: valid.includes(state as PullState) ? (state as PullState | "all") : "open", perPage: 100 });
600+ const counts = { open: await countOpenPulls(repo.id) };
601+ return { canWrite: canWrite(access), counts, pulls: list.map(pullJson) };
602+ }
603+
604+ async function openPull(viewer: User | null, owner: string, name: string, number: number, need: "read" | "write" = "read") {
605+ const opened = await openRepo(viewer, owner, name, need);
606+ const pr = await getPull(opened.repo.id, number);
607+ if (!pr) throw new ApiError(404, "There's no such pull request.");
608+ return { ...opened, pr };
609+ }
610+
611+ export async function pull(viewer: User | null, owner: string, name: string, number: number) {
612+ const { repo, access, pr } = await openPull(viewer, owner, name, number);
613+ const actor: Actor | null = viewer ? person(viewer.id) : null;
614+ const [status, reviews, comments, events, commitList] = await Promise.all([
615+ pullStatus(repo, pr, actor),
616+ listReviews(pr.id),
617+ listPullComments(pr.id),
618+ listPullEvents(pr.id),
619+ pullCommits(repo, pr),
620+ ]);
621+ const isAuthor = viewer?.id === pr.authorId;
622+ return {
623+ pull: pullJson(pr),
624+ canWrite: canWrite(access),
625+ canEdit: canWrite(access) || isAuthor,
626+ canMerge: canWrite(access) && pr.state === "open",
627+ status: {
628+ headSha: status.headSha,
629+ headExists: status.headExists,
630+ ahead: status.ahead,
631+ behind: status.behind,
632+ conflicts: status.conflicts,
633+ checks: status.checks,
634+ runs: status.runs.map(runJson),
635+ approvals: status.approvals,
636+ changesRequestedBy: status.changesRequestedBy,
637+ requirements: status.requirements,
638+ mergeable: status.mergeable,
639+ },
640+ reviews: reviews.map((r: Review) => ({ id: r.id, author: r.author, agent: r.agent, state: r.state, body: r.body, commitSha: r.commitSha, createdAt: r.createdAt })),
641+ comments: comments.filter((c) => !c.path).map((c) => ({ id: c.id, author: c.author, agent: c.agent, body: c.body, createdAt: c.createdAt })),
642+ threads: threadsOf(comments, status.headSha).map((t) => ({
643+ path: t.root.path,
644+ line: t.root.line,
645+ side: t.root.side,
646+ snippet: t.root.snippet,
647+ resolved: t.root.resolved,
648+ outdated: t.outdated,
649+ comments: [t.root, ...t.replies].map((c) => ({ id: c.id, author: c.author, agent: c.agent, body: c.body, createdAt: c.createdAt })),
650+ createdAt: t.root.createdAt,
651+ })),
652+ events: events.map((e) => ({ id: e.id, actor: e.actor, agent: e.agent, kind: e.kind, data: e.data, createdAt: e.createdAt })),
653+ commits: commitList.map(commitJson),
654+ };
655+ }
656+
657+ export async function pullFiles(viewer: User | null, owner: string, name: string, number: number) {
658+ const { repo, pr } = await openPull(viewer, owner, name, number);
659+ return { files: diffJson(await pullDiff(repo, pr)) };
660+ }
661+
662+ export async function commentOnPull(request: Request, viewer: User | null, owner: string, name: string, number: number) {
663+ const user = requireUser(viewer);
664+ const { repo, pr } = await openPull(user, owner, name, number);
665+ const body = text((await readJson(request)).body).trim();
666+ if (!body) throw new ApiError(422, "Write something first.");
667+ await addPullComment(repo, pr, person(user.id), body);
668+ return pull(user, owner, name, number);
669+ }
670+
671+ export async function reviewPull(request: Request, viewer: User | null, owner: string, name: string, number: number) {
672+ const user = requireUser(viewer);
673+ const { repo, access, pr } = await openPull(user, owner, name, number);
674+ const input = await readJson(request);
675+ const state = input.state as Review["state"];
676+ if (!["approved", "changes_requested", "commented"].includes(state)) throw new ApiError(422, "Choose what the review says.");
677+ if (state !== "commented" && !canWrite(access)) throw new ApiError(403, "Only people with push access can approve or request changes.");
678+ await submitReview(repo, pr, person(user.id), { state, body: text(input.body).trim(), comments: [] });
679+ return pull(user, owner, name, number);
680+ }
681+
682+ export async function changePull(request: Request, viewer: User | null, owner: string, name: string, number: number) {
683+ const user = requireUser(viewer);
684+ const { repo, access, pr } = await openPull(user, owner, name, number);
685+ if (!canWrite(access) && pr.authorId !== user.id) throw new ApiError(403, "Only collaborators and its author can change a pull request.");
686+ const input = await readJson(request);
687+ const patch: { state?: "open" | "closed"; draft?: boolean; title?: string } = {};
688+ if (input.state === "open" || input.state === "closed") patch.state = input.state;
689+ if (typeof input.draft === "boolean") patch.draft = input.draft;
690+ if (typeof input.title === "string") patch.title = input.title.slice(0, 300);
691+ await updatePull(repo, pr, person(user.id), patch);
692+ return pull(user, owner, name, number);
693+ }
694+
695+ export async function mergePullRequest(request: Request, viewer: User | null, owner: string, name: string, number: number) {
696+ const user = requireUser(viewer);
697+ const { repo, pr } = await openPull(user, owner, name, number, "write");
698+ const input = await readJson(request);
699+ const method = input.method === "squash" ? "squash" : input.method === "merge" || input.method === undefined ? "merge" : null;
700+ if (!method) throw new ApiError(422, "Unknown merge method.");
701+ await mergePull(repo, pr, person(user.id), {
702+ method,
703+ deleteBranch: input.deleteBranch === true,
704+ expectedHeadSha: typeof input.headSha === "string" && input.headSha ? input.headSha : undefined,
705+ });
706+ return pull(user, owner, name, number);
707+ }
708+
709+ // ---------------------------------------------------------------------------
710+ // Actions and releases
711+
712+ export async function runs(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
713+ const { repo } = await openRepo(viewer, owner, name);
714+ const page = Math.max(1, Math.min(1000, Number(params.get("page")) || 1));
715+ const list = await listRuns(repo.id, { limit: 30, offset: (page - 1) * 30 });
716+ return { runs: list.map(runJson), hasMore: list.length === 30 };
717+ }
718+
719+ /** The end of a long log is what tells why it failed. */
720+ const MAX_LOG_CHARS = 60_000;
721+
722+ export async function run(viewer: User | null, owner: string, name: string, number: number) {
723+ const { repo } = await openRepo(viewer, owner, name);
724+ const found = await getRun(repo.id, number);
725+ if (!found) throw new ApiError(404, "There's no such workflow run.");
726+ const jobs = await listJobs(found.id);
727+ const detailed = await Promise.all(
728+ jobs.map(async (j) => {
729+ const [steps, logs] = await Promise.all([listSteps(j.id), readJobLogs(j.id)]);
730+ return {
731+ key: j.key,
732+ name: j.name,
733+ status: j.status,
734+ runner: j.runnerName,
735+ startedAt: j.startedAt,
736+ finishedAt: j.finishedAt,
737+ steps: steps.map((s) => {
738+ const log = logs.get(s.index) ?? "";
739+ return { ...s, log: log.length > MAX_LOG_CHARS ? log.slice(-MAX_LOG_CHARS) : log, logTruncated: log.length > MAX_LOG_CHARS };
740+ }),
741+ };
742+ }),
743+ );
744+ return { run: runJson(found), jobs: detailed };
745+ }
746+
747+ export async function releases(viewer: User | null, owner: string, name: string) {
748+ const { repo, access } = await openRepo(viewer, owner, name);
749+ const origin = await serverOrigin();
750+ const { releases: list } = await listReleases(repo.id, { drafts: canWrite(access), page: 1, perPage: 50 });
751+ return {
752+ releases: list.map((r) => ({
753+ tag: r.tag,
754+ title: releaseTitle(r),
755+ body: r.body,
756+ draft: r.draft,
757+ prerelease: r.prerelease,
758+ author: r.author,
759+ createdAt: r.createdAt,
760+ publishedAt: r.publishedAt,
761+ assets: r.assets.map((a) => ({
762+ name: a.name,
763+ size: a.size,
764+ downloads: a.downloads,
765+ url: `${origin}/${repo.owner}/${repo.name}/releases/download/${encodeURIComponent(r.tag)}/${encodeURIComponent(a.name)}`,
766+ })),
767+ })),
768+ };
769+ }
770+
+5−3lib/auth.tsView File
@@ -42,8 +42,10 @@ export async function endSession() {
4242 }
4343
4444 /** The signed-in user for this request, or null. */
45- export const getViewer = cache(async () => {
46- const token = (await cookies()).get(SESSION_COOKIE)?.value;
45+ export const getViewer = cache(async () => userForSession((await cookies()).get(SESSION_COOKIE)?.value));
46+
47+ /** The user a session token belongs to, or null if it's expired or no longer valid. The mobile app sends these as bearer tokens (lib/app-api.ts). */
48+ export async function userForSession(token: string | undefined) {
4749 if (!token || isTokenExpired(token)) return null;
4850 const { id, type } = getTokenPayload(token);
4951 if (type !== "auth" || typeof id !== "string") return null;
@@ -57,7 +59,7 @@ export const getViewer = cache(async () => {
5759 if (e instanceof ClientResponseError && [401, 403, 404].includes(e.status)) return null;
5860 throw e;
5961 }
60- });
62+ }
6163
6264 /** Returns the signed-in user, or redirects to the login page. */
6365 export async function requireViewer(returnTo?: string) {
+7−0mobile/.gitignoreaddedView File
@@ -0,0 +1,7 @@
1+ .gradle/
2+ .kotlin/
3+ build/
4+ local.properties
5+ *.iml
6+ .idea/
7+ captures/
+61−0mobile/app/build.gradle.ktsaddedView File
@@ -0,0 +1,61 @@
1+ plugins {
2+ alias(libs.plugins.android.application)
3+ alias(libs.plugins.kotlin.compose)
4+ alias(libs.plugins.kotlin.serialization)
5+ }
6+
7+ android {
8+ namespace = "cc.vectrum.app"
9+ compileSdk = 37
10+
11+ defaultConfig {
12+ applicationId = "cc.vectrum.app"
13+ minSdk = 26
14+ targetSdk = 36
15+ versionCode = 1
16+ versionName = "1.0.0"
17+ }
18+
19+ buildTypes {
20+ release {
21+ isMinifyEnabled = true
22+ isShrinkResources = true
23+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
24+ // Signed with the debug key so `assembleRelease` gives an installable APK; use your own key to publish.
25+ signingConfig = signingConfigs.getByName("debug")
26+ }
27+ }
28+
29+ buildFeatures {
30+ compose = true
31+ buildConfig = true
32+ }
33+
34+ compileOptions {
35+ sourceCompatibility = JavaVersion.VERSION_17
36+ targetCompatibility = JavaVersion.VERSION_17
37+ }
38+ }
39+
40+ dependencies {
41+ implementation(libs.androidx.core)
42+ implementation(libs.androidx.splashscreen)
43+ implementation(libs.androidx.activity.compose)
44+ implementation(libs.androidx.lifecycle.runtime.compose)
45+ implementation(libs.androidx.lifecycle.viewmodel.compose)
46+ implementation(libs.androidx.navigation.compose)
47+ implementation(libs.androidx.datastore.preferences)
48+
49+ implementation(platform(libs.compose.bom))
50+ implementation(libs.compose.ui)
51+ implementation(libs.compose.foundation)
52+ implementation(libs.compose.material3)
53+ implementation(libs.compose.ui.tooling.preview)
54+ debugImplementation(libs.compose.ui.tooling)
55+
56+ implementation(libs.kotlinx.coroutines.android)
57+ implementation(libs.kotlinx.serialization.json)
58+ implementation(libs.okhttp)
59+ implementation(libs.coil.compose)
60+ implementation(libs.coil.network.okhttp)
61+ }
+2−0mobile/app/proguard-rules.proaddedView File
@@ -0,0 +1,2 @@
1+ # kotlinx.serialization keeps what it needs through its own consumer rules; the API models are
2+ # @Serializable classes, so nothing extra is required here.
+25−0mobile/app/src/main/AndroidManifest.xmladdedView File
@@ -0,0 +1,25 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+ <uses-permission android:name="android.permission.INTERNET" />
5+
6+ <application
7+ android:name=".VectrumApp"
8+ android:allowBackup="false"
9+ android:icon="@mipmap/ic_launcher"
10+ android:roundIcon="@mipmap/ic_launcher"
11+ android:label="@string/app_name"
12+ android:supportsRtl="true"
13+ android:networkSecurityConfig="@xml/network_security_config"
14+ android:theme="@style/Theme.Vectrum.Starting">
15+ <activity
16+ android:name=".MainActivity"
17+ android:exported="true"
18+ android:windowSoftInputMode="adjustResize">
19+ <intent-filter>
20+ <action android:name="android.intent.action.MAIN" />
21+ <category android:name="android.intent.category.LAUNCHER" />
22+ </intent-filter>
23+ </activity>
24+ </application>
25+ </manifest>
+101−0mobile/app/src/main/java/cc/vectrum/app/MainActivity.ktaddedView File
@@ -0,0 +1,101 @@
1+ package cc.vectrum.app
2+
3+ import android.graphics.Color
4+ import android.os.Bundle
5+ import androidx.activity.ComponentActivity
6+ import androidx.activity.SystemBarStyle
7+ import androidx.activity.compose.setContent
8+ import androidx.activity.enableEdgeToEdge
9+ import androidx.compose.foundation.isSystemInDarkTheme
10+ import androidx.compose.runtime.Composable
11+ import androidx.compose.runtime.DisposableEffect
12+ import androidx.compose.runtime.SideEffect
13+ import androidx.compose.runtime.getValue
14+ import androidx.compose.runtime.remember
15+ import androidx.compose.runtime.rememberCoroutineScope
16+ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
17+ import androidx.lifecycle.compose.collectAsStateWithLifecycle
18+ import cc.vectrum.app.data.Api
19+ import cc.vectrum.app.data.Settings
20+ import cc.vectrum.app.data.normalizeServer
21+ import cc.vectrum.app.ui.MainShell
22+ import cc.vectrum.app.ui.SignedOutShell
23+ import cc.vectrum.app.ui.screens.SetupScreen
24+ import cc.vectrum.app.ui.theme.ThemeMode
25+ import cc.vectrum.app.ui.theme.VectrumTheme
26+ import coil3.SingletonImageLoader
27+ import kotlinx.coroutines.flow.map
28+ import kotlinx.coroutines.launch
29+
30+ class MainActivity : ComponentActivity() {
31+ override fun onCreate(savedInstanceState: Bundle?) {
32+ val app = application as VectrumApp
33+ val splash = installSplashScreen()
34+ super.onCreate(savedInstanceState)
35+ enableEdgeToEdge()
36+
37+ // Keep the splash up until the stored session is read, so the app opens on the right screen.
38+ var ready = false
39+ splash.setKeepOnScreenCondition { !ready }
40+
41+ setContent {
42+ val settings by remember { app.store.settings.map<Settings, Settings?> { it } }.collectAsStateWithLifecycle(initialValue = null)
43+ val current = settings ?: return@setContent
44+ SideEffect { ready = true }
45+ VectrumTheme(current.theme) {
46+ SystemBars(current.theme)
47+ Root(app, current)
48+ }
49+ }
50+ }
51+
52+ /** Light status bar icons on graphite, dark ones on bone. */
53+ @Composable
54+ private fun SystemBars(theme: ThemeMode) {
55+ val dark = when (theme) {
56+ ThemeMode.System -> isSystemInDarkTheme()
57+ ThemeMode.Bone -> false
58+ ThemeMode.Graphite -> true
59+ }
60+ DisposableEffect(dark) {
61+ val style = if (dark) SystemBarStyle.dark(Color.TRANSPARENT) else SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
62+ enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
63+ onDispose {}
64+ }
65+ }
66+ }
67+
68+ @Composable
69+ private fun Root(app: VectrumApp, settings: Settings) {
70+ val scope = rememberCoroutineScope()
71+ val session = settings.session
72+ val server = session?.let { normalizeServer(it.server) }
73+ if (session == null || server == null) {
74+ app.imageAuth = null
75+ SignedOutShell {
76+ SetupScreen(
77+ lastServer = settings.lastServer,
78+ onboarded = settings.onboarded,
79+ http = app.http,
80+ onSignedIn = { scope.launch { app.store.signIn(it) } },
81+ )
82+ }
83+ return
84+ }
85+ val api = remember(session.server, session.token) { Api(app.http, server, session.token) }
86+ app.imageAuth = api.origin to session.token
87+ MainShell(
88+ session = session,
89+ api = api,
90+ theme = settings.theme,
91+ onTheme = { scope.launch { app.store.setTheme(it) } },
92+ onSignOut = {
93+ // Images of private repositories don't outlive the session.
94+ SingletonImageLoader.get(app).let { loader ->
95+ loader.memoryCache?.clear()
96+ loader.diskCache?.clear()
97+ }
98+ scope.launch { app.store.signOut() }
99+ },
100+ )
101+ }
+45−0mobile/app/src/main/java/cc/vectrum/app/VectrumApp.ktaddedView File
@@ -0,0 +1,45 @@
1+ package cc.vectrum.app
2+
3+ import android.app.Application
4+ import cc.vectrum.app.data.Api
5+ import cc.vectrum.app.data.SessionStore
6+ import coil3.ImageLoader
7+ import coil3.PlatformContext
8+ import coil3.SingletonImageLoader
9+ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
10+ import okhttp3.Interceptor
11+ import okhttp3.OkHttpClient
12+
13+ class VectrumApp : Application(), SingletonImageLoader.Factory {
14+ lateinit var store: SessionStore
15+ private set
16+
17+ /** The current session's server and token, for images: Coil's requests carry the token only to that server. */
18+ @Volatile
19+ var imageAuth: Pair<String, String>? = null
20+
21+ /** One HTTP client for the API and for images. */
22+ val http: OkHttpClient by lazy { Api.client() }
23+
24+ override fun onCreate() {
25+ super.onCreate()
26+ store = SessionStore(this)
27+ }
28+
29+ override fun newImageLoader(context: PlatformContext): ImageLoader {
30+ val authorizing = Interceptor { chain ->
31+ val request = chain.request()
32+ val auth = imageAuth
33+ val url = request.url.toString()
34+ if (auth != null && url.startsWith(auth.first.trimEnd('/') + "/api/v1/")) {
35+ chain.proceed(request.newBuilder().header("Authorization", "Bearer ${auth.second}").build())
36+ } else {
37+ chain.proceed(request)
38+ }
39+ }
40+ val client = http.newBuilder().addInterceptor(authorizing).build()
41+ return ImageLoader.Builder(context)
42+ .components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) }
43+ .build()
44+ }
45+ }
+179−0mobile/app/src/main/java/cc/vectrum/app/data/Api.ktaddedView File
@@ -0,0 +1,179 @@
1+ package cc.vectrum.app.data
2+
3+ import kotlinx.coroutines.Dispatchers
4+ import kotlinx.coroutines.suspendCancellableCoroutine
5+ import kotlinx.coroutines.withContext
6+ import kotlinx.serialization.json.Json
7+ import kotlinx.serialization.json.JsonObject
8+ import kotlinx.serialization.json.JsonPrimitive
9+ import kotlinx.serialization.json.buildJsonObject
10+ import kotlinx.serialization.json.jsonObject
11+ import kotlinx.serialization.json.jsonPrimitive
12+ import okhttp3.Call
13+ import okhttp3.Callback
14+ import okhttp3.HttpUrl
15+ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
16+ import okhttp3.MediaType.Companion.toMediaType
17+ import okhttp3.OkHttpClient
18+ import okhttp3.Request
19+ import okhttp3.RequestBody.Companion.toRequestBody
20+ import okhttp3.Response
21+ import java.io.IOException
22+ import java.util.concurrent.TimeUnit
23+ import kotlin.coroutines.resumeWithException
24+
25+ /** What went wrong talking to the server, in words a person can act on. */
26+ class ApiException(val status: Int, message: String) : Exception(message) {
27+ val isUnauthorized: Boolean get() = status == 401
28+ }
29+
30+ val json = Json {
31+ ignoreUnknownKeys = true
32+ explicitNulls = false
33+ coerceInputValues = true
34+ }
35+
36+ /** The normalized address of a Vectrum server, or null if it isn't one: "vectrum.cc" → https://vectrum.cc. */
37+ fun normalizeServer(input: String): HttpUrl? {
38+ val trimmed = input.trim().trimEnd('/')
39+ if (trimmed.isEmpty()) return null
40+ val withScheme = if (trimmed.contains("://")) trimmed else "https://$trimmed"
41+ val url = withScheme.toHttpUrlOrNull() ?: return null
42+ return url.newBuilder().query(null).fragment(null).build()
43+ }
44+
45+ /** Whether a server is on this device, the emulator's host or a private network, where plain HTTP is common. */
46+ fun isLocalHost(url: HttpUrl): Boolean {
47+ val h = url.host
48+ return h == "localhost" || h == "10.0.2.2" || h.endsWith(".local") || h.startsWith("127.") || h.startsWith("192.168.") ||
49+ h.startsWith("10.") || Regex("^172\\.(1[6-9]|2\\d|3[01])\\.").containsMatchIn(h)
50+ }
51+
52+ /**
53+ * The server's JSON API (lib/app-api.ts on the server), signed in with the session token it hands out.
54+ * One client for all requests: OkHttp shares connections, and Coil loads images through it too.
55+ */
56+ class Api(val http: OkHttpClient, private val server: HttpUrl, private val token: String?) {
57+ val origin: String get() = server.toString().trimEnd('/')
58+
59+ /** Path segments are added one by one, so OkHttp encodes each (names, refs and SHAs never contain "/"). */
60+ private fun url(path: String, query: Map<String, String?> = emptyMap()): HttpUrl {
61+ val b = server.newBuilder()
62+ for (segment in "api/v1/$path".split('/')) if (segment.isNotEmpty()) b.addPathSegment(segment)
63+ for ((k, v) in query) if (v != null) b.addQueryParameter(k, v)
64+ return b.build()
65+ }
66+
67+ private fun request(url: HttpUrl) = Request.Builder().url(url).header("Accept", "application/json").apply {
68+ if (token != null) header("Authorization", "Bearer $token")
69+ }
70+
71+ private suspend fun call(request: Request): String = withContext(Dispatchers.IO) {
72+ val response = http.newCall(request).await()
73+ response.use { r ->
74+ val body = r.body.string()
75+ if (!r.isSuccessful) {
76+ val message = runCatching { json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content }.getOrNull()
77+ throw ApiException(r.code, message ?: "The server answered ${r.code}.")
78+ }
79+ body
80+ }
81+ }
82+
83+ private suspend inline fun <reified T> get(path: String, query: Map<String, String?> = emptyMap()): T =
84+ json.decodeFromString(call(request(url(path, query)).get().build()))
85+
86+ private suspend inline fun <reified T> send(method: String, path: String, body: JsonObject): T =
87+ json.decodeFromString(call(request(url(path)).method(method, body.toString().toRequestBody(JSON)).build()))
88+
89+ /** The address of a path on the server, for images and links. */
90+ fun apiUrl(path: String, query: Map<String, String?> = emptyMap()): String = url(path, query).toString()
91+
92+ suspend fun signIn(login: String, password: String): SessionResponse =
93+ send("POST", "session", buildJsonObject { put("login", JsonPrimitive(login)); put("password", JsonPrimitive(password)) })
94+
95+ suspend fun signUp(username: String, email: String, password: String, name: String): SessionResponse =
96+ send(
97+ "POST",
98+ "signup",
99+ buildJsonObject {
100+ put("username", JsonPrimitive(username))
101+ put("email", JsonPrimitive(email))
102+ put("password", JsonPrimitive(password))
103+ put("name", JsonPrimitive(name))
104+ },
105+ )
106+
107+ suspend fun serverInfo(): ServerInfo = get("server")
108+
109+ /** Whether the server answers its liveness check (true for any Vectrum, also one without the app's API). */
110+ suspend fun healthy(): Boolean = runCatching { call(request(server.newBuilder().addPathSegments("api/health").build()).get().build()) }.isSuccess
111+
112+ suspend fun me(): User = get<UserResponse>("user").user
113+ suspend fun home(): HomeData = get("home")
114+ suspend fun explore(query: String): ReposData = get("explore", mapOf("q" to query))
115+ suspend fun profile(username: String): ProfileData = get("users/${username}")
116+
117+ private fun repo(owner: String, name: String) = "repos/${owner}/${name}"
118+
119+ suspend fun repository(owner: String, name: String): RepoDetail = get(repo(owner, name))
120+ suspend fun tree(owner: String, name: String, ref: String?, path: String): TreeData = get("${repo(owner, name)}/tree", mapOf("ref" to ref, "path" to path))
121+ suspend fun blob(owner: String, name: String, ref: String?, path: String): BlobData = get("${repo(owner, name)}/blob", mapOf("ref" to ref, "path" to path))
122+ fun rawUrl(owner: String, name: String, ref: String?, path: String) = apiUrl("${repo(owner, name)}/raw", mapOf("ref" to ref, "path" to path))
123+ fun repoImageUrl(owner: String, name: String, version: Long?) = apiUrl("${repo(owner, name)}/image", mapOf("v" to version?.toString()))
124+ suspend fun commits(owner: String, name: String, ref: String?, page: Int, path: String? = null): CommitsData =
125+ get("${repo(owner, name)}/commits", mapOf("ref" to ref, "page" to page.toString(), "path" to path))
126+ suspend fun commit(owner: String, name: String, sha: String): CommitDetail = get("${repo(owner, name)}/commits/${sha}")
127+ suspend fun branches(owner: String, name: String): BranchesData = get("${repo(owner, name)}/branches")
128+ suspend fun repoEvents(owner: String, name: String): EventsData = get("${repo(owner, name)}/events")
129+
130+ suspend fun issues(owner: String, name: String): IssuesData = get("${repo(owner, name)}/issues")
131+ suspend fun issue(owner: String, name: String, number: Int): IssueDetail = get("${repo(owner, name)}/issues/$number")
132+ suspend fun createIssue(owner: String, name: String, fields: JsonObject): CreatedIssue = send("POST", "${repo(owner, name)}/issues", fields)
133+ suspend fun updateIssue(owner: String, name: String, number: Int, patch: JsonObject): IssueDetail = send("PATCH", "${repo(owner, name)}/issues/$number", patch)
134+ suspend fun commentOnIssue(owner: String, name: String, number: Int, body: String): IssueDetail =
135+ send("POST", "${repo(owner, name)}/issues/$number/comments", buildJsonObject { put("body", JsonPrimitive(body)) })
136+
137+ suspend fun pulls(owner: String, name: String, state: String): PullsData = get("${repo(owner, name)}/pulls", mapOf("state" to state))
138+ suspend fun pull(owner: String, name: String, number: Int): PullDetail = get("${repo(owner, name)}/pulls/$number")
139+ suspend fun pullFiles(owner: String, name: String, number: Int): FilesData = get("${repo(owner, name)}/pulls/$number/files")
140+ suspend fun commentOnPull(owner: String, name: String, number: Int, body: String): PullDetail =
141+ send("POST", "${repo(owner, name)}/pulls/$number/comments", buildJsonObject { put("body", JsonPrimitive(body)) })
142+ suspend fun reviewPull(owner: String, name: String, number: Int, state: String, body: String): PullDetail =
143+ send("POST", "${repo(owner, name)}/pulls/$number/reviews", buildJsonObject { put("state", JsonPrimitive(state)); put("body", JsonPrimitive(body)) })
144+ suspend fun updatePull(owner: String, name: String, number: Int, patch: JsonObject): PullDetail = send("PATCH", "${repo(owner, name)}/pulls/$number", patch)
145+ suspend fun mergePull(owner: String, name: String, number: Int, method: String, deleteBranch: Boolean, headSha: String): PullDetail =
146+ send(
147+ "POST",
148+ "${repo(owner, name)}/pulls/$number/merge",
149+ buildJsonObject {
150+ put("method", JsonPrimitive(method))
151+ put("deleteBranch", JsonPrimitive(deleteBranch))
152+ put("headSha", JsonPrimitive(headSha))
153+ },
154+ )
155+
156+ suspend fun runs(owner: String, name: String, page: Int): RunsData = get("${repo(owner, name)}/runs", mapOf("page" to page.toString()))
157+ suspend fun run(owner: String, name: String, number: Int): RunDetail = get("${repo(owner, name)}/runs/$number")
158+ suspend fun releases(owner: String, name: String): ReleasesData = get("${repo(owner, name)}/releases")
159+
160+ companion object {
161+ private val JSON = "application/json; charset=utf-8".toMediaType()
162+
163+ fun client(): OkHttpClient = OkHttpClient.Builder()
164+ .connectTimeout(15, TimeUnit.SECONDS)
165+ .readTimeout(60, TimeUnit.SECONDS)
166+ .build()
167+ }
168+ }
169+
170+ /** Awaits an OkHttp call, cancelling it with the coroutine. */
171+ private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
172+ enqueue(object : Callback {
173+ override fun onResponse(call: Call, response: Response) = cont.resume(response) { _, _, _ -> response.close() }
174+ override fun onFailure(call: Call, e: IOException) {
175+ if (!cont.isCancelled) cont.resumeWithException(e)
176+ }
177+ })
178+ cont.invokeOnCancellation { runCatching { cancel() } }
179+ }
+356−0mobile/app/src/main/java/cc/vectrum/app/data/Models.ktaddedView File
@@ -0,0 +1,356 @@
1+ package cc.vectrum.app.data
2+
3+ import kotlinx.serialization.Serializable
4+ import kotlinx.serialization.json.JsonObject
5+
6+ // The shapes of the server's /api/v1 answers (lib/app-api.ts).
7+
8+ @Serializable
9+ data class User(
10+ val id: String,
11+ val username: String,
12+ val name: String = "",
13+ val email: String = "",
14+ val bio: String = "",
15+ val createdAt: Long = 0,
16+ ) {
17+ val displayName: String get() = name.ifBlank { username }
18+ }
19+
20+ @Serializable data class SessionResponse(val token: String, val user: User)
21+
22+ /** What a server says about itself before anyone signs in (GET /api/v1/server). */
23+ @Serializable data class ServerInfo(val name: String = "Vectrum", val api: Int = 1, val signups: Boolean = false)
24+ @Serializable data class UserResponse(val user: User)
25+
26+ @Serializable data class RepoRef(val owner: String, val name: String)
27+
28+ @Serializable
29+ data class RepoSummary(
30+ val id: String,
31+ val owner: String,
32+ val name: String,
33+ val description: String = "",
34+ val visibility: String = "public",
35+ val forkOf: RepoRef? = null,
36+ val isFork: Boolean = false,
37+ val hasImage: Boolean = false,
38+ val imageVersion: Long? = null,
39+ val createdAt: Long = 0,
40+ val updatedAt: Long = 0,
41+ val empty: Boolean = false,
42+ ) {
43+ val isPrivate: Boolean get() = visibility == "private"
44+ val fullName: String get() = "$owner/$name"
45+ }
46+
47+ @Serializable data class CommitStatus(val status: String, val runNumber: Int = 0)
48+
49+ @Serializable
50+ data class CommitInfo(
51+ val sha: String,
52+ val parents: List<String> = emptyList(),
53+ val authorName: String = "",
54+ val authorEmail: String = "",
55+ val committerName: String = "",
56+ val date: Long = 0,
57+ val subject: String = "",
58+ val body: String = "",
59+ /** The agent that made the commit for its author. */
60+ val agent: String? = null,
61+ val status: CommitStatus? = null,
62+ )
63+
64+ @Serializable data class EventTarget(val type: String, val number: Int? = null, val sha: String? = null)
65+
66+ @Serializable
67+ data class Event(
68+ val id: String,
69+ val kind: String,
70+ val action: String? = null,
71+ val actor: String? = null,
72+ val agent: String? = null,
73+ val text: String,
74+ val createdAt: Long,
75+ val repo: RepoRef? = null,
76+ val target: EventTarget? = null,
77+ )
78+
79+ @Serializable data class RepoCommit(val repo: RepoRef, val commit: CommitInfo)
80+
81+ @Serializable
82+ data class HomeData(
83+ val user: User,
84+ val repos: List<RepoSummary>,
85+ val openIssues: Int = 0,
86+ val openPulls: Int = 0,
87+ val events: List<Event> = emptyList(),
88+ val commits: List<RepoCommit> = emptyList(),
89+ val discover: List<RepoSummary> = emptyList(),
90+ )
91+
92+ @Serializable data class ReposData(val repos: List<RepoSummary>)
93+ @Serializable data class ProfileData(val user: User, val repos: List<RepoSummary>)
94+
95+ @Serializable data class TreeEntry(val name: String, val path: String, val type: String, val size: Long? = null) {
96+ val isDir: Boolean get() = type == "tree"
97+ val isSubmodule: Boolean get() = type == "commit"
98+ }
99+
100+ @Serializable data class Readme(val path: String, val text: String)
101+ @Serializable data class CloneUrls(val https: String, val ssh: String)
102+ @Serializable data class RepoCounts(val issues: Int = 0, val pulls: Int = 0, val releases: Int = 0, val commits: Int = 0, val branches: Int = 0)
103+
104+ @Serializable
105+ data class RepoDetail(
106+ val repo: RepoSummary,
107+ val access: String,
108+ val canWrite: Boolean,
109+ val defaultBranch: String? = null,
110+ val branches: List<String> = emptyList(),
111+ val tags: List<String> = emptyList(),
112+ val cloneUrls: CloneUrls,
113+ val counts: RepoCounts = RepoCounts(),
114+ val lastCommit: CommitInfo? = null,
115+ val entries: List<TreeEntry> = emptyList(),
116+ val readme: Readme? = null,
117+ val wikiEnabled: Boolean = false,
118+ )
119+
120+ @Serializable data class TreeData(val ref: String? = null, val path: String = "", val entries: List<TreeEntry> = emptyList())
121+
122+ @Serializable
123+ data class BlobData(
124+ val ref: String,
125+ val path: String,
126+ val size: Long,
127+ val binary: Boolean,
128+ val tooLarge: Boolean,
129+ val image: Boolean = false,
130+ val text: String? = null,
131+ val lastCommit: CommitInfo? = null,
132+ )
133+
134+ @Serializable data class CommitsData(val ref: String? = null, val commits: List<CommitInfo> = emptyList(), val hasMore: Boolean = false)
135+
136+ @Serializable data class DiffLine(val kind: String, val text: String, val oldNo: Int? = null, val newNo: Int? = null)
137+
138+ @Serializable
139+ data class FileDiff(
140+ val oldPath: String,
141+ val newPath: String,
142+ val status: String,
143+ val binary: Boolean = false,
144+ val additions: Int = 0,
145+ val deletions: Int = 0,
146+ val truncated: Boolean = false,
147+ val lines: List<DiffLine> = emptyList(),
148+ ) {
149+ val path: String get() = if (status == "deleted") oldPath else newPath
150+ }
151+
152+ @Serializable data class CommitDetail(val commit: CommitInfo, val files: List<FileDiff>)
153+ @Serializable data class FilesData(val files: List<FileDiff>)
154+
155+ @Serializable data class BranchInfo(val name: String, val sha: String, val date: Long = 0, val subject: String = "", val isDefault: Boolean = false)
156+ @Serializable data class BranchesData(val defaultBranch: String? = null, val branches: List<BranchInfo> = emptyList())
157+
158+ @Serializable data class EventsData(val events: List<Event>)
159+
160+ // Issues
161+
162+ @Serializable data class Label(val id: String, val name: String, val color: String)
163+
164+ /** Someone an issue can be assigned to: a person, or an agent (then `agentOf` names its person). */
165+ @Serializable data class Member(val id: String, val username: String, val name: String = "", val agentOf: String? = null)
166+
167+ @Serializable
168+ data class Issue(
169+ val id: String,
170+ val number: Int,
171+ val title: String,
172+ val authorId: String = "",
173+ val author: String = "",
174+ val authorAgent: String? = null,
175+ val status: String,
176+ val priority: Int = 0,
177+ val assigneeId: String? = null,
178+ val assignee: String? = null,
179+ val assigneeAgentOf: String? = null,
180+ val estimate: Int? = null,
181+ val dueDate: String? = null,
182+ val sortOrder: Double = 0.0,
183+ val labels: List<Label> = emptyList(),
184+ val createdAt: Long = 0,
185+ val updatedAt: Long = 0,
186+ val commentCount: Int = 0,
187+ val body: String? = null,
188+ val state: String? = null,
189+ )
190+
191+ @Serializable data class IssuesData(val canWrite: Boolean, val issues: List<Issue>, val labels: List<Label> = emptyList(), val members: List<Member> = emptyList())
192+
193+ @Serializable data class Comment(val id: String, val author: String, val agent: String? = null, val body: String, val createdAt: Long)
194+
195+ @Serializable
196+ data class IssueEvent(
197+ val id: String,
198+ val actor: String = "",
199+ val agent: String? = null,
200+ val field: String,
201+ val oldValue: String? = null,
202+ val newValue: String? = null,
203+ val createdAt: Long,
204+ )
205+
206+ @Serializable
207+ data class IssueDetail(
208+ val canWrite: Boolean,
209+ val issue: Issue,
210+ val comments: List<Comment> = emptyList(),
211+ val events: List<IssueEvent> = emptyList(),
212+ val labels: List<Label> = emptyList(),
213+ val members: List<Member> = emptyList(),
214+ )
215+
216+ @Serializable data class CreatedIssue(val number: Int)
217+
218+ // Pull requests
219+
220+ @Serializable
221+ data class Pull(
222+ val number: Int,
223+ val title: String,
224+ val body: String = "",
225+ val author: String = "",
226+ val agent: String? = null,
227+ val head: String = "",
228+ val headLabel: String = "",
229+ val base: String = "",
230+ val headSha: String = "",
231+ val state: String,
232+ val draft: Boolean = false,
233+ val mergeSha: String? = null,
234+ val mergeMethod: String? = null,
235+ val mergedBy: String? = null,
236+ val linkedIssues: List<Int> = emptyList(),
237+ val commentCount: Int = 0,
238+ val createdAt: Long = 0,
239+ val updatedAt: Long = 0,
240+ val closedAt: Long? = null,
241+ val mergedAt: Long? = null,
242+ )
243+
244+ @Serializable data class PullCounts(val open: Int = 0)
245+ @Serializable data class PullsData(val canWrite: Boolean, val counts: PullCounts = PullCounts(), val pulls: List<Pull>)
246+
247+ @Serializable data class Requirement(val key: String, val ok: Boolean, val label: String, val detail: String? = null)
248+
249+ @Serializable
250+ data class PullStatus(
251+ val headSha: String,
252+ val headExists: Boolean,
253+ val ahead: Int = 0,
254+ val behind: Int = 0,
255+ val conflicts: List<String>? = null,
256+ val checks: String = "none",
257+ val runs: List<RunInfo> = emptyList(),
258+ val approvals: List<String> = emptyList(),
259+ val changesRequestedBy: List<String> = emptyList(),
260+ val requirements: List<Requirement> = emptyList(),
261+ val mergeable: Boolean = false,
262+ )
263+
264+ @Serializable data class Review(val id: String, val author: String, val agent: String? = null, val state: String, val body: String = "", val commitSha: String = "", val createdAt: Long)
265+
266+ @Serializable
267+ data class ReviewThread(
268+ val path: String? = null,
269+ val line: Int? = null,
270+ val side: String? = null,
271+ val snippet: String? = null,
272+ val resolved: Boolean = false,
273+ val outdated: Boolean = false,
274+ val comments: List<Comment> = emptyList(),
275+ val createdAt: Long = 0,
276+ )
277+
278+ @Serializable data class PullEvent(val id: String, val actor: String? = null, val agent: String? = null, val kind: String, val data: JsonObject = JsonObject(emptyMap()), val createdAt: Long)
279+
280+ @Serializable
281+ data class PullDetail(
282+ val pull: Pull,
283+ val canWrite: Boolean,
284+ val canEdit: Boolean,
285+ val canMerge: Boolean,
286+ val status: PullStatus,
287+ val reviews: List<Review> = emptyList(),
288+ val comments: List<Comment> = emptyList(),
289+ val threads: List<ReviewThread> = emptyList(),
290+ val events: List<PullEvent> = emptyList(),
291+ val commits: List<CommitInfo> = emptyList(),
292+ )
293+
294+ // Actions and releases
295+
296+ @Serializable
297+ data class RunInfo(
298+ val number: Int,
299+ val workflowName: String,
300+ val workflowPath: String = "",
301+ val event: String = "",
302+ val ref: String = "",
303+ val sha: String = "",
304+ val title: String = "",
305+ val actor: String? = null,
306+ val agent: String? = null,
307+ val status: String,
308+ val error: String? = null,
309+ val createdAt: Long = 0,
310+ val startedAt: Long? = null,
311+ val finishedAt: Long? = null,
312+ )
313+
314+ @Serializable data class RunsData(val runs: List<RunInfo>, val hasMore: Boolean = false)
315+
316+ @Serializable
317+ data class StepInfo(
318+ val index: Int,
319+ val name: String,
320+ val status: String,
321+ val exitCode: Int? = null,
322+ val startedAt: Long? = null,
323+ val finishedAt: Long? = null,
324+ val log: String = "",
325+ val logTruncated: Boolean = false,
326+ )
327+
328+ @Serializable
329+ data class JobInfo(
330+ val key: String,
331+ val name: String,
332+ val status: String,
333+ val runner: String? = null,
334+ val startedAt: Long? = null,
335+ val finishedAt: Long? = null,
336+ val steps: List<StepInfo> = emptyList(),
337+ )
338+
339+ @Serializable data class RunDetail(val run: RunInfo, val jobs: List<JobInfo>)
340+
341+ @Serializable data class Asset(val name: String, val size: Long, val downloads: Int = 0, val url: String)
342+
343+ @Serializable
344+ data class Release(
345+ val tag: String,
346+ val title: String,
347+ val body: String = "",
348+ val draft: Boolean = false,
349+ val prerelease: Boolean = false,
350+ val author: String? = null,
351+ val createdAt: Long = 0,
352+ val publishedAt: Long? = null,
353+ val assets: List<Asset> = emptyList(),
354+ )
355+
356+ @Serializable data class ReleasesData(val releases: List<Release>)
+49−0mobile/app/src/main/java/cc/vectrum/app/data/ServerProbe.ktaddedView File
@@ -0,0 +1,49 @@
1+ package cc.vectrum.app.data
2+
3+ import okhttp3.HttpUrl
4+ import okhttp3.OkHttpClient
5+ import java.io.IOException
6+
7+ /** The hosted Vectrum. */
8+ const val HOSTED_SERVER = "https://vectrum.cc"
9+
10+ sealed interface Probe {
11+ /** A Vectrum with the app's API, at this (normalized) address. */
12+ data class Found(val url: HttpUrl, val info: ServerInfo) : Probe
13+
14+ /** Nothing usable there; the message says why, in words a person can act on. */
15+ data class Failed(val message: String) : Probe
16+ }
17+
18+ /**
19+ * Finds the Vectrum server behind what someone typed. Without a scheme it tries HTTPS first and then
20+ * plain HTTP (common for servers at home), and tells "no server", "not a Vectrum" and "a Vectrum too
21+ * old for the app" apart.
22+ */
23+ suspend fun probeServer(http: OkHttpClient, input: String): Probe {
24+ val typed = input.trim()
25+ if (typed.isEmpty()) return Probe.Failed("Enter your server's address.")
26+ val candidates = if (typed.contains("://")) listOfNotNull(normalizeServer(typed)) else listOfNotNull(normalizeServer("https://$typed"), normalizeServer("http://$typed"))
27+ if (candidates.isEmpty()) return Probe.Failed("That doesn't look like a server address. Try something like git.example.com or 192.168.1.10:3000.")
28+ var outcome: Probe? = null
29+ for (url in candidates) {
30+ val api = Api(http, url, null)
31+ try {
32+ return Probe.Found(url, api.serverInfo())
33+ } catch (e: ApiException) {
34+ // It answers, but not with the app's API: an older Vectrum, or something else entirely.
35+ if (outcome == null) {
36+ outcome = if (api.healthy()) {
37+ Probe.Failed("${url.host} runs Vectrum, but a version without the app's API. Once it's updated, you can connect.")
38+ } else {
39+ Probe.Failed("There's no Vectrum server at ${url.host}.")
40+ }
41+ }
42+ } catch (e: IOException) {
43+ // Unreachable at this scheme: try the next one.
44+ } catch (e: kotlinx.serialization.SerializationException) {
45+ if (outcome == null) outcome = Probe.Failed("There's no Vectrum server at ${url.host}.")
46+ }
47+ }
48+ return outcome ?: Probe.Failed("Can't reach ${candidates.first().host}. Check the address and your connection.")
49+ }
+71−0mobile/app/src/main/java/cc/vectrum/app/data/Session.ktaddedView File
@@ -0,0 +1,71 @@
1+ package cc.vectrum.app.data
2+
3+ import android.content.Context
4+ import androidx.datastore.preferences.core.Preferences
5+ import androidx.datastore.preferences.core.booleanPreferencesKey
6+ import androidx.datastore.preferences.core.edit
7+ import androidx.datastore.preferences.core.stringPreferencesKey
8+ import androidx.datastore.preferences.preferencesDataStore
9+ import cc.vectrum.app.ui.theme.ThemeMode
10+ import kotlinx.coroutines.flow.Flow
11+ import kotlinx.coroutines.flow.map
12+
13+ /** A signed-in account: which server, its session token, and who it is. */
14+ data class Session(val server: String, val token: String, val user: User)
15+
16+ /** `onboarded`: the setup (welcome, server, account) was finished once; later sign-ins go straight to the account step. */
17+ data class Settings(val session: Session?, val lastServer: String, val theme: ThemeMode, val onboarded: Boolean)
18+
19+ private val Context.store by preferencesDataStore(name = "vectrum")
20+
21+ /**
22+ * Where the app keeps the session and its preferences, in the app's private storage. The token is
23+ * the same 30-day session token the website keeps in its cookie; signing out forgets it.
24+ */
25+ class SessionStore(private val context: Context) {
26+ private object Keys {
27+ val server = stringPreferencesKey("server")
28+ val token = stringPreferencesKey("token")
29+ val user = stringPreferencesKey("user")
30+ val lastServer = stringPreferencesKey("last_server")
31+ val theme = stringPreferencesKey("theme")
32+ val onboarded = booleanPreferencesKey("onboarded")
33+ }
34+
35+ val settings: Flow<Settings> = context.store.data.map { p -> read(p) }
36+
37+ private fun read(p: Preferences): Settings {
38+ val server = p[Keys.server]
39+ val token = p[Keys.token]
40+ val user = p[Keys.user]?.let { runCatching { json.decodeFromString<User>(it) }.getOrNull() }
41+ val session = if (server != null && token != null && user != null) Session(server, token, user) else null
42+ val theme = p[Keys.theme]?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } } ?: ThemeMode.System
43+ return Settings(session, p[Keys.lastServer] ?: "", theme, p[Keys.onboarded] ?: false)
44+ }
45+
46+ suspend fun signIn(session: Session) {
47+ context.store.edit {
48+ it[Keys.server] = session.server
49+ it[Keys.token] = session.token
50+ it[Keys.user] = json.encodeToString(User.serializer(), session.user)
51+ it[Keys.lastServer] = session.server
52+ it[Keys.onboarded] = true
53+ }
54+ }
55+
56+ suspend fun updateUser(user: User) {
57+ context.store.edit { it[Keys.user] = json.encodeToString(User.serializer(), user) }
58+ }
59+
60+ suspend fun signOut() {
61+ context.store.edit {
62+ it.remove(Keys.token)
63+ it.remove(Keys.user)
64+ it.remove(Keys.server)
65+ }
66+ }
67+
68+ suspend fun setTheme(theme: ThemeMode) {
69+ context.store.edit { it[Keys.theme] = theme.name }
70+ }
71+ }
+320−0mobile/app/src/main/java/cc/vectrum/app/ui/App.ktaddedView File
@@ -0,0 +1,320 @@
1+ package cc.vectrum.app.ui
2+
3+ import androidx.compose.animation.AnimatedVisibility
4+ import androidx.compose.animation.EnterTransition
5+ import androidx.compose.animation.ExitTransition
6+ import androidx.compose.animation.core.tween
7+ import androidx.compose.animation.fadeIn
8+ import androidx.compose.animation.fadeOut
9+ import androidx.compose.animation.slideInHorizontally
10+ import androidx.compose.animation.slideInVertically
11+ import androidx.compose.animation.slideOutHorizontally
12+ import androidx.compose.animation.slideOutVertically
13+ import androidx.compose.foundation.background
14+ import androidx.compose.foundation.clickable
15+ import androidx.compose.foundation.interaction.MutableInteractionSource
16+ import androidx.compose.foundation.layout.Arrangement
17+ import androidx.compose.foundation.layout.Box
18+ import androidx.compose.foundation.layout.Row
19+ import androidx.compose.foundation.layout.Spacer
20+ import androidx.compose.foundation.layout.WindowInsets
21+ import androidx.compose.foundation.layout.fillMaxSize
22+ import androidx.compose.foundation.layout.fillMaxWidth
23+ import androidx.compose.foundation.layout.statusBars
24+ import androidx.compose.foundation.layout.windowInsetsTopHeight
25+ import androidx.compose.foundation.layout.height
26+ import androidx.compose.foundation.layout.navigationBars
27+ import androidx.compose.foundation.layout.padding
28+ import androidx.compose.foundation.layout.size
29+ import androidx.compose.foundation.layout.width
30+ import androidx.compose.foundation.layout.windowInsetsPadding
31+ import androidx.compose.material3.Icon
32+ import androidx.compose.material3.Text
33+ import androidx.compose.runtime.Composable
34+ import androidx.compose.runtime.CompositionLocalProvider
35+ import androidx.compose.runtime.LaunchedEffect
36+ import androidx.compose.runtime.getValue
37+ import androidx.compose.runtime.remember
38+ import androidx.compose.runtime.staticCompositionLocalOf
39+ import androidx.compose.ui.Alignment
40+ import androidx.compose.ui.Modifier
41+ import androidx.compose.ui.graphics.Brush
42+ import androidx.compose.ui.graphics.vector.ImageVector
43+ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44+ import androidx.compose.ui.platform.LocalHapticFeedback
45+ import androidx.compose.ui.platform.LocalUriHandler
46+ import androidx.compose.ui.unit.dp
47+ import androidx.navigation.NavDestination
48+ import androidx.navigation.NavDestination.Companion.hasRoute
49+ import androidx.navigation.NavGraph.Companion.findStartDestination
50+ import androidx.navigation.NavHostController
51+ import androidx.navigation.compose.NavHost
52+ import androidx.navigation.compose.composable
53+ import androidx.navigation.compose.currentBackStackEntryAsState
54+ import androidx.navigation.compose.rememberNavController
55+ import androidx.navigation.toRoute
56+ import cc.vectrum.app.data.Api
57+ import cc.vectrum.app.data.Session
58+ import cc.vectrum.app.ui.components.LocalToaster
59+ import cc.vectrum.app.ui.components.ToastHost
60+ import cc.vectrum.app.ui.components.Toaster
61+ import cc.vectrum.app.ui.icons.VIcons
62+ import cc.vectrum.app.ui.screens.CommitScreen
63+ import cc.vectrum.app.ui.screens.ExploreScreen
64+ import cc.vectrum.app.ui.screens.FileScreen
65+ import cc.vectrum.app.ui.screens.HomeScreen
66+ import cc.vectrum.app.ui.screens.IssueScreen
67+ import cc.vectrum.app.ui.screens.MeScreen
68+ import cc.vectrum.app.ui.screens.NewIssueScreen
69+ import cc.vectrum.app.ui.screens.ProfileScreen
70+ import cc.vectrum.app.ui.screens.PullScreen
71+ import cc.vectrum.app.ui.screens.RepoScreen
72+ import cc.vectrum.app.ui.screens.RunScreen
73+ import cc.vectrum.app.ui.screens.TreeScreen
74+ import cc.vectrum.app.ui.theme.Radii
75+ import cc.vectrum.app.ui.theme.ThemeMode
76+ import cc.vectrum.app.ui.theme.Vectrum
77+ import cc.vectrum.app.ui.theme.plate
78+ import cc.vectrum.app.ui.theme.raised
79+ import kotlinx.serialization.Serializable
80+
81+ // Routes
82+
83+ @Serializable object HomeRoute
84+ @Serializable object ExploreRoute
85+ @Serializable object MeRoute
86+ @Serializable data class ProfileRoute(val username: String)
87+ @Serializable data class RepoRoute(val owner: String, val name: String, val tab: String = "code")
88+ @Serializable data class TreeRoute(val owner: String, val name: String, val ref: String, val path: String)
89+ @Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String)
90+ @Serializable data class CommitRoute(val owner: String, val name: String, val sha: String)
91+ @Serializable data class IssueRoute(val owner: String, val name: String, val number: Int)
92+ @Serializable data class NewIssueRoute(val owner: String, val name: String)
93+ @Serializable data class PullRoute(val owner: String, val name: String, val number: Int)
94+ @Serializable data class RunRoute(val owner: String, val name: String, val number: Int)
95+
96+ val LocalApi = staticCompositionLocalOf<Api?> { null }
97+ val LocalSession = staticCompositionLocalOf<Session?> { null }
98+ val LocalNav = staticCompositionLocalOf<Nav> { error("No navigator") }
99+
100+ /** Everything the app can open, by what it is rather than by route. */
101+ class Nav(val controller: NavHostController, private val openUrl: (String) -> Unit, private val origin: String) {
102+ fun back() {
103+ controller.popBackStack()
104+ }
105+
106+ fun repo(owner: String, name: String, tab: String = "code") = controller.navigate(RepoRoute(owner, name, tab))
107+ fun tree(owner: String, name: String, ref: String, path: String) = controller.navigate(TreeRoute(owner, name, ref, path))
108+ fun file(owner: String, name: String, ref: String, path: String) = controller.navigate(FileRoute(owner, name, ref, path))
109+ fun commit(owner: String, name: String, sha: String) = controller.navigate(CommitRoute(owner, name, sha))
110+ fun issue(owner: String, name: String, number: Int) = controller.navigate(IssueRoute(owner, name, number))
111+ fun newIssue(owner: String, name: String) = controller.navigate(NewIssueRoute(owner, name))
112+ fun pull(owner: String, name: String, number: Int) = controller.navigate(PullRoute(owner, name, number))
113+ fun run(owner: String, name: String, number: Int) = controller.navigate(RunRoute(owner, name, number))
114+ fun profile(username: String) = controller.navigate(ProfileRoute(username))
115+
116+ /** Switches to a top-level tab (Home, Explore, You), keeping each tab's state. */
117+ fun tab(route: Any) = controller.navigate(route) {
118+ popUpTo(controller.graph.findStartDestination().id) { saveState = true }
119+ launchSingleTop = true
120+ restoreState = true
121+ }
122+
123+ /** "#12" in a repository: an issue or a pull request (they share numbers); the issue screen hands over to the pull request if it is one. */
124+ fun number(owner: String, name: String, number: Int) = issue(owner, name, number)
125+
126+ /** A link in Markdown: pages of this server open in the app, everything else in the browser. */
127+ fun link(url: String, owner: String? = null, name: String? = null, ref: String? = null, baseDir: String = "") {
128+ val scheme = Regex("^([a-z][a-z0-9+.-]*):", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.lowercase()
129+ val local = when {
130+ url == origin || url.startsWith("$origin/") -> url.removePrefix(origin).ifEmpty { "/" }
131+ url.startsWith("//") -> return openUrl("https:$url")
132+ url.startsWith("/") -> url
133+ url.startsWith("#") -> return
134+ scheme != null -> {
135+ // Links in documents are written by anyone: only the web and mail leave the app.
136+ if (scheme == "http" || scheme == "https" || scheme == "mailto") openUrl(url)
137+ return
138+ }
139+ owner != null && name != null -> {
140+ // A path in the repository, relative to the document (in issues and comments: the default branch).
141+ val path = normalize(if (baseDir.isEmpty()) url else "$baseDir/$url").substringBefore('#').substringBefore('?')
142+ if (path.isEmpty()) repo(owner, name) else file(owner, name, ref ?: "HEAD", path)
143+ return
144+ }
145+ else -> null
146+ }
147+ if (local != null && !openLocal(local)) openUrl(origin + local)
148+ }
149+
150+ private fun openLocal(path: String): Boolean {
151+ val parts = path.substringBefore('?').substringBefore('#').trim('/').split('/').filter { it.isNotEmpty() }
152+ if (parts.isEmpty()) return false
153+ if (parts.size == 1) {
154+ profile(parts[0]); return true
155+ }
156+ val (owner, name) = parts[0] to parts[1].removeSuffix(".git")
157+ val rest = parts.drop(2)
158+ when (rest.firstOrNull()) {
159+ null -> repo(owner, name)
160+ "issues" -> rest.getOrNull(1)?.toIntOrNull()?.let { issue(owner, name, it) } ?: repo(owner, name, "issues")
161+ "pulls" -> rest.getOrNull(1)?.toIntOrNull()?.let { pull(owner, name, it) } ?: repo(owner, name, "pulls")
162+ "commit" -> rest.getOrNull(1)?.let { commit(owner, name, it) } ?: return false
163+ "commits" -> repo(owner, name, "commits")
164+ "actions" -> if (rest.getOrNull(1) == "runs") rest.getOrNull(2)?.toIntOrNull()?.let { run(owner, name, it) } ?: return false else repo(owner, name, "actions")
165+ "releases" -> repo(owner, name, "releases")
166+ "blob" -> if (rest.size >= 3) file(owner, name, rest[1], rest.drop(2).joinToString("/")) else return false
167+ "tree" -> if (rest.size >= 3) tree(owner, name, rest[1], rest.drop(2).joinToString("/")) else repo(owner, name)
168+ else -> return false
169+ }
170+ return true
171+ }
172+
173+ private fun normalize(path: String): String {
174+ val out = ArrayDeque<String>()
175+ for (p in path.split('/')) when (p) {
176+ "", "." -> {}
177+ ".." -> out.removeLastOrNull()
178+ else -> out.addLast(p)
179+ }
180+ return out.joinToString("/")
181+ }
182+ }
183+
184+ private val enter: EnterTransition = slideInHorizontally(tween(320)) { it / 3 } + fadeIn(tween(240))
185+ private val exit: ExitTransition = slideOutHorizontally(tween(320)) { -it / 10 } + fadeOut(tween(200))
186+ private val popEnter: EnterTransition = slideInHorizontally(tween(320)) { -it / 10 } + fadeIn(tween(240))
187+ private val popExit: ExitTransition = slideOutHorizontally(tween(320)) { it / 3 } + fadeOut(tween(200))
188+
189+ /** The signed-in app: the screens, the floating tab bar, and toasts. */
190+ @Composable
191+ fun MainShell(session: Session, api: Api, theme: ThemeMode, onTheme: (ThemeMode) -> Unit, onSignOut: () -> Unit) {
192+ val controller = rememberNavController()
193+ val uri = LocalUriHandler.current
194+ val nav = remember(controller, api) { Nav(controller, { runCatching { uri.openUri(it) } }, api.origin) }
195+ val toaster = remember { Toaster() }
196+ val entry by controller.currentBackStackEntryAsState()
197+ val destination = entry?.destination
198+ val topLevel = destination == null || destination.hasRoute<HomeRoute>() || destination.hasRoute<ExploreRoute>() || destination.hasRoute<MeRoute>()
199+
200+ LaunchedEffect(Unit) {
201+ AppEvents.sessionExpired.collect {
202+ toaster.error("Your session has ended. Sign in again.")
203+ onSignOut()
204+ }
205+ }
206+
207+ CompositionLocalProvider(LocalApi provides api, LocalSession provides session, LocalNav provides nav, LocalToaster provides toaster) {
208+ Box(Modifier.fillMaxSize().background(Vectrum.colors.page)) {
209+ NavHost(
210+ controller,
211+ startDestination = HomeRoute,
212+ enterTransition = { enter },
213+ exitTransition = { exit },
214+ popEnterTransition = { popEnter },
215+ popExitTransition = { popExit },
216+ ) {
217+ composable<HomeRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) { HomeScreen() }
218+ composable<ExploreRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) { ExploreScreen() }
219+ composable<MeRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) {
220+ MeScreen(theme = theme, onTheme = onTheme, onSignOut = onSignOut)
221+ }
222+ composable<ProfileRoute> { ProfileScreen(it.toRoute<ProfileRoute>().username) }
223+ composable<RepoRoute> { val r = it.toRoute<RepoRoute>(); RepoScreen(r.owner, r.name, r.tab) }
224+ composable<TreeRoute> { val r = it.toRoute<TreeRoute>(); TreeScreen(r.owner, r.name, r.ref, r.path) }
225+ composable<FileRoute> { val r = it.toRoute<FileRoute>(); FileScreen(r.owner, r.name, r.ref, r.path) }
226+ composable<CommitRoute> { val r = it.toRoute<CommitRoute>(); CommitScreen(r.owner, r.name, r.sha) }
227+ composable<IssueRoute> { val r = it.toRoute<IssueRoute>(); IssueScreen(r.owner, r.name, r.number) }
228+ composable<NewIssueRoute>(enterTransition = { slideInVertically(tween(320)) { it / 4 } + fadeIn() }, popExitTransition = { slideOutVertically(tween(260)) { it / 4 } + fadeOut() }) {
229+ val r = it.toRoute<NewIssueRoute>(); NewIssueScreen(r.owner, r.name)
230+ }
231+ composable<PullRoute> { val r = it.toRoute<PullRoute>(); PullScreen(r.owner, r.name, r.number) }
232+ composable<RunRoute> { val r = it.toRoute<RunRoute>(); RunScreen(r.owner, r.name, r.number) }
233+ }
234+
235+ if (topLevel) {
236+ // The page fades in under the status bar and behind the tab bar, so content scrolls away beneath them.
237+ val page = Vectrum.colors.page
238+ Box(Modifier.align(Alignment.TopCenter).fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(page.copy(alpha = 0.96f)))
239+ Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(120.dp).background(Brush.verticalGradient(listOf(page.copy(alpha = 0f), page.copy(alpha = 0.92f)))))
240+ }
241+ AnimatedVisibility(
242+ visible = topLevel,
243+ modifier = Modifier.align(Alignment.BottomCenter),
244+ enter = slideInVertically { it } + fadeIn(),
245+ exit = slideOutVertically { it } + fadeOut(),
246+ ) {
247+ TabBar(
248+ current = when {
249+ destination?.hasRoute<ExploreRoute>() == true -> 1
250+ destination?.hasRoute<MeRoute>() == true -> 2
251+ else -> 0
252+ },
253+ onSelect = { index ->
254+ val route: Any = when (index) {
255+ 1 -> ExploreRoute
256+ 2 -> MeRoute
257+ else -> HomeRoute
258+ }
259+ nav.tab(route)
260+ },
261+ )
262+ }
263+ ToastHost(toaster)
264+ }
265+ }
266+ }
267+
268+ private fun exitFor(target: NavDestination): ExitTransition =
269+ if (target.hasRoute<HomeRoute>() || target.hasRoute<ExploreRoute>() || target.hasRoute<MeRoute>()) fadeOut(tween(160)) else exit
270+
271+ private data class Tab(val label: String, val icon: ImageVector)
272+
273+ /** The floating tab bar: a raised plate with the current tab's key standing out of it. */
274+ @Composable
275+ private fun TabBar(current: Int, onSelect: (Int) -> Unit) {
276+ val c = Vectrum.colors
277+ val haptics = LocalHapticFeedback.current
278+ val tabs = listOf(Tab("Home", VIcons.Home), Tab("Explore", VIcons.Compass), Tab("You", VIcons.Person))
279+ Box(Modifier.windowInsetsPadding(WindowInsets.navigationBars).padding(bottom = 14.dp)) {
280+ Row(
281+ Modifier.plate(Radii.pill).padding(5.dp),
282+ horizontalArrangement = Arrangement.spacedBy(2.dp),
283+ verticalAlignment = Alignment.CenterVertically,
284+ ) {
285+ tabs.forEachIndexed { i, tab ->
286+ val active = i == current
287+ Row(
288+ Modifier
289+ .height(48.dp)
290+ .then(if (active) Modifier.raised(Radii.pill) else Modifier)
291+ .clickable(remember { MutableInteractionSource() }, indication = null) {
292+ if (!active) haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
293+ onSelect(i)
294+ }
295+ .padding(horizontal = if (active) 18.dp else 16.dp),
296+ verticalAlignment = Alignment.CenterVertically,
297+ ) {
298+ Icon(tab.icon, tab.label, Modifier.size(21.dp), tint = if (active) c.ink else c.ink3)
299+ AnimatedVisibility(active) {
300+ Row {
301+ Spacer(Modifier.width(8.dp))
302+ Text(tab.label, style = Vectrum.type.ui, color = c.ink)
303+ }
304+ }
305+ }
306+ }
307+ }
308+ }
309+ }
310+
311+ @Composable
312+ fun SignedOutShell(content: @Composable () -> Unit) {
313+ val toaster = remember { Toaster() }
314+ CompositionLocalProvider(LocalToaster provides toaster) {
315+ Box(Modifier.fillMaxSize().background(Vectrum.colors.page)) {
316+ content()
317+ ToastHost(toaster)
318+ }
319+ }
320+ }
+90−0mobile/app/src/main/java/cc/vectrum/app/ui/Format.ktaddedView File
@@ -0,0 +1,90 @@
1+ package cc.vectrum.app.ui
2+
3+ import java.text.SimpleDateFormat
4+ import java.util.Calendar
5+ import java.util.Date
6+ import java.util.Locale
7+ import kotlin.math.abs
8+ import kotlin.math.roundToLong
9+
10+ // The website's formats (lib/format.ts): "3 days ago", "Oct 3, 2026", "1.2 MB", "3m 12s".
11+
12+ private val units = listOf(
13+ "year" to 365L * 24 * 3600_000,
14+ "month" to 30L * 24 * 3600_000,
15+ "week" to 7L * 24 * 3600_000,
16+ "day" to 24L * 3600_000,
17+ "hour" to 3600_000L,
18+ "minute" to 60_000L,
19+ )
20+
21+ fun timeAgo(ms: Long, now: Long = System.currentTimeMillis()): String {
22+ val diff = ms - now
23+ for ((unit, size) in units) {
24+ if (abs(diff) >= size) {
25+ val n = (diff.toDouble() / size).roundToLong()
26+ // Intl.RelativeTimeFormat's "auto" wording, as on the website: "yesterday", "last week", "1 hour ago".
27+ val named = unit == "day" || unit == "week" || unit == "month" || unit == "year"
28+ return when {
29+ n == -1L && named -> if (unit == "day") "yesterday" else "last $unit"
30+ n == 1L && named -> if (unit == "day") "tomorrow" else "next $unit"
31+ n < 0 -> "${-n} $unit${if (n == -1L) "" else "s"} ago"
32+ else -> "in $n $unit${if (n == 1L) "" else "s"}"
33+ }
34+ }
35+ }
36+ return "just now"
37+ }
38+
39+ /** "2h", "3d", "5w": for tight rows. */
40+ fun shortAgo(ms: Long, now: Long = System.currentTimeMillis()): String {
41+ val s = (now - ms).coerceAtLeast(0) / 1000
42+ return when {
43+ s < 60 -> "now"
44+ s < 3600 -> "${s / 60}m"
45+ s < 86400 -> "${s / 3600}h"
46+ s < 7 * 86400 -> "${s / 86400}d"
47+ s < 365 * 86400 -> "${s / (7 * 86400)}w"
48+ else -> "${s / (365 * 86400)}y"
49+ }
50+ }
51+
52+ fun formatDate(ms: Long): String = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).format(Date(ms))
53+
54+ fun formatBytes(n: Long): String = when {
55+ n < 1024 -> "$n Bytes"
56+ n < 1024 * 1024 -> String.format(Locale.ENGLISH, "%.1f KB", n / 1024.0)
57+ n < 1024L * 1024 * 1024 -> String.format(Locale.ENGLISH, "%.1f MB", n / 1024.0 / 1024.0)
58+ else -> String.format(Locale.ENGLISH, "%.2f GB", n / 1024.0 / 1024.0 / 1024.0)
59+ }
60+
61+ fun formatDuration(ms: Long): String {
62+ val s = (ms / 1000.0).roundToLong().coerceAtLeast(0)
63+ if (s < 60) return "${s}s"
64+ val m = s / 60
65+ if (m < 60) return "${m}m ${s % 60}s"
66+ return "${m / 60}h ${m % 60}m"
67+ }
68+
69+ fun shortSha(sha: String) = sha.take(7)
70+
71+ fun plural(n: Int, word: String, words: String = "${word}s") = "$n ${if (n == 1) word else words}"
72+
73+ /** "Good morning", like the website's dashboard. */
74+ fun greeting(): String {
75+ val h = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
76+ return when {
77+ h < 5 -> "Good evening"
78+ h < 12 -> "Good morning"
79+ h < 18 -> "Good afternoon"
80+ else -> "Good evening"
81+ }
82+ }
83+
84+ /** "Oct 3" for a YYYY-MM-DD date, without time zone conversions. */
85+ fun formatDay(date: String): String {
86+ val parts = date.split("-").mapNotNull { it.toIntOrNull() }
87+ if (parts.size != 3) return date
88+ val months = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
89+ return "${months.getOrElse(parts[1] - 1) { "?" }} ${parts[2]}"
90+ }
+175−0mobile/app/src/main/java/cc/vectrum/app/ui/Loader.ktaddedView File
@@ -0,0 +1,175 @@
1+ package cc.vectrum.app.ui
2+
3+ import androidx.compose.runtime.Composable
4+ import androidx.compose.runtime.getValue
5+ import androidx.compose.runtime.remember
6+ import androidx.compose.runtime.rememberUpdatedState
7+ import androidx.lifecycle.compose.LifecycleResumeEffect
8+ import androidx.compose.runtime.mutableStateListOf
9+ import androidx.compose.runtime.mutableStateOf
10+ import androidx.compose.runtime.setValue
11+ import androidx.lifecycle.ViewModel
12+ import androidx.lifecycle.viewModelScope
13+ import androidx.lifecycle.viewmodel.compose.viewModel
14+ import cc.vectrum.app.data.ApiException
15+ import kotlinx.coroutines.CancellationException
16+ import kotlinx.coroutines.Job
17+ import kotlinx.coroutines.flow.MutableSharedFlow
18+ import kotlinx.coroutines.flow.asSharedFlow
19+ import kotlinx.coroutines.launch
20+ import kotlinx.serialization.SerializationException
21+ import java.io.IOException
22+
23+ sealed interface Load<out T> {
24+ data object Loading : Load<Nothing>
25+ data class Ready<T>(val data: T) : Load<T>
26+ data class Failed(val message: String) : Load<Nothing>
27+ }
28+
29+ /** Things anywhere in the app tell the shell about: the session ran out. */
30+ object AppEvents {
31+ private val expired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
32+ val sessionExpired = expired.asSharedFlow()
33+ fun sessionExpired() {
34+ expired.tryEmit(Unit)
35+ }
36+ }
37+
38+ /** A failure in words for a person; a 401 also signs the app out. */
39+ fun describe(e: Throwable): String = when (e) {
40+ is ApiException -> {
41+ if (e.isUnauthorized) AppEvents.sessionExpired()
42+ e.message ?: "Something went wrong."
43+ }
44+ is IOException -> "Can't reach the server. Check your connection and try again."
45+ is SerializationException -> "The server sent something this app doesn't understand. Is it up to date?"
46+ else -> e.message ?: "Something went wrong."
47+ }
48+
49+ /**
50+ * Loads a screen's data once and keeps it across recompositions and rotation (it lives as long as its
51+ * screen's back stack entry). Pull to refresh reloads it in place; actions replace it with what the
52+ * server answered.
53+ */
54+ class Loader<T>(private val fetch: suspend () -> T) : ViewModel() {
55+ var state: Load<T> by mutableStateOf(Load.Loading)
56+ private set
57+ var refreshing by mutableStateOf(false)
58+ private set
59+ private var job: Job? = null
60+
61+ init {
62+ load()
63+ }
64+
65+ /** Loads again: in place (refresh) when there's data on screen, else from the loading state. */
66+ fun load(refresh: Boolean = state is Load.Ready) {
67+ job?.cancel()
68+ if (refresh) refreshing = true else state = Load.Loading
69+ job = viewModelScope.launch {
70+ try {
71+ state = Load.Ready(fetch())
72+ } catch (e: CancellationException) {
73+ throw e
74+ } catch (e: Exception) {
75+ // A failed refresh keeps what's on screen.
76+ if (!refresh || state !is Load.Ready) state = Load.Failed(describe(e))
77+ } finally {
78+ refreshing = false
79+ }
80+ }
81+ }
82+
83+ fun set(data: T) {
84+ job?.cancel()
85+ refreshing = false
86+ state = Load.Ready(data)
87+ }
88+
89+ val data: T? get() = (state as? Load.Ready<T>)?.data
90+ }
91+
92+ @Composable
93+ fun <T> rememberLoader(key: String, fetch: suspend () -> T): Loader<T> {
94+ @Suppress("UNCHECKED_CAST")
95+ return viewModel<Loader<*>>(key = key) { Loader(fetch) } as Loader<T>
96+ }
97+
98+ /** A list that loads page by page ("Load more"), kept like a [Loader]. */
99+ class Pager<T>(private val fetch: suspend (page: Int) -> Pair<List<T>, Boolean>) : ViewModel() {
100+ val items = mutableStateListOf<T>()
101+ var hasMore by mutableStateOf(true)
102+ private set
103+ var loading by mutableStateOf(false)
104+ private set
105+ var error by mutableStateOf<String?>(null)
106+ private set
107+ var loaded by mutableStateOf(false)
108+ private set
109+ private var page = 0
110+ private var job: Job? = null
111+
112+ init {
113+ more()
114+ }
115+
116+ fun more() {
117+ if (loading || !hasMore) return
118+ loading = true
119+ job = viewModelScope.launch {
120+ try {
121+ val (next, more) = fetch(page + 1)
122+ page += 1
123+ items.addAll(next)
124+ hasMore = more
125+ error = null
126+ loaded = true
127+ } catch (e: CancellationException) {
128+ throw e
129+ } catch (e: Exception) {
130+ error = describe(e)
131+ } finally {
132+ loading = false
133+ }
134+ }
135+ }
136+
137+ fun refresh() {
138+ job?.cancel()
139+ loading = false
140+ page = 0
141+ hasMore = true
142+ viewModelScope.launch {
143+ try {
144+ val (first, more) = fetch(1)
145+ items.clear()
146+ items.addAll(first)
147+ page = 1
148+ hasMore = more
149+ error = null
150+ loaded = true
151+ } catch (e: CancellationException) {
152+ throw e
153+ } catch (e: Exception) {
154+ error = describe(e)
155+ }
156+ }
157+ }
158+ }
159+
160+ @Composable
161+ fun <T> rememberPager(key: String, fetch: suspend (page: Int) -> Pair<List<T>, Boolean>): Pager<T> {
162+ @Suppress("UNCHECKED_CAST")
163+ return viewModel<Pager<*>>(key = key) { Pager(fetch) } as Pager<T>
164+ }
165+
166+ /** Runs `onReturn` when the screen comes back into view (after a screen on top of it closes), not on first show. */
167+ @Composable
168+ fun RefreshOnReturn(onReturn: () -> Unit) {
169+ val first = remember { mutableStateOf(true) }
170+ val action by rememberUpdatedState(onReturn)
171+ LifecycleResumeEffect(Unit) {
172+ if (first.value) first.value = false else action()
173+ onPauseOrDispose {}
174+ }
175+ }
+311−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Code.ktaddedView File
@@ -0,0 +1,311 @@
1+ package cc.vectrum.app.ui.components
2+
3+ import androidx.compose.foundation.horizontalScroll
4+ import androidx.compose.foundation.layout.Box
5+ import androidx.compose.foundation.layout.Column
6+ import androidx.compose.foundation.layout.PaddingValues
7+ import androidx.compose.foundation.layout.Row
8+ import androidx.compose.foundation.layout.fillMaxSize
9+ import androidx.compose.foundation.layout.fillMaxWidth
10+ import androidx.compose.foundation.layout.padding
11+ import androidx.compose.foundation.layout.requiredWidth
12+ import androidx.compose.foundation.layout.width
13+ import androidx.compose.foundation.lazy.LazyColumn
14+ import androidx.compose.foundation.lazy.LazyListState
15+ import androidx.compose.foundation.lazy.itemsIndexed
16+ import androidx.compose.foundation.lazy.rememberLazyListState
17+ import androidx.compose.foundation.rememberScrollState
18+ import androidx.compose.foundation.text.selection.SelectionContainer
19+ import androidx.compose.material3.Text
20+ import androidx.compose.runtime.Composable
21+ import androidx.compose.runtime.getValue
22+ import androidx.compose.runtime.produceState
23+ import androidx.compose.runtime.remember
24+ import androidx.compose.ui.Modifier
25+ import androidx.compose.ui.graphics.Color
26+ import androidx.compose.ui.platform.LocalDensity
27+ import androidx.compose.ui.text.AnnotatedString
28+ import androidx.compose.ui.text.SpanStyle
29+ import androidx.compose.ui.text.buildAnnotatedString
30+ import androidx.compose.ui.text.font.FontStyle
31+ import androidx.compose.ui.text.rememberTextMeasurer
32+ import androidx.compose.ui.text.style.TextAlign
33+ import androidx.compose.ui.text.withStyle
34+ import androidx.compose.ui.unit.dp
35+ import cc.vectrum.app.ui.theme.Syntax
36+ import kotlinx.coroutines.Dispatchers
37+ import kotlinx.coroutines.withContext
38+ import cc.vectrum.app.ui.theme.Vectrum
39+
40+ /*
41+ * A small highlighter in MetalUI's syntax colours (--mu-syn-*): comments, strings, numbers, keywords,
42+ * types and calls, for the languages people keep in repositories. Not a parser; it's there to make
43+ * code easy to scan on a phone, as Shiki does on the website.
44+ */
45+
46+ private enum class Family { CLike, Python, Shell, Ruby, Sql, Markup, Css, Yaml, Toml, Json, Plain }
47+
48+ private fun familyOf(lang: String): Family = when (lang.lowercase()) {
49+ "js", "mjs", "cjs", "jsx", "ts", "tsx", "javascript", "typescript", "java", "kt", "kts", "kotlin", "c", "h", "cc", "cpp", "hpp", "cs",
50+ "go", "rs", "rust", "swift", "scala", "dart", "php", "groovy", "gradle", "zig", "m", "mm", "proto", "graphql", "gql", "sol" -> Family.CLike
51+ "py", "python", "pyi" -> Family.Python
52+ "sh", "bash", "zsh", "fish", "shell", "console", "dockerfile", "makefile", "mk", "env", "ps1" -> Family.Shell
53+ "rb", "ruby", "ex", "exs", "elixir", "lua", "pl", "perl", "r" -> Family.Ruby
54+ "sql" -> Family.Sql
55+ "html", "htm", "xml", "svg", "vue", "svelte", "astro", "plist" -> Family.Markup
56+ "css", "scss", "sass", "less" -> Family.Css
57+ "yml", "yaml" -> Family.Yaml
58+ "toml", "ini", "cfg", "conf", "editorconfig", "properties" -> Family.Toml
59+ "json", "jsonc", "json5", "lock" -> Family.Json
60+ else -> Family.Plain
61+ }
62+
63+ /** The language of a file by its name, for highlighting ("" when unknown). */
64+ fun languageOf(path: String): String {
65+ val name = path.substringAfterLast('/')
66+ return when {
67+ name.equals("Dockerfile", true) -> "dockerfile"
68+ name.equals("Makefile", true) -> "makefile"
69+ name.startsWith(".env") -> "env"
70+ name.startsWith(".") && !name.substring(1).contains('.') -> name.substring(1)
71+ else -> name.substringAfterLast('.', "")
72+ }
73+ }
74+
75+ private val KEYWORDS = setOf(
76+ // C-like
77+ "abstract", "as", "async", "await", "break", "case", "catch", "class", "const", "continue", "data", "debugger", "default", "defer", "delete",
78+ "do", "else", "enum", "export", "extends", "false", "final", "finally", "fn", "for", "from", "fun", "func", "function", "go", "if", "impl",
79+ "implements", "import", "in", "instanceof", "interface", "internal", "is", "let", "match", "mod", "module", "mut", "namespace", "new", "nil",
80+ "null", "object", "of", "open", "override", "package", "private", "protected", "pub", "public", "readonly", "return", "sealed", "self",
81+ "static", "struct", "super", "suspend", "switch", "this", "throw", "throws", "trait", "true", "try", "type", "typeof", "undefined", "use",
82+ "val", "var", "void", "when", "where", "while", "with", "yield", "crate", "dyn", "loop", "ref", "unsafe", "chan", "select", "range", "map",
83+ "satisfies", "keyof", "declare", "lateinit", "companion", "init", "inline", "reified", "operator", "infix", "guard", "inout", "extension",
84+ "protocol", "struct", "char", "int", "long", "float", "double", "bool", "boolean", "string", "unsigned", "signed", "goto", "sizeof",
85+ // Python, Ruby, shell
86+ "and", "def", "del", "elif", "except", "global", "lambda", "nonlocal", "not", "or", "pass", "raise", "None", "True", "False", "end",
87+ "begin", "rescue", "ensure", "unless", "until", "then", "fi", "esac", "done", "local", "export", "echo", "require", "elsif", "puts",
88+ )
89+ private val SQL_KEYWORDS = setOf(
90+ "select", "from", "where", "insert", "into", "values", "update", "set", "delete", "create", "table", "drop", "alter", "add", "join", "left",
91+ "right", "inner", "outer", "on", "group", "by", "order", "having", "limit", "offset", "and", "or", "not", "null", "is", "as", "distinct",
92+ "primary", "key", "foreign", "references", "index", "unique", "default", "union", "all", "case", "when", "then", "else", "end", "in", "like",
93+ )
94+
95+ // Matched at a position (Regex.matchAt), so no "^" anchors.
96+ private val NUMBER = Regex("0x[0-9a-fA-F_]+|\\d[\\d_]*(\\.\\d+)?([eE][+-]?\\d+)?[a-zA-Z]*")
97+ private val WORD = Regex("[A-Za-z_\$][A-Za-z0-9_\$]*")
98+ private val TAG = Regex("</?[A-Za-z][\\w:.-]*")
99+ private val YAML_KEY = Regex("[\\w.-]+(?=:(\\s|$))")
100+ private val HEX_COLOR = Regex("#[0-9a-fA-F]{3,8}\\b")
101+ private val DECORATOR = Regex("@[\\w.]+")
102+
103+ /** Lines longer than this are shown uncoloured. */
104+ private const val MAX_LINE = 2_000
105+
106+ /** Files larger than this are shown uncoloured. */
107+ private const val MAX_HIGHLIGHT = 512 * 1024
108+
109+ /** Highlights code line by line, carrying open block comments and multi-line strings across lines. */
110+ class Highlighter(lang: String, private val syn: Syntax) {
111+ private val family = familyOf(lang)
112+ private var inBlockComment = false
113+ private var inTripleString: String? = null
114+
115+ private val lineComment: List<String> = when (family) {
116+ Family.CLike -> listOf("//")
117+ Family.Python, Family.Shell, Family.Ruby, Family.Yaml, Family.Toml -> listOf("#")
118+ Family.Sql -> listOf("--")
119+ Family.Css -> emptyList()
120+ Family.Json -> listOf("//")
121+ else -> emptyList()
122+ }
123+ private val blockComment: Pair<String, String>? = when (family) {
124+ Family.CLike, Family.Css, Family.Json -> "/*" to "*/"
125+ Family.Markup -> "<!--" to "-->"
126+ else -> null
127+ }
128+
129+ fun line(text: String): AnnotatedString = buildAnnotatedString {
130+ // Very long lines (minified code, data) aren't worth colouring and would slow the screen.
131+ if (family == Family.Plain || text.length > MAX_LINE) {
132+ withStyle(SpanStyle(color = syn.text)) { append(text) }
133+ return@buildAnnotatedString
134+ }
135+ var i = 0
136+ val n = text.length
137+ val indentEnd = text.indexOfFirst { !it.isWhitespace() }.let { if (it < 0) n else it }
138+ fun emit(end: Int, color: Color, italic: Boolean = false) {
139+ withStyle(SpanStyle(color = color, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal)) { append(text, i, end) }
140+ i = end
141+ }
142+ /** The first character after `from` that isn't a space, or 0. */
143+ fun nextChar(from: Int): Char {
144+ var j = from
145+ while (j < n && text[j].isWhitespace()) j++
146+ return if (j < n) text[j] else 0.toChar()
147+ }
148+ fun match(regex: Regex): Int = regex.matchAt(text, i)?.value?.length ?: 0
149+
150+ while (i < n) {
151+ // Inside a block comment or a triple-quoted string from an earlier line.
152+ if (inBlockComment) {
153+ val close = blockComment!!.second
154+ val end = text.indexOf(close, i)
155+ if (end < 0) { emit(n, syn.comment, true); break }
156+ emit(end + close.length, syn.comment, true)
157+ inBlockComment = false
158+ continue
159+ }
160+ val open = inTripleString
161+ if (open != null) {
162+ val end = text.indexOf(open, i)
163+ if (end < 0) { emit(n, syn.string); break }
164+ emit(end + open.length, syn.string)
165+ inTripleString = null
166+ continue
167+ }
168+ val ch = text[i]
169+ when {
170+ lineComment.any { text.startsWith(it, i) } && !(family == Family.Shell && i > 0 && text[i - 1] == '$') -> emit(n, syn.comment, true)
171+ blockComment != null && text.startsWith(blockComment.first, i) -> {
172+ val end = text.indexOf(blockComment.second, i + blockComment.first.length)
173+ if (end < 0) { inBlockComment = true; emit(n, syn.comment, true) } else emit(end + blockComment.second.length, syn.comment, true)
174+ }
175+ (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) && (family == Family.Python || family == Family.CLike || family == Family.Toml) -> {
176+ val q = text.substring(i, i + 3)
177+ val end = text.indexOf(q, i + 3)
178+ if (end < 0) { inTripleString = q; emit(n, syn.string) } else emit(end + 3, syn.string)
179+ }
180+ ch == '"' || ch == '\'' || ch == '`' -> {
181+ var j = i + 1
182+ while (j < n && text[j] != ch) j += if (text[j] == '\\') 2 else 1
183+ val end = minOf(j + 1, n)
184+ // In JSON, a string before a colon is a key.
185+ emit(end, if (family == Family.Json && nextChar(end) == ':') syn.member else syn.string)
186+ }
187+ family == Family.Markup && ch == '<' -> {
188+ val len = match(TAG)
189+ if (len > 0) emit(i + len, syn.keyword) else emit(i + 1, syn.punct)
190+ }
191+ family == Family.Yaml && i == indentEnd && match(YAML_KEY) > 0 -> emit(i + match(YAML_KEY), syn.member)
192+ family == Family.Toml && ch == '[' && i == indentEnd -> emit(n, syn.type)
193+ family == Family.Css && ch == '#' && match(HEX_COLOR) > 0 -> emit(i + match(HEX_COLOR), syn.number)
194+ ch.isDigit() -> emit(i + maxOf(1, match(NUMBER)), syn.number)
195+ ch == '@' && family != Family.Markup -> emit(i + maxOf(1, match(DECORATOR)), syn.type)
196+ ch.isLetter() || ch == '_' || ch == '$' -> {
197+ val len = maxOf(1, match(WORD))
198+ val word = text.substring(i, i + len)
199+ val after = i + len
200+ val color = when {
201+ family == Family.Sql && word.lowercase() in SQL_KEYWORDS -> syn.keyword
202+ family != Family.Sql && word in KEYWORDS -> syn.keyword
203+ family == Family.Shell && ch == '$' -> syn.member
204+ after < n && text[after] == '(' -> syn.member
205+ word[0].isUpperCase() && word.length > 1 -> syn.type
206+ family == Family.Toml && i == indentEnd && nextChar(after) == '=' -> syn.member
207+ family == Family.Css && nextChar(after) == ':' -> syn.member
208+ else -> syn.text
209+ }
210+ emit(after, color)
211+ }
212+ ch.isWhitespace() -> {
213+ var j = i
214+ while (j < n && text[j].isWhitespace()) j++
215+ emit(j, syn.text)
216+ }
217+ else -> emit(i + 1, syn.punct)
218+ }
219+ }
220+ }
221+ }
222+
223+ /**
224+ * Highlighted lines of a whole text. The plain lines show at once; the colours are worked out away from
225+ * the main thread and swapped in (and skipped for very large files).
226+ */
227+ @Composable
228+ fun rememberHighlighted(text: String, lang: String): List<AnnotatedString> {
229+ val syn = Vectrum.colors.syntax
230+ val plain = remember(text) {
231+ text.removeSuffix("\n").split('\n').map { line -> AnnotatedString(line.replace("\t", " "), SpanStyle(color = syn.text)) }
232+ }
233+ val colored by produceState(plain, text, lang, syn) {
234+ if (text.length <= MAX_HIGHLIGHT) {
235+ value = withContext(Dispatchers.Default) {
236+ val h = Highlighter(lang, syn)
237+ plain.map { h.line(it.text) }
238+ }
239+ }
240+ }
241+ return colored
242+ }
243+
244+ /**
245+ * A file's code with line numbers, like the website's code view. Lines scroll sideways together (or
246+ * wrap), and the list is lazy, so long files stay smooth.
247+ */
248+ @Composable
249+ fun CodeView(
250+ text: String,
251+ lang: String,
252+ modifier: Modifier = Modifier,
253+ wrap: Boolean = false,
254+ state: LazyListState = rememberLazyListState(),
255+ contentPadding: PaddingValues = PaddingValues(vertical = 12.dp),
256+ header: (@Composable () -> Unit)? = null,
257+ ) {
258+ val c = Vectrum.colors
259+ val style = Vectrum.type.code
260+ val lines = rememberHighlighted(text, lang)
261+ val measurer = rememberTextMeasurer()
262+ val density = LocalDensity.current
263+ val charWidth = remember(style, density) { measurer.measure("0000000000", style).size.width / 10f }
264+ val gutter = with(density) { (charWidth * (lines.size.toString().length + 1)).toDp() } + 18.dp
265+ val longest = remember(lines) { lines.maxOfOrNull { it.length } ?: 0 }
266+ // Compose can't lay out anything wider than about 262,000 px, so files with very long lines always wrap.
267+ val forceWrap = longest > MAX_LINE
268+ val wrapped = wrap || forceWrap
269+ val contentWidth = gutter + with(density) { (charWidth * longest).toDp() } + 40.dp
270+ val hScroll = rememberScrollState()
271+
272+ BoxWithWidth(modifier.fillMaxSize()) { viewport ->
273+ val width = if (wrapped) viewport else maxOf(viewport, contentWidth)
274+ Box(if (wrapped) Modifier.fillMaxSize() else Modifier.fillMaxSize().horizontalScroll(hScroll)) {
275+ SelectionContainer {
276+ LazyColumn(Modifier.requiredWidth(width).fillMaxSize(), state = state, contentPadding = contentPadding) {
277+ if (header != null) item { Box(Modifier.width(viewport)) { header() } }
278+ itemsIndexed(lines) { index, line ->
279+ Row(Modifier.fillMaxWidth()) {
280+ Text(
281+ "${index + 1}",
282+ modifier = Modifier.width(gutter).padding(end = 14.dp),
283+ style = style,
284+ color = c.syntax.line,
285+ textAlign = TextAlign.End,
286+ maxLines = 1,
287+ )
288+ Text(line, style = style, softWrap = wrapped, modifier = Modifier.padding(end = 16.dp))
289+ }
290+ }
291+ }
292+ }
293+ }
294+ }
295+ }
296+
297+ /** Measures the width it's given and hands it to its content. */
298+ @Composable
299+ private fun BoxWithWidth(modifier: Modifier, content: @Composable (androidx.compose.ui.unit.Dp) -> Unit) {
300+ androidx.compose.foundation.layout.BoxWithConstraints(modifier) { content(maxWidth) }
301+ }
302+
303+ /** A short block of highlighted code on a well (markdown code blocks, snippets). */
304+ @Composable
305+ fun CodeBlock(text: String, lang: String, modifier: Modifier = Modifier) {
306+ val lines = rememberHighlighted(text, lang)
307+ Column(modifier.horizontalScroll(rememberScrollState())) {
308+ // Lines past MAX_LINE are cut: in a sideways scroll they could be wider than Compose can lay out.
309+ for (line in lines) Text(if (line.length > MAX_LINE) line.subSequence(0, MAX_LINE) else line, style = Vectrum.type.code, softWrap = false)
310+ }
311+ }
+318−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Controls.ktaddedView File

Large diffs are not rendered by default.

+331−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Core.ktaddedView File

Large diffs are not rendered by default.

+139−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Diff.ktaddedView File

Large diffs are not rendered by default.

+111−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Fields.ktaddedView File

Large diffs are not rendered by default.

+222−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Glyphs.ktaddedView File

Large diffs are not rendered by default.

+416−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Markdown.ktaddedView File

Large diffs are not rendered by default.

+205−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Screen.ktaddedView File

Large diffs are not rendered by default.

+127−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Sheets.ktaddedView File

Large diffs are not rendered by default.

+90−0mobile/app/src/main/java/cc/vectrum/app/ui/components/Toast.ktaddedView File

Large diffs are not rendered by default.

+115−0mobile/app/src/main/java/cc/vectrum/app/ui/icons/Icons.ktaddedView File

Large diffs are not rendered by default.

+338−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/CodeScreens.ktaddedView File

Large diffs are not rendered by default.

+117−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/ExploreScreen.ktaddedView File

Large diffs are not rendered by default.

+157−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/HomeScreen.ktaddedView File

Large diffs are not rendered by default.

+561−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/IssueScreens.ktaddedView File

Large diffs are not rendered by default.

+157−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/ProfileScreens.ktaddedView File

Large diffs are not rendered by default.

+486−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/PullScreen.ktaddedView File

Large diffs are not rendered by default.

+378−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/RepoScreen.ktaddedView File

Large diffs are not rendered by default.

+366−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/RepoTabs.ktaddedView File

Large diffs are not rendered by default.

+287−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/Rows.ktaddedView File

Large diffs are not rendered by default.

+218−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/RunScreen.ktaddedView File

Large diffs are not rendered by default.

+610−0mobile/app/src/main/java/cc/vectrum/app/ui/screens/SetupScreen.ktaddedView File

Large diffs are not rendered by default.

+201−0mobile/app/src/main/java/cc/vectrum/app/ui/theme/Materials.ktaddedView File

Large diffs are not rendered by default.

+185−0mobile/app/src/main/java/cc/vectrum/app/ui/theme/Palette.ktaddedView File

Large diffs are not rendered by default.

+63−0mobile/app/src/main/java/cc/vectrum/app/ui/theme/Theme.ktaddedView File

Large diffs are not rendered by default.

+69−0mobile/app/src/main/java/cc/vectrum/app/ui/theme/Type.ktaddedView File

Large diffs are not rendered by default.

+22−0mobile/app/src/main/res/drawable/ic_launcher_background.xmladdedView File

Large diffs are not rendered by default.

+18−0mobile/app/src/main/res/drawable/ic_launcher_foreground.xmladdedView File

Large diffs are not rendered by default.

+17−0mobile/app/src/main/res/drawable/ic_launcher_monochrome.xmladdedView File

Large diffs are not rendered by default.

+18−0mobile/app/src/main/res/drawable/ic_splash.xmladdedView File

Large diffs are not rendered by default.

+0−0mobile/app/src/main/res/font/geist_bold.ttfaddedView File

Binary file not shown.

+0−0mobile/app/src/main/res/font/geist_medium.ttfaddedView File

Binary file not shown.

+0−0mobile/app/src/main/res/font/geist_mono_medium.ttfaddedView File

Binary file not shown.

+0−0mobile/app/src/main/res/font/geist_mono_regular.ttfaddedView File

Binary file not shown.

+0−0mobile/app/src/main/res/font/geist_mono_semibold.ttfaddedView File

Binary file not shown.

+0−0mobile/app/src/main/res/font/geist_regular.ttfaddedView File

Binary file not shown.

+0−0mobile/app/src/main/res/font/geist_semibold.ttfaddedView File

Binary file not shown.

+6−0mobile/app/src/main/res/mipmap-anydpi/ic_launcher.xmladdedView File

Large diffs are not rendered by default.

+5−0mobile/app/src/main/res/values-night/colors.xmladdedView File

Large diffs are not rendered by default.

+8−0mobile/app/src/main/res/values-night/themes.xmladdedView File

Large diffs are not rendered by default.

+6−0mobile/app/src/main/res/values/colors.xmladdedView File

Large diffs are not rendered by default.

+4−0mobile/app/src/main/res/values/strings.xmladdedView File

Large diffs are not rendered by default.

+14−0mobile/app/src/main/res/values/themes.xmladdedView File

Large diffs are not rendered by default.

+10−0mobile/app/src/main/res/xml/network_security_config.xmladdedView File

Large diffs are not rendered by default.

+7−0mobile/build.gradle.ktsaddedView File

Large diffs are not rendered by default.

+6−0mobile/gradle.propertiesaddedView File

Large diffs are not rendered by default.

+40−0mobile/gradle/libs.versions.tomladdedView File

Large diffs are not rendered by default.

+0−0mobile/gradle/wrapper/gradle-wrapper.jaraddedView File

Binary file not shown.

+9−0mobile/gradle/wrapper/gradle-wrapper.propertiesaddedView File

Large diffs are not rendered by default.

+248−0mobile/gradlewaddedView File

Large diffs are not rendered by default.

+112−0mobile/gradlew.bataddedView File

Large diffs are not rendered by default.

+18−0mobile/settings.gradle.ktsaddedView File

Large diffs are not rendered by default.

+93−0mobile/third_party/Geist-OFL.txtaddedView File

Large diffs are not rendered by default.