Vectrum
Sign In

Merge master into the organizations branch

Master split the repository into a monorepo (apps/web, apps/mobile) and
added two-factor authentication. The organization files move into
apps/web with the rest of the web app; the account settings list both
Organizations and Two-factor auth. The organizations migration becomes
1791400000_orgs.js so it runs after master's 1791300000_two_factor.js
instead of sharing its timestamp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 2 parents a27404b dc4a55ecommit86915f4

Showing 402 changed files with 10843 additions and 209 deletions.

Merge commit — showing changes against the first parent.

+10−7.dockerignoreView File
@@ -1,11 +1,14 @@
11 .git
2- .next
3- node_modules
4- bin
5- data
2+ **/node_modules
3+ **/.next
4+ **/*.tsbuildinfo
5+ **/next-env.d.ts
66 .env*
7- *.tsbuildinfo
8- next-env.d.ts
9- Dockerfile
7+ apps/web/bin
8+ apps/web/data
9+ apps/web/Dockerfile*
10+ # Only the Android app's package.json, which the workspace's lockfile lists.
11+ apps/mobile
12+ !apps/mobile/package.json
1013 docker-compose.yml
1114 .dockerignore
+6−24.gitignoreView File
@@ -1,8 +1,9 @@
11 # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+ # Each app in apps/ has its own .gitignore for what it builds.
23
34 # dependencies
4- /node_modules
5- /.pnp
5+ node_modules/
6+ .pnp
67 .pnp.*
78 .yarn/*
89 !.yarn/patches
@@ -10,16 +11,6 @@
1011 !.yarn/releases
1112 !.yarn/versions
1213
13- # testing
14- /coverage
15-
16- # next.js
17- /.next/
18- /out/
19-
20- # production
21- /build
22-
2314 # misc
2415 .DS_Store
2516 *.pem
@@ -33,15 +24,6 @@ yarn-error.log*
3324 # env files (can opt-in for committing if needed)
3425 .env*
3526
36- # vercel
37- .vercel
38-
39- # typescript
40- *.tsbuildinfo
41- next-env.d.ts
42-
43- # git repositories and issue data
44- /data/
45-
46- # the PocketBase server binary (scripts/install-pocketbase.mjs)
47- /bin/
27+ # editors
28+ .idea/
29+ *.iml
+8−6AGENTS.mdView File
@@ -1,9 +1,11 @@
1- <!-- BEGIN:nextjs-agent-rules -->
1+ # Vectrum monorepo
22
3- # This is NOT the Next.js you know
3+ Bun workspaces, one app per folder in `apps/`:
44
5- This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
5+ - `apps/web`: the forge itself, a Next.js app (website, git over SSH and HTTPS, MCP, the `/api/v1` JSON API, runners'
6+ API). Read `apps/web/AGENTS.md` before changing it.
7+ - `apps/mobile`: the Android app, in Kotlin with Jetpack Compose, built with Gradle (`apps/mobile/gradlew`). It only
8+ talks to the web app over `/api/v1` (`apps/web/lib/app-api.ts`); keep both sides in step when changing that API.
69
7- This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8-
9- <!-- END:nextjs-agent-rules -->
10+ Run commands from the repository root (`bun dev`, `bun run lint`, `bun run test`, `bun run mobile:build`), or inside an
11+ app's folder. Product documentation is in `docs/`.
+16−2README.mdView File
@@ -8,10 +8,15 @@ Everything they do is recorded as theirs and can be reverted in one click. Peopl
88 decide what reaches protected branches.
99
1010 Vectrum comes with issues, pull requests, forks, wikis, releases, a container
11- registry, and CI on self-hosted runners. See [all features](docs/features.md).
11+ registry, CI on self-hosted runners, and an Android app. See [all features](docs/features.md).
1212
1313 Website: [vectrum.cc](https://vectrum.cc)
1414
15+ This repository holds both apps, as Bun workspaces:
16+
17+ - [`apps/web`](apps/web): the forge, a Next.js app
18+ - [`apps/mobile`](apps/mobile): the Android app, in Kotlin with Jetpack Compose
19+
1520 ## Getting started
1621
1722 Requires Node.js 22+ and `git`.
@@ -23,6 +28,13 @@ bun dev
2328
2429 Open http://localhost:3000, sign up, and create a repository.
2530
31+ To build the Android app (needs JDK 17+ and the Android SDK, see [mobile.md](docs/mobile.md)):
32+
33+ ```bash
34+ bun run mobile:build # apps/mobile/app/build/outputs/apk/debug/app-debug.apk
35+ bun run mobile:deploy # onto a connected device or emulator
36+ ```
37+
2638 To run it with Docker:
2739
2840 ```bash
@@ -35,9 +47,11 @@ docker compose up -d --build
3547 - [Using Vectrum](docs/usage.md): git, container images, wikis, releases, forks
3648 - [Actions](docs/actions.md): workflows and runners
3749 - [Agents](docs/agents.md): MCP, the command line, events and webhooks
50+ - [Android app](docs/mobile.md): building it, and the API it uses
3851 - [Security](docs/security.md)
3952 - [Code layout](docs/layout.md)
4053
4154 ## Contributing
4255
43- Run `bun run lint` and `bun test` before opening a pull request.
56+ Run `bun run lint` and `bun run test` before opening a pull request, and `bun run mobile:build` if you changed the
57+ Android app or the API it uses (`apps/web/lib/app-api.ts`).
+0−36app/actions/auth.tsdeleted
@@ -1,36 +0,0 @@
1- "use server";
2-
3- import { redirect } from "next/navigation";
4- import { endSession, safeNext, signIn } from "@/lib/auth";
5- import { SIGNUPS_ENABLED } from "@/lib/signups";
6- import { createUser, UserError } from "@/lib/users";
7- import type { FormState } from "./types";
8-
9- export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> {
10- if (!SIGNUPS_ENABLED) return { error: "Sign-ups are closed." };
11- const password = String(formData.get("password") ?? "");
12- try {
13- const user = await createUser({
14- username: String(formData.get("username") ?? ""),
15- email: String(formData.get("email") ?? ""),
16- password,
17- name: String(formData.get("name") ?? ""),
18- });
19- await signIn(user.email, password);
20- } catch (e) {
21- if (e instanceof UserError) return { error: e.message };
22- throw e;
23- }
24- redirect(safeNext(formData.get("next")));
25- }
26-
27- export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
28- const user = await signIn(String(formData.get("login") ?? ""), String(formData.get("password") ?? ""));
29- if (!user) return { error: "Incorrect username or password." };
30- redirect(safeNext(formData.get("next")));
31- }
32-
33- export async function logoutAction() {
34- await endSession();
35- redirect("/");
36- }
+7−0apps/mobile/.gitignoreaddedView File
@@ -0,0 +1,7 @@
1+ .gradle/
2+ .kotlin/
3+ build/
4+ local.properties
5+ *.iml
6+ .idea/
7+ captures/
+61−0apps/mobile/app/build.gradle.ktsaddedView File
@@ -0,0 +1,61 @@
1+ plugins {
2+ alias(libs.plugins.android.application)
3+ alias(libs.plugins.kotlin.compose)
4+ alias(libs.plugins.kotlin.serialization)
5+ }
6+
7+ android {
8+ namespace = "cc.vectrum.app"
9+ compileSdk = 37
10+
11+ defaultConfig {
12+ applicationId = "cc.vectrum.app"
13+ minSdk = 26
14+ targetSdk = 36
15+ versionCode = 1
16+ versionName = "1.0.0"
17+ }
18+
19+ buildTypes {
20+ release {
21+ isMinifyEnabled = true
22+ isShrinkResources = true
23+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
24+ // Signed with the debug key so `assembleRelease` gives an installable APK; use your own key to publish.
25+ signingConfig = signingConfigs.getByName("debug")
26+ }
27+ }
28+
29+ buildFeatures {
30+ compose = true
31+ buildConfig = true
32+ }
33+
34+ compileOptions {
35+ sourceCompatibility = JavaVersion.VERSION_17
36+ targetCompatibility = JavaVersion.VERSION_17
37+ }
38+ }
39+
40+ dependencies {
41+ implementation(libs.androidx.core)
42+ implementation(libs.androidx.splashscreen)
43+ implementation(libs.androidx.activity.compose)
44+ implementation(libs.androidx.lifecycle.runtime.compose)
45+ implementation(libs.androidx.lifecycle.viewmodel.compose)
46+ implementation(libs.androidx.navigation.compose)
47+ implementation(libs.androidx.datastore.preferences)
48+
49+ implementation(platform(libs.compose.bom))
50+ implementation(libs.compose.ui)
51+ implementation(libs.compose.foundation)
52+ implementation(libs.compose.material3)
53+ implementation(libs.compose.ui.tooling.preview)
54+ debugImplementation(libs.compose.ui.tooling)
55+
56+ implementation(libs.kotlinx.coroutines.android)
57+ implementation(libs.kotlinx.serialization.json)
58+ implementation(libs.okhttp)
59+ implementation(libs.coil.compose)
60+ implementation(libs.coil.network.okhttp)
61+ }
+2−0apps/mobile/app/proguard-rules.proaddedView File
@@ -0,0 +1,2 @@
1+ # kotlinx.serialization keeps what it needs through its own consumer rules; the API models are
2+ # @Serializable classes, so nothing extra is required here.
+25−0apps/mobile/app/src/main/AndroidManifest.xmladdedView File
@@ -0,0 +1,25 @@
1+ <?xml version="1.0" encoding="utf-8"?>
2+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+ <uses-permission android:name="android.permission.INTERNET" />
5+
6+ <application
7+ android:name=".VectrumApp"
8+ android:allowBackup="false"
9+ android:icon="@mipmap/ic_launcher"
10+ android:roundIcon="@mipmap/ic_launcher"
11+ android:label="@string/app_name"
12+ android:supportsRtl="true"
13+ android:networkSecurityConfig="@xml/network_security_config"
14+ android:theme="@style/Theme.Vectrum.Starting">
15+ <activity
16+ android:name=".MainActivity"
17+ android:exported="true"
18+ android:windowSoftInputMode="adjustResize">
19+ <intent-filter>
20+ <action android:name="android.intent.action.MAIN" />
21+ <category android:name="android.intent.category.LAUNCHER" />
22+ </intent-filter>
23+ </activity>
24+ </application>
25+ </manifest>
+101−0apps/mobile/app/src/main/java/cc/vectrum/app/MainActivity.ktaddedView File
@@ -0,0 +1,101 @@
1+ package cc.vectrum.app
2+
3+ import android.graphics.Color
4+ import android.os.Bundle
5+ import androidx.activity.ComponentActivity
6+ import androidx.activity.SystemBarStyle
7+ import androidx.activity.compose.setContent
8+ import androidx.activity.enableEdgeToEdge
9+ import androidx.compose.foundation.isSystemInDarkTheme
10+ import androidx.compose.runtime.Composable
11+ import androidx.compose.runtime.DisposableEffect
12+ import androidx.compose.runtime.SideEffect
13+ import androidx.compose.runtime.getValue
14+ import androidx.compose.runtime.remember
15+ import androidx.compose.runtime.rememberCoroutineScope
16+ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
17+ import androidx.lifecycle.compose.collectAsStateWithLifecycle
18+ import cc.vectrum.app.data.Api
19+ import cc.vectrum.app.data.Settings
20+ import cc.vectrum.app.data.normalizeServer
21+ import cc.vectrum.app.ui.MainShell
22+ import cc.vectrum.app.ui.SignedOutShell
23+ import cc.vectrum.app.ui.screens.SetupScreen
24+ import cc.vectrum.app.ui.theme.ThemeMode
25+ import cc.vectrum.app.ui.theme.VectrumTheme
26+ import coil3.SingletonImageLoader
27+ import kotlinx.coroutines.flow.map
28+ import kotlinx.coroutines.launch
29+
30+ class MainActivity : ComponentActivity() {
31+ override fun onCreate(savedInstanceState: Bundle?) {
32+ val app = application as VectrumApp
33+ val splash = installSplashScreen()
34+ super.onCreate(savedInstanceState)
35+ enableEdgeToEdge()
36+
37+ // Keep the splash up until the stored session is read, so the app opens on the right screen.
38+ var ready = false
39+ splash.setKeepOnScreenCondition { !ready }
40+
41+ setContent {
42+ val settings by remember { app.store.settings.map<Settings, Settings?> { it } }.collectAsStateWithLifecycle(initialValue = null)
43+ val current = settings ?: return@setContent
44+ SideEffect { ready = true }
45+ VectrumTheme(current.theme) {
46+ SystemBars(current.theme)
47+ Root(app, current)
48+ }
49+ }
50+ }
51+
52+ /** Light status bar icons on graphite, dark ones on bone. */
53+ @Composable
54+ private fun SystemBars(theme: ThemeMode) {
55+ val dark = when (theme) {
56+ ThemeMode.System -> isSystemInDarkTheme()
57+ ThemeMode.Bone -> false
58+ ThemeMode.Graphite -> true
59+ }
60+ DisposableEffect(dark) {
61+ val style = if (dark) SystemBarStyle.dark(Color.TRANSPARENT) else SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
62+ enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
63+ onDispose {}
64+ }
65+ }
66+ }
67+
68+ @Composable
69+ private fun Root(app: VectrumApp, settings: Settings) {
70+ val scope = rememberCoroutineScope()
71+ val session = settings.session
72+ val server = session?.let { normalizeServer(it.server) }
73+ if (session == null || server == null) {
74+ app.imageAuth = null
75+ SignedOutShell {
76+ SetupScreen(
77+ lastServer = settings.lastServer,
78+ onboarded = settings.onboarded,
79+ http = app.http,
80+ onSignedIn = { scope.launch { app.store.signIn(it) } },
81+ )
82+ }
83+ return
84+ }
85+ val api = remember(session.server, session.token) { Api(app.http, server, session.token) }
86+ app.imageAuth = api.origin to session.token
87+ MainShell(
88+ session = session,
89+ api = api,
90+ theme = settings.theme,
91+ onTheme = { scope.launch { app.store.setTheme(it) } },
92+ onSignOut = {
93+ // Images of private repositories don't outlive the session.
94+ SingletonImageLoader.get(app).let { loader ->
95+ loader.memoryCache?.clear()
96+ loader.diskCache?.clear()
97+ }
98+ scope.launch { app.store.signOut() }
99+ },
100+ )
101+ }
+45−0apps/mobile/app/src/main/java/cc/vectrum/app/VectrumApp.ktaddedView File
@@ -0,0 +1,45 @@
1+ package cc.vectrum.app
2+
3+ import android.app.Application
4+ import cc.vectrum.app.data.Api
5+ import cc.vectrum.app.data.SessionStore
6+ import coil3.ImageLoader
7+ import coil3.PlatformContext
8+ import coil3.SingletonImageLoader
9+ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
10+ import okhttp3.Interceptor
11+ import okhttp3.OkHttpClient
12+
13+ class VectrumApp : Application(), SingletonImageLoader.Factory {
14+ lateinit var store: SessionStore
15+ private set
16+
17+ /** The current session's server and token, for images: Coil's requests carry the token only to that server. */
18+ @Volatile
19+ var imageAuth: Pair<String, String>? = null
20+
21+ /** One HTTP client for the API and for images. */
22+ val http: OkHttpClient by lazy { Api.client() }
23+
24+ override fun onCreate() {
25+ super.onCreate()
26+ store = SessionStore(this)
27+ }
28+
29+ override fun newImageLoader(context: PlatformContext): ImageLoader {
30+ val authorizing = Interceptor { chain ->
31+ val request = chain.request()
32+ val auth = imageAuth
33+ val url = request.url.toString()
34+ if (auth != null && url.startsWith(auth.first.trimEnd('/') + "/api/v1/")) {
35+ chain.proceed(request.newBuilder().header("Authorization", "Bearer ${auth.second}").build())
36+ } else {
37+ chain.proceed(request)
38+ }
39+ }
40+ val client = http.newBuilder().addInterceptor(authorizing).build()
41+ return ImageLoader.Builder(context)
42+ .components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) }
43+ .build()
44+ }
45+ }
+191−0apps/mobile/app/src/main/java/cc/vectrum/app/data/Api.ktaddedView File
@@ -0,0 +1,191 @@
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, val twoFactor: Boolean = false) : 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 fields = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull()
77+ val message = fields?.get("error")?.let { runCatching { it.jsonPrimitive.content }.getOrNull() }
78+ // A sign-in to an account with two-factor authentication on, that needs a code.
79+ val twoFactor = fields?.get("twoFactor")?.let { runCatching { it.jsonPrimitive.content == "true" }.getOrNull() } ?: false
80+ throw ApiException(r.code, message ?: "The server answered ${r.code}.", twoFactor)
81+ }
82+ body
83+ }
84+ }
85+
86+ private suspend inline fun <reified T> get(path: String, query: Map<String, String?> = emptyMap()): T =
87+ json.decodeFromString(call(request(url(path, query)).get().build()))
88+
89+ private suspend inline fun <reified T> send(method: String, path: String, body: JsonObject): T =
90+ json.decodeFromString(call(request(url(path)).method(method, body.toString().toRequestBody(JSON)).build()))
91+
92+ /** The address of a path on the server, for images and links. */
93+ fun apiUrl(path: String, query: Map<String, String?> = emptyMap()): String = url(path, query).toString()
94+
95+ /** [code] is from the authenticator app (or a recovery code), for accounts with two-factor authentication on. */
96+ suspend fun signIn(login: String, password: String, code: String? = null): SessionResponse =
97+ send(
98+ "POST",
99+ "session",
100+ buildJsonObject {
101+ put("login", JsonPrimitive(login))
102+ put("password", JsonPrimitive(password))
103+ if (code != null) put("code", JsonPrimitive(code))
104+ },
105+ )
106+
107+ suspend fun signUp(username: String, email: String, password: String, name: String): SessionResponse =
108+ send(
109+ "POST",
110+ "signup",
111+ buildJsonObject {
112+ put("username", JsonPrimitive(username))
113+ put("email", JsonPrimitive(email))
114+ put("password", JsonPrimitive(password))
115+ put("name", JsonPrimitive(name))
116+ },
117+ )
118+
119+ suspend fun serverInfo(): ServerInfo = get("server")
120+
121+ /** Whether the server answers its liveness check (true for any Vectrum, also one without the app's API). */
122+ suspend fun healthy(): Boolean = runCatching { call(request(server.newBuilder().addPathSegments("api/health").build()).get().build()) }.isSuccess
123+
124+ suspend fun me(): User = get<UserResponse>("user").user
125+ suspend fun home(): HomeData = get("home")
126+ suspend fun explore(query: String): ReposData = get("explore", mapOf("q" to query))
127+ suspend fun profile(username: String): ProfileData = get("users/${username}")
128+
129+ private fun repo(owner: String, name: String) = "repos/${owner}/${name}"
130+
131+ suspend fun repository(owner: String, name: String): RepoDetail = get(repo(owner, name))
132+ suspend fun tree(owner: String, name: String, ref: String?, path: String): TreeData = get("${repo(owner, name)}/tree", mapOf("ref" to ref, "path" to path))
133+ suspend fun blob(owner: String, name: String, ref: String?, path: String): BlobData = get("${repo(owner, name)}/blob", mapOf("ref" to ref, "path" to path))
134+ fun rawUrl(owner: String, name: String, ref: String?, path: String) = apiUrl("${repo(owner, name)}/raw", mapOf("ref" to ref, "path" to path))
135+ fun repoImageUrl(owner: String, name: String, version: Long?) = apiUrl("${repo(owner, name)}/image", mapOf("v" to version?.toString()))
136+ suspend fun commits(owner: String, name: String, ref: String?, page: Int, path: String? = null): CommitsData =
137+ get("${repo(owner, name)}/commits", mapOf("ref" to ref, "page" to page.toString(), "path" to path))
138+ suspend fun commit(owner: String, name: String, sha: String): CommitDetail = get("${repo(owner, name)}/commits/${sha}")
139+ suspend fun branches(owner: String, name: String): BranchesData = get("${repo(owner, name)}/branches")
140+ suspend fun repoEvents(owner: String, name: String): EventsData = get("${repo(owner, name)}/events")
141+
142+ suspend fun issues(owner: String, name: String): IssuesData = get("${repo(owner, name)}/issues")
143+ suspend fun issue(owner: String, name: String, number: Int): IssueDetail = get("${repo(owner, name)}/issues/$number")
144+ suspend fun createIssue(owner: String, name: String, fields: JsonObject): CreatedIssue = send("POST", "${repo(owner, name)}/issues", fields)
145+ suspend fun updateIssue(owner: String, name: String, number: Int, patch: JsonObject): IssueDetail = send("PATCH", "${repo(owner, name)}/issues/$number", patch)
146+ suspend fun commentOnIssue(owner: String, name: String, number: Int, body: String): IssueDetail =
147+ send("POST", "${repo(owner, name)}/issues/$number/comments", buildJsonObject { put("body", JsonPrimitive(body)) })
148+
149+ suspend fun pulls(owner: String, name: String, state: String): PullsData = get("${repo(owner, name)}/pulls", mapOf("state" to state))
150+ suspend fun pull(owner: String, name: String, number: Int): PullDetail = get("${repo(owner, name)}/pulls/$number")
151+ suspend fun pullFiles(owner: String, name: String, number: Int): FilesData = get("${repo(owner, name)}/pulls/$number/files")
152+ suspend fun commentOnPull(owner: String, name: String, number: Int, body: String): PullDetail =
153+ send("POST", "${repo(owner, name)}/pulls/$number/comments", buildJsonObject { put("body", JsonPrimitive(body)) })
154+ suspend fun reviewPull(owner: String, name: String, number: Int, state: String, body: String): PullDetail =
155+ send("POST", "${repo(owner, name)}/pulls/$number/reviews", buildJsonObject { put("state", JsonPrimitive(state)); put("body", JsonPrimitive(body)) })
156+ suspend fun updatePull(owner: String, name: String, number: Int, patch: JsonObject): PullDetail = send("PATCH", "${repo(owner, name)}/pulls/$number", patch)
157+ suspend fun mergePull(owner: String, name: String, number: Int, method: String, deleteBranch: Boolean, headSha: String): PullDetail =
158+ send(
159+ "POST",
160+ "${repo(owner, name)}/pulls/$number/merge",
161+ buildJsonObject {
162+ put("method", JsonPrimitive(method))
163+ put("deleteBranch", JsonPrimitive(deleteBranch))
164+ put("headSha", JsonPrimitive(headSha))
165+ },
166+ )
167+
168+ suspend fun runs(owner: String, name: String, page: Int): RunsData = get("${repo(owner, name)}/runs", mapOf("page" to page.toString()))
169+ suspend fun run(owner: String, name: String, number: Int): RunDetail = get("${repo(owner, name)}/runs/$number")
170+ suspend fun releases(owner: String, name: String): ReleasesData = get("${repo(owner, name)}/releases")
171+
172+ companion object {
173+ private val JSON = "application/json; charset=utf-8".toMediaType()
174+
175+ fun client(): OkHttpClient = OkHttpClient.Builder()
176+ .connectTimeout(15, TimeUnit.SECONDS)
177+ .readTimeout(60, TimeUnit.SECONDS)
178+ .build()
179+ }
180+ }
181+
182+ /** Awaits an OkHttp call, cancelling it with the coroutine. */
183+ private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
184+ enqueue(object : Callback {
185+ override fun onResponse(call: Call, response: Response) = cont.resume(response) { _, _, _ -> response.close() }
186+ override fun onFailure(call: Call, e: IOException) {
187+ if (!cont.isCancelled) cont.resumeWithException(e)
188+ }
189+ })
190+ cont.invokeOnCancellation { runCatching { cancel() } }
191+ }
+356−0apps/mobile/app/src/main/java/cc/vectrum/app/data/Models.ktaddedView File
@@ -0,0 +1,356 @@
1+ package cc.vectrum.app.data
2+
3+ import kotlinx.serialization.Serializable
4+ import kotlinx.serialization.json.JsonObject
5+
6+ // The shapes of the server's /api/v1 answers (lib/app-api.ts).
7+
8+ @Serializable
9+ data class User(
10+ val id: String,
11+ val username: String,
12+ val name: String = "",
13+ val email: String = "",
14+ val bio: String = "",
15+ val createdAt: Long = 0,
16+ ) {
17+ val displayName: String get() = name.ifBlank { username }
18+ }
19+
20+ @Serializable data class SessionResponse(val token: String, val user: User)
21+
22+ /** What a server says about itself before anyone signs in (GET /api/v1/server). */
23+ @Serializable data class ServerInfo(val name: String = "Vectrum", val api: Int = 1, val signups: Boolean = false)
24+ @Serializable data class UserResponse(val user: User)
25+
26+ @Serializable data class RepoRef(val owner: String, val name: String)
27+
28+ @Serializable
29+ data class RepoSummary(
30+ val id: String,
31+ val owner: String,
32+ val name: String,
33+ val description: String = "",
34+ val visibility: String = "public",
35+ val forkOf: RepoRef? = null,
36+ val isFork: Boolean = false,
37+ val hasImage: Boolean = false,
38+ val imageVersion: Long? = null,
39+ val createdAt: Long = 0,
40+ val updatedAt: Long = 0,
41+ val empty: Boolean = false,
42+ ) {
43+ val isPrivate: Boolean get() = visibility == "private"
44+ val fullName: String get() = "$owner/$name"
45+ }
46+
47+ @Serializable data class CommitStatus(val status: String, val runNumber: Int = 0)
48+
49+ @Serializable
50+ data class CommitInfo(
51+ val sha: String,
52+ val parents: List<String> = emptyList(),
53+ val authorName: String = "",
54+ val authorEmail: String = "",
55+ val committerName: String = "",
56+ val date: Long = 0,
57+ val subject: String = "",
58+ val body: String = "",
59+ /** The agent that made the commit for its author. */
60+ val agent: String? = null,
61+ val status: CommitStatus? = null,
62+ )
63+
64+ @Serializable data class EventTarget(val type: String, val number: Int? = null, val sha: String? = null)
65+
66+ @Serializable
67+ data class Event(
68+ val id: String,
69+ val kind: String,
70+ val action: String? = null,
71+ val actor: String? = null,
72+ val agent: String? = null,
73+ val text: String,
74+ val createdAt: Long,
75+ val repo: RepoRef? = null,
76+ val target: EventTarget? = null,
77+ )
78+
79+ @Serializable data class RepoCommit(val repo: RepoRef, val commit: CommitInfo)
80+
81+ @Serializable
82+ data class HomeData(
83+ val user: User,
84+ val repos: List<RepoSummary>,
85+ val openIssues: Int = 0,
86+ val openPulls: Int = 0,
87+ val events: List<Event> = emptyList(),
88+ val commits: List<RepoCommit> = emptyList(),
89+ val discover: List<RepoSummary> = emptyList(),
90+ )
91+
92+ @Serializable data class ReposData(val repos: List<RepoSummary>)
93+ @Serializable data class ProfileData(val user: User, val repos: List<RepoSummary>)
94+
95+ @Serializable data class TreeEntry(val name: String, val path: String, val type: String, val size: Long? = null) {
96+ val isDir: Boolean get() = type == "tree"
97+ val isSubmodule: Boolean get() = type == "commit"
98+ }
99+
100+ @Serializable data class Readme(val path: String, val text: String)
101+ @Serializable data class CloneUrls(val https: String, val ssh: String)
102+ @Serializable data class RepoCounts(val issues: Int = 0, val pulls: Int = 0, val releases: Int = 0, val commits: Int = 0, val branches: Int = 0)
103+
104+ @Serializable
105+ data class RepoDetail(
106+ val repo: RepoSummary,
107+ val access: String,
108+ val canWrite: Boolean,
109+ val defaultBranch: String? = null,
110+ val branches: List<String> = emptyList(),
111+ val tags: List<String> = emptyList(),
112+ val cloneUrls: CloneUrls,
113+ val counts: RepoCounts = RepoCounts(),
114+ val lastCommit: CommitInfo? = null,
115+ val entries: List<TreeEntry> = emptyList(),
116+ val readme: Readme? = null,
117+ val wikiEnabled: Boolean = false,
118+ )
119+
120+ @Serializable data class TreeData(val ref: String? = null, val path: String = "", val entries: List<TreeEntry> = emptyList())
121+
122+ @Serializable
123+ data class BlobData(
124+ val ref: String,
125+ val path: String,
126+ val size: Long,
127+ val binary: Boolean,
128+ val tooLarge: Boolean,
129+ val image: Boolean = false,
130+ val text: String? = null,
131+ val lastCommit: CommitInfo? = null,
132+ )
133+
134+ @Serializable data class CommitsData(val ref: String? = null, val commits: List<CommitInfo> = emptyList(), val hasMore: Boolean = false)
135+
136+ @Serializable data class DiffLine(val kind: String, val text: String, val oldNo: Int? = null, val newNo: Int? = null)
137+
138+ @Serializable
139+ data class FileDiff(
140+ val oldPath: String,
141+ val newPath: String,
142+ val status: String,
143+ val binary: Boolean = false,
144+ val additions: Int = 0,
145+ val deletions: Int = 0,
146+ val truncated: Boolean = false,
147+ val lines: List<DiffLine> = emptyList(),
148+ ) {
149+ val path: String get() = if (status == "deleted") oldPath else newPath
150+ }
151+
152+ @Serializable data class CommitDetail(val commit: CommitInfo, val files: List<FileDiff>)
153+ @Serializable data class FilesData(val files: List<FileDiff>)
154+
155+ @Serializable data class BranchInfo(val name: String, val sha: String, val date: Long = 0, val subject: String = "", val isDefault: Boolean = false)
156+ @Serializable data class BranchesData(val defaultBranch: String? = null, val branches: List<BranchInfo> = emptyList())
157+
158+ @Serializable data class EventsData(val events: List<Event>)
159+
160+ // Issues
161+
162+ @Serializable data class Label(val id: String, val name: String, val color: String)
163+
164+ /** Someone an issue can be assigned to: a person, or an agent (then `agentOf` names its person). */
165+ @Serializable data class Member(val id: String, val username: String, val name: String = "", val agentOf: String? = null)
166+
167+ @Serializable
168+ data class Issue(
169+ val id: String,
170+ val number: Int,
171+ val title: String,
172+ val authorId: String = "",
173+ val author: String = "",
174+ val authorAgent: String? = null,
175+ val status: String,
176+ val priority: Int = 0,
177+ val assigneeId: String? = null,
178+ val assignee: String? = null,
179+ val assigneeAgentOf: String? = null,
180+ val estimate: Int? = null,
181+ val dueDate: String? = null,
182+ val sortOrder: Double = 0.0,
183+ val labels: List<Label> = emptyList(),
184+ val createdAt: Long = 0,
185+ val updatedAt: Long = 0,
186+ val commentCount: Int = 0,
187+ val body: String? = null,
188+ val state: String? = null,
189+ )
190+
191+ @Serializable data class IssuesData(val canWrite: Boolean, val issues: List<Issue>, val labels: List<Label> = emptyList(), val members: List<Member> = emptyList())
192+
193+ @Serializable data class Comment(val id: String, val author: String, val agent: String? = null, val body: String, val createdAt: Long)
194+
195+ @Serializable
196+ data class IssueEvent(
197+ val id: String,
198+ val actor: String = "",
199+ val agent: String? = null,
200+ val field: String,
201+ val oldValue: String? = null,
202+ val newValue: String? = null,
203+ val createdAt: Long,
204+ )
205+
206+ @Serializable
207+ data class IssueDetail(
208+ val canWrite: Boolean,
209+ val issue: Issue,
210+ val comments: List<Comment> = emptyList(),
211+ val events: List<IssueEvent> = emptyList(),
212+ val labels: List<Label> = emptyList(),
213+ val members: List<Member> = emptyList(),
214+ )
215+
216+ @Serializable data class CreatedIssue(val number: Int)
217+
218+ // Pull requests
219+
220+ @Serializable
221+ data class Pull(
222+ val number: Int,
223+ val title: String,
224+ val body: String = "",
225+ val author: String = "",
226+ val agent: String? = null,
227+ val head: String = "",
228+ val headLabel: String = "",
229+ val base: String = "",
230+ val headSha: String = "",
231+ val state: String,
232+ val draft: Boolean = false,
233+ val mergeSha: String? = null,
234+ val mergeMethod: String? = null,
235+ val mergedBy: String? = null,
236+ val linkedIssues: List<Int> = emptyList(),
237+ val commentCount: Int = 0,
238+ val createdAt: Long = 0,
239+ val updatedAt: Long = 0,
240+ val closedAt: Long? = null,
241+ val mergedAt: Long? = null,
242+ )
243+
244+ @Serializable data class PullCounts(val open: Int = 0)
245+ @Serializable data class PullsData(val canWrite: Boolean, val counts: PullCounts = PullCounts(), val pulls: List<Pull>)
246+
247+ @Serializable data class Requirement(val key: String, val ok: Boolean, val label: String, val detail: String? = null)
248+
249+ @Serializable
250+ data class PullStatus(
251+ val headSha: String,
252+ val headExists: Boolean,
253+ val ahead: Int = 0,
254+ val behind: Int = 0,
255+ val conflicts: List<String>? = null,
256+ val checks: String = "none",
257+ val runs: List<RunInfo> = emptyList(),
258+ val approvals: List<String> = emptyList(),
259+ val changesRequestedBy: List<String> = emptyList(),
260+ val requirements: List<Requirement> = emptyList(),
261+ val mergeable: Boolean = false,
262+ )
263+
264+ @Serializable data class Review(val id: String, val author: String, val agent: String? = null, val state: String, val body: String = "", val commitSha: String = "", val createdAt: Long)
265+
266+ @Serializable
267+ data class ReviewThread(
268+ val path: String? = null,
269+ val line: Int? = null,
270+ val side: String? = null,
271+ val snippet: String? = null,
272+ val resolved: Boolean = false,
273+ val outdated: Boolean = false,
274+ val comments: List<Comment> = emptyList(),
275+ val createdAt: Long = 0,
276+ )
277+
278+ @Serializable data class PullEvent(val id: String, val actor: String? = null, val agent: String? = null, val kind: String, val data: JsonObject = JsonObject(emptyMap()), val createdAt: Long)
279+
280+ @Serializable
281+ data class PullDetail(
282+ val pull: Pull,
283+ val canWrite: Boolean,
284+ val canEdit: Boolean,
285+ val canMerge: Boolean,
286+ val status: PullStatus,
287+ val reviews: List<Review> = emptyList(),
288+ val comments: List<Comment> = emptyList(),
289+ val threads: List<ReviewThread> = emptyList(),
290+ val events: List<PullEvent> = emptyList(),
291+ val commits: List<CommitInfo> = emptyList(),
292+ )
293+
294+ // Actions and releases
295+
296+ @Serializable
297+ data class RunInfo(
298+ val number: Int,
299+ val workflowName: String,
300+ val workflowPath: String = "",
301+ val event: String = "",
302+ val ref: String = "",
303+ val sha: String = "",
304+ val title: String = "",
305+ val actor: String? = null,
306+ val agent: String? = null,
307+ val status: String,
308+ val error: String? = null,
309+ val createdAt: Long = 0,
310+ val startedAt: Long? = null,
311+ val finishedAt: Long? = null,
312+ )
313+
314+ @Serializable data class RunsData(val runs: List<RunInfo>, val hasMore: Boolean = false)
315+
316+ @Serializable
317+ data class StepInfo(
318+ val index: Int,
319+ val name: String,
320+ val status: String,
321+ val exitCode: Int? = null,
322+ val startedAt: Long? = null,
323+ val finishedAt: Long? = null,
324+ val log: String = "",
325+ val logTruncated: Boolean = false,
326+ )
327+
328+ @Serializable
329+ data class JobInfo(
330+ val key: String,
331+ val name: String,
332+ val status: String,
333+ val runner: String? = null,
334+ val startedAt: Long? = null,
335+ val finishedAt: Long? = null,
336+ val steps: List<StepInfo> = emptyList(),
337+ )
338+
339+ @Serializable data class RunDetail(val run: RunInfo, val jobs: List<JobInfo>)
340+
341+ @Serializable data class Asset(val name: String, val size: Long, val downloads: Int = 0, val url: String)
342+
343+ @Serializable
344+ data class Release(
345+ val tag: String,
346+ val title: String,
347+ val body: String = "",
348+ val draft: Boolean = false,
349+ val prerelease: Boolean = false,
350+ val author: String? = null,
351+ val createdAt: Long = 0,
352+ val publishedAt: Long? = null,
353+ val assets: List<Asset> = emptyList(),
354+ )
355+
356+ @Serializable data class ReleasesData(val releases: List<Release>)
+49−0apps/mobile/app/src/main/java/cc/vectrum/app/data/ServerProbe.ktaddedView File
@@ -0,0 +1,49 @@
1+ package cc.vectrum.app.data
2+
3+ import okhttp3.HttpUrl
4+ import okhttp3.OkHttpClient
5+ import java.io.IOException
6+
7+ /** The hosted Vectrum. */
8+ const val HOSTED_SERVER = "https://vectrum.cc"
9+
10+ sealed interface Probe {
11+ /** A Vectrum with the app's API, at this (normalized) address. */
12+ data class Found(val url: HttpUrl, val info: ServerInfo) : Probe
13+
14+ /** Nothing usable there; the message says why, in words a person can act on. */
15+ data class Failed(val message: String) : Probe
16+ }
17+
18+ /**
19+ * Finds the Vectrum server behind what someone typed. Without a scheme it tries HTTPS first and then
20+ * plain HTTP (common for servers at home), and tells "no server", "not a Vectrum" and "a Vectrum too
21+ * old for the app" apart.
22+ */
23+ suspend fun probeServer(http: OkHttpClient, input: String): Probe {
24+ val typed = input.trim()
25+ if (typed.isEmpty()) return Probe.Failed("Enter your server's address.")
26+ val candidates = if (typed.contains("://")) listOfNotNull(normalizeServer(typed)) else listOfNotNull(normalizeServer("https://$typed"), normalizeServer("http://$typed"))
27+ if (candidates.isEmpty()) return Probe.Failed("That doesn't look like a server address. Try something like git.example.com or 192.168.1.10:3000.")
28+ var outcome: Probe? = null
29+ for (url in candidates) {
30+ val api = Api(http, url, null)
31+ try {
32+ return Probe.Found(url, api.serverInfo())
33+ } catch (e: ApiException) {
34+ // It answers, but not with the app's API: an older Vectrum, or something else entirely.
35+ if (outcome == null) {
36+ outcome = if (api.healthy()) {
37+ Probe.Failed("${url.host} runs Vectrum, but a version without the app's API. Once it's updated, you can connect.")
38+ } else {
39+ Probe.Failed("There's no Vectrum server at ${url.host}.")
40+ }
41+ }
42+ } catch (e: IOException) {
43+ // Unreachable at this scheme: try the next one.
44+ } catch (e: kotlinx.serialization.SerializationException) {
45+ if (outcome == null) outcome = Probe.Failed("There's no Vectrum server at ${url.host}.")
46+ }
47+ }
48+ return outcome ?: Probe.Failed("Can't reach ${candidates.first().host}. Check the address and your connection.")
49+ }
+71−0apps/mobile/app/src/main/java/cc/vectrum/app/data/Session.ktaddedView File
@@ -0,0 +1,71 @@
1+ package cc.vectrum.app.data
2+
3+ import android.content.Context
4+ import androidx.datastore.preferences.core.Preferences
5+ import androidx.datastore.preferences.core.booleanPreferencesKey
6+ import androidx.datastore.preferences.core.edit
7+ import androidx.datastore.preferences.core.stringPreferencesKey
8+ import androidx.datastore.preferences.preferencesDataStore
9+ import cc.vectrum.app.ui.theme.ThemeMode
10+ import kotlinx.coroutines.flow.Flow
11+ import kotlinx.coroutines.flow.map
12+
13+ /** A signed-in account: which server, its session token, and who it is. */
14+ data class Session(val server: String, val token: String, val user: User)
15+
16+ /** `onboarded`: the setup (welcome, server, account) was finished once; later sign-ins go straight to the account step. */
17+ data class Settings(val session: Session?, val lastServer: String, val theme: ThemeMode, val onboarded: Boolean)
18+
19+ private val Context.store by preferencesDataStore(name = "vectrum")
20+
21+ /**
22+ * Where the app keeps the session and its preferences, in the app's private storage. The token is
23+ * the same 30-day session token the website keeps in its cookie; signing out forgets it.
24+ */
25+ class SessionStore(private val context: Context) {
26+ private object Keys {
27+ val server = stringPreferencesKey("server")
28+ val token = stringPreferencesKey("token")
29+ val user = stringPreferencesKey("user")
30+ val lastServer = stringPreferencesKey("last_server")
31+ val theme = stringPreferencesKey("theme")
32+ val onboarded = booleanPreferencesKey("onboarded")
33+ }
34+
35+ val settings: Flow<Settings> = context.store.data.map { p -> read(p) }
36+
37+ private fun read(p: Preferences): Settings {
38+ val server = p[Keys.server]
39+ val token = p[Keys.token]
40+ val user = p[Keys.user]?.let { runCatching { json.decodeFromString<User>(it) }.getOrNull() }
41+ val session = if (server != null && token != null && user != null) Session(server, token, user) else null
42+ val theme = p[Keys.theme]?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } } ?: ThemeMode.System
43+ return Settings(session, p[Keys.lastServer] ?: "", theme, p[Keys.onboarded] ?: false)
44+ }
45+
46+ suspend fun signIn(session: Session) {
47+ context.store.edit {
48+ it[Keys.server] = session.server
49+ it[Keys.token] = session.token
50+ it[Keys.user] = json.encodeToString(User.serializer(), session.user)
51+ it[Keys.lastServer] = session.server
52+ it[Keys.onboarded] = true
53+ }
54+ }
55+
56+ suspend fun updateUser(user: User) {
57+ context.store.edit { it[Keys.user] = json.encodeToString(User.serializer(), user) }
58+ }
59+
60+ suspend fun signOut() {
61+ context.store.edit {
62+ it.remove(Keys.token)
63+ it.remove(Keys.user)
64+ it.remove(Keys.server)
65+ }
66+ }
67+
68+ suspend fun setTheme(theme: ThemeMode) {
69+ context.store.edit { it[Keys.theme] = theme.name }
70+ }
71+ }
+320−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/App.ktaddedView File
@@ -0,0 +1,320 @@
1+ package cc.vectrum.app.ui
2+
3+ import androidx.compose.animation.AnimatedVisibility
4+ import androidx.compose.animation.EnterTransition
5+ import androidx.compose.animation.ExitTransition
6+ import androidx.compose.animation.core.tween
7+ import androidx.compose.animation.fadeIn
8+ import androidx.compose.animation.fadeOut
9+ import androidx.compose.animation.slideInHorizontally
10+ import androidx.compose.animation.slideInVertically
11+ import androidx.compose.animation.slideOutHorizontally
12+ import androidx.compose.animation.slideOutVertically
13+ import androidx.compose.foundation.background
14+ import androidx.compose.foundation.clickable
15+ import androidx.compose.foundation.interaction.MutableInteractionSource
16+ import androidx.compose.foundation.layout.Arrangement
17+ import androidx.compose.foundation.layout.Box
18+ import androidx.compose.foundation.layout.Row
19+ import androidx.compose.foundation.layout.Spacer
20+ import androidx.compose.foundation.layout.WindowInsets
21+ import androidx.compose.foundation.layout.fillMaxSize
22+ import androidx.compose.foundation.layout.fillMaxWidth
23+ import androidx.compose.foundation.layout.statusBars
24+ import androidx.compose.foundation.layout.windowInsetsTopHeight
25+ import androidx.compose.foundation.layout.height
26+ import androidx.compose.foundation.layout.navigationBars
27+ import androidx.compose.foundation.layout.padding
28+ import androidx.compose.foundation.layout.size
29+ import androidx.compose.foundation.layout.width
30+ import androidx.compose.foundation.layout.windowInsetsPadding
31+ import androidx.compose.material3.Icon
32+ import androidx.compose.material3.Text
33+ import androidx.compose.runtime.Composable
34+ import androidx.compose.runtime.CompositionLocalProvider
35+ import androidx.compose.runtime.LaunchedEffect
36+ import androidx.compose.runtime.getValue
37+ import androidx.compose.runtime.remember
38+ import androidx.compose.runtime.staticCompositionLocalOf
39+ import androidx.compose.ui.Alignment
40+ import androidx.compose.ui.Modifier
41+ import androidx.compose.ui.graphics.Brush
42+ import androidx.compose.ui.graphics.vector.ImageVector
43+ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44+ import androidx.compose.ui.platform.LocalHapticFeedback
45+ import androidx.compose.ui.platform.LocalUriHandler
46+ import androidx.compose.ui.unit.dp
47+ import androidx.navigation.NavDestination
48+ import androidx.navigation.NavDestination.Companion.hasRoute
49+ import androidx.navigation.NavGraph.Companion.findStartDestination
50+ import androidx.navigation.NavHostController
51+ import androidx.navigation.compose.NavHost
52+ import androidx.navigation.compose.composable
53+ import androidx.navigation.compose.currentBackStackEntryAsState
54+ import androidx.navigation.compose.rememberNavController
55+ import androidx.navigation.toRoute
56+ import cc.vectrum.app.data.Api
57+ import cc.vectrum.app.data.Session
58+ import cc.vectrum.app.ui.components.LocalToaster
59+ import cc.vectrum.app.ui.components.ToastHost
60+ import cc.vectrum.app.ui.components.Toaster
61+ import cc.vectrum.app.ui.icons.VIcons
62+ import cc.vectrum.app.ui.screens.CommitScreen
63+ import cc.vectrum.app.ui.screens.ExploreScreen
64+ import cc.vectrum.app.ui.screens.FileScreen
65+ import cc.vectrum.app.ui.screens.HomeScreen
66+ import cc.vectrum.app.ui.screens.IssueScreen
67+ import cc.vectrum.app.ui.screens.MeScreen
68+ import cc.vectrum.app.ui.screens.NewIssueScreen
69+ import cc.vectrum.app.ui.screens.ProfileScreen
70+ import cc.vectrum.app.ui.screens.PullScreen
71+ import cc.vectrum.app.ui.screens.RepoScreen
72+ import cc.vectrum.app.ui.screens.RunScreen
73+ import cc.vectrum.app.ui.screens.TreeScreen
74+ import cc.vectrum.app.ui.theme.Radii
75+ import cc.vectrum.app.ui.theme.ThemeMode
76+ import cc.vectrum.app.ui.theme.Vectrum
77+ import cc.vectrum.app.ui.theme.plate
78+ import cc.vectrum.app.ui.theme.raised
79+ import kotlinx.serialization.Serializable
80+
81+ // Routes
82+
83+ @Serializable object HomeRoute
84+ @Serializable object ExploreRoute
85+ @Serializable object MeRoute
86+ @Serializable data class ProfileRoute(val username: String)
87+ @Serializable data class RepoRoute(val owner: String, val name: String, val tab: String = "code")
88+ @Serializable data class TreeRoute(val owner: String, val name: String, val ref: String, val path: String)
89+ @Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String)
90+ @Serializable data class CommitRoute(val owner: String, val name: String, val sha: String)
91+ @Serializable data class IssueRoute(val owner: String, val name: String, val number: Int)
92+ @Serializable data class NewIssueRoute(val owner: String, val name: String)
93+ @Serializable data class PullRoute(val owner: String, val name: String, val number: Int)
94+ @Serializable data class RunRoute(val owner: String, val name: String, val number: Int)
95+
96+ val LocalApi = staticCompositionLocalOf<Api?> { null }
97+ val LocalSession = staticCompositionLocalOf<Session?> { null }
98+ val LocalNav = staticCompositionLocalOf<Nav> { error("No navigator") }
99+
100+ /** Everything the app can open, by what it is rather than by route. */
101+ class Nav(val controller: NavHostController, private val openUrl: (String) -> Unit, private val origin: String) {
102+ fun back() {
103+ controller.popBackStack()
104+ }
105+
106+ fun repo(owner: String, name: String, tab: String = "code") = controller.navigate(RepoRoute(owner, name, tab))
107+ fun tree(owner: String, name: String, ref: String, path: String) = controller.navigate(TreeRoute(owner, name, ref, path))
108+ fun file(owner: String, name: String, ref: String, path: String) = controller.navigate(FileRoute(owner, name, ref, path))
109+ fun commit(owner: String, name: String, sha: String) = controller.navigate(CommitRoute(owner, name, sha))
110+ fun issue(owner: String, name: String, number: Int) = controller.navigate(IssueRoute(owner, name, number))
111+ fun newIssue(owner: String, name: String) = controller.navigate(NewIssueRoute(owner, name))
112+ fun pull(owner: String, name: String, number: Int) = controller.navigate(PullRoute(owner, name, number))
113+ fun run(owner: String, name: String, number: Int) = controller.navigate(RunRoute(owner, name, number))
114+ fun profile(username: String) = controller.navigate(ProfileRoute(username))
115+
116+ /** Switches to a top-level tab (Home, Explore, You), keeping each tab's state. */
117+ fun tab(route: Any) = controller.navigate(route) {
118+ popUpTo(controller.graph.findStartDestination().id) { saveState = true }
119+ launchSingleTop = true
120+ restoreState = true
121+ }
122+
123+ /** "#12" in a repository: an issue or a pull request (they share numbers); the issue screen hands over to the pull request if it is one. */
124+ fun number(owner: String, name: String, number: Int) = issue(owner, name, number)
125+
126+ /** A link in Markdown: pages of this server open in the app, everything else in the browser. */
127+ fun link(url: String, owner: String? = null, name: String? = null, ref: String? = null, baseDir: String = "") {
128+ val scheme = Regex("^([a-z][a-z0-9+.-]*):", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.lowercase()
129+ val local = when {
130+ url == origin || url.startsWith("$origin/") -> url.removePrefix(origin).ifEmpty { "/" }
131+ url.startsWith("//") -> return openUrl("https:$url")
132+ url.startsWith("/") -> url
133+ url.startsWith("#") -> return
134+ scheme != null -> {
135+ // Links in documents are written by anyone: only the web and mail leave the app.
136+ if (scheme == "http" || scheme == "https" || scheme == "mailto") openUrl(url)
137+ return
138+ }
139+ owner != null && name != null -> {
140+ // A path in the repository, relative to the document (in issues and comments: the default branch).
141+ val path = normalize(if (baseDir.isEmpty()) url else "$baseDir/$url").substringBefore('#').substringBefore('?')
142+ if (path.isEmpty()) repo(owner, name) else file(owner, name, ref ?: "HEAD", path)
143+ return
144+ }
145+ else -> null
146+ }
147+ if (local != null && !openLocal(local)) openUrl(origin + local)
148+ }
149+
150+ private fun openLocal(path: String): Boolean {
151+ val parts = path.substringBefore('?').substringBefore('#').trim('/').split('/').filter { it.isNotEmpty() }
152+ if (parts.isEmpty()) return false
153+ if (parts.size == 1) {
154+ profile(parts[0]); return true
155+ }
156+ val (owner, name) = parts[0] to parts[1].removeSuffix(".git")
157+ val rest = parts.drop(2)
158+ when (rest.firstOrNull()) {
159+ null -> repo(owner, name)
160+ "issues" -> rest.getOrNull(1)?.toIntOrNull()?.let { issue(owner, name, it) } ?: repo(owner, name, "issues")
161+ "pulls" -> rest.getOrNull(1)?.toIntOrNull()?.let { pull(owner, name, it) } ?: repo(owner, name, "pulls")
162+ "commit" -> rest.getOrNull(1)?.let { commit(owner, name, it) } ?: return false
163+ "commits" -> repo(owner, name, "commits")
164+ "actions" -> if (rest.getOrNull(1) == "runs") rest.getOrNull(2)?.toIntOrNull()?.let { run(owner, name, it) } ?: return false else repo(owner, name, "actions")
165+ "releases" -> repo(owner, name, "releases")
166+ "blob" -> if (rest.size >= 3) file(owner, name, rest[1], rest.drop(2).joinToString("/")) else return false
167+ "tree" -> if (rest.size >= 3) tree(owner, name, rest[1], rest.drop(2).joinToString("/")) else repo(owner, name)
168+ else -> return false
169+ }
170+ return true
171+ }
172+
173+ private fun normalize(path: String): String {
174+ val out = ArrayDeque<String>()
175+ for (p in path.split('/')) when (p) {
176+ "", "." -> {}
177+ ".." -> out.removeLastOrNull()
178+ else -> out.addLast(p)
179+ }
180+ return out.joinToString("/")
181+ }
182+ }
183+
184+ private val enter: EnterTransition = slideInHorizontally(tween(320)) { it / 3 } + fadeIn(tween(240))
185+ private val exit: ExitTransition = slideOutHorizontally(tween(320)) { -it / 10 } + fadeOut(tween(200))
186+ private val popEnter: EnterTransition = slideInHorizontally(tween(320)) { -it / 10 } + fadeIn(tween(240))
187+ private val popExit: ExitTransition = slideOutHorizontally(tween(320)) { it / 3 } + fadeOut(tween(200))
188+
189+ /** The signed-in app: the screens, the floating tab bar, and toasts. */
190+ @Composable
191+ fun MainShell(session: Session, api: Api, theme: ThemeMode, onTheme: (ThemeMode) -> Unit, onSignOut: () -> Unit) {
192+ val controller = rememberNavController()
193+ val uri = LocalUriHandler.current
194+ val nav = remember(controller, api) { Nav(controller, { runCatching { uri.openUri(it) } }, api.origin) }
195+ val toaster = remember { Toaster() }
196+ val entry by controller.currentBackStackEntryAsState()
197+ val destination = entry?.destination
198+ val topLevel = destination == null || destination.hasRoute<HomeRoute>() || destination.hasRoute<ExploreRoute>() || destination.hasRoute<MeRoute>()
199+
200+ LaunchedEffect(Unit) {
201+ AppEvents.sessionExpired.collect {
202+ toaster.error("Your session has ended. Sign in again.")
203+ onSignOut()
204+ }
205+ }
206+
207+ CompositionLocalProvider(LocalApi provides api, LocalSession provides session, LocalNav provides nav, LocalToaster provides toaster) {
208+ Box(Modifier.fillMaxSize().background(Vectrum.colors.page)) {
209+ NavHost(
210+ controller,
211+ startDestination = HomeRoute,
212+ enterTransition = { enter },
213+ exitTransition = { exit },
214+ popEnterTransition = { popEnter },
215+ popExitTransition = { popExit },
216+ ) {
217+ composable<HomeRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) { HomeScreen() }
218+ composable<ExploreRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) { ExploreScreen() }
219+ composable<MeRoute>(enterTransition = { fadeIn(tween(220)) }, exitTransition = { exitFor(targetState.destination) }) {
220+ MeScreen(theme = theme, onTheme = onTheme, onSignOut = onSignOut)
221+ }
222+ composable<ProfileRoute> { ProfileScreen(it.toRoute<ProfileRoute>().username) }
223+ composable<RepoRoute> { val r = it.toRoute<RepoRoute>(); RepoScreen(r.owner, r.name, r.tab) }
224+ composable<TreeRoute> { val r = it.toRoute<TreeRoute>(); TreeScreen(r.owner, r.name, r.ref, r.path) }
225+ composable<FileRoute> { val r = it.toRoute<FileRoute>(); FileScreen(r.owner, r.name, r.ref, r.path) }
226+ composable<CommitRoute> { val r = it.toRoute<CommitRoute>(); CommitScreen(r.owner, r.name, r.sha) }
227+ composable<IssueRoute> { val r = it.toRoute<IssueRoute>(); IssueScreen(r.owner, r.name, r.number) }
228+ composable<NewIssueRoute>(enterTransition = { slideInVertically(tween(320)) { it / 4 } + fadeIn() }, popExitTransition = { slideOutVertically(tween(260)) { it / 4 } + fadeOut() }) {
229+ val r = it.toRoute<NewIssueRoute>(); NewIssueScreen(r.owner, r.name)
230+ }
231+ composable<PullRoute> { val r = it.toRoute<PullRoute>(); PullScreen(r.owner, r.name, r.number) }
232+ composable<RunRoute> { val r = it.toRoute<RunRoute>(); RunScreen(r.owner, r.name, r.number) }
233+ }
234+
235+ if (topLevel) {
236+ // The page fades in under the status bar and behind the tab bar, so content scrolls away beneath them.
237+ val page = Vectrum.colors.page
238+ Box(Modifier.align(Alignment.TopCenter).fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(page.copy(alpha = 0.96f)))
239+ Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(120.dp).background(Brush.verticalGradient(listOf(page.copy(alpha = 0f), page.copy(alpha = 0.92f)))))
240+ }
241+ AnimatedVisibility(
242+ visible = topLevel,
243+ modifier = Modifier.align(Alignment.BottomCenter),
244+ enter = slideInVertically { it } + fadeIn(),
245+ exit = slideOutVertically { it } + fadeOut(),
246+ ) {
247+ TabBar(
248+ current = when {
249+ destination?.hasRoute<ExploreRoute>() == true -> 1
250+ destination?.hasRoute<MeRoute>() == true -> 2
251+ else -> 0
252+ },
253+ onSelect = { index ->
254+ val route: Any = when (index) {
255+ 1 -> ExploreRoute
256+ 2 -> MeRoute
257+ else -> HomeRoute
258+ }
259+ nav.tab(route)
260+ },
261+ )
262+ }
263+ ToastHost(toaster)
264+ }
265+ }
266+ }
267+
268+ private fun exitFor(target: NavDestination): ExitTransition =
269+ if (target.hasRoute<HomeRoute>() || target.hasRoute<ExploreRoute>() || target.hasRoute<MeRoute>()) fadeOut(tween(160)) else exit
270+
271+ private data class Tab(val label: String, val icon: ImageVector)
272+
273+ /** The floating tab bar: a raised plate with the current tab's key standing out of it. */
274+ @Composable
275+ private fun TabBar(current: Int, onSelect: (Int) -> Unit) {
276+ val c = Vectrum.colors
277+ val haptics = LocalHapticFeedback.current
278+ val tabs = listOf(Tab("Home", VIcons.Home), Tab("Explore", VIcons.Compass), Tab("You", VIcons.Person))
279+ Box(Modifier.windowInsetsPadding(WindowInsets.navigationBars).padding(bottom = 14.dp)) {
280+ Row(
281+ Modifier.plate(Radii.pill).padding(5.dp),
282+ horizontalArrangement = Arrangement.spacedBy(2.dp),
283+ verticalAlignment = Alignment.CenterVertically,
284+ ) {
285+ tabs.forEachIndexed { i, tab ->
286+ val active = i == current
287+ Row(
288+ Modifier
289+ .height(48.dp)
290+ .then(if (active) Modifier.raised(Radii.pill) else Modifier)
291+ .clickable(remember { MutableInteractionSource() }, indication = null) {
292+ if (!active) haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
293+ onSelect(i)
294+ }
295+ .padding(horizontal = if (active) 18.dp else 16.dp),
296+ verticalAlignment = Alignment.CenterVertically,
297+ ) {
298+ Icon(tab.icon, tab.label, Modifier.size(21.dp), tint = if (active) c.ink else c.ink3)
299+ AnimatedVisibility(active) {
300+ Row {
301+ Spacer(Modifier.width(8.dp))
302+ Text(tab.label, style = Vectrum.type.ui, color = c.ink)
303+ }
304+ }
305+ }
306+ }
307+ }
308+ }
309+ }
310+
311+ @Composable
312+ fun SignedOutShell(content: @Composable () -> Unit) {
313+ val toaster = remember { Toaster() }
314+ CompositionLocalProvider(LocalToaster provides toaster) {
315+ Box(Modifier.fillMaxSize().background(Vectrum.colors.page)) {
316+ content()
317+ ToastHost(toaster)
318+ }
319+ }
320+ }
+90−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/Format.ktaddedView File
@@ -0,0 +1,90 @@
1+ package cc.vectrum.app.ui
2+
3+ import java.text.SimpleDateFormat
4+ import java.util.Calendar
5+ import java.util.Date
6+ import java.util.Locale
7+ import kotlin.math.abs
8+ import kotlin.math.roundToLong
9+
10+ // The website's formats (lib/format.ts): "3 days ago", "Oct 3, 2026", "1.2 MB", "3m 12s".
11+
12+ private val units = listOf(
13+ "year" to 365L * 24 * 3600_000,
14+ "month" to 30L * 24 * 3600_000,
15+ "week" to 7L * 24 * 3600_000,
16+ "day" to 24L * 3600_000,
17+ "hour" to 3600_000L,
18+ "minute" to 60_000L,
19+ )
20+
21+ fun timeAgo(ms: Long, now: Long = System.currentTimeMillis()): String {
22+ val diff = ms - now
23+ for ((unit, size) in units) {
24+ if (abs(diff) >= size) {
25+ val n = (diff.toDouble() / size).roundToLong()
26+ // Intl.RelativeTimeFormat's "auto" wording, as on the website: "yesterday", "last week", "1 hour ago".
27+ val named = unit == "day" || unit == "week" || unit == "month" || unit == "year"
28+ return when {
29+ n == -1L && named -> if (unit == "day") "yesterday" else "last $unit"
30+ n == 1L && named -> if (unit == "day") "tomorrow" else "next $unit"
31+ n < 0 -> "${-n} $unit${if (n == -1L) "" else "s"} ago"
32+ else -> "in $n $unit${if (n == 1L) "" else "s"}"
33+ }
34+ }
35+ }
36+ return "just now"
37+ }
38+
39+ /** "2h", "3d", "5w": for tight rows. */
40+ fun shortAgo(ms: Long, now: Long = System.currentTimeMillis()): String {
41+ val s = (now - ms).coerceAtLeast(0) / 1000
42+ return when {
43+ s < 60 -> "now"
44+ s < 3600 -> "${s / 60}m"
45+ s < 86400 -> "${s / 3600}h"
46+ s < 7 * 86400 -> "${s / 86400}d"
47+ s < 365 * 86400 -> "${s / (7 * 86400)}w"
48+ else -> "${s / (365 * 86400)}y"
49+ }
50+ }
51+
52+ fun formatDate(ms: Long): String = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).format(Date(ms))
53+
54+ fun formatBytes(n: Long): String = when {
55+ n < 1024 -> "$n Bytes"
56+ n < 1024 * 1024 -> String.format(Locale.ENGLISH, "%.1f KB", n / 1024.0)
57+ n < 1024L * 1024 * 1024 -> String.format(Locale.ENGLISH, "%.1f MB", n / 1024.0 / 1024.0)
58+ else -> String.format(Locale.ENGLISH, "%.2f GB", n / 1024.0 / 1024.0 / 1024.0)
59+ }
60+
61+ fun formatDuration(ms: Long): String {
62+ val s = (ms / 1000.0).roundToLong().coerceAtLeast(0)
63+ if (s < 60) return "${s}s"
64+ val m = s / 60
65+ if (m < 60) return "${m}m ${s % 60}s"
66+ return "${m / 60}h ${m % 60}m"
67+ }
68+
69+ fun shortSha(sha: String) = sha.take(7)
70+
71+ fun plural(n: Int, word: String, words: String = "${word}s") = "$n ${if (n == 1) word else words}"
72+
73+ /** "Good morning", like the website's dashboard. */
74+ fun greeting(): String {
75+ val h = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
76+ return when {
77+ h < 5 -> "Good evening"
78+ h < 12 -> "Good morning"
79+ h < 18 -> "Good afternoon"
80+ else -> "Good evening"
81+ }
82+ }
83+
84+ /** "Oct 3" for a YYYY-MM-DD date, without time zone conversions. */
85+ fun formatDay(date: String): String {
86+ val parts = date.split("-").mapNotNull { it.toIntOrNull() }
87+ if (parts.size != 3) return date
88+ val months = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
89+ return "${months.getOrElse(parts[1] - 1) { "?" }} ${parts[2]}"
90+ }
+175−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/Loader.ktaddedView File
@@ -0,0 +1,175 @@
1+ package cc.vectrum.app.ui
2+
3+ import androidx.compose.runtime.Composable
4+ import androidx.compose.runtime.getValue
5+ import androidx.compose.runtime.remember
6+ import androidx.compose.runtime.rememberUpdatedState
7+ import androidx.lifecycle.compose.LifecycleResumeEffect
8+ import androidx.compose.runtime.mutableStateListOf
9+ import androidx.compose.runtime.mutableStateOf
10+ import androidx.compose.runtime.setValue
11+ import androidx.lifecycle.ViewModel
12+ import androidx.lifecycle.viewModelScope
13+ import androidx.lifecycle.viewmodel.compose.viewModel
14+ import cc.vectrum.app.data.ApiException
15+ import kotlinx.coroutines.CancellationException
16+ import kotlinx.coroutines.Job
17+ import kotlinx.coroutines.flow.MutableSharedFlow
18+ import kotlinx.coroutines.flow.asSharedFlow
19+ import kotlinx.coroutines.launch
20+ import kotlinx.serialization.SerializationException
21+ import java.io.IOException
22+
23+ sealed interface Load<out T> {
24+ data object Loading : Load<Nothing>
25+ data class Ready<T>(val data: T) : Load<T>
26+ data class Failed(val message: String) : Load<Nothing>
27+ }
28+
29+ /** Things anywhere in the app tell the shell about: the session ran out. */
30+ object AppEvents {
31+ private val expired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
32+ val sessionExpired = expired.asSharedFlow()
33+ fun sessionExpired() {
34+ expired.tryEmit(Unit)
35+ }
36+ }
37+
38+ /** A failure in words for a person; a 401 also signs the app out. */
39+ fun describe(e: Throwable): String = when (e) {
40+ is ApiException -> {
41+ if (e.isUnauthorized) AppEvents.sessionExpired()
42+ e.message ?: "Something went wrong."
43+ }
44+ is IOException -> "Can't reach the server. Check your connection and try again."
45+ is SerializationException -> "The server sent something this app doesn't understand. Is it up to date?"
46+ else -> e.message ?: "Something went wrong."
47+ }
48+
49+ /**
50+ * Loads a screen's data once and keeps it across recompositions and rotation (it lives as long as its
51+ * screen's back stack entry). Pull to refresh reloads it in place; actions replace it with what the
52+ * server answered.
53+ */
54+ class Loader<T>(private val fetch: suspend () -> T) : ViewModel() {
55+ var state: Load<T> by mutableStateOf(Load.Loading)
56+ private set
57+ var refreshing by mutableStateOf(false)
58+ private set
59+ private var job: Job? = null
60+
61+ init {
62+ load()
63+ }
64+
65+ /** Loads again: in place (refresh) when there's data on screen, else from the loading state. */
66+ fun load(refresh: Boolean = state is Load.Ready) {
67+ job?.cancel()
68+ if (refresh) refreshing = true else state = Load.Loading
69+ job = viewModelScope.launch {
70+ try {
71+ state = Load.Ready(fetch())
72+ } catch (e: CancellationException) {
73+ throw e
74+ } catch (e: Exception) {
75+ // A failed refresh keeps what's on screen.
76+ if (!refresh || state !is Load.Ready) state = Load.Failed(describe(e))
77+ } finally {
78+ refreshing = false
79+ }
80+ }
81+ }
82+
83+ fun set(data: T) {
84+ job?.cancel()
85+ refreshing = false
86+ state = Load.Ready(data)
87+ }
88+
89+ val data: T? get() = (state as? Load.Ready<T>)?.data
90+ }
91+
92+ @Composable
93+ fun <T> rememberLoader(key: String, fetch: suspend () -> T): Loader<T> {
94+ @Suppress("UNCHECKED_CAST")
95+ return viewModel<Loader<*>>(key = key) { Loader(fetch) } as Loader<T>
96+ }
97+
98+ /** A list that loads page by page ("Load more"), kept like a [Loader]. */
99+ class Pager<T>(private val fetch: suspend (page: Int) -> Pair<List<T>, Boolean>) : ViewModel() {
100+ val items = mutableStateListOf<T>()
101+ var hasMore by mutableStateOf(true)
102+ private set
103+ var loading by mutableStateOf(false)
104+ private set
105+ var error by mutableStateOf<String?>(null)
106+ private set
107+ var loaded by mutableStateOf(false)
108+ private set
109+ private var page = 0
110+ private var job: Job? = null
111+
112+ init {
113+ more()
114+ }
115+
116+ fun more() {
117+ if (loading || !hasMore) return
118+ loading = true
119+ job = viewModelScope.launch {
120+ try {
121+ val (next, more) = fetch(page + 1)
122+ page += 1
123+ items.addAll(next)
124+ hasMore = more
125+ error = null
126+ loaded = true
127+ } catch (e: CancellationException) {
128+ throw e
129+ } catch (e: Exception) {
130+ error = describe(e)
131+ } finally {
132+ loading = false
133+ }
134+ }
135+ }
136+
137+ fun refresh() {
138+ job?.cancel()
139+ loading = false
140+ page = 0
141+ hasMore = true
142+ viewModelScope.launch {
143+ try {
144+ val (first, more) = fetch(1)
145+ items.clear()
146+ items.addAll(first)
147+ page = 1
148+ hasMore = more
149+ error = null
150+ loaded = true
151+ } catch (e: CancellationException) {
152+ throw e
153+ } catch (e: Exception) {
154+ error = describe(e)
155+ }
156+ }
157+ }
158+ }
159+
160+ @Composable
161+ fun <T> rememberPager(key: String, fetch: suspend (page: Int) -> Pair<List<T>, Boolean>): Pager<T> {
162+ @Suppress("UNCHECKED_CAST")
163+ return viewModel<Pager<*>>(key = key) { Pager(fetch) } as Pager<T>
164+ }
165+
166+ /** Runs `onReturn` when the screen comes back into view (after a screen on top of it closes), not on first show. */
167+ @Composable
168+ fun RefreshOnReturn(onReturn: () -> Unit) {
169+ val first = remember { mutableStateOf(true) }
170+ val action by rememberUpdatedState(onReturn)
171+ LifecycleResumeEffect(Unit) {
172+ if (first.value) first.value = false else action()
173+ onPauseOrDispose {}
174+ }
175+ }
+311−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/components/Code.ktaddedView File
@@ -0,0 +1,311 @@
1+ package cc.vectrum.app.ui.components
2+
3+ import androidx.compose.foundation.horizontalScroll
4+ import androidx.compose.foundation.layout.Box
5+ import androidx.compose.foundation.layout.Column
6+ import androidx.compose.foundation.layout.PaddingValues
7+ import androidx.compose.foundation.layout.Row
8+ import androidx.compose.foundation.layout.fillMaxSize
9+ import androidx.compose.foundation.layout.fillMaxWidth
10+ import androidx.compose.foundation.layout.padding
11+ import androidx.compose.foundation.layout.requiredWidth
12+ import androidx.compose.foundation.layout.width
13+ import androidx.compose.foundation.lazy.LazyColumn
14+ import androidx.compose.foundation.lazy.LazyListState
15+ import androidx.compose.foundation.lazy.itemsIndexed
16+ import androidx.compose.foundation.lazy.rememberLazyListState
17+ import androidx.compose.foundation.rememberScrollState
18+ import androidx.compose.foundation.text.selection.SelectionContainer
19+ import androidx.compose.material3.Text
20+ import androidx.compose.runtime.Composable
21+ import androidx.compose.runtime.getValue
22+ import androidx.compose.runtime.produceState
23+ import androidx.compose.runtime.remember
24+ import androidx.compose.ui.Modifier
25+ import androidx.compose.ui.graphics.Color
26+ import androidx.compose.ui.platform.LocalDensity
27+ import androidx.compose.ui.text.AnnotatedString
28+ import androidx.compose.ui.text.SpanStyle
29+ import androidx.compose.ui.text.buildAnnotatedString
30+ import androidx.compose.ui.text.font.FontStyle
31+ import androidx.compose.ui.text.rememberTextMeasurer
32+ import androidx.compose.ui.text.style.TextAlign
33+ import androidx.compose.ui.text.withStyle
34+ import androidx.compose.ui.unit.dp
35+ import cc.vectrum.app.ui.theme.Syntax
36+ import kotlinx.coroutines.Dispatchers
37+ import kotlinx.coroutines.withContext
38+ import cc.vectrum.app.ui.theme.Vectrum
39+
40+ /*
41+ * A small highlighter in MetalUI's syntax colours (--mu-syn-*): comments, strings, numbers, keywords,
42+ * types and calls, for the languages people keep in repositories. Not a parser; it's there to make
43+ * code easy to scan on a phone, as Shiki does on the website.
44+ */
45+
46+ private enum class Family { CLike, Python, Shell, Ruby, Sql, Markup, Css, Yaml, Toml, Json, Plain }
47+
48+ private fun familyOf(lang: String): Family = when (lang.lowercase()) {
49+ "js", "mjs", "cjs", "jsx", "ts", "tsx", "javascript", "typescript", "java", "kt", "kts", "kotlin", "c", "h", "cc", "cpp", "hpp", "cs",
50+ "go", "rs", "rust", "swift", "scala", "dart", "php", "groovy", "gradle", "zig", "m", "mm", "proto", "graphql", "gql", "sol" -> Family.CLike
51+ "py", "python", "pyi" -> Family.Python
52+ "sh", "bash", "zsh", "fish", "shell", "console", "dockerfile", "makefile", "mk", "env", "ps1" -> Family.Shell
53+ "rb", "ruby", "ex", "exs", "elixir", "lua", "pl", "perl", "r" -> Family.Ruby
54+ "sql" -> Family.Sql
55+ "html", "htm", "xml", "svg", "vue", "svelte", "astro", "plist" -> Family.Markup
56+ "css", "scss", "sass", "less" -> Family.Css
57+ "yml", "yaml" -> Family.Yaml
58+ "toml", "ini", "cfg", "conf", "editorconfig", "properties" -> Family.Toml
59+ "json", "jsonc", "json5", "lock" -> Family.Json
60+ else -> Family.Plain
61+ }
62+
63+ /** The language of a file by its name, for highlighting ("" when unknown). */
64+ fun languageOf(path: String): String {
65+ val name = path.substringAfterLast('/')
66+ return when {
67+ name.equals("Dockerfile", true) -> "dockerfile"
68+ name.equals("Makefile", true) -> "makefile"
69+ name.startsWith(".env") -> "env"
70+ name.startsWith(".") && !name.substring(1).contains('.') -> name.substring(1)
71+ else -> name.substringAfterLast('.', "")
72+ }
73+ }
74+
75+ private val KEYWORDS = setOf(
76+ // C-like
77+ "abstract", "as", "async", "await", "break", "case", "catch", "class", "const", "continue", "data", "debugger", "default", "defer", "delete",
78+ "do", "else", "enum", "export", "extends", "false", "final", "finally", "fn", "for", "from", "fun", "func", "function", "go", "if", "impl",
79+ "implements", "import", "in", "instanceof", "interface", "internal", "is", "let", "match", "mod", "module", "mut", "namespace", "new", "nil",
80+ "null", "object", "of", "open", "override", "package", "private", "protected", "pub", "public", "readonly", "return", "sealed", "self",
81+ "static", "struct", "super", "suspend", "switch", "this", "throw", "throws", "trait", "true", "try", "type", "typeof", "undefined", "use",
82+ "val", "var", "void", "when", "where", "while", "with", "yield", "crate", "dyn", "loop", "ref", "unsafe", "chan", "select", "range", "map",
83+ "satisfies", "keyof", "declare", "lateinit", "companion", "init", "inline", "reified", "operator", "infix", "guard", "inout", "extension",
84+ "protocol", "struct", "char", "int", "long", "float", "double", "bool", "boolean", "string", "unsigned", "signed", "goto", "sizeof",
85+ // Python, Ruby, shell
86+ "and", "def", "del", "elif", "except", "global", "lambda", "nonlocal", "not", "or", "pass", "raise", "None", "True", "False", "end",
87+ "begin", "rescue", "ensure", "unless", "until", "then", "fi", "esac", "done", "local", "export", "echo", "require", "elsif", "puts",
88+ )
89+ private val SQL_KEYWORDS = setOf(
90+ "select", "from", "where", "insert", "into", "values", "update", "set", "delete", "create", "table", "drop", "alter", "add", "join", "left",
91+ "right", "inner", "outer", "on", "group", "by", "order", "having", "limit", "offset", "and", "or", "not", "null", "is", "as", "distinct",
92+ "primary", "key", "foreign", "references", "index", "unique", "default", "union", "all", "case", "when", "then", "else", "end", "in", "like",
93+ )
94+
95+ // Matched at a position (Regex.matchAt), so no "^" anchors.
96+ private val NUMBER = Regex("0x[0-9a-fA-F_]+|\\d[\\d_]*(\\.\\d+)?([eE][+-]?\\d+)?[a-zA-Z]*")
97+ private val WORD = Regex("[A-Za-z_\$][A-Za-z0-9_\$]*")
98+ private val TAG = Regex("</?[A-Za-z][\\w:.-]*")
99+ private val YAML_KEY = Regex("[\\w.-]+(?=:(\\s|$))")
100+ private val HEX_COLOR = Regex("#[0-9a-fA-F]{3,8}\\b")
101+ private val DECORATOR = Regex("@[\\w.]+")
102+
103+ /** Lines longer than this are shown uncoloured. */
104+ private const val MAX_LINE = 2_000
105+
106+ /** Files larger than this are shown uncoloured. */
107+ private const val MAX_HIGHLIGHT = 512 * 1024
108+
109+ /** Highlights code line by line, carrying open block comments and multi-line strings across lines. */
110+ class Highlighter(lang: String, private val syn: Syntax) {
111+ private val family = familyOf(lang)
112+ private var inBlockComment = false
113+ private var inTripleString: String? = null
114+
115+ private val lineComment: List<String> = when (family) {
116+ Family.CLike -> listOf("//")
117+ Family.Python, Family.Shell, Family.Ruby, Family.Yaml, Family.Toml -> listOf("#")
118+ Family.Sql -> listOf("--")
119+ Family.Css -> emptyList()
120+ Family.Json -> listOf("//")
121+ else -> emptyList()
122+ }
123+ private val blockComment: Pair<String, String>? = when (family) {
124+ Family.CLike, Family.Css, Family.Json -> "/*" to "*/"
125+ Family.Markup -> "<!--" to "-->"
126+ else -> null
127+ }
128+
129+ fun line(text: String): AnnotatedString = buildAnnotatedString {
130+ // Very long lines (minified code, data) aren't worth colouring and would slow the screen.
131+ if (family == Family.Plain || text.length > MAX_LINE) {
132+ withStyle(SpanStyle(color = syn.text)) { append(text) }
133+ return@buildAnnotatedString
134+ }
135+ var i = 0
136+ val n = text.length
137+ val indentEnd = text.indexOfFirst { !it.isWhitespace() }.let { if (it < 0) n else it }
138+ fun emit(end: Int, color: Color, italic: Boolean = false) {
139+ withStyle(SpanStyle(color = color, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal)) { append(text, i, end) }
140+ i = end
141+ }
142+ /** The first character after `from` that isn't a space, or 0. */
143+ fun nextChar(from: Int): Char {
144+ var j = from
145+ while (j < n && text[j].isWhitespace()) j++
146+ return if (j < n) text[j] else 0.toChar()
147+ }
148+ fun match(regex: Regex): Int = regex.matchAt(text, i)?.value?.length ?: 0
149+
150+ while (i < n) {
151+ // Inside a block comment or a triple-quoted string from an earlier line.
152+ if (inBlockComment) {
153+ val close = blockComment!!.second
154+ val end = text.indexOf(close, i)
155+ if (end < 0) { emit(n, syn.comment, true); break }
156+ emit(end + close.length, syn.comment, true)
157+ inBlockComment = false
158+ continue
159+ }
160+ val open = inTripleString
161+ if (open != null) {
162+ val end = text.indexOf(open, i)
163+ if (end < 0) { emit(n, syn.string); break }
164+ emit(end + open.length, syn.string)
165+ inTripleString = null
166+ continue
167+ }
168+ val ch = text[i]
169+ when {
170+ lineComment.any { text.startsWith(it, i) } && !(family == Family.Shell && i > 0 && text[i - 1] == '$') -> emit(n, syn.comment, true)
171+ blockComment != null && text.startsWith(blockComment.first, i) -> {
172+ val end = text.indexOf(blockComment.second, i + blockComment.first.length)
173+ if (end < 0) { inBlockComment = true; emit(n, syn.comment, true) } else emit(end + blockComment.second.length, syn.comment, true)
174+ }
175+ (text.startsWith("\"\"\"", i) || text.startsWith("'''", i)) && (family == Family.Python || family == Family.CLike || family == Family.Toml) -> {
176+ val q = text.substring(i, i + 3)
177+ val end = text.indexOf(q, i + 3)
178+ if (end < 0) { inTripleString = q; emit(n, syn.string) } else emit(end + 3, syn.string)
179+ }
180+ ch == '"' || ch == '\'' || ch == '`' -> {
181+ var j = i + 1
182+ while (j < n && text[j] != ch) j += if (text[j] == '\\') 2 else 1
183+ val end = minOf(j + 1, n)
184+ // In JSON, a string before a colon is a key.
185+ emit(end, if (family == Family.Json && nextChar(end) == ':') syn.member else syn.string)
186+ }
187+ family == Family.Markup && ch == '<' -> {
188+ val len = match(TAG)
189+ if (len > 0) emit(i + len, syn.keyword) else emit(i + 1, syn.punct)
190+ }
191+ family == Family.Yaml && i == indentEnd && match(YAML_KEY) > 0 -> emit(i + match(YAML_KEY), syn.member)
192+ family == Family.Toml && ch == '[' && i == indentEnd -> emit(n, syn.type)
193+ family == Family.Css && ch == '#' && match(HEX_COLOR) > 0 -> emit(i + match(HEX_COLOR), syn.number)
194+ ch.isDigit() -> emit(i + maxOf(1, match(NUMBER)), syn.number)
195+ ch == '@' && family != Family.Markup -> emit(i + maxOf(1, match(DECORATOR)), syn.type)
196+ ch.isLetter() || ch == '_' || ch == '$' -> {
197+ val len = maxOf(1, match(WORD))
198+ val word = text.substring(i, i + len)
199+ val after = i + len
200+ val color = when {
201+ family == Family.Sql && word.lowercase() in SQL_KEYWORDS -> syn.keyword
202+ family != Family.Sql && word in KEYWORDS -> syn.keyword
203+ family == Family.Shell && ch == '$' -> syn.member
204+ after < n && text[after] == '(' -> syn.member
205+ word[0].isUpperCase() && word.length > 1 -> syn.type
206+ family == Family.Toml && i == indentEnd && nextChar(after) == '=' -> syn.member
207+ family == Family.Css && nextChar(after) == ':' -> syn.member
208+ else -> syn.text
209+ }
210+ emit(after, color)
211+ }
212+ ch.isWhitespace() -> {
213+ var j = i
214+ while (j < n && text[j].isWhitespace()) j++
215+ emit(j, syn.text)
216+ }
217+ else -> emit(i + 1, syn.punct)
218+ }
219+ }
220+ }
221+ }
222+
223+ /**
224+ * Highlighted lines of a whole text. The plain lines show at once; the colours are worked out away from
225+ * the main thread and swapped in (and skipped for very large files).
226+ */
227+ @Composable
228+ fun rememberHighlighted(text: String, lang: String): List<AnnotatedString> {
229+ val syn = Vectrum.colors.syntax
230+ val plain = remember(text) {
231+ text.removeSuffix("\n").split('\n').map { line -> AnnotatedString(line.replace("\t", " "), SpanStyle(color = syn.text)) }
232+ }
233+ val colored by produceState(plain, text, lang, syn) {
234+ if (text.length <= MAX_HIGHLIGHT) {
235+ value = withContext(Dispatchers.Default) {
236+ val h = Highlighter(lang, syn)
237+ plain.map { h.line(it.text) }
238+ }
239+ }
240+ }
241+ return colored
242+ }
243+
244+ /**
245+ * A file's code with line numbers, like the website's code view. Lines scroll sideways together (or
246+ * wrap), and the list is lazy, so long files stay smooth.
247+ */
248+ @Composable
249+ fun CodeView(
250+ text: String,
251+ lang: String,
252+ modifier: Modifier = Modifier,
253+ wrap: Boolean = false,
254+ state: LazyListState = rememberLazyListState(),
255+ contentPadding: PaddingValues = PaddingValues(vertical = 12.dp),
256+ header: (@Composable () -> Unit)? = null,
257+ ) {
258+ val c = Vectrum.colors
259+ val style = Vectrum.type.code
260+ val lines = rememberHighlighted(text, lang)
261+ val measurer = rememberTextMeasurer()
262+ val density = LocalDensity.current
263+ val charWidth = remember(style, density) { measurer.measure("0000000000", style).size.width / 10f }
264+ val gutter = with(density) { (charWidth * (lines.size.toString().length + 1)).toDp() } + 18.dp
265+ val longest = remember(lines) { lines.maxOfOrNull { it.length } ?: 0 }
266+ // Compose can't lay out anything wider than about 262,000 px, so files with very long lines always wrap.
267+ val forceWrap = longest > MAX_LINE
268+ val wrapped = wrap || forceWrap
269+ val contentWidth = gutter + with(density) { (charWidth * longest).toDp() } + 40.dp
270+ val hScroll = rememberScrollState()
271+
272+ BoxWithWidth(modifier.fillMaxSize()) { viewport ->
273+ val width = if (wrapped) viewport else maxOf(viewport, contentWidth)
274+ Box(if (wrapped) Modifier.fillMaxSize() else Modifier.fillMaxSize().horizontalScroll(hScroll)) {
275+ SelectionContainer {
276+ LazyColumn(Modifier.requiredWidth(width).fillMaxSize(), state = state, contentPadding = contentPadding) {
277+ if (header != null) item { Box(Modifier.width(viewport)) { header() } }
278+ itemsIndexed(lines) { index, line ->
279+ Row(Modifier.fillMaxWidth()) {
280+ Text(
281+ "${index + 1}",
282+ modifier = Modifier.width(gutter).padding(end = 14.dp),
283+ style = style,
284+ color = c.syntax.line,
285+ textAlign = TextAlign.End,
286+ maxLines = 1,
287+ )
288+ Text(line, style = style, softWrap = wrapped, modifier = Modifier.padding(end = 16.dp))
289+ }
290+ }
291+ }
292+ }
293+ }
294+ }
295+ }
296+
297+ /** Measures the width it's given and hands it to its content. */
298+ @Composable
299+ private fun BoxWithWidth(modifier: Modifier, content: @Composable (androidx.compose.ui.unit.Dp) -> Unit) {
300+ androidx.compose.foundation.layout.BoxWithConstraints(modifier) { content(maxWidth) }
301+ }
302+
303+ /** A short block of highlighted code on a well (markdown code blocks, snippets). */
304+ @Composable
305+ fun CodeBlock(text: String, lang: String, modifier: Modifier = Modifier) {
306+ val lines = rememberHighlighted(text, lang)
307+ Column(modifier.horizontalScroll(rememberScrollState())) {
308+ // Lines past MAX_LINE are cut: in a sideways scroll they could be wider than Compose can lay out.
309+ for (line in lines) Text(if (line.length > MAX_LINE) line.subSequence(0, MAX_LINE) else line, style = Vectrum.type.code, softWrap = false)
310+ }
311+ }
+318−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/components/Controls.ktaddedView File
@@ -0,0 +1,318 @@
1+ package cc.vectrum.app.ui.components
2+
3+ import androidx.compose.animation.animateColorAsState
4+ import androidx.compose.runtime.mutableStateOf
5+ import androidx.compose.runtime.setValue
6+ import androidx.compose.ui.unit.Constraints
7+ import androidx.compose.ui.layout.layout
8+ import androidx.compose.ui.geometry.Offset
9+ import androidx.compose.animation.core.VectorConverter
10+ import androidx.compose.animation.core.Animatable
11+ import androidx.compose.animation.core.Spring
12+ import androidx.compose.animation.core.animateFloatAsState
13+ import androidx.compose.animation.core.spring
14+ import androidx.compose.foundation.background
15+ import androidx.compose.foundation.clickable
16+ import androidx.compose.foundation.interaction.MutableInteractionSource
17+ import androidx.compose.foundation.interaction.collectIsPressedAsState
18+ import androidx.compose.foundation.layout.Arrangement
19+ import androidx.compose.foundation.layout.Box
20+ import androidx.compose.foundation.layout.PaddingValues
21+ import androidx.compose.foundation.layout.Row
22+ import androidx.compose.foundation.layout.Spacer
23+ import androidx.compose.foundation.layout.fillMaxWidth
24+ import androidx.compose.foundation.layout.height
25+ import androidx.compose.foundation.layout.padding
26+ import androidx.compose.foundation.layout.size
27+ import androidx.compose.foundation.layout.width
28+ import androidx.compose.foundation.rememberScrollState
29+ import androidx.compose.foundation.horizontalScroll
30+ import androidx.compose.foundation.shape.CircleShape
31+ import androidx.compose.foundation.shape.RoundedCornerShape
32+ import androidx.compose.material3.CircularProgressIndicator
33+ import androidx.compose.material3.Icon
34+ import androidx.compose.material3.Text
35+ import androidx.compose.runtime.Composable
36+ import androidx.compose.runtime.LaunchedEffect
37+ import androidx.compose.runtime.getValue
38+ import androidx.compose.runtime.mutableStateMapOf
39+ import androidx.compose.runtime.remember
40+ import androidx.compose.ui.Alignment
41+ import androidx.compose.ui.Modifier
42+ import androidx.compose.ui.graphics.Brush
43+ import androidx.compose.ui.graphics.Color
44+ import androidx.compose.ui.graphics.Shape
45+ import androidx.compose.ui.graphics.graphicsLayer
46+ import androidx.compose.ui.graphics.vector.ImageVector
47+ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
48+ import androidx.compose.ui.layout.onGloballyPositioned
49+ import androidx.compose.ui.layout.positionInParent
50+ import androidx.compose.ui.platform.LocalHapticFeedback
51+ import androidx.compose.ui.semantics.Role
52+ import androidx.compose.ui.text.style.TextOverflow
53+ import androidx.compose.ui.unit.Dp
54+ import androidx.compose.ui.unit.dp
55+ import cc.vectrum.app.ui.theme.Radii
56+ import cc.vectrum.app.ui.theme.Vectrum
57+ import cc.vectrum.app.ui.theme.material
58+ import cc.vectrum.app.ui.theme.materials
59+ import cc.vectrum.app.ui.theme.raised
60+ import cc.vectrum.app.ui.theme.well
61+ import kotlin.math.roundToInt
62+
63+ enum class ButtonKind { Standard, Primary, Danger, Ghost }
64+
65+ /** Spring of MetalUI's caps: quick, a hint of overshoot. */
66+ private val pressSpring = spring<Float>(dampingRatio = 0.6f, stiffness = Spring.StiffnessHigh)
67+
68+ /**
69+ * A cap (MetalUI's Button): soft-touch standard, the dark primary, the red destructive, or a quiet
70+ * ghost key. It travels down and sinks while held, with a tick of haptics.
71+ */
72+ @Composable
73+ fun VButton(
74+ text: String,
75+ onClick: () -> Unit,
76+ modifier: Modifier = Modifier,
77+ kind: ButtonKind = ButtonKind.Standard,
78+ icon: ImageVector? = null,
79+ enabled: Boolean = true,
80+ loading: Boolean = false,
81+ small: Boolean = false,
82+ fill: Boolean = false,
83+ ) {
84+ val c = Vectrum.colors
85+ val interaction = remember { MutableInteractionSource() }
86+ val pressed by interaction.collectIsPressedAsState()
87+ val haptics = LocalHapticFeedback.current
88+ val travel by animateFloatAsState(if (pressed && enabled) 1f else 0f, pressSpring, label = "travel")
89+ val m = c.materials
90+ val shape = Radii.pill
91+ // Disabled caps sink into a well with faded ink (an alpha layer would clip their shadows).
92+ val (material, brush, ink) = when {
93+ !enabled && kind != ButtonKind.Ghost -> Triple(m.well, Brush.verticalGradient(listOf(c.wellTop, c.wellBottom)), c.ink3)
94+ kind == ButtonKind.Standard -> Triple(if (pressed) m.pressed else m.button, Brush.verticalGradient(if (pressed) listOf(c.pressedTop, c.pressedBottom) else listOf(c.buttonTop, c.buttonBottom)), c.ink)
95+ kind == ButtonKind.Primary -> Triple(m.primary, Brush.verticalGradient(listOf(c.primaryTop, c.primaryBottom)), c.primaryInk)
96+ kind == ButtonKind.Danger -> Triple(m.primary, Brush.verticalGradient(listOf(c.dangerTop, c.dangerBottom)), Color.White)
97+ else -> Triple(null, Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)), if (enabled) c.ink2 else c.ink3)
98+ }
99+ val height = if (small) 34.dp else 44.dp
100+ val base = modifier
101+ .graphicsLayer { translationY = travel * 1.dp.toPx(); scaleX = 1f - travel * 0.015f; scaleY = 1f - travel * 0.015f }
102+ .height(height)
103+ .let { if (fill) it.fillMaxWidth() else it }
104+ val surface = if (material != null) base.material(material, shape, brush) else base.background(if (pressed) c.rowHover else Color.Transparent, shape)
105+ Row(
106+ surface
107+ .clickable(interaction, indication = null, enabled = enabled && !loading, role = Role.Button) {
108+ haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
109+ onClick()
110+ }
111+ .padding(horizontal = if (small) 13.dp else 18.dp),
112+ verticalAlignment = Alignment.CenterVertically,
113+ horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.CenterHorizontally),
114+ ) {
115+ if (loading) {
116+ CircularProgressIndicator(Modifier.size(15.dp), color = ink, strokeWidth = 1.7.dp)
117+ } else if (icon != null) {
118+ Icon(icon, null, Modifier.size(if (small) 16.dp else 17.dp), tint = ink)
119+ }
120+ Text(text, style = Vectrum.type.ui, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
121+ }
122+ }
123+
124+ /** A round key with a glyph: raised (back buttons, toolbar actions) or a quiet ghost. */
125+ @Composable
126+ fun RoundButton(
127+ icon: ImageVector,
128+ contentDescription: String?,
129+ onClick: () -> Unit,
130+ modifier: Modifier = Modifier,
131+ size: Dp = 40.dp,
132+ ghost: Boolean = false,
133+ primary: Boolean = false,
134+ enabled: Boolean = true,
135+ loading: Boolean = false,
136+ tint: Color = if (primary) Vectrum.colors.primaryInk else Vectrum.colors.icon,
137+ ) {
138+ val c = Vectrum.colors
139+ val interaction = remember { MutableInteractionSource() }
140+ val pressed by interaction.collectIsPressedAsState()
141+ val haptics = LocalHapticFeedback.current
142+ val travel by animateFloatAsState(if (pressed) 1f else 0f, pressSpring, label = "travel")
143+ val shape = CircleShape
144+ val surface = when {
145+ ghost -> Modifier.background(if (pressed) c.rowHover else Color.Transparent, shape)
146+ !enabled -> Modifier.material(c.materials.well, shape, Brush.verticalGradient(listOf(c.wellTop, c.wellBottom)))
147+ primary -> Modifier.material(c.materials.primary, shape, Brush.verticalGradient(listOf(c.primaryTop, c.primaryBottom)))
148+ pressed -> Modifier.material(c.materials.pressed, shape, Brush.verticalGradient(listOf(c.pressedTop, c.pressedBottom)))
149+ else -> Modifier.material(c.materials.button, shape, Brush.verticalGradient(listOf(c.buttonTop, c.buttonBottom)))
150+ }
151+ Box(
152+ modifier
153+ .size(size)
154+ .graphicsLayer { translationY = travel * 1.dp.toPx() }
155+ .then(surface)
156+ .clickable(interaction, indication = null, enabled = enabled && !loading, role = Role.Button) {
157+ haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
158+ onClick()
159+ },
160+ contentAlignment = Alignment.Center,
161+ ) {
162+ val ink = if (enabled || ghost) tint else c.ink3
163+ if (loading) {
164+ CircularProgressIndicator(Modifier.size(size * 0.4f), color = ink, strokeWidth = 1.7.dp)
165+ } else {
166+ Icon(icon, contentDescription, Modifier.size(size * 0.46f), tint = ink)
167+ }
168+ }
169+ }
170+
171+ data class SegmentOption<T>(val value: T, val label: String, val icon: ImageVector? = null, val count: Int? = null)
172+
173+ /**
174+ * MetalUI's Segmented control, as the website's repository tabs: a sunk track with a raised thumb
175+ * that slides to the chosen option. Scrolls sideways when the options don't fit.
176+ */
177+ @Composable
178+ fun <T> Segmented(
179+ options: List<SegmentOption<T>>,
180+ selected: T,
181+ onSelect: (T) -> Unit,
182+ modifier: Modifier = Modifier,
183+ scrollable: Boolean = false,
184+ equalWidth: Boolean = !scrollable,
185+ ) {
186+ val c = Vectrum.colors
187+ val haptics = LocalHapticFeedback.current
188+ val bounds = remember { mutableStateMapOf<Int, Pair<Int, Int>>() }
189+ val index = options.indexOfFirst { it.value == selected }.coerceAtLeast(0)
190+ val scroll = rememberScrollState()
191+ val target = bounds[index]
192+ // The thumb's left and right edges, moved as one so they never drift apart mid-slide. It appears
193+ // in place the first time (no growing out of the corner), then glides to each new choice.
194+ val edges = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
195+ var placed by remember { mutableStateOf(false) }
196+ LaunchedEffect(target) {
197+ if (target == null) return@LaunchedEffect
198+ val goal = Offset(target.first.toFloat(), (target.first + target.second).toFloat())
199+ if (!placed) {
200+ edges.snapTo(goal)
201+ placed = true
202+ } else {
203+ edges.animateTo(goal, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
204+ }
205+ }
206+
207+ LaunchedEffect(index, target) {
208+ if (scrollable && target != null) {
209+ val viewport = scroll.viewportSize
210+ val center = target.first + target.second / 2 - viewport / 2
211+ scroll.animateScrollTo(center.coerceIn(0, scroll.maxValue))
212+ }
213+ }
214+
215+ val track = RoundedCornerShape(50)
216+ Box(modifier.well(track).padding(3.dp)) {
217+ val rowModifier = if (scrollable) Modifier.horizontalScroll(scroll) else Modifier.fillMaxWidth()
218+ Box(rowModifier) {
219+ if (placed) {
220+ Box(
221+ Modifier
222+ .layout { measurable, _ ->
223+ // Read in layout, so the slide doesn't recompose the control every frame.
224+ val left = edges.value.x.roundToInt()
225+ val width = (edges.value.y - edges.value.x).roundToInt().coerceAtLeast(0)
226+ val thumb = measurable.measure(Constraints.fixed(width, 38.dp.roundToPx()))
227+ layout(thumb.width, thumb.height) { thumb.place(left, 0) }
228+ }
229+ .raised(track),
230+ )
231+ }
232+ Row(if (scrollable) Modifier else Modifier.fillMaxWidth()) {
233+ options.forEachIndexed { i, option ->
234+ val active = i == index
235+ val ink by animateColorAsState(if (active) c.ink else c.ink2, label = "ink")
236+ Row(
237+ Modifier
238+ .let { if (equalWidth) it.weight(1f) else it }
239+ .height(38.dp)
240+ .onGloballyPositioned { bounds[i] = it.positionInParent().x.roundToInt() to it.size.width }
241+ .clickable(remember { MutableInteractionSource() }, indication = null, role = Role.Tab) {
242+ if (!active) haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
243+ onSelect(option.value)
244+ }
245+ .padding(horizontal = 14.dp),
246+ verticalAlignment = Alignment.CenterVertically,
247+ horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
248+ ) {
249+ if (option.icon != null) Icon(option.icon, null, Modifier.size(16.dp), tint = ink)
250+ Text(option.label, style = Vectrum.type.ui, color = ink, maxLines = 1)
251+ if (option.count != null) Counter(option.count)
252+ }
253+ }
254+ }
255+ }
256+ }
257+ }
258+
259+ /** A row of choices as chips, for filters ("Open", "Merged", "Closed"). */
260+ @Composable
261+ fun <T> ChipRow(
262+ options: List<SegmentOption<T>>,
263+ selected: T,
264+ onSelect: (T) -> Unit,
265+ modifier: Modifier = Modifier,
266+ contentPadding: PaddingValues = PaddingValues(0.dp),
267+ ) {
268+ val c = Vectrum.colors
269+ val haptics = LocalHapticFeedback.current
270+ // Vertical room inside the scroller, so the chips' shadows aren't cut off by its clip.
271+ Row(
272+ modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(contentPadding).padding(vertical = 6.dp),
273+ horizontalArrangement = Arrangement.spacedBy(8.dp),
274+ ) {
275+ for (option in options) {
276+ val active = option.value == selected
277+ val shape: Shape = Radii.pill
278+ Row(
279+ (if (active) Modifier.material(c.materials.primary, shape, Brush.verticalGradient(listOf(c.primaryTop, c.primaryBottom))) else Modifier.raised(shape))
280+ .height(34.dp)
281+ .clickable(remember { MutableInteractionSource() }, indication = null) {
282+ haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
283+ onSelect(option.value)
284+ }
285+ .padding(horizontal = 14.dp),
286+ verticalAlignment = Alignment.CenterVertically,
287+ horizontalArrangement = Arrangement.spacedBy(6.dp),
288+ ) {
289+ val ink = if (active) c.primaryInk else c.ink
290+ if (option.icon != null) Icon(option.icon, null, Modifier.size(15.dp), tint = ink)
291+ Text(option.label, style = Vectrum.type.ui, color = ink)
292+ if (option.count != null) Text("${option.count}", style = Vectrum.type.readout, color = if (active) c.primaryInk.copy(alpha = 0.7f) else c.ink3)
293+ }
294+ }
295+ }
296+ }
297+
298+ /** A tappable row inside a plate: lifts out of it while pressed, like the website's list rows. */
299+ @Composable
300+ fun PressableRow(onClick: (() -> Unit)?, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
301+ val c = Vectrum.colors
302+ val interaction = remember { MutableInteractionSource() }
303+ val pressed by interaction.collectIsPressedAsState()
304+ val bg by animateColorAsState(if (pressed) c.rowHover else Color.Transparent, label = "row")
305+ Box(
306+ modifier
307+ .background(bg)
308+ .let { if (onClick != null) it.clickable(interaction, indication = null, onClick = onClick) else it },
309+ ) { content() }
310+ }
311+
312+ @Composable
313+ fun Gap(size: Dp) = Spacer(Modifier.size(size))
314+
315+ /** Tappable without the ripple: the website's links and crumbs don't flash. */
316+ @Composable
317+ fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier =
318+ clickable(remember { MutableInteractionSource() }, indication = null, onClick = onClick)
+331−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/components/Core.ktaddedView File
@@ -0,0 +1,331 @@
1+ package cc.vectrum.app.ui.components
2+
3+ import androidx.compose.foundation.Canvas
4+ import androidx.compose.foundation.background
5+ import androidx.compose.foundation.border
6+ import androidx.compose.foundation.layout.Arrangement
7+ import androidx.compose.foundation.layout.Box
8+ import androidx.compose.foundation.layout.Column
9+ import androidx.compose.foundation.layout.ColumnScope
10+ import androidx.compose.foundation.layout.PaddingValues
11+ import androidx.compose.foundation.layout.Row
12+ import androidx.compose.foundation.layout.RowScope
13+ import androidx.compose.foundation.layout.Spacer
14+ import androidx.compose.foundation.layout.fillMaxWidth
15+ import androidx.compose.foundation.layout.height
16+ import androidx.compose.foundation.layout.padding
17+ import androidx.compose.foundation.layout.size
18+ import androidx.compose.foundation.layout.widthIn
19+ import androidx.compose.foundation.shape.CircleShape
20+ import androidx.compose.foundation.shape.RoundedCornerShape
21+ import androidx.compose.material3.Icon
22+ import androidx.compose.material3.Text
23+ import androidx.compose.runtime.Composable
24+ import androidx.compose.runtime.remember
25+ import androidx.compose.ui.Alignment
26+ import androidx.compose.ui.Modifier
27+ import androidx.compose.ui.draw.clip
28+ import androidx.compose.ui.geometry.Offset
29+ import androidx.compose.ui.geometry.Size
30+ import androidx.compose.ui.graphics.Brush
31+ import androidx.compose.ui.graphics.Color
32+ import androidx.compose.ui.graphics.Shadow
33+ import androidx.compose.ui.graphics.Shape
34+ import androidx.compose.ui.graphics.lerp
35+ import androidx.compose.ui.graphics.vector.ImageVector
36+ import androidx.compose.ui.layout.ContentScale
37+ import androidx.compose.ui.platform.LocalContext
38+ import androidx.compose.ui.text.TextStyle
39+ import androidx.compose.ui.text.font.FontWeight
40+ import androidx.compose.ui.text.style.TextOverflow
41+ import androidx.compose.ui.unit.Dp
42+ import androidx.compose.ui.unit.dp
43+ import androidx.compose.ui.unit.sp
44+ import cc.vectrum.app.data.RepoSummary
45+ import cc.vectrum.app.ui.LocalApi
46+ import cc.vectrum.app.ui.icons.VIcons
47+ import cc.vectrum.app.ui.theme.Radii
48+ import cc.vectrum.app.ui.theme.Vectrum
49+ import cc.vectrum.app.ui.theme.plate
50+ import cc.vectrum.app.ui.theme.raised
51+ import cc.vectrum.app.ui.theme.well
52+ import coil3.compose.AsyncImage
53+ import coil3.request.ImageRequest
54+ import coil3.request.crossfade
55+
56+ /** Engraved type: uppercase mono with a light lip, like the website's "ABOUT" and "PULL REQUEST · #8". */
57+ @Composable
58+ fun Engraved(text: String, modifier: Modifier = Modifier, color: Color = Vectrum.colors.engrave, style: TextStyle = Vectrum.type.label) {
59+ Text(
60+ text.uppercase(),
61+ modifier = modifier,
62+ style = style.copy(shadow = Shadow(color = Vectrum.colors.lip, offset = Offset(0f, 1.5f), blurRadius = 0f)),
63+ color = color,
64+ maxLines = 1,
65+ overflow = TextOverflow.Ellipsis,
66+ )
67+ }
68+
69+ /** An engraved hairline with its lip. */
70+ @Composable
71+ fun Rule(modifier: Modifier = Modifier) {
72+ val c = Vectrum.colors
73+ Column(modifier.fillMaxWidth()) {
74+ Box(Modifier.fillMaxWidth().height(1.dp).background(c.rule))
75+ Box(Modifier.fillMaxWidth().height(1.dp).background(c.ruleLip))
76+ }
77+ }
78+
79+ /** A raised plate holding a block of content. */
80+ @Composable
81+ fun Plate(
82+ modifier: Modifier = Modifier,
83+ shape: Shape = Radii.card,
84+ padding: PaddingValues = PaddingValues(0.dp),
85+ content: @Composable ColumnScope.() -> Unit,
86+ ) {
87+ Column(modifier.plate(shape).clip(shape).padding(padding), content = content)
88+ }
89+
90+ /** A titled section: an engraved label (with an optional count and trailing action) above its plate. */
91+ @Composable
92+ fun Section(
93+ title: String,
94+ modifier: Modifier = Modifier,
95+ count: Int? = null,
96+ trailing: (@Composable RowScope.() -> Unit)? = null,
97+ content: @Composable ColumnScope.() -> Unit,
98+ ) {
99+ Column(modifier) {
100+ Row(Modifier.fillMaxWidth().padding(start = 6.dp, end = 2.dp, bottom = 10.dp).height(28.dp), verticalAlignment = Alignment.CenterVertically) {
101+ Engraved(title)
102+ if (count != null) {
103+ Spacer(Modifier.size(8.dp))
104+ Counter(count)
105+ }
106+ Spacer(Modifier.weight(1f))
107+ trailing?.invoke(this)
108+ }
109+ content()
110+ }
111+ }
112+
113+ /** A small number beside a label: "Issues 5". */
114+ @Composable
115+ fun Counter(n: Int, modifier: Modifier = Modifier) {
116+ Text("$n", modifier = modifier, style = Vectrum.type.readout.copy(fontSize = 11.sp), color = Vectrum.colors.ink3)
117+ }
118+
119+ enum class LedKind { Live, Waiting, Danger, Off, Info }
120+
121+ /** A lit status dot (MetalUI's LED): green live, amber waiting, red, unlit. */
122+ @Composable
123+ fun Led(kind: LedKind, modifier: Modifier = Modifier, size: Dp = 7.dp) {
124+ val (hi, mid, lo) = when (kind) {
125+ LedKind.Live -> Triple(Color(0xFFD9FFE9), Color(0xFF7BEAAE), Color(0xFF2FB673))
126+ LedKind.Danger -> Triple(Color(0xFFFFD9D2), Color(0xFFFF7A66), Color(0xFFD5392A))
127+ LedKind.Waiting -> Triple(Color(0xFFFFF1CF), Color(0xFFF5BF55), Color(0xFFC98A18))
128+ LedKind.Info -> Triple(Color(0xFFD8E6FF), Color(0xFF6F9BFF), Color(0xFF2457F2))
129+ LedKind.Off -> Triple(Color(0xFF8B8B8E), Color(0xFF6A6A6D), Color(0xFF4A4A4D))
130+ }
131+ Canvas(modifier.size(size)) {
132+ val r = this.size.minDimension / 2
133+ drawCircle(Color.Black.copy(alpha = 0.25f), radius = r + 0.5f)
134+ drawCircle(
135+ Brush.radialGradient(0f to hi, 0.18f to hi, 0.45f to mid, 1f to lo, center = Offset(r * 0.8f, r * 0.7f), radius = r * 1.3f),
136+ radius = r,
137+ )
138+ }
139+ }
140+
141+ enum class Tone { Default, Success, Attention, Danger, Accent, Merged }
142+
143+ /** The website's Label: an engraved mono word on a raised pill, with its LED ("● PUBLIC", "● OPEN"). */
144+ @Composable
145+ fun Pill(text: String, modifier: Modifier = Modifier, tone: Tone = Tone.Default, icon: ImageVector? = null) {
146+ val c = Vectrum.colors
147+ val ink = when (tone) {
148+ Tone.Default -> c.ink2
149+ Tone.Success -> c.greenDeep
150+ Tone.Attention -> c.amber
151+ Tone.Danger -> c.red
152+ Tone.Accent -> c.link
153+ Tone.Merged -> c.merged
154+ }
155+ Row(
156+ modifier.raised(Radii.pill).height(24.dp).padding(horizontal = 9.dp),
157+ verticalAlignment = Alignment.CenterVertically,
158+ horizontalArrangement = Arrangement.spacedBy(6.dp),
159+ ) {
160+ if (icon != null) {
161+ Icon(icon, null, Modifier.size(13.dp), tint = ink)
162+ } else {
163+ Led(
164+ when (tone) {
165+ Tone.Success -> LedKind.Live
166+ Tone.Attention -> LedKind.Waiting
167+ Tone.Danger -> LedKind.Danger
168+ Tone.Accent, Tone.Merged -> LedKind.Info
169+ Tone.Default -> LedKind.Off
170+ },
171+ size = 6.dp,
172+ )
173+ }
174+ Text(text.uppercase(), style = Vectrum.type.label.copy(fontSize = 10.sp, fontWeight = FontWeight.Medium), color = if (tone == Tone.Default) c.ink2 else ink, maxLines = 1)
175+ }
176+ }
177+
178+ /** A commit SHA or branch name, set in mono on a small well. */
179+ @Composable
180+ fun RefChip(text: String, modifier: Modifier = Modifier, raisedChip: Boolean = false) {
181+ val shape = RoundedCornerShape(7.dp)
182+ Box(
183+ (if (raisedChip) modifier.raised(shape) else modifier.well(shape)).padding(horizontal = 7.dp, vertical = 2.dp),
184+ contentAlignment = Alignment.Center,
185+ ) {
186+ Text(text, style = Vectrum.type.readout, color = Vectrum.colors.ink2, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 220.dp))
187+ }
188+ }
189+
190+ /** An issue label: a lit swatch of its colour and its name on a hairline pill. */
191+ @Composable
192+ fun LabelChip(name: String, color: String, modifier: Modifier = Modifier) {
193+ val c = Vectrum.colors
194+ Row(
195+ modifier
196+ .border(0.75.dp, c.rule, Radii.pill)
197+ .background(c.surfaceHi.copy(alpha = 0.5f), Radii.pill)
198+ .height(22.dp)
199+ .padding(start = 7.dp, end = 9.dp),
200+ verticalAlignment = Alignment.CenterVertically,
201+ horizontalArrangement = Arrangement.spacedBy(6.dp),
202+ ) {
203+ LabelDot(color, size = 7.dp)
204+ Text(name, style = Vectrum.type.meta.copy(fontWeight = FontWeight.Medium), color = c.ink2, maxLines = 1)
205+ }
206+ }
207+
208+ /** A label's colour as a small lit swatch: the colour under an LED's hairline ring and top light. */
209+ @Composable
210+ fun LabelDot(color: String, modifier: Modifier = Modifier, size: Dp = 8.dp) {
211+ val swatch = parseHex(color) ?: Vectrum.colors.ink3
212+ Canvas(modifier.size(size)) {
213+ val r = this.size.minDimension / 2
214+ val lit = lerp(swatch, Color.White, 0.55f)
215+ drawCircle(Color.Black.copy(alpha = 0.25f), radius = r + 0.5f)
216+ drawCircle(Brush.radialGradient(0f to lit, 0.18f to lit, 0.6f to swatch, center = Offset(r * 0.8f, r * 0.7f), radius = r * 1.2f), radius = r)
217+ }
218+ }
219+
220+ fun parseHex(hex: String): Color? {
221+ val h = hex.removePrefix("#")
222+ if (h.length != 6) return null
223+ return h.toLongOrNull(16)?.let { Color(0xFF000000 or it) }
224+ }
225+
226+ /** Initials of a name, like MetalUI's avatars: "Alice Moreau" → AM, "bob" → B. */
227+ fun initialsOf(name: String): String {
228+ val words = name.trim().split(Regex("[\\s._-]+")).filter { it.isNotEmpty() }
229+ return when {
230+ words.isEmpty() -> "?"
231+ words.size == 1 -> words[0].take(1).uppercase()
232+ else -> (words[0].take(1) + words[1].take(1)).uppercase()
233+ }
234+ }
235+
236+ /** A person: a small raised disc with their initials. */
237+ @Composable
238+ fun Avatar(name: String, modifier: Modifier = Modifier, size: Dp = 28.dp) {
239+ Box(modifier.size(size).raised(CircleShape), contentAlignment = Alignment.Center) {
240+ Text(
241+ initialsOf(name),
242+ style = Vectrum.type.ui.copy(fontSize = (size.value * 0.36f).sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp),
243+ color = Vectrum.colors.ink2,
244+ maxLines = 1,
245+ )
246+ }
247+ }
248+
249+ /** An agent: the sparkle the website marks agents with, on a disc. */
250+ @Composable
251+ fun AgentAvatar(modifier: Modifier = Modifier, size: Dp = 28.dp) {
252+ Box(modifier.size(size).raised(CircleShape), contentAlignment = Alignment.Center) {
253+ Icon(VIcons.Sparkle, null, Modifier.size(size * 0.5f), tint = Vectrum.colors.ink2)
254+ }
255+ }
256+
257+ /** Seeded 32-bit FNV-1a with a final avalanche, exactly as components/ui.tsx does it. */
258+ private fun hash32(text: String, seed: Int = 0): Long {
259+ var h = (0x811c9dc5.toInt() xor seed)
260+ for (ch in text) {
261+ h = h xor ch.code
262+ h *= 0x01000193
263+ }
264+ h = h xor (h ushr 16)
265+ h *= 0x85ebca6b.toInt()
266+ h = h xor (h ushr 13)
267+ h *= 0xc2b2ae35.toInt()
268+ h = h xor (h ushr 16)
269+ return h.toLong() and 0xFFFFFFFFL
270+ }
271+
272+ /** The website's identicon: a horizontally mirrored 5×5 grid in one of MetalUI's tints. */
273+ @Composable
274+ fun Identicon(name: String, modifier: Modifier = Modifier) {
275+ val key = name.trim().lowercase()
276+ val cells = remember(key) {
277+ val bits = hash32(key)
278+ buildList {
279+ for (row in 0 until 5) for (col in 0 until 3) {
280+ if (bits and (1L shl (row * 3 + col)) == 0L) continue
281+ add(col to row)
282+ if (col < 2) add((4 - col) to row)
283+ }
284+ }.ifEmpty { (0 until 5).map { 2 to it } }
285+ }
286+ val tints = Vectrum.colors.tints
287+ val color = tints[(hash32(key, 0x9e3779b9.toInt()) % tints.size).toInt()]
288+ Canvas(modifier) {
289+ // viewBox -1.2 -1.2 7.4 7.4, like the website's.
290+ val unit = size.minDimension / 7.4f
291+ for ((x, y) in cells) {
292+ drawRect(color.copy(alpha = 0.85f), topLeft = Offset((x + 1.2f) * unit, (y + 1.2f) * unit), size = Size(unit + 0.5f, unit + 0.5f))
293+ }
294+ }
295+ }
296+
297+ /** A repository's mark on a raised key: its uploaded image, or the identicon of its owner. */
298+ @Composable
299+ fun RepoAvatar(repo: RepoSummary, modifier: Modifier = Modifier, size: Dp = 40.dp) {
300+ val shape = RoundedCornerShape(maxOf(4.dp, size * 0.3f))
301+ val api = LocalApi.current
302+ Box(modifier.size(size).raised(shape).clip(shape), contentAlignment = Alignment.Center) {
303+ if (repo.hasImage && api != null) {
304+ AsyncImage(
305+ model = ImageRequest.Builder(LocalContext.current).data(api.repoImageUrl(repo.owner, repo.name, repo.imageVersion)).crossfade(true).build(),
306+ contentDescription = null,
307+ contentScale = ContentScale.Crop,
308+ modifier = Modifier.size(size),
309+ )
310+ } else {
311+ Identicon(repo.owner, Modifier.size(size))
312+ }
313+ }
314+ }
315+
316+ /** A glyph sunk in a well, like the website's empty states and feature cards. */
317+ @Composable
318+ fun IconTile(icon: ImageVector, modifier: Modifier = Modifier, size: Dp = 40.dp, shape: Shape = RoundedCornerShape(size * 0.32f), tint: Color = Vectrum.colors.ink3) {
319+ Box(modifier.size(size).well(shape), contentAlignment = Alignment.Center) {
320+ Icon(icon, null, Modifier.size(size * 0.46f), tint = tint)
321+ }
322+ }
323+
324+ /** Text with a leading glyph, in a row. */
325+ @Composable
326+ fun IconText(icon: ImageVector, text: String, modifier: Modifier = Modifier, tint: Color = Vectrum.colors.ink3, style: TextStyle = Vectrum.type.meta, color: Color = Vectrum.colors.ink2) {
327+ Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
328+ Icon(icon, null, Modifier.size(14.dp), tint = tint)
329+ Text(text, style = style, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
330+ }
331+ }
+139−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/components/Diff.ktaddedView File
@@ -0,0 +1,139 @@
1+ package cc.vectrum.app.ui.components
2+
3+ import androidx.compose.animation.animateContentSize
4+ import androidx.compose.foundation.background
5+ import androidx.compose.foundation.layout.Arrangement
6+ import androidx.compose.foundation.layout.Box
7+ import androidx.compose.foundation.layout.Column
8+ import androidx.compose.foundation.layout.Row
9+ import androidx.compose.foundation.layout.fillMaxWidth
10+ import androidx.compose.foundation.layout.padding
11+ import androidx.compose.foundation.layout.size
12+ import androidx.compose.foundation.layout.width
13+ import androidx.compose.material3.Icon
14+ import androidx.compose.material3.Text
15+ import androidx.compose.runtime.Composable
16+ import androidx.compose.runtime.getValue
17+ import androidx.compose.runtime.mutableStateOf
18+ import androidx.compose.runtime.remember
19+ import androidx.compose.runtime.saveable.rememberSaveable
20+ import androidx.compose.runtime.setValue
21+ import androidx.compose.ui.Alignment
22+ import androidx.compose.ui.Modifier
23+ import androidx.compose.ui.graphics.Color
24+ import androidx.compose.ui.draw.rotate
25+ import androidx.compose.ui.text.font.FontWeight
26+ import androidx.compose.ui.text.style.TextAlign
27+ import androidx.compose.ui.text.style.TextOverflow
28+ import androidx.compose.ui.unit.dp
29+ import androidx.compose.ui.unit.sp
30+ import cc.vectrum.app.data.DiffLine
31+ import cc.vectrum.app.data.FileDiff
32+ import cc.vectrum.app.ui.icons.VIcons
33+ import cc.vectrum.app.ui.theme.Radii
34+ import cc.vectrum.app.ui.theme.Vectrum
35+
36+ /** Lines shown before a long file asks to be opened up. */
37+ private const val PREVIEW_LINES = 250
38+
39+ /** A summary bar of a change: five blocks, green for additions and red for deletions, like the website. */
40+ @Composable
41+ fun DiffStat(additions: Int, deletions: Int, modifier: Modifier = Modifier) {
42+ val c = Vectrum.colors
43+ val total = additions + deletions
44+ val green = if (total == 0) 0 else Math.round(5f * additions / total).coerceIn(if (additions > 0) 1 else 0, 5)
45+ Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
46+ Text("+$additions", style = Vectrum.type.readout, color = c.greenDeep)
47+ Text("−$deletions", style = Vectrum.type.readout, color = c.red)
48+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
49+ repeat(5) { i ->
50+ val color = when {
51+ total == 0 -> c.wellTop
52+ i < green -> c.greenDeep
53+ else -> c.red.copy(alpha = 0.85f)
54+ }
55+ Box(Modifier.size(7.dp).background(color, Radii.key))
56+ }
57+ }
58+ }
59+ }
60+
61+ /** One file of a diff, on its own plate: a header to fold it, then its lines with both line numbers. */
62+ @Composable
63+ fun DiffFile(file: FileDiff, modifier: Modifier = Modifier, startExpanded: Boolean = true) {
64+ val c = Vectrum.colors
65+ var expanded by rememberSaveable(file.path) { mutableStateOf(startExpanded) }
66+ var showAll by rememberSaveable(file.path + ":all") { mutableStateOf(false) }
67+ Plate(modifier.fillMaxWidth().animateContentSize(), shape = Radii.plate) {
68+ PressableRow({ expanded = !expanded }) {
69+ Row(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
70+ Icon(VIcons.ChevronDown, null, Modifier.size(16.dp).rotate(if (expanded) 0f else -90f), tint = c.ink3)
71+ Gap(8.dp)
72+ Column(Modifier.weight(1f)) {
73+ val (dir, name) = file.path.substringBeforeLast('/', "") to file.path.substringAfterLast('/')
74+ Text(name, style = Vectrum.type.ui.copy(fontWeight = FontWeight.SemiBold), color = c.ink, maxLines = 1, overflow = TextOverflow.Ellipsis)
75+ val sub = when (file.status) {
76+ "renamed" -> "renamed from ${file.oldPath}"
77+ "added" -> if (dir.isEmpty()) "new file" else "$dir · new file"
78+ "deleted" -> if (dir.isEmpty()) "deleted" else "$dir · deleted"
79+ else -> dir
80+ }
81+ if (sub.isNotEmpty()) Text(sub, style = Vectrum.type.meta, color = c.ink3, maxLines = 1, overflow = TextOverflow.Ellipsis)
82+ }
83+ Gap(8.dp)
84+ DiffStat(file.additions, file.deletions)
85+ }
86+ }
87+ if (expanded) {
88+ Rule()
89+ when {
90+ file.binary -> Text("Binary file not shown.", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(16.dp))
91+ file.lines.isEmpty() -> Text(if (file.status == "renamed") "Renamed without changes." else "No changes to show.", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(16.dp))
92+ else -> {
93+ val shown = if (showAll) file.lines else file.lines.take(PREVIEW_LINES)
94+ Column(Modifier.padding(vertical = 6.dp)) {
95+ for (line in shown) DiffLineRow(line)
96+ }
97+ if (file.lines.size > shown.size) {
98+ VButton("Show ${file.lines.size - shown.size} More Lines", { showAll = true }, kind = ButtonKind.Ghost, small = true, modifier = Modifier.padding(8.dp))
99+ }
100+ if (file.truncated) {
101+ Text("This file's diff is too long to show in full.", style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp))
102+ }
103+ }
104+ }
105+ }
106+ }
107+ }
108+
109+ @Composable
110+ private fun DiffLineRow(line: DiffLine) {
111+ val c = Vectrum.colors
112+ val style = Vectrum.type.code.copy(fontSize = 12.sp, lineHeight = 19.sp)
113+ if (line.kind == "hunk") {
114+ Text(
115+ line.text,
116+ style = style,
117+ color = c.ink3,
118+ maxLines = 2,
119+ overflow = TextOverflow.Ellipsis,
120+ modifier = Modifier.fillMaxWidth().background(c.diffHunk).padding(horizontal = 14.dp, vertical = 5.dp),
121+ )
122+ return
123+ }
124+ val (bg, gutter, sign) = when (line.kind) {
125+ "add" -> Triple(c.diffAdd, c.diffAddGutter, "+")
126+ "del" -> Triple(c.diffDel, c.diffDelGutter, "−")
127+ else -> Triple(Color.Transparent, Color.Transparent, " ")
128+ }
129+ val numbers = remember(line) { (line.oldNo?.toString() ?: "") to (line.newNo?.toString() ?: "") }
130+ Row(Modifier.fillMaxWidth().background(bg)) {
131+ val numberInk = if (line.kind == "ctx") c.syntax.line else c.ink3
132+ Row(Modifier.background(gutter).padding(horizontal = 4.dp)) {
133+ Text(numbers.first, style = style, color = numberInk, textAlign = TextAlign.End, modifier = Modifier.width(30.dp), maxLines = 1)
134+ Text(numbers.second, style = style, color = numberInk, textAlign = TextAlign.End, modifier = Modifier.width(30.dp), maxLines = 1)
135+ }
136+ Text(sign, style = style, color = when (line.kind) { "add" -> c.greenDeep; "del" -> c.red; else -> c.ink3 }, modifier = Modifier.padding(start = 8.dp, end = 4.dp))
137+ Text(line.text.replace("\t", " "), style = style, color = c.syntax.text, modifier = Modifier.weight(1f).padding(end = 12.dp))
138+ }
139+ }
+111−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/components/Fields.ktaddedView File
@@ -0,0 +1,111 @@
1+ package cc.vectrum.app.ui.components
2+
3+ import androidx.compose.foundation.layout.Arrangement
4+ import androidx.compose.foundation.layout.Box
5+ import androidx.compose.foundation.layout.Column
6+ import androidx.compose.foundation.layout.Row
7+ import androidx.compose.foundation.layout.fillMaxWidth
8+ import androidx.compose.foundation.layout.heightIn
9+ import androidx.compose.foundation.layout.padding
10+ import androidx.compose.foundation.layout.size
11+ import androidx.compose.foundation.shape.RoundedCornerShape
12+ import androidx.compose.foundation.text.BasicTextField
13+ import androidx.compose.foundation.text.KeyboardActions
14+ import androidx.compose.foundation.text.KeyboardOptions
15+ import androidx.compose.material3.Icon
16+ import androidx.compose.material3.Text
17+ import androidx.compose.runtime.Composable
18+ import androidx.compose.ui.Alignment
19+ import androidx.compose.ui.Modifier
20+ import androidx.compose.ui.focus.FocusRequester
21+ import androidx.compose.ui.focus.focusRequester
22+ import androidx.compose.ui.graphics.SolidColor
23+ import androidx.compose.ui.graphics.vector.ImageVector
24+ import androidx.compose.ui.text.TextStyle
25+ import androidx.compose.ui.text.input.PasswordVisualTransformation
26+ import androidx.compose.ui.text.input.VisualTransformation
27+ import androidx.compose.ui.unit.dp
28+ import cc.vectrum.app.ui.icons.VIcons
29+ import cc.vectrum.app.ui.theme.Vectrum
30+ import cc.vectrum.app.ui.theme.well
31+
32+ /**
33+ * A text field on MetalUI's well: sunk into the surface, focus shown by its green caret rather than
34+ * a ring (as on the website).
35+ */
36+ @Composable
37+ fun Field(
38+ value: String,
39+ onValueChange: (String) -> Unit,
40+ modifier: Modifier = Modifier,
41+ label: String? = null,
42+ placeholder: String = "",
43+ leading: ImageVector? = null,
44+ trailing: (@Composable () -> Unit)? = null,
45+ password: Boolean = false,
46+ singleLine: Boolean = true,
47+ minLines: Int = 1,
48+ enabled: Boolean = true,
49+ keyboard: KeyboardOptions = KeyboardOptions.Default,
50+ actions: KeyboardActions = KeyboardActions.Default,
51+ style: TextStyle = Vectrum.type.lead,
52+ hint: String? = null,
53+ focusRequester: FocusRequester? = null,
54+ ) {
55+ val c = Vectrum.colors
56+ Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
57+ if (label != null) Engraved(label, Modifier.padding(start = 4.dp))
58+ val shape = RoundedCornerShape(if (singleLine) 16.dp else 18.dp)
59+ BasicTextField(
60+ value = value,
61+ onValueChange = onValueChange,
62+ enabled = enabled,
63+ singleLine = singleLine,
64+ minLines = minLines,
65+ textStyle = style.copy(color = c.ink),
66+ cursorBrush = SolidColor(c.greenDeep),
67+ visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
68+ keyboardOptions = keyboard,
69+ keyboardActions = actions,
70+ modifier = Modifier.fillMaxWidth().let { m -> if (focusRequester != null) m.focusRequester(focusRequester) else m },
71+ decorationBox = { inner ->
72+ Row(
73+ Modifier
74+ .fillMaxWidth()
75+ .well(shape)
76+ .heightIn(min = 50.dp)
77+ .padding(horizontal = 16.dp, vertical = if (singleLine) 0.dp else 14.dp),
78+ verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top,
79+ horizontalArrangement = Arrangement.spacedBy(10.dp),
80+ ) {
81+ if (leading != null) Icon(leading, null, Modifier.size(18.dp), tint = c.ink3)
82+ Box(Modifier.weight(1f).padding(vertical = if (singleLine) 14.dp else 0.dp)) {
83+ if (value.isEmpty()) Text(placeholder, style = style, color = c.ink3.copy(alpha = 0.8f), maxLines = if (singleLine) 1 else Int.MAX_VALUE)
84+ inner()
85+ }
86+ trailing?.invoke()
87+ }
88+ },
89+ )
90+ if (hint != null) Text(hint, style = Vectrum.type.meta, color = c.ink3, modifier = Modifier.padding(horizontal = 4.dp))
91+ }
92+ }
93+
94+ /** A search field: the website's "Search…" well. */
95+ @Composable
96+ fun SearchField(value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, placeholder: String = "Search") {
97+ Field(
98+ value = value,
99+ onValueChange = onValueChange,
100+ modifier = modifier,
101+ placeholder = placeholder,
102+ leading = VIcons.Search,
103+ style = Vectrum.type.ui.copy(fontSize = Vectrum.type.lead.fontSize),
104+ keyboard = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search),
105+ trailing = if (value.isNotEmpty()) {
106+ { RoundButton(VIcons.Close, "Clear", { onValueChange("") }, size = 30.dp, ghost = true, tint = Vectrum.colors.ink3) }
107+ } else {
108+ null
109+ },
110+ )
111+ }
+222−0apps/mobile/app/src/main/java/cc/vectrum/app/ui/components/Glyphs.ktaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+6−0apps/mobile/gradle.propertiesaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Binary file not shown.

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

Large diffs are not rendered by default.

+248−0apps/mobile/gradlewaddedView File

Large diffs are not rendered by default.

+112−0apps/mobile/gradlew.bataddedView File

Large diffs are not rendered by default.

+13−0apps/mobile/package.jsonaddedView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+22−0apps/web/.gitignoreaddedView File

Large diffs are not rendered by default.

+9−0apps/web/AGENTS.mdaddedView File

Large diffs are not rendered by default.

+1−0apps/web/CLAUDE.mdaddedView File

Large diffs are not rendered by default.

+17−9Dockerfile → apps/web/DockerfilerenamedView File

Large diffs are not rendered by default.

+2−2Dockerfile.runner → apps/web/Dockerfile.runnerrenamedView File

Large diffs are not rendered by default.

+0−0app/.well-known/oauth-authorization-server/route.ts → apps/web/app/.well-known/oauth-authorization-server/route.tsrenamedView File

File mode or name changed only.

+0−0app/.well-known/oauth-protected-resource/mcp/route.ts → apps/web/app/.well-known/oauth-protected-resource/mcp/route.tsrenamedView File

File mode or name changed only.

+0−0app/.well-known/oauth-protected-resource/route.ts → apps/web/app/.well-known/oauth-protected-resource/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/actions/page.tsx → apps/web/app/[owner]/[repo]/actions/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/actions/runs/[number]/logs/[job]/route.ts → apps/web/app/[owner]/[repo]/actions/runs/[number]/logs/[job]/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/actions/runs/[number]/page.tsx → apps/web/app/[owner]/[repo]/actions/runs/[number]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/archive/[...segments]/route.ts → apps/web/app/[owner]/[repo]/archive/[...segments]/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/blob/[...segments]/page.tsx → apps/web/app/[owner]/[repo]/blob/[...segments]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/branches/page.tsx → apps/web/app/[owner]/[repo]/branches/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/commit/[sha]/page.tsx → apps/web/app/[owner]/[repo]/commit/[sha]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/commits/[...segments]/page.tsx → apps/web/app/[owner]/[repo]/commits/[...segments]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/commits/page.tsx → apps/web/app/[owner]/[repo]/commits/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/compare/page.tsx → apps/web/app/[owner]/[repo]/compare/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/delete/[...segments]/page.tsx → apps/web/app/[owner]/[repo]/delete/[...segments]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/edit/[...segments]/page.tsx → apps/web/app/[owner]/[repo]/edit/[...segments]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/fork/form.tsx → apps/web/app/[owner]/[repo]/fork/form.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/fork/page.tsx → apps/web/app/[owner]/[repo]/fork/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/forks/page.tsx → apps/web/app/[owner]/[repo]/forks/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/git-receive-pack/route.ts → apps/web/app/[owner]/[repo]/git-receive-pack/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/git-upload-pack/route.ts → apps/web/app/[owner]/[repo]/git-upload-pack/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/image/route.ts → apps/web/app/[owner]/[repo]/image/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/info/refs/route.ts → apps/web/app/[owner]/[repo]/info/refs/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/issues/[number]/page.tsx → apps/web/app/[owner]/[repo]/issues/[number]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/issues/new/form.tsx → apps/web/app/[owner]/[repo]/issues/new/form.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/issues/new/page.tsx → apps/web/app/[owner]/[repo]/issues/new/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/issues/page.tsx → apps/web/app/[owner]/[repo]/issues/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/layout.tsx → apps/web/app/[owner]/[repo]/layout.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/llms.txt/route.ts → apps/web/app/[owner]/[repo]/llms.txt/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/new/[...segments]/page.tsx → apps/web/app/[owner]/[repo]/new/[...segments]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/packages/page.tsx → apps/web/app/[owner]/[repo]/packages/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/page.tsx → apps/web/app/[owner]/[repo]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/pulls/[number]/commits/page.tsx → apps/web/app/[owner]/[repo]/pulls/[number]/commits/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/pulls/[number]/files/page.tsx → apps/web/app/[owner]/[repo]/pulls/[number]/files/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/pulls/[number]/layout.tsx → apps/web/app/[owner]/[repo]/pulls/[number]/layout.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/pulls/[number]/page.tsx → apps/web/app/[owner]/[repo]/pulls/[number]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/pulls/page.tsx → apps/web/app/[owner]/[repo]/pulls/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/raw/[...segments]/route.ts → apps/web/app/[owner]/[repo]/raw/[...segments]/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/releases/download/[...segments]/route.ts → apps/web/app/[owner]/[repo]/releases/download/[...segments]/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/releases/edit/[...tag]/page.tsx → apps/web/app/[owner]/[repo]/releases/edit/[...tag]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/releases/latest/download/[name]/route.ts → apps/web/app/[owner]/[repo]/releases/latest/download/[name]/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/releases/latest/page.tsx → apps/web/app/[owner]/[repo]/releases/latest/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/releases/new/page.tsx → apps/web/app/[owner]/[repo]/releases/new/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/releases/page.tsx → apps/web/app/[owner]/[repo]/releases/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/releases/tag/[...tag]/page.tsx → apps/web/app/[owner]/[repo]/releases/tag/[...tag]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/search/page.tsx → apps/web/app/[owner]/[repo]/search/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/access/form.tsx → apps/web/app/[owner]/[repo]/settings/access/form.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/access/page.tsx → apps/web/app/[owner]/[repo]/settings/access/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/branches/forms.tsx → apps/web/app/[owner]/[repo]/settings/branches/forms.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/branches/page.tsx → apps/web/app/[owner]/[repo]/settings/branches/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/forms.tsx → apps/web/app/[owner]/[repo]/settings/forms.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/labels/form.tsx → apps/web/app/[owner]/[repo]/settings/labels/form.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/labels/page.tsx → apps/web/app/[owner]/[repo]/settings/labels/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/layout.tsx → apps/web/app/[owner]/[repo]/settings/layout.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/page.tsx → apps/web/app/[owner]/[repo]/settings/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/runners/page.tsx → apps/web/app/[owner]/[repo]/settings/runners/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/webhooks/[id]/page.tsx → apps/web/app/[owner]/[repo]/settings/webhooks/[id]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/webhooks/forms.tsx → apps/web/app/[owner]/[repo]/settings/webhooks/forms.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/settings/webhooks/page.tsx → apps/web/app/[owner]/[repo]/settings/webhooks/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/tree/[...segments]/page.tsx → apps/web/app/[owner]/[repo]/tree/[...segments]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/upload/[...segments]/page.tsx → apps/web/app/[owner]/[repo]/upload/[...segments]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/wiki/%5Fraw/[...path]/route.ts → apps/web/app/[owner]/[repo]/wiki/%5Fraw/[...path]/route.tsrenamedView File

File mode or name changed only.

+0−0app/[owner]/[repo]/wiki/[[...segments]]/page.tsx → apps/web/app/[owner]/[repo]/wiki/[[...segments]]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/org-profile.tsx → apps/web/app/[owner]/org-profile.tsxrenamedView File

File mode or name changed only.

+0−0app/[owner]/page.tsx → apps/web/app/[owner]/page.tsxrenamedView File

File mode or name changed only.

+0−0app/actions/activity.ts → apps/web/app/actions/activity.tsrenamedView File

File mode or name changed only.

+47−0apps/web/app/actions/auth.tsaddedView File

Large diffs are not rendered by default.

+0−0app/actions/branches.ts → apps/web/app/actions/branches.tsrenamedView File

File mode or name changed only.

+0−0app/actions/ci.ts → apps/web/app/actions/ci.tsrenamedView File

File mode or name changed only.

+0−0app/actions/files.ts → apps/web/app/actions/files.tsrenamedView File

File mode or name changed only.

+0−0app/actions/forks.ts → apps/web/app/actions/forks.tsrenamedView File

File mode or name changed only.

+0−0app/actions/guard.ts → apps/web/app/actions/guard.tsrenamedView File

File mode or name changed only.

+0−0app/actions/issues.ts → apps/web/app/actions/issues.tsrenamedView File

File mode or name changed only.

+0−0app/actions/oauth.ts → apps/web/app/actions/oauth.tsrenamedView File

File mode or name changed only.

+0−0app/actions/orgs.ts → apps/web/app/actions/orgs.tsrenamedView File

File mode or name changed only.

+0−0app/actions/packages.ts → apps/web/app/actions/packages.tsrenamedView File

File mode or name changed only.

+0−0app/actions/protection.ts → apps/web/app/actions/protection.tsrenamedView File

File mode or name changed only.

+0−0app/actions/pulls.ts → apps/web/app/actions/pulls.tsrenamedView File

File mode or name changed only.

+0−0app/actions/releases.ts → apps/web/app/actions/releases.tsrenamedView File

File mode or name changed only.

+0−0app/actions/repo.ts → apps/web/app/actions/repo.tsrenamedView File

File mode or name changed only.

+55−2app/actions/settings.ts → apps/web/app/actions/settings.tsrenamedView File

Large diffs are not rendered by default.

+1−1app/actions/types.ts → apps/web/app/actions/types.tsrenamedView File

Large diffs are not rendered by default.

+0−0app/actions/webhooks.ts → apps/web/app/actions/webhooks.tsrenamedView File

File mode or name changed only.

+0−0app/actions/wiki.ts → apps/web/app/actions/wiki.tsrenamedView File

File mode or name changed only.

+0−0app/api/health/route.ts → apps/web/app/api/health/route.tsrenamedView File

File mode or name changed only.

+0−0app/api/markdown/[[...path]]/route.ts → apps/web/app/api/markdown/[[...path]]/route.tsrenamedView File

File mode or name changed only.

+0−0app/api/runner/claim/route.ts → apps/web/app/api/runner/claim/route.tsrenamedView File

File mode or name changed only.

+0−0app/api/runner/jobs/[id]/route.ts → apps/web/app/api/runner/jobs/[id]/route.tsrenamedView File

File mode or name changed only.

+109−0apps/web/app/api/v1/[...path]/route.tsaddedView File

Large diffs are not rendered by default.

+0−0app/apple-icon.png → apps/web/app/apple-icon.pngrenamedView File

File mode or name changed only.

+0−0app/explore/page.tsx → apps/web/app/explore/page.tsxrenamedView File

File mode or name changed only.

+0−0app/favicon.ico → apps/web/app/favicon.icorenamedView File

File mode or name changed only.

+0−0app/forbidden.tsx → apps/web/app/forbidden.tsxrenamedView File

File mode or name changed only.

+0−0app/globals.css → apps/web/app/globals.cssrenamedView File

File mode or name changed only.

+0−0app/icon.svg → apps/web/app/icon.svgrenamedView File

File mode or name changed only.

+0−0app/layout.tsx → apps/web/app/layout.tsxrenamedView File

File mode or name changed only.

+0−0app/llms.txt/route.ts → apps/web/app/llms.txt/route.tsrenamedView File

File mode or name changed only.

+8−2app/login/page.tsx → apps/web/app/login/page.tsxrenamedView File

Large diffs are not rendered by default.

+39−0apps/web/app/login/two-factor/page.tsxaddedView File

Large diffs are not rendered by default.

+0−0app/mcp/route.ts → apps/web/app/mcp/route.tsrenamedView File

File mode or name changed only.

+0−0app/new/form.tsx → apps/web/app/new/form.tsxrenamedView File

File mode or name changed only.

+0−0app/new/page.tsx → apps/web/app/new/page.tsxrenamedView File

File mode or name changed only.

+0−0app/not-found.tsx → apps/web/app/not-found.tsxrenamedView File

File mode or name changed only.

+0−0app/oauth/authorize/form.tsx → apps/web/app/oauth/authorize/form.tsxrenamedView File

File mode or name changed only.

+0−0app/oauth/authorize/page.tsx → apps/web/app/oauth/authorize/page.tsxrenamedView File

File mode or name changed only.

+0−0app/oauth/register/route.ts → apps/web/app/oauth/register/route.tsrenamedView File

File mode or name changed only.

+0−0app/oauth/revoke/route.ts → apps/web/app/oauth/revoke/route.tsrenamedView File

File mode or name changed only.

+0−0app/oauth/token/route.ts → apps/web/app/oauth/token/route.tsrenamedView File

File mode or name changed only.

+0−0app/organizations/[org]/settings/forms.tsx → apps/web/app/organizations/[org]/settings/forms.tsxrenamedView File

File mode or name changed only.

+0−0app/organizations/[org]/settings/layout.tsx → apps/web/app/organizations/[org]/settings/layout.tsxrenamedView File

File mode or name changed only.

+0−0app/organizations/[org]/settings/load.ts → apps/web/app/organizations/[org]/settings/load.tsrenamedView File

File mode or name changed only.

+0−0app/organizations/[org]/settings/members/page.tsx → apps/web/app/organizations/[org]/settings/members/page.tsxrenamedView File

File mode or name changed only.

+0−0app/organizations/[org]/settings/page.tsx → apps/web/app/organizations/[org]/settings/page.tsxrenamedView File

File mode or name changed only.

+0−0app/organizations/new/form.tsx → apps/web/app/organizations/new/form.tsxrenamedView File

File mode or name changed only.

+0−0app/organizations/new/page.tsx → apps/web/app/organizations/new/page.tsxrenamedView File

File mode or name changed only.

+0−0app/page.tsx → apps/web/app/page.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/activity/page.tsx → apps/web/app/settings/activity/page.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/applications/page.tsx → apps/web/app/settings/applications/page.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/keys/form.tsx → apps/web/app/settings/keys/form.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/keys/page.tsx → apps/web/app/settings/keys/page.tsxrenamedView File

File mode or name changed only.

+2−1app/settings/layout.tsx → apps/web/app/settings/layout.tsxrenamedView File

Large diffs are not rendered by default.

+0−0app/settings/organizations/page.tsx → apps/web/app/settings/organizations/page.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/page.tsx → apps/web/app/settings/page.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/profile/forms.tsx → apps/web/app/settings/profile/forms.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/profile/page.tsx → apps/web/app/settings/profile/page.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/runners/page.tsx → apps/web/app/settings/runners/page.tsxrenamedView File

File mode or name changed only.

+141−0apps/web/app/settings/security/forms.tsxaddedView File

Large diffs are not rendered by default.

+81−0apps/web/app/settings/security/page.tsxaddedView File

Large diffs are not rendered by default.

+0−0app/settings/tokens/form.tsx → apps/web/app/settings/tokens/form.tsxrenamedView File

File mode or name changed only.

+0−0app/settings/tokens/page.tsx → apps/web/app/settings/tokens/page.tsxrenamedView File

File mode or name changed only.

+0−0app/signup/page.tsx → apps/web/app/signup/page.tsxrenamedView File

File mode or name changed only.

+0−0app/v2/[[...path]]/route.ts → apps/web/app/v2/[[...path]]/route.tsrenamedView File

File mode or name changed only.

+0−0components/action-button.tsx → apps/web/components/action-button.tsxrenamedView File

File mode or name changed only.

+0−0components/agent.tsx → apps/web/components/agent.tsxrenamedView File

File mode or name changed only.

+26−1components/auth-forms.tsx → apps/web/components/auth-forms.tsxrenamedView File

Large diffs are not rendered by default.

+0−0components/branch-select.tsx → apps/web/components/branch-select.tsxrenamedView File

File mode or name changed only.

+0−0components/branches/new-branch-form.tsx → apps/web/components/branches/new-branch-form.tsxrenamedView File

File mode or name changed only.

+0−0components/brand.tsx → apps/web/components/brand.tsxrenamedView File

File mode or name changed only.

+0−0components/breadcrumbs.tsx → apps/web/components/breadcrumbs.tsxrenamedView File

File mode or name changed only.

+0−0components/ci/dispatch-form.tsx → apps/web/components/ci/dispatch-form.tsxrenamedView File

File mode or name changed only.

+0−0components/ci/job-steps.tsx → apps/web/components/ci/job-steps.tsxrenamedView File

File mode or name changed only.

+0−0components/ci/live-refresh.tsx → apps/web/components/ci/live-refresh.tsxrenamedView File

File mode or name changed only.

+0−0components/ci/new-runner-form.tsx → apps/web/components/ci/new-runner-form.tsxrenamedView File

File mode or name changed only.

+0−0components/ci/run-status.tsx → apps/web/components/ci/run-status.tsxrenamedView File

File mode or name changed only.

+0−0components/ci/run-text.ts → apps/web/components/ci/run-text.tsrenamedView File

File mode or name changed only.

+0−0components/ci/runner-list.tsx → apps/web/components/ci/runner-list.tsxrenamedView File

File mode or name changed only.

+0−0components/ci/workflow-nav.tsx → apps/web/components/ci/workflow-nav.tsxrenamedView File

File mode or name changed only.

+0−0components/clone-button.tsx → apps/web/components/clone-button.tsxrenamedView File

File mode or name changed only.

+0−0components/code-view.tsx → apps/web/components/code-view.tsxrenamedView File

File mode or name changed only.

+0−0components/commit-dialog.tsx → apps/web/components/commit-dialog.tsxrenamedView File

File mode or name changed only.

+0−0components/confirm-button.tsx → apps/web/components/confirm-button.tsxrenamedView File

File mode or name changed only.

+0−0components/conversation.tsx → apps/web/components/conversation.tsxrenamedView File

File mode or name changed only.

+0−0components/delete-file-form.tsx → apps/web/components/delete-file-form.tsxrenamedView File

File mode or name changed only.

+0−0components/delete-zone.tsx → apps/web/components/delete-zone.tsxrenamedView File

File mode or name changed only.

+0−0components/doc-tabs.tsx → apps/web/components/doc-tabs.tsxrenamedView File

File mode or name changed only.

+0−0components/dropdown.tsx → apps/web/components/dropdown.tsxrenamedView File

File mode or name changed only.

+0−0components/editor-file-actions.tsx → apps/web/components/editor-file-actions.tsxrenamedView File

File mode or name changed only.

+0−0components/editor-theme.ts → apps/web/components/editor-theme.tsrenamedView File

File mode or name changed only.

+0−0components/expiry.tsx → apps/web/components/expiry.tsxrenamedView File

File mode or name changed only.

+0−0components/file-diff.tsx → apps/web/components/file-diff.tsxrenamedView File

File mode or name changed only.

+0−0components/file-editor.tsx → apps/web/components/file-editor.tsxrenamedView File

File mode or name changed only.

+0−0components/file-table.tsx → apps/web/components/file-table.tsxrenamedView File

File mode or name changed only.

+0−0components/fork-status.tsx → apps/web/components/fork-status.tsxrenamedView File

File mode or name changed only.

+0−0components/forks.tsx → apps/web/components/forks.tsxrenamedView File

File mode or name changed only.

+0−0components/header.tsx → apps/web/components/header.tsxrenamedView File

File mode or name changed only.

+0−0components/home-status.tsx → apps/web/components/home-status.tsxrenamedView File

File mode or name changed only.

+0−0components/home-terminal.tsx → apps/web/components/home-terminal.tsxrenamedView File

File mode or name changed only.

+0−0components/icons.tsx → apps/web/components/icons.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/issue-composer.tsx → apps/web/components/issues/issue-composer.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/issue-detail.tsx → apps/web/components/issues/issue-detail.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/issue-icons.tsx → apps/web/components/issues/issue-icons.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/issue-tracker.tsx → apps/web/components/issues/issue-tracker.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/picker.tsx → apps/web/components/issues/picker.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/properties.tsx → apps/web/components/issues/properties.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/tracker-context.tsx → apps/web/components/issues/tracker-context.tsxrenamedView File

File mode or name changed only.

+0−0components/issues/view.ts → apps/web/components/issues/view.tsrenamedView File

File mode or name changed only.

+0−0components/markdown-preview.tsx → apps/web/components/markdown-preview.tsxrenamedView File

File mode or name changed only.

+0−0components/markdown.tsx → apps/web/components/markdown.tsxrenamedView File

File mode or name changed only.

+0−0components/metal.tsx → apps/web/components/metal.tsxrenamedView File

File mode or name changed only.

+0−0components/nav-links.tsx → apps/web/components/nav-links.tsxrenamedView File

File mode or name changed only.

+0−0components/org-invitations.tsx → apps/web/components/org-invitations.tsxrenamedView File

File mode or name changed only.

+0−0components/providers.tsx → apps/web/components/providers.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/compare-select.tsx → apps/web/components/pulls/compare-select.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/create-pull-form.tsx → apps/web/components/pulls/create-pull-form.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/edit-pull-form.tsx → apps/web/components/pulls/edit-pull-form.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/line-key.ts → apps/web/components/pulls/line-key.tsrenamedView File

File mode or name changed only.

+0−0components/pulls/merge-form.tsx → apps/web/components/pulls/merge-form.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/pull-diff.tsx → apps/web/components/pulls/pull-diff.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/pull-icons.tsx → apps/web/components/pulls/pull-icons.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/pull-tabs.tsx → apps/web/components/pulls/pull-tabs.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/recent-pushes.tsx → apps/web/components/pulls/recent-pushes.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/respond-form.tsx → apps/web/components/pulls/respond-form.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/snippet.tsx → apps/web/components/pulls/snippet.tsxrenamedView File

File mode or name changed only.

+0−0components/pulls/thread.tsx → apps/web/components/pulls/thread.tsxrenamedView File

File mode or name changed only.

+14−0apps/web/components/qr-code.tsxaddedView File

Large diffs are not rendered by default.

+0−0components/releases/release-card.tsx → apps/web/components/releases/release-card.tsxrenamedView File

File mode or name changed only.

+0−0components/releases/release-form.tsx → apps/web/components/releases/release-form.tsxrenamedView File

File mode or name changed only.

+0−0components/repo-add-file-menu.tsx → apps/web/components/repo-add-file-menu.tsxrenamedView File

File mode or name changed only.

+0−0components/repo-checklist.tsx → apps/web/components/repo-checklist.tsxrenamedView File

File mode or name changed only.

+0−0components/repo-filter-list.tsx → apps/web/components/repo-filter-list.tsxrenamedView File

File mode or name changed only.

+0−0components/repo-list.tsx → apps/web/components/repo-list.tsxrenamedView File

File mode or name changed only.

+0−0components/repo-rows.ts → apps/web/components/repo-rows.tsrenamedView File

File mode or name changed only.

+0−0components/repo-tabs.tsx → apps/web/components/repo-tabs.tsxrenamedView File

File mode or name changed only.

+0−0components/search-box.tsx → apps/web/components/search-box.tsxrenamedView File

File mode or name changed only.

+0−0components/settings-fields.tsx → apps/web/components/settings-fields.tsxrenamedView File

File mode or name changed only.

+0−0components/settings-nav.tsx → apps/web/components/settings-nav.tsxrenamedView File

File mode or name changed only.

+0−0components/settings-plate.tsx → apps/web/components/settings-plate.tsxrenamedView File

File mode or name changed only.

+0−0components/sidebar-nav.tsx → apps/web/components/sidebar-nav.tsxrenamedView File

File mode or name changed only.

+0−0components/sliding-indicator.ts → apps/web/components/sliding-indicator.tsrenamedView File

File mode or name changed only.

+0−0components/theme-toggle.tsx → apps/web/components/theme-toggle.tsxrenamedView File

File mode or name changed only.

+0−0components/tree-view.tsx → apps/web/components/tree-view.tsxrenamedView File

File mode or name changed only.

+0−0components/ui.tsx → apps/web/components/ui.tsxrenamedView File

File mode or name changed only.

+0−0components/upload-form.tsx → apps/web/components/upload-form.tsxrenamedView File

File mode or name changed only.

+0−0components/wiki/wiki-editor.tsx → apps/web/components/wiki/wiki-editor.tsxrenamedView File

File mode or name changed only.

+0−0components/wiki/wiki-shell.tsx → apps/web/components/wiki/wiki-shell.tsxrenamedView File

File mode or name changed only.

+0−0eslint.config.mjs → apps/web/eslint.config.mjsrenamedView File

File mode or name changed only.

+0−0instrumentation.ts → apps/web/instrumentation.tsrenamedView File

File mode or name changed only.

+0−0lib/actor.ts → apps/web/lib/actor.tsrenamedView File

File mode or name changed only.

+0−0lib/agents.ts → apps/web/lib/agents.tsrenamedView File

File mode or name changed only.

+784−0apps/web/lib/app-api.tsaddedView File

Large diffs are not rendered by default.

+181−0apps/web/lib/auth.tsaddedView File

Large diffs are not rendered by default.

+0−0lib/branch-rules.test.ts → apps/web/lib/branch-rules.test.tsrenamedView File

File mode or name changed only.

+0−0lib/branch-rules.ts → apps/web/lib/branch-rules.tsrenamedView File

File mode or name changed only.

+0−0lib/branches.ts → apps/web/lib/branches.tsrenamedView File

File mode or name changed only.

+0−0lib/ci.ts → apps/web/lib/ci.tsrenamedView File

File mode or name changed only.

+0−0lib/cors.ts → apps/web/lib/cors.tsrenamedView File

File mode or name changed only.

+0−0lib/db.ts → apps/web/lib/db.tsrenamedView File

File mode or name changed only.

+0−0lib/event-text.ts → apps/web/lib/event-text.tsrenamedView File

File mode or name changed only.

+0−0lib/events.ts → apps/web/lib/events.tsrenamedView File

File mode or name changed only.

+0−0lib/forks.ts → apps/web/lib/forks.tsrenamedView File

File mode or name changed only.

+0−0lib/format.ts → apps/web/lib/format.tsrenamedView File

File mode or name changed only.

+0−0lib/git-agent.test.ts → apps/web/lib/git-agent.test.tsrenamedView File

File mode or name changed only.

+0−0lib/git-fork.test.ts → apps/web/lib/git-fork.test.tsrenamedView File

File mode or name changed only.

+0−0lib/git-http.ts → apps/web/lib/git-http.tsrenamedView File

File mode or name changed only.

+0−0lib/git-write.ts → apps/web/lib/git-write.tsrenamedView File

File mode or name changed only.

+0−0lib/git.test.ts → apps/web/lib/git.test.tsrenamedView File

File mode or name changed only.

+0−0lib/git.ts → apps/web/lib/git.tsrenamedView File

File mode or name changed only.

+0−0lib/hook-script.ts → apps/web/lib/hook-script.tsrenamedView File

File mode or name changed only.

+0−0lib/issue-meta.ts → apps/web/lib/issue-meta.tsrenamedView File

File mode or name changed only.

+0−0lib/issues.ts → apps/web/lib/issues.tsrenamedView File

File mode or name changed only.

+0−0lib/keys.ts → apps/web/lib/keys.tsrenamedView File

File mode or name changed only.

+0−0lib/languages.ts → apps/web/lib/languages.tsrenamedView File

File mode or name changed only.

+0−0lib/listing.ts → apps/web/lib/listing.tsrenamedView File

File mode or name changed only.

+0−0lib/logs.ts → apps/web/lib/logs.tsrenamedView File

File mode or name changed only.

+0−0lib/markdown-views.ts → apps/web/lib/markdown-views.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/agent-tools.ts → apps/web/lib/mcp/agent-tools.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/code-tools.ts → apps/web/lib/mcp/code-tools.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/kit.ts → apps/web/lib/mcp/kit.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/params.test.ts → apps/web/lib/mcp/params.test.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/params.ts → apps/web/lib/mcp/params.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/pull-tools.ts → apps/web/lib/mcp/pull-tools.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/server.ts → apps/web/lib/mcp/server.tsrenamedView File

File mode or name changed only.

+0−0lib/mcp/tools.ts → apps/web/lib/mcp/tools.tsrenamedView File

File mode or name changed only.

+0−0lib/mime.ts → apps/web/lib/mime.tsrenamedView File

File mode or name changed only.

+0−0lib/oauth-rules.test.ts → apps/web/lib/oauth-rules.test.tsrenamedView File

File mode or name changed only.

+0−0lib/oauth-rules.ts → apps/web/lib/oauth-rules.tsrenamedView File

File mode or name changed only.

+0−0lib/oauth.ts → apps/web/lib/oauth.tsrenamedView File

File mode or name changed only.

+0−0lib/org-roles.ts → apps/web/lib/org-roles.tsrenamedView File

File mode or name changed only.

+0−0lib/orgs.ts → apps/web/lib/orgs.tsrenamedView File

File mode or name changed only.

+0−0lib/paths.ts → apps/web/lib/paths.tsrenamedView File

File mode or name changed only.

+0−0lib/protection.ts → apps/web/lib/protection.tsrenamedView File

File mode or name changed only.

+0−0lib/pull-page.ts → apps/web/lib/pull-page.tsrenamedView File

File mode or name changed only.

+0−0lib/pulls.ts → apps/web/lib/pulls.tsrenamedView File

File mode or name changed only.

+0−0lib/push-hooks.ts → apps/web/lib/push-hooks.tsrenamedView File

File mode or name changed only.

+0−0lib/push.ts → apps/web/lib/push.tsrenamedView File

File mode or name changed only.

+0−0lib/recent-pushes.ts → apps/web/lib/recent-pushes.tsrenamedView File

File mode or name changed only.

+0−0lib/registry-http.ts → apps/web/lib/registry-http.tsrenamedView File

File mode or name changed only.

+0−0lib/registry.ts → apps/web/lib/registry.tsrenamedView File

File mode or name changed only.

+0−0lib/release-http.ts → apps/web/lib/release-http.tsrenamedView File

File mode or name changed only.

+0−0lib/release-upload.ts → apps/web/lib/release-upload.tsrenamedView File

File mode or name changed only.

+0−0lib/releases.ts → apps/web/lib/releases.tsrenamedView File

File mode or name changed only.

+0−0lib/repo.ts → apps/web/lib/repo.tsrenamedView File

File mode or name changed only.

+0−0lib/repos.ts → apps/web/lib/repos.tsrenamedView File

File mode or name changed only.

+0−0lib/request-origin.ts → apps/web/lib/request-origin.tsrenamedView File

File mode or name changed only.

+0−0lib/revert.ts → apps/web/lib/revert.tsrenamedView File

File mode or name changed only.

+0−0lib/runner-api.ts → apps/web/lib/runner-api.tsrenamedView File

File mode or name changed only.

+0−0lib/runners.ts → apps/web/lib/runners.tsrenamedView File

File mode or name changed only.

+0−0lib/runs.ts → apps/web/lib/runs.tsrenamedView File

File mode or name changed only.

+0−0lib/signups.ts → apps/web/lib/signups.tsrenamedView File

File mode or name changed only.

+0−0lib/ssh-config.ts → apps/web/lib/ssh-config.tsrenamedView File

File mode or name changed only.

+0−0lib/ssh-server.ts → apps/web/lib/ssh-server.tsrenamedView File

File mode or name changed only.

+0−0lib/theme.ts → apps/web/lib/theme.tsrenamedView File

File mode or name changed only.

+0−0lib/tokens.ts → apps/web/lib/tokens.tsrenamedView File

File mode or name changed only.

+61−0apps/web/lib/totp.test.tsaddedView File

Large diffs are not rendered by default.

+96−0apps/web/lib/totp.tsaddedView File

Large diffs are not rendered by default.

+0−0lib/tracker.ts → apps/web/lib/tracker.tsrenamedView File

File mode or name changed only.

+111−0apps/web/lib/two-factor.tsaddedView File

Large diffs are not rendered by default.

+0−0lib/users.ts → apps/web/lib/users.tsrenamedView File

File mode or name changed only.

+0−0lib/webhook-rules.ts → apps/web/lib/webhook-rules.tsrenamedView File

File mode or name changed only.

+0−0lib/webhooks.ts → apps/web/lib/webhooks.tsrenamedView File

File mode or name changed only.

+0−0lib/wiki-links.test.ts → apps/web/lib/wiki-links.test.tsrenamedView File

File mode or name changed only.

+0−0lib/wiki-links.ts → apps/web/lib/wiki-links.tsrenamedView File

File mode or name changed only.

+0−0lib/wiki.ts → apps/web/lib/wiki.tsrenamedView File

File mode or name changed only.

+0−0lib/workflows-events.test.ts → apps/web/lib/workflows-events.test.tsrenamedView File

File mode or name changed only.

+0−0lib/workflows.ts → apps/web/lib/workflows.tsrenamedView File

File mode or name changed only.

+0−0next.config.ts → apps/web/next.config.tsrenamedView File

File mode or name changed only.

+48−0apps/web/package.jsonaddedView File

Large diffs are not rendered by default.

+0−0pb_migrations/1759320000_init.js → apps/web/pb_migrations/1759320000_init.jsrenamedView File

File mode or name changed only.

+0−0pb_migrations/1790954144_runner_expiry.js → apps/web/pb_migrations/1790954144_runner_expiry.jsrenamedView File

File mode or name changed only.

+0−0pb_migrations/1790964309_releases.js → apps/web/pb_migrations/1790964309_releases.jsrenamedView File

File mode or name changed only.

+0−0pb_migrations/1790964401_oauth.js → apps/web/pb_migrations/1790964401_oauth.jsrenamedView File

File mode or name changed only.

+0−0pb_migrations/1790971005_wiki.js → apps/web/pb_migrations/1790971005_wiki.jsrenamedView File

File mode or name changed only.

+0−0pb_migrations/1791100000_agent_first.js → apps/web/pb_migrations/1791100000_agent_first.jsrenamedView File

File mode or name changed only.

+0−0pb_migrations/1791200000_forks.js → apps/web/pb_migrations/1791200000_forks.jsrenamedView File

File mode or name changed only.

+23−0apps/web/pb_migrations/1791300000_two_factor.jsaddedView File

Large diffs are not rendered by default.

+0−0pb_migrations/1791300000_orgs.js → apps/web/pb_migrations/1791400000_orgs.jsrenamedView File

File mode or name changed only.

+0−0postcss.config.mjs → apps/web/postcss.config.mjsrenamedView File

File mode or name changed only.

+0−0public/file.svg → apps/web/public/file.svgrenamedView File

File mode or name changed only.

+0−0public/globe.svg → apps/web/public/globe.svgrenamedView File

File mode or name changed only.

+0−0public/next.svg → apps/web/public/next.svgrenamedView File

File mode or name changed only.

+0−0public/vectrum-runner.mjs → apps/web/public/vectrum-runner.mjsrenamedView File

File mode or name changed only.

+0−0public/vectrum.mjs → apps/web/public/vectrum.mjsrenamedView File

File mode or name changed only.

+0−0public/vercel.svg → apps/web/public/vercel.svgrenamedView File

File mode or name changed only.

+0−0public/window.svg → apps/web/public/window.svgrenamedView File

File mode or name changed only.

+0−0scripts/create-user.mjs → apps/web/scripts/create-user.mjsrenamedView File

File mode or name changed only.

+0−0scripts/install-pocketbase.mjs → apps/web/scripts/install-pocketbase.mjsrenamedView File

File mode or name changed only.

+0−0tsconfig.json → apps/web/tsconfig.jsonrenamedView File

File mode or name changed only.

+16−0bun.lockView File

Large diffs are not rendered by default.

+3−1docker-compose.ymlView File

Large diffs are not rendered by default.

+2−0docs/features.mdView File

Large diffs are not rendered by default.

+6−2docs/installation.mdView File

Large diffs are not rendered by default.

+16−1docs/layout.mdView File

Large diffs are not rendered by default.

+92−0docs/mobile.mdaddedView File

Large diffs are not rendered by default.

+1−0docs/security.mdView File

Large diffs are not rendered by default.

+0−72lib/auth.tsdeleted

Large diffs are not rendered by default.

+12−40package.jsonView File

Large diffs are not rendered by default.