diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ef80e4b8212..e543a8d4ae8 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -23,46 +23,54 @@ if ( ); } -const VARIANT_CONFIG: Record< - AppVariant, - { - readonly appName: string; - readonly scheme: string; - readonly iosIcon: string; - readonly splashIcon: string; - readonly iosBundleIdentifier: string; - readonly androidPackage: string; - readonly relyingParty?: string; - } -> = { +const DEVELOPMENT_ASSETS = { + appIcon: "./assets/splash-icon-dev.png", + iosIcon: "./assets/icon-composer-dev.icon", + splashIcon: "./assets/splash-icon-dev.png", + androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png", + androidAdaptiveBackgroundColor: "#00639B", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#00639B", +} as const; + +const RELEASE_ASSETS = { + appIcon: "./assets/splash-icon-prod.png", + iosIcon: "./assets/icon-composer-prod.icon", + splashIcon: "./assets/splash-icon-prod.png", + androidAdaptiveForeground: "./assets/android-icon-mark.png", + androidAdaptiveBackgroundColor: "#000000", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#FFFFFF", +} as const; + +const VARIANT_CONFIG = { development: { appName: "T3 Code Dev", scheme: "t3code-dev", - iosIcon: "./assets/icon-composer-dev.icon", - splashIcon: "./assets/splash-icon-dev.png", iosBundleIdentifier: "com.t3tools.t3code.dev", androidPackage: "com.t3tools.t3code.dev", relyingParty: "clerk.t3.codes", + assets: DEVELOPMENT_ASSETS, }, preview: { appName: "T3 Code Preview", scheme: "t3code-preview", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", iosBundleIdentifier: "com.t3tools.t3code.preview", androidPackage: "com.t3tools.t3code.preview", relyingParty: "clerk.t3.codes", + assets: RELEASE_ASSETS, }, production: { appName: "T3 Code", scheme: "t3code", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", iosBundleIdentifier: "com.t3tools.t3code", androidPackage: "com.t3tools.t3code", relyingParty: "clerk.t3.codes", + assets: RELEASE_ASSETS, }, -}; +} as const; function resolveAppVariant(value: string | undefined): AppVariant { switch (value) { @@ -121,7 +129,7 @@ const config: ExpoConfig = { policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", }, orientation: "portrait", - icon: "./assets/icon.png", + icon: variant.assets.appIcon, userInterfaceStyle: "automatic", updates: { enabled: true, @@ -130,7 +138,7 @@ const config: ExpoConfig = { fallbackToCacheTimeout: 0, }, ios: { - icon: variant.iosIcon, + icon: variant.assets.iosIcon, supportsTablet: true, bundleIdentifier: variant.iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` @@ -151,13 +159,12 @@ const config: ExpoConfig = { }, }, android: { - icon: "./assets/icon.png", + icon: variant.assets.appIcon, package: variant.androidPackage, adaptiveIcon: { - backgroundColor: "#E6F4FE", - foregroundImage: "./assets/android-icon-foreground.png", - backgroundImage: "./assets/android-icon-background.png", - monochromeImage: "./assets/android-icon-monochrome.png", + backgroundColor: variant.assets.androidAdaptiveBackgroundColor, + foregroundImage: variant.assets.androidAdaptiveForeground, + monochromeImage: variant.assets.androidMonochromeIcon, }, // Opts into OnBackInvokedCallback-based back dispatch (Android 13+). // JS back handling survives it via react-native's Android 16 shim plus @@ -165,7 +172,7 @@ const config: ExpoConfig = { predictiveBackGestureEnabled: true, }, web: { - favicon: "./assets/favicon.png", + favicon: variant.assets.appIcon, }, plugins: [ "expo-asset", @@ -195,6 +202,14 @@ const config: ExpoConfig = { ], "expo-secure-store", "expo-sqlite", + [ + "expo-notifications", + { + icon: variant.assets.androidNotificationIcon, + color: variant.assets.androidNotificationColor, + mode: APP_VARIANT === "development" ? "development" : "production", + }, + ], // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before // plugins earlier in this array, so it cannot strip the entitlement Clerk would add. ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], @@ -206,8 +221,8 @@ const config: ExpoConfig = { // the shortcut items set in src/features/shortcuts. androidIcons: { shortcut_icon: { - foregroundImage: "./assets/android-icon-foreground.png", - backgroundColor: "#E6F4FE", + foregroundImage: variant.assets.androidAdaptiveForeground, + backgroundColor: variant.assets.androidAdaptiveBackgroundColor, }, }, }, @@ -217,17 +232,18 @@ const config: ExpoConfig = { { cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", barcodeScannerEnabled: true, + recordAudioAndroid: false, }, ], [ "expo-splash-screen", { - image: variant.splashIcon, + image: variant.assets.splashIcon, resizeMode: "contain", backgroundColor: "#ffffff", imageWidth: 220, dark: { - image: variant.splashIcon, + image: variant.assets.splashIcon, backgroundColor: "#0a0a0a", }, }, diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-dev-foreground.png similarity index 100% rename from apps/mobile/assets/android-icon-background.png rename to apps/mobile/assets/android-icon-dev-foreground.png diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-foreground.png and /dev/null differ diff --git a/apps/mobile/assets/android-icon-mark.png b/apps/mobile/assets/android-icon-mark.png new file mode 100644 index 00000000000..3300f22fd5a Binary files /dev/null and b/apps/mobile/assets/android-icon-mark.png differ diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-monochrome.png and /dev/null differ diff --git a/apps/mobile/assets/android-notification-icon.png b/apps/mobile/assets/android-notification-icon.png new file mode 100644 index 00000000000..74de6633cbb Binary files /dev/null and b/apps/mobile/assets/android-notification-icon.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png deleted file mode 100644 index e0e1b9659b8..00000000000 Binary files a/apps/mobile/assets/favicon.png and /dev/null differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png deleted file mode 100644 index b33d6f337b0..00000000000 Binary files a/apps/mobile/assets/icon.png and /dev/null differ diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt new file mode 100644 index 00000000000..6782e6894d9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -0,0 +1,315 @@ +package expo.modules.t3reviewdiff + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import kotlin.math.max +import kotlin.math.min + +internal class ReviewDiffCanvasDrawing(context: Context) { + private val density = context.resources.displayMetrics.density + var theme: DiffTheme = DiffTheme.fallback("light") + + val backgroundPaint = Paint() + val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + hinting = Paint.HINTING_ON + isSubpixelText = false + clearShadowLayer() + } + val uiPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + hinting = Paint.HINTING_ON + isSubpixelText = false + clearShadowLayer() + } + + private val italicCodeTypeface by lazy { + Typeface.create(ReviewDiffTypefaces.regular, Typeface.ITALIC) + } + private val boldItalicCodeTypeface by lazy { + Typeface.create(ReviewDiffTypefaces.bold, Typeface.ITALIC) + } + + init { + textPaint.typeface = ReviewDiffTypefaces.regular + uiPaint.typeface = Typeface.DEFAULT_BOLD + } + + fun fileHeaderChevronRect(top: Int, bottom: Int, style: DiffStyle): RectF { + val centerY = (top + bottom) / 2f + val left = style.fileHeaderHorizontalPaddingPx + return RectF(left, centerY - 10f * density, left + 20f * density, centerY + 10f * density) + } + + fun fileHeaderIconRect(top: Int, bottom: Int, style: DiffStyle): RectF { + val chevron = fileHeaderChevronRect(top, bottom, style) + return RectF( + chevron.right + 8f * density, + chevron.top, + chevron.right + 28f * density, + chevron.bottom, + ) + } + + fun fileHeaderCheckboxRect(top: Int, bottom: Int, width: Int, style: DiffStyle): RectF { + val centerY = (top + bottom) / 2f + val right = width - style.fileHeaderHorizontalPaddingPx + return RectF( + right - 20f * density, + centerY - 10f * density, + right, + centerY + 10f * density, + ) + } + + fun drawDisclosureChevron( + canvas: Canvas, + rect: RectF, + color: Int, + collapsed: Boolean + ) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path() + if (collapsed) { + path.moveTo(rect.left + rect.width() * 0.4f, rect.top + rect.height() * 0.28f) + path.lineTo(rect.left + rect.width() * 0.6f, rect.centerY()) + path.lineTo(rect.left + rect.width() * 0.4f, rect.bottom - rect.height() * 0.28f) + } else { + path.moveTo(rect.left + rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + path.lineTo(rect.centerX(), rect.top + rect.height() * 0.62f) + path.lineTo(rect.right - rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + } + canvas.drawPath(path, borderPaint) + borderPaint.style = Paint.Style.FILL + } + + fun drawFileIcon(canvas: Canvas, rect: RectF, changeType: String) { + val color = when (changeType) { + "new" -> theme.addText + "deleted" -> theme.deleteText + else -> theme.hunkText + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + + if (changeType == "rename-pure" || changeType == "rename-changed" || changeType == "renamed") { + drawRenameChevronIcon(canvas, rect, color) + } else { + backgroundPaint.color = color + canvas.drawCircle(rect.centerX(), rect.centerY(), 3f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.FILL + } + + fun drawViewedCheckbox(canvas: Canvas, rect: RectF, checked: Boolean) { + if (checked) { + backgroundPaint.color = theme.hunkText + canvas.drawRoundRect(rect, 6f * density, 6f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = if (checked) theme.hunkText else theme.mutedText + borderPaint.strokeWidth = 1.8f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + if (checked) { + borderPaint.color = theme.background + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path().apply { + moveTo(rect.left + rect.width() * 0.28f, rect.centerY()) + lineTo(rect.left + rect.width() * 0.44f, rect.bottom - rect.height() * 0.3f) + lineTo(rect.right - rect.width() * 0.25f, rect.top + rect.height() * 0.3f) + } + canvas.drawPath(path, borderPaint) + } + borderPaint.style = Paint.Style.FILL + } + + fun drawNoticeIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.7f * density + borderPaint.strokeCap = Paint.Cap.ROUND + canvas.drawOval(rect, borderPaint) + canvas.drawLine( + rect.centerX(), + rect.top + rect.height() * 0.3f, + rect.centerX(), + rect.top + rect.height() * 0.58f, + borderPaint, + ) + borderPaint.style = Paint.Style.FILL + canvas.drawCircle(rect.centerX(), rect.bottom - rect.height() * 0.24f, density, borderPaint) + } + + fun configureUiPaint(paint: Paint, color: Int, size: Float, weight: String) { + paint.typeface = if (isBoldFontWeight(weight)) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + configureTextPaint(paint, color, size) + } + + fun configureMonospacePaint(color: Int, size: Float, weight: String) { + textPaint.typeface = ReviewDiffTypefaces.forWeight(weight) + configureTextPaint(textPaint, color, size) + } + + fun configureCodePaint(color: Int, fontStyle: Int, style: DiffStyle) { + val tokenBold = fontStyle and 2 != 0 + val italic = fontStyle and 1 != 0 + textPaint.typeface = when { + tokenBold && italic -> boldItalicCodeTypeface + tokenBold -> ReviewDiffTypefaces.bold + italic -> italicCodeTypeface + else -> ReviewDiffTypefaces.forWeight(style.codeFontWeight) + } + configureTextPaint(textPaint, color, style.codeFontSizePx) + textPaint.isUnderlineText = fontStyle and 4 != 0 + } + + fun lineNumberColor(change: String): Int = when (change) { + "add" -> theme.addText + "delete" -> theme.deleteText + else -> theme.mutedText + } + + fun drawDeleteStripes( + canvas: Canvas, + top: Int, + bottom: Int, + width: Float, + color: Int + ) { + backgroundPaint.color = color + var y = top.toFloat() + while (y < bottom) { + canvas.drawRect(0f, y, width, min(bottom.toFloat(), y + density), backgroundPaint) + y += 2f * density + } + } + + fun drawWordDiffRanges( + canvas: Canvas, + row: DiffRow, + codeX: Float, + top: Int, + bottom: Int + ) { + if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return + val color = if (row.change == "add") theme.addBar else theme.deleteBar + backgroundPaint.color = withAlpha(color, 71) + val characterWidth = textPaint.measureText("M") + val fontHeight = textPaint.fontMetrics.run { descent - ascent } + val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) + val highlightTop = (top + bottom - highlightHeight) / 2f + row.wordDiffRanges.forEach { range -> + val left = codeX + range.start * characterWidth + val right = max(left + 2f * density, codeX + range.end * characterWidth) + canvas.drawRoundRect( + RectF(left, highlightTop, right, highlightTop + highlightHeight), + 3f * density, + 3f * density, + backgroundPaint, + ) + } + } + + fun drawFileHeaderPathScrollFades( + canvas: Canvas, + pathRect: RectF, + horizontalOffset: Int, + maxOffset: Int + ) { + if (maxOffset <= 0 || pathRect.width() <= 0f) return + val fadeWidth = min(28f * density, pathRect.width() / 3f) + if (horizontalOffset > 0) { + drawHorizontalFade( + canvas, + RectF(pathRect.left, pathRect.top, pathRect.left + fadeWidth, pathRect.bottom), + fadesToRight = false, + ) + } + if (horizontalOffset < maxOffset) { + drawHorizontalFade( + canvas, + RectF(pathRect.right - fadeWidth, pathRect.top, pathRect.right, pathRect.bottom), + fadesToRight = true, + ) + } + } + + private fun drawRenameChevronIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.8f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val chevronWidth = 3.6f * density + val chevronHeight = 8f * density + val gap = 2.4f * density + val startX = rect.centerX() - (chevronWidth * 2f + gap) / 2f + val path = Path() + for (x in listOf(startX, startX + chevronWidth + gap)) { + path.moveTo(x, rect.centerY() - chevronHeight / 2f) + path.lineTo(x + chevronWidth, rect.centerY()) + path.lineTo(x, rect.centerY() + chevronHeight / 2f) + } + canvas.drawPath(path, borderPaint) + } + + private fun configureTextPaint(paint: Paint, color: Int, size: Float) { + paint.textSize = size + paint.color = color + paint.style = Paint.Style.FILL + paint.textAlign = Paint.Align.LEFT + paint.isUnderlineText = false + paint.isFakeBoldText = false + paint.clearShadowLayer() + } + + private fun drawHorizontalFade(canvas: Canvas, rect: RectF, fadesToRight: Boolean) { + val opaque = theme.headerBackground + val transparent = Color.argb(0, Color.red(opaque), Color.green(opaque), Color.blue(opaque)) + backgroundPaint.shader = LinearGradient( + rect.left, + rect.centerY(), + rect.right, + rect.centerY(), + if (fadesToRight) transparent else opaque, + if (fadesToRight) opaque else transparent, + Shader.TileMode.CLAMP, + ) + canvas.drawRect(rect, backgroundPaint) + backgroundPaint.shader = null + } + + private fun isBoldFontWeight(weight: String): Boolean = when (weight.lowercase()) { + "medium", "semibold", "semi-bold", "bold", "heavy", "black" -> true + else -> false + } + + private fun withAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) +} + +private object ReviewDiffTypefaces { + val regular: Typeface = Typeface.create("monospace", Typeface.NORMAL) + private val medium: Typeface = Typeface.create("monospace-medium", Typeface.NORMAL) + val bold: Typeface = Typeface.create("monospace", Typeface.BOLD) + + fun forWeight(weight: String): Typeface = when (weight.lowercase()) { + "medium", "semibold", "semi-bold" -> medium + "bold", "heavy", "black" -> bold + else -> regular + } +} 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 index 6f960f818a0..97e9f696db9 100644 --- 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 @@ -4,7 +4,7 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.graphics.Typeface +import android.graphics.RectF import android.view.GestureDetector import android.view.MotionEvent import android.view.VelocityTracker @@ -19,6 +19,7 @@ import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.Executors import kotlin.math.abs +import kotlin.math.ceil import kotlin.math.max import kotlin.math.min @@ -49,12 +50,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private val verticalScroller = OverScroller(context) private val horizontalScroller = OverScroller(context) private var dragAxis: DragAxis? = null + private var horizontalDragTarget: HorizontalPanTarget? = null private var lastTouchX = 0f private var lastTouchY = 0f private var velocityTracker: VelocityTracker? = null init { - canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) } + canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } canvasView.onVisibleRowsChanged = { first, last -> onDebug( mapOf( @@ -86,12 +88,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont lastVisibleFileId = null pendingInitialScroll = true canvasView.setVerticalOffset(0) - canvasView.setHorizontalOffset(0) + canvasView.resetHorizontalOffsets() applyPendingInitialScroll() } fun setCollapsedFileIdsJson(value: String) { collapsedFileIds = parseStringSet(value) + canvasView.collapsedFileIds = collapsedFileIds rebuildVisibleRows() } @@ -107,6 +110,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont fun setCollapsedCommentIdsJson(value: String) { collapsedCommentIds = parseStringSet(value) + canvasView.collapsedCommentIds = collapsedCommentIds rebuildVisibleRows() } @@ -202,6 +206,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont verticalScroller.forceFinished(true) horizontalScroller.forceFinished(true) dragAxis = null + horizontalDragTarget = canvasView.horizontalPanTarget(event.y) lastTouchX = event.x lastTouchY = event.y parent?.requestDisallowInterceptTouchEvent(true) @@ -212,7 +217,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont val deltaX = event.x - lastTouchX val deltaY = event.y - lastTouchY if (max(abs(deltaX), abs(deltaY)) > touchSlop) { - dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL + dragAxis = if (abs(deltaY) >= abs(deltaX)) { + DragAxis.VERTICAL + } else { + horizontalDragTarget + ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 } + ?.let { DragAxis.HORIZONTAL } + } } } return dragAxis != null @@ -238,6 +249,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) { velocityTracker?.recycle() velocityTracker = null + horizontalDragTarget = null parent?.requestDisallowInterceptTouchEvent(false) } return handled @@ -253,7 +265,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont if (axis == DragAxis.VERTICAL) { canvasView.scrollByVertical(deltaY) } else { - canvasView.scrollByHorizontal(deltaX) + horizontalDragTarget?.let { canvasView.scrollByHorizontal(deltaX, it) } } lastTouchX = event.x lastTouchY = event.y @@ -276,7 +288,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont ) postInvalidateOnAnimation() } - } else { + } else if (horizontalDragTarget?.kind == HorizontalPanKind.CODE) { val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt() if (abs(velocity) >= minimumFlingVelocity) { horizontalScroller.fling( @@ -294,6 +306,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } dragAxis = null + horizontalDragTarget = null velocityTracker?.recycle() velocityTracker = null parent?.requestDisallowInterceptTouchEvent(false) @@ -325,11 +338,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId) filtered.add(row) } else if (!currentFileCollapsed) { - if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) { - filtered.add(row) - } else { - filtered.add(row.copy(commentText = "Comment collapsed")) - } + filtered.add(row) } } visibleRows = filtered @@ -339,10 +348,11 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont applyPendingInitialScroll() } - private fun handleRowTap(row: DiffRow, gesture: String) { + private fun handleRowTap(row: DiffRow, gesture: String, target: RowTapTarget) { when (row.kind) { "file" -> { - if (gesture == "longPress") { + if (gesture != "tap") return + if (target == RowTapTarget.VIEWED_CHECKBOX) { onToggleViewedFile(mapOf("fileId" to row.resolvedFileId)) } else { onToggleFile(mapOf("fileId" to row.resolvedFileId)) @@ -408,7 +418,22 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } -private data class DiffRow( +private enum class RowTapTarget { + ROW, + VIEWED_CHECKBOX +} + +private enum class HorizontalPanKind { + CODE, + FILE_HEADER_PATH +} + +private data class HorizontalPanTarget( + val fileId: String, + val kind: HorizontalPanKind +) + +internal data class DiffRow( val kind: String, val id: String, val fileId: String, @@ -422,6 +447,7 @@ private data class DiffRow( val change: String, val oldLineNumber: Int?, val newLineNumber: Int?, + val wordDiffRanges: List, val commentText: String, val commentRangeLabel: String, val commentSectionTitle: String @@ -429,13 +455,18 @@ private data class DiffRow( val resolvedFileId: String get() = fileId.ifEmpty { id } } +internal data class DiffWordDiffRange( + val start: Int, + val end: Int +) + private data class DiffToken( val content: String, val color: Int?, val fontStyle: Int ) -private data class DiffTheme( +internal data class DiffTheme( val background: Int, val text: Int, val mutedText: Int, @@ -511,14 +542,25 @@ private data class DiffTheme( } } -private data class DiffStyle( +internal data class DiffStyle( val rowHeightPx: Float, val gutterWidthPx: Float, val codePaddingPx: Float, val changeBarWidthPx: Float, val fileHeaderHeightPx: Float, + val fileHeaderHorizontalPaddingPx: Float, val codeFontSizePx: Float, - val lineNumberFontSizePx: Float + val codeFontWeight: String, + val lineNumberFontSizePx: Float, + val lineNumberFontWeight: String, + val hunkFontSizePx: Float, + val hunkFontWeight: String, + val fileHeaderFontSizePx: Float, + val fileHeaderFontWeight: String, + val fileHeaderMetaFontSizePx: Float, + val fileHeaderMetaFontWeight: String, + val fileHeaderSubtextFontSizePx: Float, + val fileHeaderSubtextFontWeight: String ) { companion object { fun defaults(density: Float): DiffStyle = DiffStyle( @@ -527,8 +569,19 @@ private data class DiffStyle( codePaddingPx = 10f * density, changeBarWidthPx = 3f * density, fileHeaderHeightPx = 44f * density, + fileHeaderHorizontalPaddingPx = 10f * density, codeFontSizePx = 12f * density, + codeFontWeight = "regular", lineNumberFontSizePx = 10f * density, + lineNumberFontWeight = "regular", + hunkFontSizePx = 11f * density, + hunkFontWeight = "medium", + fileHeaderFontSizePx = 11f * density, + fileHeaderFontWeight = "semibold", + fileHeaderMetaFontSizePx = 10f * density, + fileHeaderMetaFontWeight = "semibold", + fileHeaderSubtextFontSizePx = 11f * density, + fileHeaderSubtextFontWeight = "medium", ) fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { @@ -539,11 +592,50 @@ private data class DiffStyle( codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density), fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density), + fileHeaderHorizontalPaddingPx = json.floatDp( + "fileHeaderHorizontalPadding", + fallback.fileHeaderHorizontalPaddingPx, + density, + ), codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density), + codeFontWeight = json.optString("codeFontWeight", fallback.codeFontWeight), lineNumberFontSizePx = json.floatSp( "lineNumberFontSize", fallback.lineNumberFontSizePx, - density + density, + ), + lineNumberFontWeight = json.optString( + "lineNumberFontWeight", + fallback.lineNumberFontWeight, + ), + hunkFontSizePx = json.floatSp("hunkFontSize", fallback.hunkFontSizePx, density), + hunkFontWeight = json.optString("hunkFontWeight", fallback.hunkFontWeight), + fileHeaderFontSizePx = json.floatSp( + "fileHeaderFontSize", + fallback.fileHeaderFontSizePx, + density, + ), + fileHeaderFontWeight = json.optString( + "fileHeaderFontWeight", + fallback.fileHeaderFontWeight, + ), + fileHeaderMetaFontSizePx = json.floatSp( + "fileHeaderMetaFontSize", + fallback.fileHeaderMetaFontSizePx, + density, + ), + fileHeaderMetaFontWeight = json.optString( + "fileHeaderMetaFontWeight", + fallback.fileHeaderMetaFontWeight, + ), + fileHeaderSubtextFontSizePx = json.floatSp( + "fileHeaderSubtextFontSize", + fallback.fileHeaderSubtextFontSizePx, + density, + ), + fileHeaderSubtextFontWeight = json.optString( + "fileHeaderSubtextFontWeight", + fallback.fileHeaderSubtextFontWeight, ), ) } catch (_: Exception) { @@ -554,35 +646,54 @@ private data class DiffStyle( private class DiffCanvasView(context: Context) : View(context) { private val density = resources.displayMetrics.density - private val backgroundPaint = Paint() - private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } - private val boldTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - } + private val drawing = ReviewDiffCanvasDrawing(context) + private val backgroundPaint = drawing.backgroundPaint + private val borderPaint = drawing.borderPaint + private val textPaint = drawing.textPaint + private val boldTextPaint = drawing.uiPaint private val gestureDetector = GestureDetector( context, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(event: MotionEvent): Boolean = true override fun onSingleTapUp(event: MotionEvent): Boolean { - rowAt(event.y)?.let { onRowTap?.invoke(it, "tap") } + rowHitAt(event.y)?.let { hit -> + val target = if ( + hit.row.kind == "file" && + drawing.fileHeaderCheckboxRect( + hit.top, + hit.bottom, + width, + style + ).contains(event.x, event.y) + ) { + RowTapTarget.VIEWED_CHECKBOX + } else { + RowTapTarget.ROW + } + onRowTap?.invoke(hit.row, "tap", target) + } return true } override fun onLongPress(event: MotionEvent) { - rowAt(event.y)?.let { onRowTap?.invoke(it, "longPress") } + rowHitAt(event.y)?.row + ?.takeIf { it.kind == "line" } + ?.let { onRowTap?.invoke(it, "longPress", RowTapTarget.ROW) } } }, ) private var rowOffsets = intArrayOf(0) private var verticalOffset = 0 private var horizontalOffset = 0 + private val headerPathOffsetsByFileId = mutableMapOf() private var lastVisibleRange: Pair? = null - var rows: List = emptyList() set(value) { field = value + headerPathOffsetsByFileId.keys.retainAll( + value.asSequence().filter { it.kind == "file" }.map { it.resolvedFileId }.toSet(), + ) rebuildOffsets() } var tokensByRowId: Map> = emptyMap() @@ -595,6 +706,16 @@ private class DiffCanvasView(context: Context) : View(context) { field = value invalidate() } + var collapsedFileIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var collapsedCommentIds: Set = emptySet() + set(value) { + field = value + rebuildOffsets() + } var selectedRowIds: Set = emptySet() set(value) { field = value @@ -603,6 +724,7 @@ private class DiffCanvasView(context: Context) : View(context) { var theme: DiffTheme = DiffTheme.fallback("light") set(value) { field = value + drawing.theme = value invalidate() } var style: DiffStyle = DiffStyle.defaults(density) @@ -614,9 +736,10 @@ private class DiffCanvasView(context: Context) : View(context) { set(value) { field = max(value, suggestedMinimumWidth) setHorizontalOffset(horizontalOffset) + clampHeaderPathOffsets() invalidate() } - var onRowTap: ((DiffRow, String) -> Unit)? = null + var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -630,6 +753,7 @@ private class DiffCanvasView(context: Context) : View(context) { super.onSizeChanged(width, height, oldWidth, oldHeight) setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) + clampHeaderPathOffsets() } override fun onDraw(canvas: Canvas) { @@ -681,27 +805,63 @@ private class DiffCanvasView(context: Context) : View(context) { invalidate() } - fun scrollByHorizontal(delta: Int) { - setHorizontalOffset(horizontalOffset + delta) + fun scrollByHorizontal(delta: Int, target: HorizontalPanTarget) { + if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { + setHeaderPathOffset( + target.fileId, + (headerPathOffsetsByFileId[target.fileId] ?: 0) + delta, + ) + } else { + setHorizontalOffset(horizontalOffset + delta) + } } fun horizontalOffset(): Int = horizontalOffset fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + fun maxHorizontalOffset(target: HorizontalPanTarget): Int = + if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { + maxHeaderPathOffset(target.fileId) + } else { + maxHorizontalOffset() + } + + fun horizontalPanTarget(y: Float): HorizontalPanTarget? { + val row = rowHitAt(y)?.row ?: return null + return HorizontalPanTarget( + fileId = row.resolvedFileId, + kind = if (row.kind == "file") HorizontalPanKind.FILE_HEADER_PATH else HorizontalPanKind.CODE, + ) + } + + fun resetHorizontalOffsets() { + setHorizontalOffset(0) + if (headerPathOffsetsByFileId.isNotEmpty()) { + headerPathOffsetsByFileId.clear() + invalidate() + } + } + private fun rebuildOffsets() { rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) } setVerticalOffset(verticalOffset) + clampHeaderPathOffsets() requestLayout() invalidate() } private fun rowHeight(row: DiffRow): Int = when (row.kind) { "file" -> style.fileHeaderHeightPx.toInt() - "comment" -> max((style.rowHeightPx * 3.2f).toInt(), (56 * density).toInt()) + "notice" -> max((style.rowHeightPx * 2f).toInt(), (44 * density).toInt()) + "comment" -> if (collapsedCommentIds.contains(row.id)) { + (44 * density).toInt() + } else { + (124 * density).toInt() + } else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -721,13 +881,20 @@ private class DiffCanvasView(context: Context) : View(context) { return low.coerceIn(0, rows.lastIndex) } - private fun rowAt(y: Float): DiffRow? { + private fun rowHitAt(y: Float): RowHit? { stickyFileHeader(firstVisibleRow())?.let { sticky -> if (y >= max(0, sticky.top).toFloat() && y < sticky.bottom.toFloat()) { - return rows.getOrNull(sticky.index) + return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) } } } - return rows.getOrNull(rowIndexAt(verticalOffset + y.toInt())) + val index = rowIndexAt(verticalOffset + y.toInt()) + return rows.getOrNull(index)?.let { + RowHit( + row = it, + top = rowOffsets[index] - verticalOffset, + bottom = rowOffsets[index + 1] - verticalOffset, + ) + } } private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset) @@ -754,23 +921,140 @@ private class DiffCanvasView(context: Context) : View(context) { private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - val baseline = centeredBaseline(top, bottom, boldTextPaint.apply { textSize = 13f * density }) - boldTextPaint.color = theme.text - val marker = if (viewedFileIds.contains(row.resolvedFileId)) "[x] " else "[ ] " - textPaint.color = theme.mutedText - textPaint.textSize = 11f * density - val stats = "+${row.additions} -${row.deletions}" - val statsX = max(12f * density, width - textPaint.measureText(stats) - 16f * density) - val title = ellipsize(marker + row.filePath, boldTextPaint, statsX - 28f * density) - canvas.drawText(title, 12f * density, baseline, boldTextPaint) - canvas.drawText(stats, statsX, baseline, textPaint) + + val chevronRect = drawing.fileHeaderChevronRect(top, bottom, style) + val iconRect = drawing.fileHeaderIconRect(top, bottom, style) + val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) + drawing.drawDisclosureChevron( + canvas, + chevronRect, + theme.mutedText, + collapsed = collapsedFileIds.contains(row.resolvedFileId), + ) + drawing.drawFileIcon(canvas, iconRect, row.changeType) + drawing.drawViewedCheckbox( + canvas, + checkboxRect, + viewedFileIds.contains(row.resolvedFileId), + ) + + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.deleteText, + size = style.fileHeaderMetaFontSizePx, + weight = style.fileHeaderMetaFontWeight, + ) + val deleteText = "-${row.deletions}" + val deleteWidth = boldTextPaint.measureText(deleteText) + val addText = "+${row.additions}" + val addWidth = boldTextPaint.measureText(addText) + val countGap = 4f * density + val countsX = checkboxRect.left - 10f * density - deleteWidth - countGap - addWidth + val baseline = centeredBaseline(top, bottom, boldTextPaint) + canvas.drawText(deleteText, countsX, baseline, boldTextPaint) + boldTextPaint.color = theme.addText + canvas.drawText(addText, countsX + deleteWidth + countGap, baseline, boldTextPaint) + + val pathLayout = fileHeaderPathLayout(row, top, bottom, iconRect, countsX) + val maxPathOffset = maxHeaderPathOffset(pathLayout) + val pathOffset = (headerPathOffsetsByFileId[row.resolvedFileId] ?: 0) + .coerceIn(0, maxPathOffset) + canvas.save() + canvas.clipRect(pathLayout.rect) + canvas.drawText( + pathLayout.displayPath, + pathLayout.rect.left - pathOffset, + centeredBaseline(top, bottom, boldTextPaint), + boldTextPaint, + ) + canvas.restore() + drawing.drawFileHeaderPathScrollFades( + canvas, + pathLayout.rect, + pathOffset, + maxPathOffset, + ) drawBottomBorder(canvas, bottom) } + private fun fileHeaderPathLayout( + row: DiffRow, + top: Int, + bottom: Int, + iconRect: RectF, + countsX: Float + ): FileHeaderPathLayout { + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.text, + size = style.fileHeaderFontSizePx, + weight = style.fileHeaderFontWeight, + ) + val pathX = iconRect.right + 10f * density + val pathWidth = max(24f * density, countsX - pathX - 12f * density) + val centerY = (top + bottom) / 2f + val displayPath = if ( + !row.previousPath.isNullOrEmpty() && row.previousPath != row.filePath + ) { + "${row.previousPath} -> ${row.filePath}" + } else { + row.filePath + } + return FileHeaderPathLayout( + displayPath = displayPath, + rect = RectF(pathX, centerY - 10f * density, pathX + pathWidth, centerY + 10f * density), + ) + } + + private fun maxHeaderPathOffset(fileId: String): Int { + val row = rows.firstOrNull { it.kind == "file" && it.resolvedFileId == fileId } ?: return 0 + val top = 0 + val bottom = rowHeight(row) + val iconRect = drawing.fileHeaderIconRect(top, bottom, style) + val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.deleteText, + size = style.fileHeaderMetaFontSizePx, + weight = style.fileHeaderMetaFontWeight, + ) + val countsX = checkboxRect.left - 10f * density - + boldTextPaint.measureText("-${row.deletions}") - 4f * density - + boldTextPaint.measureText("+${row.additions}") + return maxHeaderPathOffset(fileHeaderPathLayout(row, top, bottom, iconRect, countsX)) + } + + private fun maxHeaderPathOffset(layout: FileHeaderPathLayout): Int = max( + 0, + ceil(boldTextPaint.measureText(layout.displayPath) - layout.rect.width()).toInt(), + ) + + private fun setHeaderPathOffset(fileId: String, value: Int) { + val nextOffset = value.coerceIn(0, maxHeaderPathOffset(fileId)) + if ((headerPathOffsetsByFileId[fileId] ?: 0) == nextOffset) return + headerPathOffsetsByFileId[fileId] = nextOffset + invalidate() + } + + private fun clampHeaderPathOffsets() { + headerPathOffsetsByFileId.keys.toList().forEach { fileId -> + val nextOffset = (headerPathOffsetsByFileId[fileId] ?: 0) + .coerceIn(0, maxHeaderPathOffset(fileId)) + if (nextOffset == 0) { + headerPathOffsetsByFileId.remove(fileId) + } else { + headerPathOffsetsByFileId[fileId] = nextOffset + } + } + } + private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) - textPaint.color = theme.hunkText - textPaint.textSize = style.codeFontSizePx + drawing.configureMonospacePaint( + color = theme.hunkText, + size = style.hunkFontSizePx, + weight = style.hunkFontWeight, + ) drawScrollableCode(canvas, top, bottom) { codeX -> canvas.drawText( row.text.ifEmpty { row.content }, @@ -782,36 +1066,82 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - textPaint.color = theme.mutedText - textPaint.textSize = style.codeFontSizePx - drawScrollableCode(canvas, top, bottom) { codeX -> - canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint) - } + fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val iconSize = 16f * density + val iconRect = RectF( + style.fileHeaderHorizontalPaddingPx + 2f * density, + (top + bottom - iconSize) / 2f, + style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize, + (top + bottom + iconSize) / 2f, + ) + drawing.drawNoticeIcon(canvas, iconRect, theme.mutedText) + drawing.configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val textX = iconRect.right + 10f * density + canvas.drawText( + ellipsize(row.text, textPaint, width - textX - style.fileHeaderHorizontalPaddingPx), + textX, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + drawBottomBorder(canvas, bottom) } private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - fill( - canvas, - theme.headerBackground, - style.gutterWidthPx, - top.toFloat(), - width.toFloat(), - bottom.toFloat() + fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val cardRect = RectF( + 8f * density, + top + 5f * density, + width - 8f * density, + bottom - 5f * density, ) - boldTextPaint.color = theme.text - boldTextPaint.textSize = 12f * density - drawScrollableCode(canvas, top, bottom) { codeX -> + backgroundPaint.color = theme.headerBackground + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint) + borderPaint.style = Paint.Style.STROKE + borderPaint.color = withAlpha(theme.border, 217) + borderPaint.strokeWidth = density + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, borderPaint) + borderPaint.style = Paint.Style.FILL + + val collapsed = collapsedCommentIds.contains(row.id) + val chevronRect = RectF( + cardRect.left + 10f * density, + cardRect.top + 11f * density, + cardRect.left + 26f * density, + cardRect.top + 27f * density, + ) + drawing.drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed) + drawing.configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val title = "Comment on ${row.commentRangeLabel.ifEmpty { "line" }}" + canvas.drawText( + ellipsize(title, textPaint, cardRect.right - chevronRect.right - 20f * density), + chevronRect.right + 10f * density, + cardRect.top + 22f * density, + textPaint, + ) + if (!collapsed) { + textPaint.color = theme.text + val bodyX = cardRect.left + 18f * density canvas.drawText( - row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } }, - codeX, - top + 20f * density, - boldTextPaint, + ellipsize( + row.commentText.ifEmpty { "Comment" }, + textPaint, + cardRect.right - bodyX - 18f * density + ), + bodyX, + cardRect.top + 58f * density, + textPaint, ) - textPaint.color = theme.mutedText - textPaint.textSize = 12f * density - canvas.drawText(row.commentText, codeX, top + 42f * density, textPaint) } - drawBottomBorder(canvas, bottom) } @Suppress("CyclomaticComplexMethod") @@ -822,65 +1152,79 @@ private class DiffCanvasView(context: Context) : View(context) { else -> theme.background } fill(canvas, background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + if (style.changeBarWidthPx > 0) { + when (row.change) { + "add" -> fill( + canvas, + theme.addBar, + 0f, + top.toFloat(), + style.changeBarWidthPx, + bottom.toFloat() + ) + "delete" -> drawing.drawDeleteStripes( + canvas, + top, + bottom, + style.changeBarWidthPx, + theme.deleteBar, + ) + } + } val selected = selectedRowIds.contains(row.id) if (selected) { fill( canvas, - withAlpha(theme.hunkText, if (theme.background == Color.WHITE) 54 else 76), + withAlpha(theme.hunkText, 56), 0f, top.toFloat(), width.toFloat(), bottom.toFloat(), ) - } - val barColor = when (row.change) { - "add" -> theme.addBar - "delete" -> theme.deleteBar - else -> Color.TRANSPARENT - } - if (barColor != Color.TRANSPARENT && style.changeBarWidthPx > 0) { - fill(canvas, barColor, 0f, top.toFloat(), style.changeBarWidthPx, bottom.toFloat()) + fill( + canvas, + withAlpha(theme.hunkText, 242), + 0f, + top.toFloat(), + style.changeBarWidthPx, + bottom.toFloat() + ) } val tokens = tokensByRowId[row.id] drawScrollableCode(canvas, top, bottom) { codeX -> + drawing.configureCodePaint(theme.text, 0, style) + drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) if (tokens.isNullOrEmpty()) { - textPaint.textSize = style.codeFontSizePx - textPaint.color = when (row.change) { - "add" -> theme.addText - "delete" -> theme.deleteText - else -> theme.text - } canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) } else { var x = codeX tokens.forEach { token -> - textPaint.textSize = style.codeFontSizePx - textPaint.color = token.color ?: theme.text - textPaint.typeface = when { - token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC) - token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) - else -> Typeface.MONOSPACE - } + drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) x += textPaint.measureText(token.content) } - textPaint.typeface = Typeface.MONOSPACE } } - textPaint.textSize = style.lineNumberFontSizePx - textPaint.color = if (selected) theme.text else theme.mutedText - val oldNumber = row.oldLineNumber?.toString().orEmpty() - val newNumber = row.newLineNumber?.toString().orEmpty() - val baseline = centeredBaseline(top, bottom, textPaint) - canvas.drawText(oldNumber, style.changeBarWidthPx + 6f * density, baseline, textPaint) + drawLineNumber(canvas, row, top, bottom) + } + + private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + val lineNumber = row.newLineNumber ?: row.oldLineNumber ?: return + drawing.configureMonospacePaint( + color = drawing.lineNumberColor(row.change), + size = style.lineNumberFontSizePx, + weight = style.lineNumberFontWeight, + ) + textPaint.textAlign = Paint.Align.RIGHT canvas.drawText( - newNumber, - style.changeBarWidthPx + style.gutterWidthPx / 2f, - baseline, - textPaint + lineNumber.toString(), + style.changeBarWidthPx + style.gutterWidthPx - style.codePaddingPx, + centeredBaseline(top, bottom, textPaint), + textPaint, ) + textPaint.textAlign = Paint.Align.LEFT } private fun drawScrollableCode( @@ -972,6 +1316,17 @@ private class DiffCanvasView(context: Context) : View(context) { val top: Int, val bottom: Int ) + + private data class RowHit( + val row: DiffRow, + val top: Int, + val bottom: Int + ) + + private data class FileHeaderPathLayout( + val displayPath: String, + val rect: RectF + ) } private fun withAlpha(color: Int, alpha: Int): Int = @@ -995,6 +1350,7 @@ private fun parseRows(value: String): List = try { change = row.optString("change", "context"), oldLineNumber = row.optNullableInt("oldLineNumber"), newLineNumber = row.optNullableInt("newLineNumber"), + wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(), commentText = row.optString("commentText"), commentRangeLabel = row.optString("commentRangeLabel"), commentSectionTitle = row.optString("commentSectionTitle"), @@ -1004,6 +1360,17 @@ private fun parseRows(value: String): List = try { emptyList() } +private fun parseWordDiffRanges(value: JSONArray): List = buildList { + for (index in 0 until value.length()) { + val range = value.optJSONObject(index) ?: continue + val start = range.optInt("start", -1) + val end = range.optInt("end", -1) + if (start >= 0 && end > start) { + add(DiffWordDiffRange(start = start, end = end)) + } + } +} + private fun parseTokensObject(value: String): Map> = try { parseTokensObject(JSONObject(value)) } catch (_: Exception) { @@ -1020,7 +1387,7 @@ private fun parseTokensObject(value: JSONObject): Map> { val token = array.getJSONObject(index) DiffToken( content = token.optString("content"), - color = token.optNullableString("color")?.let { parseColor(it, Color.TRANSPARENT) }, + color = token.optNullableString("color")?.let(::parseColorOrNull), fontStyle = token.optInt("fontStyle"), ) } @@ -1043,6 +1410,12 @@ private fun parseColor(value: String, fallback: Int): Int = try { fallback } +private fun parseColorOrNull(value: String): Int? = try { + Color.parseColor(value) +} catch (_: Exception) { + null +} + private fun JSONObject.optNullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 3682da6dab1..73c9ee9414a 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -440,9 +440,9 @@ export const RootStack = createNativeStackNavigator({ screen: ConnectOnboardingRouteScreen, linking: "connect-onboarding", options: { - // Root screenOptions hide headers; formSheets that want the native - // title bar opt back in with the sheet header preset. - ...SHEET_SOLID_HEADER_OPTIONS, + // A root-level Android formSheet does not host the native stack bar; + // the route renders an embedded AndroidSheetHeader instead. + ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, presentation: "formSheet", diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 8e803d3c1a6..ef5319a1b6f 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: { readonly actions?: ReadonlyArray; readonly trailing?: ReactNode; readonly onBack?: () => void; + readonly embedded?: boolean; }) { const insets = useSafeAreaInsets(); const foregroundColor = useThemeColor("--color-foreground"); @@ -59,7 +60,7 @@ export function AndroidScreenHeader(props: { @@ -108,3 +109,9 @@ export function AndroidScreenHeader(props: { ); } + +export function AndroidSheetHeader( + props: Omit[0], "embedded">, +) { + return ; +} diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 1f56ea8da17..952d1c38841 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -2,10 +2,11 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useAuth } from "@clerk/expo"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useState } from "react"; -import { Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; +import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; @@ -81,9 +82,16 @@ function ConfiguredConnectOnboardingRouteScreen() { return ( - - - + {Platform.OS === "android" ? ( + + ) : ( + + + + )} - - - New branch - - - { - const branch = sanitizeFeatureBranchName(newBranchName.trim()); - if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then(() => { - setNewBranchName(""); - navigation.goBack(); - }); - }} - /> - + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + New branch + + + { + const branch = sanitizeFeatureBranchName(newBranchName.trim()); + if (branch.length === 0) return; + void gitActions.onCreateSelectedThreadBranch(branch).then(() => { + setNewBranchName(""); + navigation.goBack(); + }); + }} + /> + - - - New worktree - - - - { - const baseBranch = worktreeBaseBranch.trim(); - const newBranch = worktreeBranchName.trim(); - if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { - setWorktreeBranchName(""); - navigation.goBack(); - }); - }} - /> - + + + New worktree + + + + { + const baseBranch = worktreeBaseBranch.trim(); + const newBranch = worktreeBranchName.trim(); + if (baseBranch.length === 0 || newBranch.length === 0) return; + void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { + setWorktreeBranchName(""); + navigation.goBack(); + }); + }} + /> + - - - Existing branches - - {branchesLoading ? ( - Loading branches... - ) : null} - {!branchesLoading && availableBranches.length === 0 ? ( - - No local branches found. + + + Existing branches - ) : null} - {availableBranches.map((branch) => { - const disabled = disabledExistingBranches.has(branch.name); - const subtitle = branch.worktreePath - ? branch.worktreePath === currentWorktreePath - ? "Checked out in this thread" - : "Checked out in another worktree" - : branch.isDefault - ? "Default branch" - : "Local branch"; + {branchesLoading ? ( + + Loading branches... + + ) : null} + {!branchesLoading && availableBranches.length === 0 ? ( + + No local branches found. + + ) : null} + {availableBranches.map((branch) => { + const disabled = disabledExistingBranches.has(branch.name); + const subtitle = branch.worktreePath + ? branch.worktreePath === currentWorktreePath + ? "Checked out in this thread" + : "Checked out in another worktree" + : branch.isDefault + ? "Default branch" + : "Local branch"; - return ( - { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); - }); - }} - > - - {branch.name} - {subtitle} - - ); - })} - - + return ( + { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { + navigation.goBack(); + }); + }} + > + + {branch.name} + {subtitle} + + ); + })} + + + ); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 2f158013046..cdc7f1a64a9 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,8 +1,9 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; @@ -65,160 +66,168 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ); return ( - - - - Branch - - {gitStatus.data?.refName ?? "(detached HEAD)"} - - - {isDefaultRef ? ( - - Warning: this is the default branch. - - ) : null} - - - - - - Files - - {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + Branch + + {gitStatus.data?.refName ?? "(detached HEAD)"} - - {!allSelected && isEditingFiles ? ( + {isDefaultRef ? ( + + Warning: this is the default branch. + + ) : null} + + + + + + Files + + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + + + {!allSelected && isEditingFiles ? ( + setExcludedFiles(new Set())} + > + Reset + + ) : null} setExcludedFiles(new Set())} + onPress={() => setIsEditingFiles((current) => !current)} > - Reset + + {isEditingFiles ? "Done" : "Edit"} + - ) : null} - setIsEditingFiles((current) => !current)} - > - - {isEditingFiles ? "Done" : "Edit"} - - + - - {allFiles.length === 0 ? ( - - No changed files are available to commit. - - ) : !isEditingFiles ? ( - - {selectedFilePreview.map((file) => ( - - - {file.path} + {allFiles.length === 0 ? ( + + No changed files are available to commit. + + ) : !isEditingFiles ? ( + + {selectedFilePreview.map((file) => ( + + + {file.path} + + +{file.insertions} + -{file.deletions} + + ))} + {selectedFiles.length > selectedFilePreview.length ? ( + + +{selectedFiles.length - selectedFilePreview.length} more files - +{file.insertions} - -{file.deletions} - - ))} - {selectedFiles.length > selectedFilePreview.length ? ( - - +{selectedFiles.length - selectedFilePreview.length} more files - - ) : null} - - ) : ( - - {allFiles.map((file) => { - const included = !excludedFiles.has(file.path); - return ( - { - setExcludedFiles((current) => { - const next = new Set(current); - if (next.has(file.path)) { - next.delete(file.path); - } else { - next.add(file.path); - } - return next; - }); - }} - > - - - - - {file.path} - - {!included ? ( - - Excluded from this commit + ) : null} + + ) : ( + + {allFiles.map((file) => { + const included = !excludedFiles.has(file.path); + return ( + { + setExcludedFiles((current) => { + const next = new Set(current); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + > + + + + + {file.path} - ) : null} - - - - +{file.insertions} - - -{file.deletions} + {!included ? ( + + Excluded from this commit + + ) : null} + + + + +{file.insertions} + + + -{file.deletions} + + - - - ); - })} - - )} - - - - Commit message - - + + ); + })} + + )} + - - - void runCommitAction(true)} + + Commit message + - - void runCommitAction(false)} - /> + + + + void runCommitAction(true)} + /> + + + void runCommitAction(false)} + /> + - - + + ); } diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index e5bf3e6bb32..cddf1c614bd 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -4,10 +4,11 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useMemo } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; @@ -102,7 +103,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { return ( - + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : ( + + )} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 89e84df92a3..17e4de0ab6f 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -5,7 +5,12 @@ import { requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + CommonActions, + StackActions, + useNavigation, + type StaticScreenProps, +} from "@react-navigation/native"; import { SymbolView } from "../../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; @@ -14,6 +19,7 @@ import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-scree import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; @@ -23,6 +29,7 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; import { vcsEnvironment } from "../../../state/vcs"; +import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -255,7 +262,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { title="Review changes" subtitle="Inspect turn diffs, worktree changes, and base branch diff" disabled={busy || !isRepo} - onPress={() => navigation.navigate("ThreadReview", { environmentId, threadId })} + onPress={() => { + const params = { environmentId, threadId }; + navigation.dispatch( + resolveGitOverviewReviewNavigationAction(presentation) === "replace" + ? StackActions.replace("ThreadReview", params) + : CommonActions.navigate("ThreadReview", params), + ); + }} /> - + {isInspector ? ( + + ) : null} {isInspector ? ( @@ -383,24 +399,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : ( - // Compact header row: labeled branch on the left, status summary at - // the trailing end. Horizontal padding lines the text up with the - // rows' icon column inside the card below (20 screen + 16 card + 4 - // row). The sheet relies on pull-to-refresh instead of a corner - // refresh button. - - - - Branch - - - {currentBranchLabel} - - - - {currentStatusSummary} - - + navigation.goBack()} + actions={[ + { + accessibilityLabel: "Refresh repository status", + disabled: busy, + icon: "arrow.clockwise", + onPress: () => void gitActions.refreshSelectedThreadGitStatus(), + }, + ]} + /> )} {content} diff --git a/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts b/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts new file mode 100644 index 00000000000..b50bc42a9b3 --- /dev/null +++ b/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; + +describe("resolveGitOverviewReviewNavigationAction", () => { + it("replaces the sheet so Back returns directly to the thread", () => { + expect(resolveGitOverviewReviewNavigationAction("sheet")).toBe("replace"); + }); + + it("pushes Review normally when Git is a persistent inspector", () => { + expect(resolveGitOverviewReviewNavigationAction("inspector")).toBe("navigate"); + }); +}); diff --git a/apps/mobile/src/features/threads/git/git-overview-navigation.ts b/apps/mobile/src/features/threads/git/git-overview-navigation.ts new file mode 100644 index 00000000000..53927356c65 --- /dev/null +++ b/apps/mobile/src/features/threads/git/git-overview-navigation.ts @@ -0,0 +1,5 @@ +export function resolveGitOverviewReviewNavigationAction( + presentation: "sheet" | "inspector", +): "replace" | "navigate" { + return presentation === "sheet" ? "replace" : "navigate"; +}