From 500ac38870c646eb7558d7ee327b5ec0eda76db0 Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Sun, 28 Jun 2026 17:54:11 +0200 Subject: [PATCH 1/3] ci: publish Android APK previews from PR label --- .github/workflows/issue-labels.yml | 5 + .../workflows/mobile-android-apk-preview.yml | 266 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 .github/workflows/mobile-android-apk-preview.yml diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml index d6571d65d45..607d6c26cd7 100644 --- a/.github/workflows/issue-labels.yml +++ b/.github/workflows/issue-labels.yml @@ -37,6 +37,11 @@ jobs: color: "fbca04", description: "Issue needs maintainer review and initial categorization.", }, + { + name: "android:apk-preview", + color: "0e8a16", + description: "Build and publish a self-contained Android preview APK for this PR.", + }, ]; for (const label of managedLabels) { diff --git a/.github/workflows/mobile-android-apk-preview.yml b/.github/workflows/mobile-android-apk-preview.yml new file mode 100644 index 00000000000..75aa32e9d2e --- /dev/null +++ b/.github/workflows/mobile-android-apk-preview.yml @@ -0,0 +1,266 @@ +name: Mobile Android APK Preview + +on: + pull_request: + types: [labeled] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + build: + name: Build Android APK preview + if: github.event.label.name == 'android:apk-preview' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + concurrency: + group: android-apk-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + env: + ANDROID_APK_PATH: dist/mobile/android/t3code-preview-release.apk + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + T3CODE_ANDROID_PREVIEW_RELEASE_APK_PATH: dist/mobile/android/t3code-preview-release.apk + T3CODE_ANDROID_PREVIEW_RELEASE_ARCHITECTURES: arm64-v8a + steps: + - name: Checkout PR head + uses: actions/checkout@v6 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Build Android preview release APK + run: vp run dist:mobile:android:preview:release + + - id: apk + name: Verify APK artifact + shell: bash + run: | + set -euo pipefail + + test -f "$ANDROID_APK_PATH" + unzip -l "$ANDROID_APK_PATH" | grep -E 'assets/(index\.android\.bundle|app\.manifest)' + + size_bytes="$(stat -c '%s' "$ANDROID_APK_PATH")" + size_mib="$(awk "BEGIN { printf \"%.1f\", $size_bytes / 1024 / 1024 }")" + sha256="$(sha256sum "$ANDROID_APK_PATH" | awk '{ print $1 }')" + + echo "size_mib=$size_mib" >> "$GITHUB_OUTPUT" + echo "sha256=$sha256" >> "$GITHUB_OUTPUT" + + - id: upload-apk + name: Upload APK artifact + uses: actions/upload-artifact@v7 + with: + name: t3code-preview-release-pr-${{ github.event.pull_request.number }} + path: ${{ env.ANDROID_APK_PATH }} + if-no-files-found: error + retention-days: 14 + + - id: release-apk + name: Publish direct APK download + uses: actions/github-script@v8 + env: + APK_ASSET_NAME: t3code-preview-release.apk + APK_SOURCE_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const fs = require("node:fs"); + + const issueNumber = context.payload.pull_request.number; + const serverUrl = process.env.GITHUB_SERVER_URL ?? "https://github.com"; + const tag = `android-apk-preview-pr-${issueNumber}`; + const name = `Android APK preview for PR #${issueNumber}`; + const body = [ + `Generated from PR #${issueNumber}.`, + "", + `Source: ${process.env.APK_SOURCE_SHA}`, + `Workflow run: ${serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + ].join("\n"); + + let release; + try { + release = ( + await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag, + }) + ).data; + + try { + await github.rest.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${tag}`, + sha: process.env.APK_SOURCE_SHA, + force: true, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + release = ( + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + tag_name: tag, + target_commitish: process.env.APK_SOURCE_SHA, + name, + body, + prerelease: true, + make_latest: "false", + }) + ).data; + } catch (error) { + if (error.status !== 404) { + throw error; + } + + release = ( + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + target_commitish: process.env.APK_SOURCE_SHA, + name, + body, + prerelease: true, + make_latest: "false", + }) + ).data; + } + + const assets = await github.paginate(github.rest.repos.listReleaseAssets, { + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + per_page: 100, + }); + for (const asset of assets) { + if (asset.name === process.env.APK_ASSET_NAME) { + await github.rest.repos.deleteReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + asset_id: asset.id, + }); + } + } + + const apk = fs.readFileSync(process.env.ANDROID_APK_PATH); + const uploadedAsset = ( + await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + name: process.env.APK_ASSET_NAME, + data: apk, + headers: { + "content-type": "application/vnd.android.package-archive", + "content-length": apk.length, + }, + }) + ).data; + + core.setOutput("apk-url", uploadedAsset.browser_download_url); + core.setOutput("release-url", release.html_url); + + - name: Comment APK artifact and remove trigger label + uses: actions/github-script@v8 + env: + APK_ARTIFACT_NAME: t3code-preview-release.apk + APK_ARTIFACT_URL: ${{ steps.upload-apk.outputs.artifact-url }} + APK_DOWNLOAD_URL: ${{ steps.release-apk.outputs.apk-url }} + APK_RELEASE_URL: ${{ steps.release-apk.outputs.release-url }} + APK_SHA256: ${{ steps.apk.outputs.sha256 }} + APK_SIZE_MIB: ${{ steps.apk.outputs.size_mib }} + APK_SOURCE_SHA: ${{ github.event.pull_request.head.sha }} + LABEL_NAME: android:apk-preview + with: + script: | + const marker = ""; + const issueNumber = context.payload.pull_request.number; + const serverUrl = process.env.GITHUB_SERVER_URL ?? "https://github.com"; + const artifactUrl = process.env.APK_ARTIFACT_URL || `${serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const apkDownloadUrl = process.env.APK_DOWNLOAD_URL || artifactUrl; + const shortSha = (process.env.APK_SOURCE_SHA ?? "").slice(0, 12); + const body = [ + marker, + "## Android APK preview", + "", + "A self-contained Android preview APK was built for this PR.", + "", + `- APK: [${process.env.APK_ARTIFACT_NAME}](${apkDownloadUrl})`, + `- Size: ${process.env.APK_SIZE_MIB} MiB`, + `- SHA-256: \`${process.env.APK_SHA256}\``, + `- Source: \`${shortSha}\``, + `- Release: ${process.env.APK_RELEASE_URL}`, + `- Actions artifact: [zip fallback](${artifactUrl})`, + "", + "Re-add the `android:apk-preview` label to build a fresh APK.", + ].join("\n"); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + + const existing = comments.find((comment) => + comment.user?.type === "Bot" && comment.body?.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: process.env.LABEL_NAME, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } From a2f3e426d1225aad5691a12ab414ed4d6ac2e73d Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Sun, 28 Jun 2026 18:36:46 +0200 Subject: [PATCH 2/3] Port review diff surface to Android --- .../t3-review-diff/android/build.gradle | 19 + .../android/src/main/AndroidManifest.xml | 1 + .../t3reviewdiff/T3ReviewDiffModule.kt | 70 + .../modules/t3reviewdiff/T3ReviewDiffView.kt | 1190 +++++++++++++++++ .../t3-review-diff/expo-module.config.json | 5 +- .../modules/t3-review-diff/package.json | 5 + .../diffs/nativeReviewDiffSurface.test.ts | 12 + .../src/features/review/ReviewSheet.tsx | 26 +- 8 files changed, 1324 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/modules/t3-review-diff/android/build.gradle create mode 100644 apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt create mode 100644 apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle new file mode 100644 index 00000000000..22bb070b3b8 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.reviewdiff' +version = '0.0.0' + +android { + namespace 'expo.modules.t3reviewdiff' + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt new file mode 100644 index 00000000000..b5aa5450527 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -0,0 +1,70 @@ +package expo.modules.t3reviewdiff + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3ReviewDiffModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3ReviewDiffSurface") + + View(T3ReviewDiffView::class) { + Prop("rowsJson") { view: T3ReviewDiffView, rowsJson: String -> + view.setRowsJson(rowsJson) + } + + Prop("tokensJson") { view: T3ReviewDiffView, tokensJson: String -> + view.setTokensJson(tokensJson) + } + + Prop("tokensPatchJson") { view: T3ReviewDiffView, tokensPatchJson: String -> + view.setTokensPatchJson(tokensPatchJson) + } + + Prop("tokensResetKey") { view: T3ReviewDiffView, tokensResetKey: String -> + view.setTokensResetKey(tokensResetKey) + } + + Prop("collapsedFileIdsJson") { view: T3ReviewDiffView, collapsedFileIdsJson: String -> + view.setCollapsedFileIdsJson(collapsedFileIdsJson) + } + + Prop("viewedFileIdsJson") { view: T3ReviewDiffView, viewedFileIdsJson: String -> + view.setViewedFileIdsJson(viewedFileIdsJson) + } + + Prop("selectedRowIdsJson") { view: T3ReviewDiffView, selectedRowIdsJson: String -> + view.setSelectedRowIdsJson(selectedRowIdsJson) + } + + Prop("collapsedCommentIdsJson") { view: T3ReviewDiffView, collapsedCommentIdsJson: String -> + view.setCollapsedCommentIdsJson(collapsedCommentIdsJson) + } + + Prop("appearanceScheme") { view: T3ReviewDiffView, appearanceScheme: String -> + view.setAppearanceScheme(appearanceScheme) + } + + Prop("themeJson") { view: T3ReviewDiffView, themeJson: String -> + view.setThemeJson(themeJson) + } + + Prop("styleJson") { view: T3ReviewDiffView, styleJson: String -> + view.setStyleJson(styleJson) + } + + Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double -> + view.setRowHeight(rowHeight) + } + + Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> + view.setContentWidth(contentWidth) + } + + Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> + view.setInitialRowIndex(initialRowIndex) + } + + Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment") + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt new file mode 100644 index 00000000000..7c0b0c4bce4 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -0,0 +1,1190 @@ +package expo.modules.t3reviewdiff + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.ScrollView +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private val horizontalScrollView = HorizontalScrollView(context) + private val scrollView = ScrollView(context) + private val contentView = ReviewDiffContentView(context) + private var appearanceScheme = "light" + private var themePayload: JSONObject? = null + private var stylePayload: JSONObject? = null + private var rowHeightOverride: Double? = null + private var contentWidthOverride: Double? = null + private var tokensResetKey = "" + private var lastMetricsDebugKey = "" + private var lastVisibleRangeDebugKey = "" + private var initialRowIndex: Int? = null + private var hasAppliedInitialRowIndex = false + + private val onDebug by EventDispatcher() + private val onToggleFile by EventDispatcher() + private val onToggleViewedFile by EventDispatcher() + private val onPressLine by EventDispatcher() + private val onToggleComment by EventDispatcher() + + init { + clipToOutline = true + horizontalScrollView.isFillViewport = true + horizontalScrollView.isHorizontalScrollBarEnabled = false + scrollView.isFillViewport = true + scrollView.isVerticalScrollBarEnabled = true + + contentView.onToggleFile = { fileId -> onToggleFile(mapOf("fileId" to fileId)) } + contentView.onToggleViewedFile = { fileId -> onToggleViewedFile(mapOf("fileId" to fileId)) } + contentView.onPressLine = { payload -> onPressLine(payload) } + contentView.onToggleComment = { commentId -> onToggleComment(mapOf("commentId" to commentId)) } + contentView.onDrawMetrics = { metrics -> emitDebug("draw-metrics", metrics) } + contentView.onContentMetricsChanged = { updateContentLayout() } + + scrollView.setOnScrollChangeListener { _, _, scrollY, _, _ -> + contentView.verticalOffset = scrollY.toFloat() + contentView.invalidate() + emitVisibleRange("scroll") + } + horizontalScrollView.setOnScrollChangeListener { _, scrollX, _, _, _ -> + contentView.horizontalOffset = scrollX.toFloat() + contentView.invalidate() + } + + scrollView.addView(contentView, FrameLayout.LayoutParams(1, 1)) + horizontalScrollView.addView( + scrollView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + addView( + horizontalScrollView, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + applyTheme() + applyStyle() + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + contentView.viewportWidth = width.toFloat() + contentView.viewportHeight = height.toFloat() + updateContentLayout() + emitVisibleRange("layout") + } + + fun setRowsJson(rowsJson: String) { + try { + val rows = parseRows(rowsJson) + contentView.rows = rows + hasAppliedInitialRowIndex = false + emitDebug( + "rows-decoded", + mapOf("rows" to rows.size, "firstKind" to (rows.firstOrNull()?.kind ?: "none")), + ) + } catch (error: JSONException) { + contentView.rows = emptyList() + hasAppliedInitialRowIndex = false + emitDebug("rows-decode-failed", mapOf("error" to error.message.orEmpty())) + } + updateContentLayout() + } + + fun setTokensJson(tokensJson: String) { + try { + contentView.tokensByRowId = parseTokensByRowId(JSONObject(tokensJson)) + } catch (error: JSONException) { + contentView.tokensByRowId = emptyMap() + emitDebug("tokens-decode-failed", mapOf("error" to error.message.orEmpty())) + } + } + + fun setTokensPatchJson(tokensPatchJson: String) { + try { + applyTokensPatch(JSONObject(tokensPatchJson)) + } catch (error: JSONException) { + emitDebug("tokens-patch-decode-failed", mapOf("error" to error.message.orEmpty())) + } + } + + fun setTokensResetKey(nextTokensResetKey: String) { + if (nextTokensResetKey == tokensResetKey) return + tokensResetKey = nextTokensResetKey + contentView.tokensByRowId = emptyMap() + emitDebug("tokens-reset", mapOf("resetKey" to nextTokensResetKey)) + } + + fun setCollapsedFileIdsJson(collapsedFileIdsJson: String) { + contentView.collapsedFileIds = decodeStringSet(collapsedFileIdsJson) + updateContentLayout() + } + + fun setViewedFileIdsJson(viewedFileIdsJson: String) { + contentView.viewedFileIds = decodeStringSet(viewedFileIdsJson) + } + + fun setSelectedRowIdsJson(selectedRowIdsJson: String) { + contentView.selectedRowIds = decodeStringSet(selectedRowIdsJson) + } + + fun setCollapsedCommentIdsJson(collapsedCommentIdsJson: String) { + contentView.collapsedCommentIds = decodeStringSet(collapsedCommentIdsJson) + updateContentLayout() + } + + fun setAppearanceScheme(nextAppearanceScheme: String) { + appearanceScheme = nextAppearanceScheme + applyTheme() + } + + fun setThemeJson(themeJson: String) { + themePayload = parseObjectOrNull(themeJson, "theme-decode-failed") + applyTheme() + } + + fun setStyleJson(styleJson: String) { + stylePayload = parseObjectOrNull(styleJson, "style-decode-failed") + applyStyle() + } + + fun setRowHeight(rowHeight: Double) { + rowHeightOverride = rowHeight.takeIf { it.isFinite() && it > 0 } + applyStyle() + } + + fun setContentWidth(contentWidth: Double) { + contentWidthOverride = contentWidth.takeIf { it.isFinite() && it > 0 } + applyStyle() + } + + fun setInitialRowIndex(initialRowIndex: Double) { + this.initialRowIndex = initialRowIndex + .takeIf { it.isFinite() && it >= 0 } + ?.let { floor(it).toInt() } + hasAppliedInitialRowIndex = false + applyInitialRowIndexIfNeeded() + } + + private fun applyTokensPatch(patch: JSONObject) { + val resetKey = patch.optStringOrNull("resetKey") + if (resetKey != null && resetKey != tokensResetKey) { + tokensResetKey = resetKey + contentView.tokensByRowId = emptyMap() + } + + val patchTokens = patch.optJSONObject("tokensByRowId")?.let(::parseTokensByRowId).orEmpty() + if (patchTokens.isEmpty()) return + contentView.mergeTokensByRowId(patchTokens) + + val chunkIndex = patch.optNullableInt("chunkIndex") + if (chunkIndex != null && (chunkIndex < 5 || chunkIndex % 10 == 0)) { + emitDebug( + "tokens-patch-decoded", + mapOf("chunkIndex" to chunkIndex, "rows" to patchTokens.size, "totalRows" to contentView.tokenRowCount), + ) + } + } + + private fun decodeStringSet(json: String): Set = + try { + val array = JSONArray(json) + buildSet { + for (index in 0 until array.length()) { + array.optString(index).takeIf { it.isNotEmpty() }?.let(::add) + } + } + } catch (error: JSONException) { + emitDebug("file-id-set-decode-failed", mapOf("error" to error.message.orEmpty())) + emptySet() + } + + private fun parseObjectOrNull(json: String, failureMessage: String): JSONObject? = + try { + JSONObject(json) + } catch (error: JSONException) { + emitDebug(failureMessage, mapOf("error" to error.message.orEmpty())) + null + } + + private fun applyTheme() { + contentView.theme = ReviewDiffNativeTheme.resolve(appearanceScheme, themePayload) + setBackgroundColor(contentView.theme.background) + scrollView.setBackgroundColor(contentView.theme.background) + horizontalScrollView.setBackgroundColor(contentView.theme.background) + } + + private fun applyStyle() { + contentView.style = ReviewDiffNativeStyle.resolve( + context, + stylePayload, + rowHeightOverride, + contentWidthOverride, + ) + updateContentLayout() + } + + private fun updateContentLayout() { + contentView.viewportWidth = width.toFloat() + contentView.viewportHeight = height.toFloat() + contentView.horizontalOffset = horizontalScrollView.scrollX.toFloat() + contentView.verticalOffset = scrollView.scrollY.toFloat() + + val targetWidth = max(max(width, 1), ceil(contentView.contentPixelWidth).toInt()) + val targetHeight = max(max(height, 1), ceil(contentView.contentHeight).toInt()) + updateLayoutSize(scrollView, targetWidth, ViewGroup.LayoutParams.MATCH_PARENT) + updateLayoutSize(contentView, targetWidth, targetHeight) + emitMetrics(targetWidth, targetHeight) + applyInitialRowIndexIfNeeded() + } + + private fun updateLayoutSize(view: View, targetWidth: Int, targetHeight: Int) { + val params = view.layoutParams ?: FrameLayout.LayoutParams(targetWidth, targetHeight) + if (params.width == targetWidth && params.height == targetHeight) return + params.width = targetWidth + params.height = targetHeight + view.layoutParams = params + } + + private fun emitMetrics(targetWidth: Int, targetHeight: Int) { + val debugKey = "${contentView.rowCount}:$width:$height:$targetWidth:$targetHeight" + if (debugKey == lastMetricsDebugKey) return + lastMetricsDebugKey = debugKey + emitDebug( + "metrics", + mapOf( + "rows" to contentView.rowCount, + "boundsWidth" to width, + "boundsHeight" to height, + "contentHeight" to targetHeight, + "contentWidth" to targetWidth, + "fileHeaderHeight" to contentView.style.fileHeaderHeight, + "rowHeight" to contentView.style.rowHeight, + ), + ) + } + + private fun applyInitialRowIndexIfNeeded() { + val rowIndex = initialRowIndex ?: return + if (hasAppliedInitialRowIndex || height <= 0) return + val rowFrame = contentView.frameForRow(rowIndex) ?: return + val targetScreenY = max(0f, (height - rowFrame.height()) * 0.3f) + val maxOffset = max(contentView.contentHeight - height, 0f) + val targetOffset = min(max(rowFrame.top - targetScreenY, 0f), maxOffset).roundToInt() + hasAppliedInitialRowIndex = true + scrollView.post { + scrollView.scrollTo(0, targetOffset) + contentView.verticalOffset = scrollView.scrollY.toFloat() + emitVisibleRange("initial-row") + } + } + + private fun emitVisibleRange(reason: String) { + val range = contentView.currentVisibleRowRange() ?: return + val debugKey = "${range.first}:${range.last}:$height" + if (debugKey == lastVisibleRangeDebugKey) return + lastVisibleRangeDebugKey = debugKey + emitDebug( + "visible-range", + mapOf( + "reason" to reason, + "firstRowIndex" to range.first, + "lastRowIndex" to range.last, + "totalRows" to contentView.rowCount, + ), + ) + } + + private fun emitDebug(message: String, details: Map) { + onDebug(details + mapOf("message" to message)) + } +} + +private data class ReviewDiffNativeRow( + val kind: String, + val id: String, + val fileId: String?, + val filePath: String?, + val previousPath: String?, + val changeType: String?, + val additions: Int?, + val deletions: Int?, + val text: String?, + val content: String?, + val change: String?, + val oldLineNumber: Int?, + val newLineNumber: Int?, + val wordDiffRanges: List, + val commentText: String?, + val commentRangeLabel: String?, + val commentSectionTitle: String?, +) + +private data class ReviewDiffNativeWordDiffRange(val start: Int, val end: Int) + +private data class ReviewDiffNativeToken( + val content: String, + val color: Int?, + val fontStyle: Int?, +) + +private data class ReviewDiffNativeTheme( + val background: Int, + val text: Int, + val mutedText: Int, + val headerBackground: Int, + val border: Int, + val hunkBackground: Int, + val hunkText: Int, + val addBackground: Int, + val deleteBackground: Int, + val addBar: Int, + val deleteBar: Int, + val addText: Int, + val deleteText: Int, +) { + companion object { + fun resolve(scheme: String, payload: JSONObject?): ReviewDiffNativeTheme { + val fallback = fallback(scheme) + return ReviewDiffNativeTheme( + background = parseColor(payload?.optStringOrNull("background"), fallback.background), + text = parseColor(payload?.optStringOrNull("text"), fallback.text), + mutedText = parseColor(payload?.optStringOrNull("mutedText"), fallback.mutedText), + headerBackground = parseColor( + payload?.optStringOrNull("headerBackground"), + fallback.headerBackground, + ), + border = parseColor(payload?.optStringOrNull("border"), fallback.border), + hunkBackground = parseColor(payload?.optStringOrNull("hunkBackground"), fallback.hunkBackground), + hunkText = parseColor(payload?.optStringOrNull("hunkText"), fallback.hunkText), + addBackground = parseColor(payload?.optStringOrNull("addBackground"), fallback.addBackground), + deleteBackground = parseColor(payload?.optStringOrNull("deleteBackground"), fallback.deleteBackground), + addBar = parseColor(payload?.optStringOrNull("addBar"), fallback.addBar), + deleteBar = parseColor(payload?.optStringOrNull("deleteBar"), fallback.deleteBar), + addText = parseColor(payload?.optStringOrNull("addText"), fallback.addText), + deleteText = parseColor(payload?.optStringOrNull("deleteText"), fallback.deleteText), + ) + } + + private fun fallback(scheme: String): ReviewDiffNativeTheme = + if (scheme == "dark") { + ReviewDiffNativeTheme( + background = Color.rgb(18, 18, 18), + text = Color.rgb(230, 230, 230), + mutedText = Color.rgb(132, 132, 138), + headerBackground = Color.rgb(26, 26, 26), + border = Color.rgb(41, 41, 41), + hunkBackground = Color.rgb(7, 31, 40), + hunkText = Color.rgb(104, 205, 242), + addBackground = Color.rgb(13, 47, 40), + deleteBackground = Color.rgb(57, 20, 21), + addBar = Color.rgb(0, 202, 177), + deleteBar = Color.rgb(255, 103, 98), + addText = Color.rgb(94, 204, 113), + deleteText = Color.rgb(255, 103, 98), + ) + } else { + ReviewDiffNativeTheme( + background = Color.WHITE, + text = Color.rgb(7, 7, 7), + mutedText = Color.rgb(120, 120, 128), + headerBackground = Color.WHITE, + border = Color.rgb(224, 224, 230), + hunkBackground = Color.rgb(224, 242, 255), + hunkText = Color.rgb(0, 115, 189), + addBackground = Color.rgb(229, 248, 245), + deleteBackground = Color.rgb(255, 230, 231), + addBar = Color.rgb(0, 202, 177), + deleteBar = Color.rgb(255, 46, 63), + addText = Color.rgb(25, 159, 67), + deleteText = Color.rgb(213, 44, 54), + ) + } + } +} + +private data class ReviewDiffNativeStyle( + val rowHeight: Float, + val contentWidth: Float, + val changeBarWidth: Float, + val gutterWidth: Float, + val codePadding: Float, + val textVerticalInset: Float, + val fileHeaderHeight: Float, + val fileHeaderHorizontalPadding: Float, + val fileHeaderCountGap: Float, + val codeFontSize: Float, + val codeFontWeight: String, + val lineNumberFontSize: Float, + val lineNumberFontWeight: String, + val hunkFontSize: Float, + val hunkFontWeight: String, + val fileHeaderFontSize: Float, + val fileHeaderFontWeight: String, + val fileHeaderMetaFontSize: Float, + val fileHeaderMetaFontWeight: String, + val fileHeaderSubtextFontSize: Float, + val fileHeaderSubtextFontWeight: String, + val emptyStateFontSize: Float, + val emptyStateFontWeight: String, +) { + companion object { + fun resolve( + context: Context, + payload: JSONObject?, + rowHeightOverride: Double?, + contentWidthOverride: Double?, + ): ReviewDiffNativeStyle { + val density = context.resources.displayMetrics.density + val scaledDensity = context.resources.displayMetrics.scaledDensity + fun dp(name: String, fallback: Double) = metric(payload, name, fallback) * density + fun sp(name: String, fallback: Double) = metric(payload, name, fallback) * scaledDensity + return ReviewDiffNativeStyle( + rowHeight = ((rowHeightOverride ?: metric(payload, "rowHeight", 24.0)) * density).toFloat(), + contentWidth = ((contentWidthOverride ?: metric(payload, "contentWidth", 2800.0)) * density).toFloat(), + changeBarWidth = dp("changeBarWidth", 4.0).toFloat(), + gutterWidth = dp("gutterWidth", 50.0).toFloat(), + codePadding = dp("codePadding", 8.0).toFloat(), + textVerticalInset = dp("textVerticalInset", 3.0).toFloat(), + fileHeaderHeight = dp("fileHeaderHeight", 54.0).toFloat(), + fileHeaderHorizontalPadding = dp("fileHeaderHorizontalPadding", 12.0).toFloat(), + fileHeaderCountGap = dp("fileHeaderCountGap", 6.0).toFloat(), + codeFontSize = sp("codeFontSize", 12.0).toFloat(), + codeFontWeight = payload?.optStringOrNull("codeFontWeight") ?: "bold", + lineNumberFontSize = sp("lineNumberFontSize", 11.0).toFloat(), + lineNumberFontWeight = payload?.optStringOrNull("lineNumberFontWeight") ?: "bold", + hunkFontSize = sp("hunkFontSize", 12.0).toFloat(), + hunkFontWeight = payload?.optStringOrNull("hunkFontWeight") ?: "bold", + fileHeaderFontSize = sp("fileHeaderFontSize", 13.0).toFloat(), + fileHeaderFontWeight = payload?.optStringOrNull("fileHeaderFontWeight") ?: "bold", + fileHeaderMetaFontSize = sp("fileHeaderMetaFontSize", 12.0).toFloat(), + fileHeaderMetaFontWeight = payload?.optStringOrNull("fileHeaderMetaFontWeight") ?: "bold", + fileHeaderSubtextFontSize = sp("fileHeaderSubtextFontSize", 11.0).toFloat(), + fileHeaderSubtextFontWeight = payload?.optStringOrNull("fileHeaderSubtextFontWeight") ?: "medium", + emptyStateFontSize = sp("emptyStateFontSize", 13.0).toFloat(), + emptyStateFontWeight = payload?.optStringOrNull("emptyStateFontWeight") ?: "semibold", + ) + } + + private fun metric(payload: JSONObject?, name: String, fallback: Double): Double { + val value = payload?.optDouble(name, Double.NaN) ?: Double.NaN + return if (value.isFinite() && value > 0) value else fallback + } + } +} + +private data class VisibleRowRange(val first: Int, val last: Int) +private data class StickyFileHeaderTarget(val rowIndex: Int, val row: ReviewDiffNativeRow, val rect: RectF) +private data class FileHeaderInteractiveRects(val chevron: RectF, val checkbox: RectF) + +private class ReviewDiffContentView(context: Context) : View(context) { + var rows: List = emptyList() + set(value) { + field = value + tokenTextWidthCache.clear() + rebuildRowLayout() + } + var tokensByRowId: Map> = emptyMap() + set(value) { + field = value + invalidate() + } + var collapsedFileIds: Set = emptySet() + set(value) { + field = value + rebuildRowLayout() + } + var viewedFileIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var selectedRowIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var collapsedCommentIds: Set = emptySet() + set(value) { + field = value + rebuildRowLayout() + } + var theme = ReviewDiffNativeTheme.resolve("light", null) + set(value) { + field = value + invalidate() + } + var style = ReviewDiffNativeStyle.resolve(context, null, null, null) + set(value) { + field = value + configurePaints() + rebuildRowLayout() + } + var viewportWidth = 0f + var viewportHeight = 0f + var horizontalOffset = 0f + var verticalOffset = 0f + var contentHeight = 0f + private set + val contentPixelWidth: Float get() = max(style.contentWidth, viewportWidth) + val rowCount: Int get() = rows.size + val tokenRowCount: Int get() = tokensByRowId.size + + var onToggleFile: ((String) -> Unit)? = null + var onToggleViewedFile: ((String) -> Unit)? = null + var onPressLine: ((Map) -> Unit)? = null + var onToggleComment: ((String) -> Unit)? = null + var onDrawMetrics: ((Map) -> Unit)? = null + var onContentMetricsChanged: (() -> Unit)? = null + + private val rowOffsets = mutableListOf() + private val fileHeaderRowIndices = mutableListOf() + private val tokenTextWidthCache = mutableMapOf() + private val clipBounds = Rect() + private val rowRect = RectF() + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val path = Path() + private var lastDrawMetricsTimestamp = 0L + + private val gestureDetector = GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent) = true + + override fun onSingleTapUp(event: MotionEvent): Boolean { + performClick() + handleTap(event.x, event.y) + return true + } + + override fun onLongPress(event: MotionEvent) { + handleLongPress(event.x, event.y) + } + }, + ) + + init { + setWillNotDraw(false) + configurePaints() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + setMeasuredDimension(ceil(contentPixelWidth).toInt(), max(1, ceil(contentHeight).toInt())) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + parent.requestDisallowInterceptTouchEvent(false) + gestureDetector.onTouchEvent(event) + return true + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + fun mergeTokensByRowId(tokensPatch: Map>) { + tokensByRowId = tokensByRowId + tokensPatch + } + + fun frameForRow(index: Int): RectF? { + if (index !in rows.indices || index !in rowOffsets.indices) return null + val top = rowOffsets[index] + return RectF(0f, top, contentPixelWidth, top + heightFor(rows[index])) + } + + fun currentVisibleRowRange(): VisibleRowRange? { + if (rows.isEmpty()) return null + val first = firstVisibleRowIndex(verticalOffset) ?: return null + val last = lastVisibleRowIndex(verticalOffset + max(viewportHeight, 1f)) ?: return null + return if (first <= last) VisibleRowRange(first, last) else null + } + + private fun configurePaints() { + textPaint.typeface = typeface(style.codeFontWeight, monospace = true) + textPaint.textSize = style.codeFontSize + } + + private fun rebuildRowLayout() { + rowOffsets.clear() + fileHeaderRowIndices.clear() + var offset = 0f + rows.forEachIndexed { index, row -> + rowOffsets.add(offset) + if (row.kind == "file") fileHeaderRowIndices.add(index) + offset += heightFor(row) + } + contentHeight = offset + requestLayout() + invalidate() + onContentMetricsChanged?.invoke() + } + + override fun onDraw(canvas: Canvas) { + val startedAt = System.nanoTime() + canvas.getClipBounds(clipBounds) + paint.color = theme.background + canvas.drawRect(clipBounds, paint) + if (rows.isEmpty()) { + drawEmptyState(canvas) + return + } + val range = visibleRangeForDraw() ?: return + var drawnRows = 0 + for (rowIndex in range.first..range.last) { + val row = rows[rowIndex] + val rowHeight = heightFor(row) + if (rowHeight <= 0f) continue + val top = rowOffsets[rowIndex] + rowRect.set(0f, top, contentPixelWidth, top + rowHeight) + if (!RectF.intersects(rowRect, RectF(clipBounds))) continue + drawRow(canvas, row, rowIndex, rowRect) + drawnRows += 1 + } + drawStickyFileHeader(canvas) + maybeEmitDrawMetrics(startedAt, drawnRows, range) + } + + private fun visibleRangeForDraw(): VisibleRowRange? { + val overscan = max(style.rowHeight, style.fileHeaderHeight) * 4f + val first = firstVisibleRowIndex(max(0f, clipBounds.top - overscan)) ?: return null + val last = lastVisibleRowIndex(clipBounds.bottom + overscan) ?: return null + return if (first <= last) VisibleRowRange(first, last) else null + } + + private fun maybeEmitDrawMetrics(startedAt: Long, drawnRows: Int, range: VisibleRowRange) { + val now = System.nanoTime() + if (now - lastDrawMetricsTimestamp < 1_000_000_000L) return + lastDrawMetricsTimestamp = now + onDrawMetrics?.invoke( + mapOf( + "drawnRows" to drawnRows, + "durationMs" to ((now - startedAt).toDouble() / 1_000_000.0), + "firstRowIndex" to range.first, + "lastRowIndex" to range.last, + "scannedRows" to (range.last - range.first + 1), + "totalRows" to rows.size, + ), + ) + } + + private fun drawRow(canvas: Canvas, row: ReviewDiffNativeRow, rowIndex: Int, rect: RectF) { + when (row.kind) { + "file" -> drawFileRow(canvas, row, rect) + "hunk" -> drawHunkRow(canvas, row, rect) + "notice" -> drawNoticeRow(canvas, row, rect) + "comment" -> drawCommentRow(canvas, row, rect) + else -> drawCodeRow(canvas, row, rowIndex, rect) + } + } + + private fun drawEmptyState(canvas: Canvas) { + configureText(theme.mutedText, style.emptyStateFontSize, style.emptyStateFontWeight) + canvas.drawText("No native diff rows", horizontalOffset + dp(16f), verticalOffset + dp(32f), textPaint) + } + + private fun drawFileRow(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF) { + val headerRect = visibleHeaderRect(rect) + paint.color = theme.headerBackground + canvas.drawRect(headerRect, paint) + paint.color = theme.border + canvas.drawRect(headerRect.left, headerRect.bottom - hairline(), headerRect.right, headerRect.bottom, paint) + val interactiveRects = fileHeaderInteractiveRects(headerRect) + drawDisclosureChevron(canvas, interactiveRects.chevron, collapsedFileIds.contains(resolvedFileId(row))) + drawFileIcon(canvas, interactiveRects.chevron.right + dp(8f), headerRect.centerY(), row.changeType) + drawViewedCheckbox(canvas, interactiveRects.checkbox, viewedFileIds.contains(resolvedFileId(row))) + drawFileCounts(canvas, row, headerRect, interactiveRects.checkbox.left) + drawHeaderPath(canvas, row, headerRect) + } + + private fun drawFileCounts(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF, right: Float) { + configureText(theme.deleteText, style.fileHeaderMetaFontSize, style.fileHeaderMetaFontWeight) + val deleteText = "-${row.deletions ?: 0}" + val addText = "+${row.additions ?: 0}" + val deleteWidth = textPaint.measureText(deleteText) + val addWidth = textPaint.measureText(addText) + val startX = right - dp(10f) - deleteWidth - style.fileHeaderCountGap - addWidth + val baseline = textBaseline(rect, textPaint) + canvas.drawText(deleteText, startX, baseline, textPaint) + configureText(theme.addText, style.fileHeaderMetaFontSize, style.fileHeaderMetaFontWeight) + canvas.drawText(addText, startX + deleteWidth + style.fileHeaderCountGap, baseline, textPaint) + } + + private fun drawHeaderPath(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF) { + val pathText = row.filePath ?: row.id + val left = rect.left + style.fileHeaderHorizontalPadding + dp(42f) + val right = rect.right - dp(116f) + configureText(theme.text, style.fileHeaderFontSize, style.fileHeaderFontWeight) + drawClippedText(canvas, pathText, RectF(left, rect.top, max(left, right), rect.bottom), textPaint) + } + + private fun drawHunkRow(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF) { + if (collapsedFileIds.contains(resolvedFileId(row))) return + paint.color = theme.hunkBackground + canvas.drawRect(rect, paint) + configureText(theme.hunkText, style.hunkFontSize, style.hunkFontWeight, monospace = true) + canvas.drawText(row.text ?: "", style.codePadding + horizontalOffset, textBaseline(rect, textPaint), textPaint) + } + + private fun drawNoticeRow(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF) { + if (collapsedFileIds.contains(resolvedFileId(row))) return + paint.color = theme.background + canvas.drawRect(rect, paint) + paint.color = withAlpha(theme.border, 0.65f) + canvas.drawRect(horizontalOffset, rect.bottom - hairline(), horizontalOffset + viewportWidth, rect.bottom, paint) + configureText(theme.mutedText, style.fileHeaderSubtextFontSize, style.fileHeaderSubtextFontWeight) + canvas.drawText(row.text ?: "", horizontalOffset + dp(18f), textBaseline(rect, textPaint), textPaint) + } + + private fun drawCommentRow(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF) { + if (collapsedFileIds.contains(resolvedFileId(row))) return + val card = RectF( + horizontalOffset + dp(8f), + rect.top + dp(5f), + horizontalOffset + viewportWidth - dp(8f), + rect.bottom - dp(5f), + ) + paint.color = theme.headerBackground + canvas.drawRoundRect(card, dp(10f), dp(10f), paint) + paint.style = Paint.Style.STROKE + paint.strokeWidth = hairline() + paint.color = theme.border + canvas.drawRoundRect(card, dp(10f), dp(10f), paint) + paint.style = Paint.Style.FILL + val isCollapsed = collapsedCommentIds.contains(row.id) + drawDisclosureChevron( + canvas, + RectF(card.left + dp(10f), card.top + dp(11f), card.left + dp(26f), card.top + dp(27f)), + isCollapsed, + ) + configureText(theme.mutedText, style.fileHeaderSubtextFontSize, style.fileHeaderSubtextFontWeight) + canvas.drawText("Comment on ${row.commentRangeLabel ?: "line"}", card.left + dp(36f), card.top + dp(23f), textPaint) + if (!isCollapsed) drawCommentBody(canvas, row, card) + } + + private fun drawCommentBody(canvas: Canvas, row: ReviewDiffNativeRow, card: RectF) { + configureText(theme.text, style.fileHeaderSubtextFontSize, style.fileHeaderSubtextFontWeight) + val body = row.commentText?.trim().orEmpty().ifEmpty { "Comment" } + val lines = wrapLines(body, card.width() - dp(36f), textPaint, 3) + var baseline = card.top + dp(52f) + for (line in lines) { + canvas.drawText(line, card.left + dp(18f), baseline, textPaint) + baseline += textPaint.fontSpacing + } + } + + private fun drawCodeRow(canvas: Canvas, row: ReviewDiffNativeRow, rowIndex: Int, rect: RectF) { + val background = backgroundForChange(row.change) + paint.color = background + canvas.drawRect(rect, paint) + if (selectedRowIds.contains(row.id)) { + paint.color = withAlpha(theme.hunkText, 0.18f) + canvas.drawRect(rect, paint) + } + drawWordDiffRanges(canvas, row, rect) + drawCodeText(canvas, row, rect) + drawStickyGutter(canvas, row, rect, background, rowIndex) + } + + private fun drawCodeText(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF) { + val baseline = rect.top + style.textVerticalInset - codeFontMetrics().ascent + val tokens = tokensByRowId[row.id].orEmpty() + if (tokens.isEmpty()) { + configureText(theme.text, style.codeFontSize, style.codeFontWeight, monospace = true) + canvas.drawText(row.content.orEmpty().ifEmpty { " " }, codeStartX(), baseline, textPaint) + return + } + var x = codeStartX() + for (token in tokens) { + configureTokenPaint(token) + canvas.drawText(token.content.ifEmpty { " " }, x, baseline, textPaint) + x += tokenWidth(row.id, token) + if (x > horizontalOffset + viewportWidth + dp(80f)) break + } + } + + private fun drawWordDiffRanges(canvas: Canvas, row: ReviewDiffNativeRow, rect: RectF) { + val content = row.content ?: return + val ranges = row.wordDiffRanges + if (ranges.isEmpty()) return + configureText(theme.text, style.codeFontSize, style.codeFontWeight, monospace = true) + paint.color = withAlpha(if (row.change == "delete") theme.deleteBar else theme.addBar, 0.22f) + for (range in ranges) { + val start = range.start.coerceIn(0, content.length) + val end = range.end.coerceIn(start, content.length) + val left = codeStartX() + textPaint.measureText(content, 0, start) + val right = codeStartX() + textPaint.measureText(content, 0, end) + canvas.drawRoundRect(RectF(left, rect.top + dp(3f), right, rect.bottom - dp(3f)), dp(3f), dp(3f), paint) + } + } + + private fun drawStickyGutter( + canvas: Canvas, + row: ReviewDiffNativeRow, + rect: RectF, + background: Int, + rowIndex: Int, + ) { + val left = horizontalOffset + paint.color = background + canvas.drawRect(left, rect.top, left + stickyWidth(), rect.bottom, paint) + paint.color = barColorForChange(row.change) + canvas.drawRect(left, rect.top, left + style.changeBarWidth, rect.bottom, paint) + configureText(theme.mutedText, style.lineNumberFontSize, style.lineNumberFontWeight, monospace = true) + val oldText = row.oldLineNumber?.toString().orEmpty() + val newText = row.newLineNumber?.toString().orEmpty() + val baseline = textBaseline(rect, textPaint) + canvas.drawText(oldText, left + dp(8f), baseline, textPaint) + canvas.drawText(newText, left + style.gutterWidth * 0.52f, baseline, textPaint) + if (rowIndex > 0) drawRowBorder(canvas, rect) + } + + private fun drawStickyFileHeader(canvas: Canvas) { + val target = stickyFileHeaderTarget() ?: return + drawFileRow(canvas, target.row, target.rect) + } + + private fun handleTap(x: Float, y: Float) { + stickyFileHeaderTarget()?.takeIf { it.rect.contains(x, y) }?.let { + handleFileHeaderTap(it.row, it.rect, x, y) + return + } + val rowIndex = rowIndexAt(y) ?: return + val row = rows.getOrNull(rowIndex) ?: return + when (row.kind) { + "file" -> frameForRow(rowIndex)?.let { handleFileHeaderTap(row, it, x, y) } + "comment" -> onToggleComment?.invoke(row.id) + "line" -> onPressLine?.invoke(linePressPayload(row, "tap")) + } + } + + private fun handleLongPress(x: Float, y: Float) { + if (stickyFileHeaderTarget()?.rect?.contains(x, y) == true) return + val row = rowIndexAt(y)?.let(rows::getOrNull) ?: return + if (row.kind == "line") onPressLine?.invoke(linePressPayload(row, "longPress")) + } + + private fun handleFileHeaderTap(row: ReviewDiffNativeRow, rect: RectF, x: Float, y: Float) { + val headerRect = visibleHeaderRect(rect) + val interactiveRects = fileHeaderInteractiveRects(headerRect) + val fileId = resolvedFileId(row) + if (interactiveRects.checkbox.contains(x, y)) { + onToggleViewedFile?.invoke(fileId) + } else if (headerRect.contains(x, y)) { + onToggleFile?.invoke(fileId) + } + } + + private fun linePressPayload(row: ReviewDiffNativeRow, gesture: String): Map = + buildMap { + put("rowId", row.id) + put("fileId", resolvedFileId(row)) + put("gesture", gesture) + row.oldLineNumber?.let { put("oldLineNumber", it) } + row.newLineNumber?.let { put("newLineNumber", it) } + row.change?.let { put("change", it) } + } + + private fun heightFor(row: ReviewDiffNativeRow): Float { + if (row.kind == "file") return style.fileHeaderHeight + if (collapsedFileIds.contains(resolvedFileId(row))) return 0f + if (row.kind == "notice") return max(style.rowHeight * 2f, dp(44f)) + if (row.kind == "comment") return if (collapsedCommentIds.contains(row.id)) dp(44f) else dp(124f) + return style.rowHeight + } + + private fun stickyFileHeaderTarget(): StickyFileHeaderTarget? { + val stickyIndex = stickyFileHeaderRowIndex() ?: return null + val nextIndex = fileHeaderRowIndices.getOrNull(fileHeaderRowIndices.indexOf(stickyIndex) + 1) + val pushedY = nextIndex?.let { min(0f, rowOffsets[it] - verticalOffset - style.fileHeaderHeight) } ?: 0f + if (pushedY <= -style.fileHeaderHeight) return null + val rect = RectF(0f, verticalOffset + pushedY, contentPixelWidth, verticalOffset + pushedY + style.fileHeaderHeight) + return StickyFileHeaderTarget(stickyIndex, rows[stickyIndex], rect) + } + + private fun stickyFileHeaderRowIndex(): Int? { + if (fileHeaderRowIndices.isEmpty()) return null + var match: Int? = null + for (rowIndex in fileHeaderRowIndices) { + if (rowOffsets[rowIndex] <= verticalOffset) match = rowIndex else break + } + return match?.takeIf { rowOffsets[it] < verticalOffset } + } + + private fun rowIndexAt(y: Float): Int? { + var low = 0 + var high = rows.size - 1 + while (low <= high) { + val mid = (low + high) / 2 + val start = rowOffsets[mid] + val end = start + heightFor(rows[mid]) + if (y < start) high = mid - 1 else if (y >= end) low = mid + 1 else return mid + } + return null + } + + private fun firstVisibleRowIndex(y: Float): Int? { + var low = 0 + var high = rows.size + while (low < high) { + val mid = (low + high) / 2 + if (rowOffsets[mid] + heightFor(rows[mid]) < y) low = mid + 1 else high = mid + } + return low.takeIf { it < rows.size } + } + + private fun lastVisibleRowIndex(y: Float): Int? { + var low = 0 + var high = rows.size + while (low < high) { + val mid = (low + high) / 2 + if (rowOffsets[mid] <= y) low = mid + 1 else high = mid + } + return (low - 1).takeIf { it >= 0 } + } + + private fun resolvedFileId(row: ReviewDiffNativeRow): String = + row.fileId ?: row.filePath ?: row.id.substringBefore(":header").substringBefore(":hunk:").substringBefore(":line:") + + private fun visibleHeaderRect(rect: RectF): RectF = + RectF(horizontalOffset, rect.top, horizontalOffset + max(viewportWidth, dp(320f)), rect.bottom) + + private fun fileHeaderInteractiveRects(rect: RectF): FileHeaderInteractiveRects = + FileHeaderInteractiveRects( + chevron = RectF(rect.left + dp(10f), rect.centerY() - dp(8f), rect.left + dp(26f), rect.centerY() + dp(8f)), + checkbox = RectF(rect.right - dp(34f), rect.centerY() - dp(10f), rect.right - dp(14f), rect.centerY() + dp(10f)), + ) + + private fun drawDisclosureChevron(canvas: Canvas, rect: RectF, collapsed: Boolean) { + paint.color = theme.mutedText + path.reset() + if (collapsed) { + path.moveTo(rect.left + dp(5f), rect.top + dp(3f)) + path.lineTo(rect.right - dp(4f), rect.centerY()) + path.lineTo(rect.left + dp(5f), rect.bottom - dp(3f)) + } else { + path.moveTo(rect.left + dp(3f), rect.top + dp(5f)) + path.lineTo(rect.centerX(), rect.bottom - dp(4f)) + path.lineTo(rect.right - dp(3f), rect.top + dp(5f)) + } + path.close() + canvas.drawPath(path, paint) + } + + private fun drawFileIcon(canvas: Canvas, left: Float, centerY: Float, changeType: String?) { + paint.color = when (changeType) { + "new" -> theme.addBar + "deleted" -> theme.deleteBar + "renamed", "rename-pure", "rename-changed" -> theme.hunkText + else -> theme.mutedText + } + canvas.drawRoundRect(RectF(left, centerY - dp(8f), left + dp(16f), centerY + dp(8f)), dp(3f), dp(3f), paint) + } + + private fun drawViewedCheckbox(canvas: Canvas, rect: RectF, checked: Boolean) { + paint.style = Paint.Style.STROKE + paint.strokeWidth = dp(1.5f) + paint.color = if (checked) theme.addBar else theme.mutedText + canvas.drawRoundRect(rect, dp(5f), dp(5f), paint) + paint.style = Paint.Style.FILL + if (!checked) return + canvas.drawRect(rect.left + dp(5f), rect.centerY(), rect.centerX(), rect.bottom - dp(5f), paint) + canvas.drawRect(rect.centerX(), rect.top + dp(5f), rect.right - dp(5f), rect.bottom - dp(5f), paint) + } + + private fun drawRowBorder(canvas: Canvas, rect: RectF) { + paint.color = withAlpha(theme.border, 0.45f) + canvas.drawRect(horizontalOffset, rect.top, horizontalOffset + viewportWidth, rect.top + hairline(), paint) + } + + private fun backgroundForChange(change: String?): Int = + when (change) { + "add" -> theme.addBackground + "delete" -> theme.deleteBackground + else -> theme.background + } + + private fun barColorForChange(change: String?): Int = + when (change) { + "add" -> theme.addBar + "delete" -> theme.deleteBar + else -> withAlpha(theme.border, 0.7f) + } + + private fun codeStartX(): Float = style.changeBarWidth + style.gutterWidth + style.codePadding + private fun stickyWidth(): Float = style.changeBarWidth + style.gutterWidth + private fun hairline(): Float = 1f / resources.displayMetrics.density + private fun dp(value: Float): Float = value * resources.displayMetrics.density + + private fun configureText( + color: Int, + size: Float, + weight: String, + monospace: Boolean = false, + italic: Boolean = false, + ) { + textPaint.color = color + textPaint.textSize = size + textPaint.typeface = typeface(weight, monospace, italic) + textPaint.style = Paint.Style.FILL + } + + private fun configureTokenPaint(token: ReviewDiffNativeToken) { + val bold = token.fontStyle?.and(2) == 2 + val italic = token.fontStyle?.and(1) == 1 + configureText( + token.color ?: theme.text, + style.codeFontSize, + if (bold) "bold" else style.codeFontWeight, + monospace = true, + italic = italic, + ) + } + + private fun typeface(weight: String, monospace: Boolean = false, italic: Boolean = false): Typeface { + val styleValue = if (weight.lowercase().contains("bold") || weight.lowercase() == "semibold") { + if (italic) Typeface.BOLD_ITALIC else Typeface.BOLD + } else if (italic) { + Typeface.ITALIC + } else { + Typeface.NORMAL + } + return Typeface.create(if (monospace) Typeface.MONOSPACE else Typeface.DEFAULT, styleValue) + } + + private fun codeFontMetrics(): Paint.FontMetrics { + configureText(theme.text, style.codeFontSize, style.codeFontWeight, monospace = true) + return textPaint.fontMetrics + } + + private fun textBaseline(rect: RectF, textPaint: Paint): Float { + val metrics = textPaint.fontMetrics + return rect.centerY() - (metrics.ascent + metrics.descent) / 2f + } + + private fun drawClippedText(canvas: Canvas, text: String, rect: RectF, paint: Paint) { + canvas.save() + canvas.clipRect(rect) + canvas.drawText(text, rect.left, textBaseline(rect, paint), paint) + canvas.restore() + } + + private fun wrapLines(text: String, width: Float, paint: Paint, maxLines: Int): List { + val words = text.replace('\n', ' ').split(Regex("\\s+")).filter(String::isNotEmpty) + val lines = mutableListOf() + var current = "" + for (word in words) { + val next = if (current.isEmpty()) word else "$current $word" + if (paint.measureText(next) <= width || current.isEmpty()) { + current = next + } else { + lines.add(current) + current = word + } + if (lines.size == maxLines) return lines + } + if (current.isNotEmpty() && lines.size < maxLines) lines.add(current) + return lines + } + + private fun tokenWidth(rowId: String, token: ReviewDiffNativeToken): Float { + val key = "$rowId:${token.content}:${token.fontStyle}:${token.color}" + return tokenTextWidthCache.getOrPut(key) { textPaint.measureText(token.content) } + } +} + +private fun parseRows(rowsJson: String): List { + val array = JSONArray(rowsJson) + return List(array.length()) { index -> + val row = array.getJSONObject(index) + ReviewDiffNativeRow( + kind = row.getString("kind"), + id = row.getString("id"), + fileId = row.optStringOrNull("fileId"), + filePath = row.optStringOrNull("filePath"), + previousPath = row.optStringOrNull("previousPath"), + changeType = row.optStringOrNull("changeType"), + additions = row.optNullableInt("additions"), + deletions = row.optNullableInt("deletions"), + text = row.optStringOrNull("text"), + content = row.optStringOrNull("content"), + change = row.optStringOrNull("change"), + oldLineNumber = row.optNullableInt("oldLineNumber"), + newLineNumber = row.optNullableInt("newLineNumber"), + wordDiffRanges = parseWordDiffRanges(row.optJSONArray("wordDiffRanges")), + commentText = row.optStringOrNull("commentText"), + commentRangeLabel = row.optStringOrNull("commentRangeLabel"), + commentSectionTitle = row.optStringOrNull("commentSectionTitle"), + ) + } +} + +private fun parseWordDiffRanges(array: JSONArray?): List { + if (array == null) return emptyList() + return List(array.length()) { index -> + val range = array.getJSONObject(index) + ReviewDiffNativeWordDiffRange(range.optInt("start"), range.optInt("end")) + } +} + +private fun parseTokensByRowId(payload: JSONObject): Map> { + val result = mutableMapOf>() + val keys = payload.keys() + while (keys.hasNext()) { + val rowId = keys.next() + val tokens = payload.optJSONArray(rowId) ?: continue + result[rowId] = List(tokens.length()) { index -> + val token = tokens.getJSONObject(index) + ReviewDiffNativeToken( + content = token.optString("content"), + color = token.optStringOrNull("color")?.let { parseColor(it, Color.TRANSPARENT) }, + fontStyle = token.optNullableInt("fontStyle"), + ) + } + } + return result +} + +private fun parseColor(value: String?, fallback: Int): Int = + try { + if (value.isNullOrBlank()) fallback else Color.parseColor(value) + } catch (_: IllegalArgumentException) { + fallback + } + +private fun JSONObject.optStringOrNull(name: String): String? = + if (has(name) && !isNull(name)) optString(name).takeIf(String::isNotEmpty) else null + +private fun JSONObject.optNullableInt(name: String): Int? = + if (has(name) && !isNull(name)) optInt(name) else null + +private fun withAlpha(color: Int, alpha: Float): Int = + Color.argb( + (Color.alpha(color) * alpha.coerceIn(0f, 1f)).roundToInt(), + Color.red(color), + Color.green(color), + Color.blue(color), + ) diff --git a/apps/mobile/modules/t3-review-diff/expo-module.config.json b/apps/mobile/modules/t3-review-diff/expo-module.config.json index fe6b11b649c..bf5baf4fc5c 100644 --- a/apps/mobile/modules/t3-review-diff/expo-module.config.json +++ b/apps/mobile/modules/t3-review-diff/expo-module.config.json @@ -1,7 +1,10 @@ { - "platforms": ["apple"], + "platforms": ["apple", "android"], "apple": { "modules": ["T3ReviewDiffModule"], "podspecPath": "T3ReviewDiffNative.podspec" + }, + "android": { + "modules": ["expo.modules.t3reviewdiff.T3ReviewDiffModule"] } } diff --git a/apps/mobile/modules/t3-review-diff/package.json b/apps/mobile/modules/t3-review-diff/package.json index 75ac49e5a99..4b3f91dbd86 100644 --- a/apps/mobile/modules/t3-review-diff/package.json +++ b/apps/mobile/modules/t3-review-diff/package.json @@ -7,6 +7,11 @@ "modules": [ "T3ReviewDiffModule" ] + }, + "android": { + "modules": [ + "expo.modules.t3reviewdiff.T3ReviewDiffModule" + ] } } } diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 975bf7be13d..12180d24613 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import reviewDiffModuleConfig from "../../../modules/t3-review-diff/expo-module.config.json" with { type: "json" }; +import reviewDiffPackage from "../../../modules/t3-review-diff/package.json" with { type: "json" }; const expoMocks = vi.hoisted(() => ({ requireNativeView: vi.fn(), @@ -78,3 +80,13 @@ describe("resolveNativeReviewDiffView", () => { expect(consoleError).toHaveBeenCalledTimes(1); }); }); + +describe("native review diff module registration", () => { + it("registers the Android view manager for Expo autolinking", () => { + const androidModule = "expo.modules.t3reviewdiff.T3ReviewDiffModule"; + + expect(reviewDiffModuleConfig.platforms).toContain("android"); + expect(reviewDiffModuleConfig.android.modules).toEqual([androidModule]); + expect(reviewDiffPackage["expo-module"].android.modules).toEqual([androidModule]); + }); +}); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 92203c0ed4e..89cd8305b69 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -35,6 +35,7 @@ import { useReviewSections } from "./useReviewSections"; import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; import { resolveReviewAvailability } from "./reviewAvailability"; +import { REVIEW_MONO_FONT_FAMILY } from "./reviewDiffRendering"; const IOS_NAV_BAR_HEIGHT = 44; const REVIEW_HEADER_SPACING = 0; @@ -147,7 +148,7 @@ export function ReviewSheet() { selectedSection, draftMessage, }); - const NativeReviewDiffView = resolveNativeReviewDiffView()!; + const NativeReviewDiffView = resolveNativeReviewDiffView(); const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; const fileVisibility = useReviewFileVisibility({ threadKey: reviewCache.threadKey, @@ -176,7 +177,7 @@ export function ReviewSheet() { collapsedFileIds, viewedFileIds, selectedRowIds: commentSelection.selectedRowIds, - canHighlight: parsedDiff.kind === "files", + canHighlight: parsedDiff.kind === "files" && NativeReviewDiffView != null, }); const handleNativeToggleFile = useCallback( @@ -381,7 +382,7 @@ export function ReviewSheet() { onRetry={handleRetryEnvironment} /> - ) : selectedSection && parsedDiff.kind === "files" ? ( + ) : selectedSection && parsedDiff.kind === "files" && NativeReviewDiffView ? ( + ) : parsedDiff.kind === "files" ? ( + + + Native diff renderer unavailable + + + + {selectedSection.diff?.trim() || "No diff text available."} + + + ) : null} )} From c2b254ae06c43fc46b3b4fec8ede5114c89bc8e2 Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Sun, 28 Jun 2026 19:24:12 +0200 Subject: [PATCH 3/3] Fix Android toolbar access to review changes --- .../mobile/src/assets/toolbar-icons/git-branch.xml | 9 +++++++++ .../src/assets/toolbar-icons/more-vertical.xml | 9 +++++++++ apps/mobile/src/assets/toolbar-icons/terminal.xml | 9 +++++++++ apps/mobile/src/features/review/ReviewSheet.tsx | 8 +++++++- .../src/features/threads/ThreadGitControls.tsx | 11 +++++++++-- apps/mobile/src/lib/nativeToolbarIcons.ts | 14 ++++++++++++++ 6 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/assets/toolbar-icons/git-branch.xml create mode 100644 apps/mobile/src/assets/toolbar-icons/more-vertical.xml create mode 100644 apps/mobile/src/assets/toolbar-icons/terminal.xml create mode 100644 apps/mobile/src/lib/nativeToolbarIcons.ts diff --git a/apps/mobile/src/assets/toolbar-icons/git-branch.xml b/apps/mobile/src/assets/toolbar-icons/git-branch.xml new file mode 100644 index 00000000000..36a94da6ad0 --- /dev/null +++ b/apps/mobile/src/assets/toolbar-icons/git-branch.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/assets/toolbar-icons/more-vertical.xml b/apps/mobile/src/assets/toolbar-icons/more-vertical.xml new file mode 100644 index 00000000000..cc3137df8e8 --- /dev/null +++ b/apps/mobile/src/assets/toolbar-icons/more-vertical.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/assets/toolbar-icons/terminal.xml b/apps/mobile/src/assets/toolbar-icons/terminal.xml new file mode 100644 index 00000000000..0d0e63dc719 --- /dev/null +++ b/apps/mobile/src/assets/toolbar-icons/terminal.xml @@ -0,0 +1,9 @@ + + + diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 89cd8305b69..7f49e91a71b 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -21,6 +21,7 @@ import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThemeColor } from "../../lib/useThemeColor"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { nativeToolbarIcon } from "../../lib/nativeToolbarIcons"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useReviewCacheForThread } from "./reviewState"; @@ -340,11 +341,16 @@ export function ReviewSheet() { {showSectionToolbar ? ( - + {reviewSections.map((section) => ( selectSection(section.id)} subtitle={section.subtitle ?? undefined} > diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index d5920a72411..014c2f2cf30 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -16,6 +16,7 @@ import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { nativeToolbarIcon } from "../../lib/nativeToolbarIcons"; import { basename, getTerminalStatusLabel, @@ -183,7 +184,11 @@ export function ThreadGitControls(props: { return ( - + {props.projectScripts.length > 0 ? ( props.projectScripts.map((script) => ( Open new terminal - + >; + +export function nativeToolbarIcon( + iosSymbol: IosSymbol, + androidIcon: keyof typeof ANDROID_TOOLBAR_ICONS, +): IosSymbol | ImageSourcePropType { + return Platform.OS === "android" ? ANDROID_TOOLBAR_ICONS[androidIcon] : iosSymbol; +}