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>
Showing 74 changed files with 9696 additions and 4 deletions.
| @@ -9,3 +9,4 @@ next-env.d.ts | ||
| 9 | 9 | Dockerfile |
| 10 | 10 | docker-compose.yml |
| 11 | 11 | .dockerignore |
| 12 | + mobile | |
| @@ -8,7 +8,7 @@ Everything they do is recorded as theirs and can be reverted in one click. Peopl | ||
| 8 | 8 | decide what reaches protected branches. |
| 9 | 9 | |
| 10 | 10 | 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). | |
| 12 | 12 | |
| 13 | 13 | Website: [vectrum.cc](https://vectrum.cc) |
| 14 | 14 | |
| @@ -35,6 +35,7 @@ docker compose up -d --build | ||
| 35 | 35 | - [Using Vectrum](docs/usage.md): git, container images, wikis, releases, forks |
| 36 | 36 | - [Actions](docs/actions.md): workflows and runners |
| 37 | 37 | - [Agents](docs/agents.md): MCP, the command line, events and webhooks |
| 38 | + - [Android app](docs/mobile.md): building it, and the API it uses | |
| 38 | 39 | - [Security](docs/security.md) |
| 39 | 40 | - [Code layout](docs/layout.md) |
| 40 | 41 | |
| @@ -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; | |
| @@ -30,4 +30,5 @@ | ||
| 30 | 30 | - **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 |
| 31 | 31 | - **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`) |
| 32 | 32 | - **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) | |
| 33 | 34 | - Light and dark mode |
| @@ -18,3 +18,4 @@ | ||
| 18 | 18 | - `lib/releases.ts`, `lib/release-http.ts`: releases and their files, and the download, upload and source archive routes |
| 19 | 19 | - `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 |
| 20 | 20 | - `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)) | |
| @@ -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 | |
| @@ -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 | + | |
| @@ -42,8 +42,10 @@ export async function endSession() { | ||
| 42 | 42 | } |
| 43 | 43 | |
| 44 | 44 | /** 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) { | |
| 47 | 49 | if (!token || isTokenExpired(token)) return null; |
| 48 | 50 | const { id, type } = getTokenPayload(token); |
| 49 | 51 | if (type !== "auth" || typeof id !== "string") return null; |
| @@ -57,7 +59,7 @@ export const getViewer = cache(async () => { | ||
| 57 | 59 | if (e instanceof ClientResponseError && [401, 403, 404].includes(e.status)) return null; |
| 58 | 60 | throw e; |
| 59 | 61 | } |
| 60 | - }); | |
| 62 | + } | |
| 61 | 63 | |
| 62 | 64 | /** Returns the signed-in user, or redirects to the login page. */ |
| 63 | 65 | export async function requireViewer(returnTo?: string) { |
| @@ -0,0 +1,7 @@ | ||
| 1 | + .gradle/ | |
| 2 | + .kotlin/ | |
| 3 | + build/ | |
| 4 | + local.properties | |
| 5 | + *.iml | |
| 6 | + .idea/ | |
| 7 | + captures/ | |
| @@ -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 | + } | |
| @@ -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. | |
| @@ -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> | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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>) | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Binary file not shown.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.