authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-14 19:21:12+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-14 19:21:12+02:00
log3513fa04fbf12c1d4fa2b8d07cfc7f0905714d31
tree39bca758734118bb4a4081c2d1ad59b78e703a18
parent735240f3162470032120603474605b8a6e42580b
signature Signed by PGP key B5690EEEBB952194

Prepare Android beta branding and review diff UI (#3967)


20 files changed, 1191 insertions(+), 418 deletions(-)

apps/mobile/app.config.ts+47-31
......@@ -23,46 +23,54 @@ if (
2323 );
2424}
2525
26const VARIANT_CONFIG: Record<
27 AppVariant,
28 {
29 readonly appName: string;
30 readonly scheme: string;
31 readonly iosIcon: string;
32 readonly splashIcon: string;
33 readonly iosBundleIdentifier: string;
34 readonly androidPackage: string;
35 readonly relyingParty?: string;
36 }
37> = {
26const DEVELOPMENT_ASSETS = {
27 appIcon: "./assets/splash-icon-dev.png",
28 iosIcon: "./assets/icon-composer-dev.icon",
29 splashIcon: "./assets/splash-icon-dev.png",
30 androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png",
31 androidAdaptiveBackgroundColor: "#00639B",
32 androidMonochromeIcon: "./assets/android-icon-mark.png",
33 androidNotificationIcon: "./assets/android-notification-icon.png",
34 androidNotificationColor: "#00639B",
35} as const;
36
37const RELEASE_ASSETS = {
38 appIcon: "./assets/splash-icon-prod.png",
39 iosIcon: "./assets/icon-composer-prod.icon",
40 splashIcon: "./assets/splash-icon-prod.png",
41 androidAdaptiveForeground: "./assets/android-icon-mark.png",
42 androidAdaptiveBackgroundColor: "#000000",
43 androidMonochromeIcon: "./assets/android-icon-mark.png",
44 androidNotificationIcon: "./assets/android-notification-icon.png",
45 androidNotificationColor: "#FFFFFF",
46} as const;
47
48const VARIANT_CONFIG = {
3849 development: {
3950 appName: "T3 Code Dev",
4051 scheme: "t3code-dev",
41 iosIcon: "./assets/icon-composer-dev.icon",
42 splashIcon: "./assets/splash-icon-dev.png",
4352 iosBundleIdentifier: "com.t3tools.t3code.dev",
4453 androidPackage: "com.t3tools.t3code.dev",
4554 relyingParty: "clerk.t3.codes",
55 assets: DEVELOPMENT_ASSETS,
4656 },
4757 preview: {
4858 appName: "T3 Code Preview",
4959 scheme: "t3code-preview",
50 iosIcon: "./assets/icon-composer-prod.icon",
51 splashIcon: "./assets/splash-icon-prod.png",
5260 iosBundleIdentifier: "com.t3tools.t3code.preview",
5361 androidPackage: "com.t3tools.t3code.preview",
5462 relyingParty: "clerk.t3.codes",
63 assets: RELEASE_ASSETS,
5564 },
5665 production: {
5766 appName: "T3 Code",
5867 scheme: "t3code",
59 iosIcon: "./assets/icon-composer-prod.icon",
60 splashIcon: "./assets/splash-icon-prod.png",
6168 iosBundleIdentifier: "com.t3tools.t3code",
6269 androidPackage: "com.t3tools.t3code",
6370 relyingParty: "clerk.t3.codes",
71 assets: RELEASE_ASSETS,
6472 },
65};
73} as const;
6674
6775function resolveAppVariant(value: string | undefined): AppVariant {
6876 switch (value) {
......@@ -121,7 +129,7 @@ const config: ExpoConfig = {
121129 policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint",
122130 },
123131 orientation: "portrait",
124 icon: "./assets/icon.png",
132 icon: variant.assets.appIcon,
125133 userInterfaceStyle: "automatic",
126134 updates: {
127135 enabled: true,
......@@ -130,7 +138,7 @@ const config: ExpoConfig = {
130138 fallbackToCacheTimeout: 0,
131139 },
132140 ios: {
133 icon: variant.iosIcon,
141 icon: variant.assets.iosIcon,
134142 supportsTablet: true,
135143 bundleIdentifier: variant.iosBundleIdentifier,
136144 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
......@@ -151,13 +159,12 @@ const config: ExpoConfig = {
151159 },
152160 },
153161 android: {
154 icon: "./assets/icon.png",
162 icon: variant.assets.appIcon,
155163 package: variant.androidPackage,
156164 adaptiveIcon: {
157 backgroundColor: "#E6F4FE",
158 foregroundImage: "./assets/android-icon-foreground.png",
159 backgroundImage: "./assets/android-icon-background.png",
160 monochromeImage: "./assets/android-icon-monochrome.png",
165 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
166 foregroundImage: variant.assets.androidAdaptiveForeground,
167 monochromeImage: variant.assets.androidMonochromeIcon,
161168 },
162169 // Opts into OnBackInvokedCallback-based back dispatch (Android 13+).
163170 // JS back handling survives it via react-native's Android 16 shim plus
......@@ -165,7 +172,7 @@ const config: ExpoConfig = {
165172 predictiveBackGestureEnabled: true,
166173 },
167174 web: {
168 favicon: "./assets/favicon.png",
175 favicon: variant.assets.appIcon,
169176 },
170177 plugins: [
171178 "expo-asset",
......@@ -195,6 +202,14 @@ const config: ExpoConfig = {
195202 ],
196203 "expo-secure-store",
197204 "expo-sqlite",
205 [
206 "expo-notifications",
207 {
208 icon: variant.assets.androidNotificationIcon,
209 color: variant.assets.androidNotificationColor,
210 mode: APP_VARIANT === "development" ? "development" : "production",
211 },
212 ],
198213 // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before
199214 // plugins earlier in this array, so it cannot strip the entitlement Clerk would add.
200215 ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],
......@@ -206,8 +221,8 @@ const config: ExpoConfig = {
206221 // the shortcut items set in src/features/shortcuts.
207222 androidIcons: {
208223 shortcut_icon: {
209 foregroundImage: "./assets/android-icon-foreground.png",
210 backgroundColor: "#E6F4FE",
224 foregroundImage: variant.assets.androidAdaptiveForeground,
225 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
211226 },
212227 },
213228 },
......@@ -217,17 +232,18 @@ const config: ExpoConfig = {
217232 {
218233 cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.",
219234 barcodeScannerEnabled: true,
235 recordAudioAndroid: false,
220236 },
221237 ],
222238 [
223239 "expo-splash-screen",
224240 {
225 image: variant.splashIcon,
241 image: variant.assets.splashIcon,
226242 resizeMode: "contain",
227243 backgroundColor: "#ffffff",
228244 imageWidth: 220,
229245 dark: {
230 image: variant.splashIcon,
246 image: variant.assets.splashIcon,
231247 backgroundColor: "#0a0a0a",
232248 },
233249 },
apps/mobile/assets/android-icon-background.png deleted
Binary files a/apps/mobile/assets/android-icon-background.png and /dev/null differ
apps/mobile/assets/android-icon-dev-foreground.png created
Binary files /dev/null and b/apps/mobile/assets/android-icon-dev-foreground.png differ
apps/mobile/assets/android-icon-foreground.png deleted
Binary files a/apps/mobile/assets/android-icon-foreground.png and /dev/null differ
apps/mobile/assets/android-icon-mark.png created
Binary files /dev/null and b/apps/mobile/assets/android-icon-mark.png differ
apps/mobile/assets/android-icon-monochrome.png deleted
Binary files a/apps/mobile/assets/android-icon-monochrome.png and /dev/null differ
apps/mobile/assets/android-notification-icon.png created
Binary files /dev/null and b/apps/mobile/assets/android-notification-icon.png differ
apps/mobile/assets/favicon.png deleted
Binary files a/apps/mobile/assets/favicon.png and /dev/null differ
apps/mobile/assets/icon.png deleted
Binary files a/apps/mobile/assets/icon.png and /dev/null differ
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt created+315
......@@ -0,0 +1,315 @@
1package expo.modules.t3reviewdiff
2
3import android.content.Context
4import android.graphics.Canvas
5import android.graphics.Color
6import android.graphics.LinearGradient
7import android.graphics.Paint
8import android.graphics.Path
9import android.graphics.RectF
10import android.graphics.Shader
11import android.graphics.Typeface
12import kotlin.math.max
13import kotlin.math.min
14
15internal class ReviewDiffCanvasDrawing(context: Context) {
16 private val density = context.resources.displayMetrics.density
17 var theme: DiffTheme = DiffTheme.fallback("light")
18
19 val backgroundPaint = Paint()
20 val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
21 val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
22 hinting = Paint.HINTING_ON
23 isSubpixelText = false
24 clearShadowLayer()
25 }
26 val uiPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
27 hinting = Paint.HINTING_ON
28 isSubpixelText = false
29 clearShadowLayer()
30 }
31
32 private val italicCodeTypeface by lazy {
33 Typeface.create(ReviewDiffTypefaces.regular, Typeface.ITALIC)
34 }
35 private val boldItalicCodeTypeface by lazy {
36 Typeface.create(ReviewDiffTypefaces.bold, Typeface.ITALIC)
37 }
38
39 init {
40 textPaint.typeface = ReviewDiffTypefaces.regular
41 uiPaint.typeface = Typeface.DEFAULT_BOLD
42 }
43
44 fun fileHeaderChevronRect(top: Int, bottom: Int, style: DiffStyle): RectF {
45 val centerY = (top + bottom) / 2f
46 val left = style.fileHeaderHorizontalPaddingPx
47 return RectF(left, centerY - 10f * density, left + 20f * density, centerY + 10f * density)
48 }
49
50 fun fileHeaderIconRect(top: Int, bottom: Int, style: DiffStyle): RectF {
51 val chevron = fileHeaderChevronRect(top, bottom, style)
52 return RectF(
53 chevron.right + 8f * density,
54 chevron.top,
55 chevron.right + 28f * density,
56 chevron.bottom,
57 )
58 }
59
60 fun fileHeaderCheckboxRect(top: Int, bottom: Int, width: Int, style: DiffStyle): RectF {
61 val centerY = (top + bottom) / 2f
62 val right = width - style.fileHeaderHorizontalPaddingPx
63 return RectF(
64 right - 20f * density,
65 centerY - 10f * density,
66 right,
67 centerY + 10f * density,
68 )
69 }
70
71 fun drawDisclosureChevron(
72 canvas: Canvas,
73 rect: RectF,
74 color: Int,
75 collapsed: Boolean
76 ) {
77 borderPaint.style = Paint.Style.STROKE
78 borderPaint.color = color
79 borderPaint.strokeWidth = 2f * density
80 borderPaint.strokeCap = Paint.Cap.ROUND
81 borderPaint.strokeJoin = Paint.Join.ROUND
82 val path = Path()
83 if (collapsed) {
84 path.moveTo(rect.left + rect.width() * 0.4f, rect.top + rect.height() * 0.28f)
85 path.lineTo(rect.left + rect.width() * 0.6f, rect.centerY())
86 path.lineTo(rect.left + rect.width() * 0.4f, rect.bottom - rect.height() * 0.28f)
87 } else {
88 path.moveTo(rect.left + rect.width() * 0.28f, rect.top + rect.height() * 0.42f)
89 path.lineTo(rect.centerX(), rect.top + rect.height() * 0.62f)
90 path.lineTo(rect.right - rect.width() * 0.28f, rect.top + rect.height() * 0.42f)
91 }
92 canvas.drawPath(path, borderPaint)
93 borderPaint.style = Paint.Style.FILL
94 }
95
96 fun drawFileIcon(canvas: Canvas, rect: RectF, changeType: String) {
97 val color = when (changeType) {
98 "new" -> theme.addText
99 "deleted" -> theme.deleteText
100 else -> theme.hunkText
101 }
102 borderPaint.style = Paint.Style.STROKE
103 borderPaint.color = color
104 borderPaint.strokeWidth = 2f * density
105 canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint)
106
107 if (changeType == "rename-pure" || changeType == "rename-changed" || changeType == "renamed") {
108 drawRenameChevronIcon(canvas, rect, color)
109 } else {
110 backgroundPaint.color = color
111 canvas.drawCircle(rect.centerX(), rect.centerY(), 3f * density, backgroundPaint)
112 }
113 borderPaint.style = Paint.Style.FILL
114 }
115
116 fun drawViewedCheckbox(canvas: Canvas, rect: RectF, checked: Boolean) {
117 if (checked) {
118 backgroundPaint.color = theme.hunkText
119 canvas.drawRoundRect(rect, 6f * density, 6f * density, backgroundPaint)
120 }
121 borderPaint.style = Paint.Style.STROKE
122 borderPaint.color = if (checked) theme.hunkText else theme.mutedText
123 borderPaint.strokeWidth = 1.8f * density
124 canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint)
125 if (checked) {
126 borderPaint.color = theme.background
127 borderPaint.strokeWidth = 2f * density
128 borderPaint.strokeCap = Paint.Cap.ROUND
129 borderPaint.strokeJoin = Paint.Join.ROUND
130 val path = Path().apply {
131 moveTo(rect.left + rect.width() * 0.28f, rect.centerY())
132 lineTo(rect.left + rect.width() * 0.44f, rect.bottom - rect.height() * 0.3f)
133 lineTo(rect.right - rect.width() * 0.25f, rect.top + rect.height() * 0.3f)
134 }
135 canvas.drawPath(path, borderPaint)
136 }
137 borderPaint.style = Paint.Style.FILL
138 }
139
140 fun drawNoticeIcon(canvas: Canvas, rect: RectF, color: Int) {
141 borderPaint.style = Paint.Style.STROKE
142 borderPaint.color = color
143 borderPaint.strokeWidth = 1.7f * density
144 borderPaint.strokeCap = Paint.Cap.ROUND
145 canvas.drawOval(rect, borderPaint)
146 canvas.drawLine(
147 rect.centerX(),
148 rect.top + rect.height() * 0.3f,
149 rect.centerX(),
150 rect.top + rect.height() * 0.58f,
151 borderPaint,
152 )
153 borderPaint.style = Paint.Style.FILL
154 canvas.drawCircle(rect.centerX(), rect.bottom - rect.height() * 0.24f, density, borderPaint)
155 }
156
157 fun configureUiPaint(paint: Paint, color: Int, size: Float, weight: String) {
158 paint.typeface = if (isBoldFontWeight(weight)) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
159 configureTextPaint(paint, color, size)
160 }
161
162 fun configureMonospacePaint(color: Int, size: Float, weight: String) {
163 textPaint.typeface = ReviewDiffTypefaces.forWeight(weight)
164 configureTextPaint(textPaint, color, size)
165 }
166
167 fun configureCodePaint(color: Int, fontStyle: Int, style: DiffStyle) {
168 val tokenBold = fontStyle and 2 != 0
169 val italic = fontStyle and 1 != 0
170 textPaint.typeface = when {
171 tokenBold && italic -> boldItalicCodeTypeface
172 tokenBold -> ReviewDiffTypefaces.bold
173 italic -> italicCodeTypeface
174 else -> ReviewDiffTypefaces.forWeight(style.codeFontWeight)
175 }
176 configureTextPaint(textPaint, color, style.codeFontSizePx)
177 textPaint.isUnderlineText = fontStyle and 4 != 0
178 }
179
180 fun lineNumberColor(change: String): Int = when (change) {
181 "add" -> theme.addText
182 "delete" -> theme.deleteText
183 else -> theme.mutedText
184 }
185
186 fun drawDeleteStripes(
187 canvas: Canvas,
188 top: Int,
189 bottom: Int,
190 width: Float,
191 color: Int
192 ) {
193 backgroundPaint.color = color
194 var y = top.toFloat()
195 while (y < bottom) {
196 canvas.drawRect(0f, y, width, min(bottom.toFloat(), y + density), backgroundPaint)
197 y += 2f * density
198 }
199 }
200
201 fun drawWordDiffRanges(
202 canvas: Canvas,
203 row: DiffRow,
204 codeX: Float,
205 top: Int,
206 bottom: Int
207 ) {
208 if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return
209 val color = if (row.change == "add") theme.addBar else theme.deleteBar
210 backgroundPaint.color = withAlpha(color, 71)
211 val characterWidth = textPaint.measureText("M")
212 val fontHeight = textPaint.fontMetrics.run { descent - ascent }
213 val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight))
214 val highlightTop = (top + bottom - highlightHeight) / 2f
215 row.wordDiffRanges.forEach { range ->
216 val left = codeX + range.start * characterWidth
217 val right = max(left + 2f * density, codeX + range.end * characterWidth)
218 canvas.drawRoundRect(
219 RectF(left, highlightTop, right, highlightTop + highlightHeight),
220 3f * density,
221 3f * density,
222 backgroundPaint,
223 )
224 }
225 }
226
227 fun drawFileHeaderPathScrollFades(
228 canvas: Canvas,
229 pathRect: RectF,
230 horizontalOffset: Int,
231 maxOffset: Int
232 ) {
233 if (maxOffset <= 0 || pathRect.width() <= 0f) return
234 val fadeWidth = min(28f * density, pathRect.width() / 3f)
235 if (horizontalOffset > 0) {
236 drawHorizontalFade(
237 canvas,
238 RectF(pathRect.left, pathRect.top, pathRect.left + fadeWidth, pathRect.bottom),
239 fadesToRight = false,
240 )
241 }
242 if (horizontalOffset < maxOffset) {
243 drawHorizontalFade(
244 canvas,
245 RectF(pathRect.right - fadeWidth, pathRect.top, pathRect.right, pathRect.bottom),
246 fadesToRight = true,
247 )
248 }
249 }
250
251 private fun drawRenameChevronIcon(canvas: Canvas, rect: RectF, color: Int) {
252 borderPaint.style = Paint.Style.STROKE
253 borderPaint.color = color
254 borderPaint.strokeWidth = 1.8f * density
255 borderPaint.strokeCap = Paint.Cap.ROUND
256 borderPaint.strokeJoin = Paint.Join.ROUND
257 val chevronWidth = 3.6f * density
258 val chevronHeight = 8f * density
259 val gap = 2.4f * density
260 val startX = rect.centerX() - (chevronWidth * 2f + gap) / 2f
261 val path = Path()
262 for (x in listOf(startX, startX + chevronWidth + gap)) {
263 path.moveTo(x, rect.centerY() - chevronHeight / 2f)
264 path.lineTo(x + chevronWidth, rect.centerY())
265 path.lineTo(x, rect.centerY() + chevronHeight / 2f)
266 }
267 canvas.drawPath(path, borderPaint)
268 }
269
270 private fun configureTextPaint(paint: Paint, color: Int, size: Float) {
271 paint.textSize = size
272 paint.color = color
273 paint.style = Paint.Style.FILL
274 paint.textAlign = Paint.Align.LEFT
275 paint.isUnderlineText = false
276 paint.isFakeBoldText = false
277 paint.clearShadowLayer()
278 }
279
280 private fun drawHorizontalFade(canvas: Canvas, rect: RectF, fadesToRight: Boolean) {
281 val opaque = theme.headerBackground
282 val transparent = Color.argb(0, Color.red(opaque), Color.green(opaque), Color.blue(opaque))
283 backgroundPaint.shader = LinearGradient(
284 rect.left,
285 rect.centerY(),
286 rect.right,
287 rect.centerY(),
288 if (fadesToRight) transparent else opaque,
289 if (fadesToRight) opaque else transparent,
290 Shader.TileMode.CLAMP,
291 )
292 canvas.drawRect(rect, backgroundPaint)
293 backgroundPaint.shader = null
294 }
295
296 private fun isBoldFontWeight(weight: String): Boolean = when (weight.lowercase()) {
297 "medium", "semibold", "semi-bold", "bold", "heavy", "black" -> true
298 else -> false
299 }
300
301 private fun withAlpha(color: Int, alpha: Int): Int =
302 Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
303}
304
305private object ReviewDiffTypefaces {
306 val regular: Typeface = Typeface.create("monospace", Typeface.NORMAL)
307 private val medium: Typeface = Typeface.create("monospace-medium", Typeface.NORMAL)
308 val bold: Typeface = Typeface.create("monospace", Typeface.BOLD)
309
310 fun forWeight(weight: String): Typeface = when (weight.lowercase()) {
311 "medium", "semibold", "semi-bold" -> medium
312 "bold", "heavy", "black" -> bold
313 else -> regular
314 }
315}
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt+476-103
......@@ -4,7 +4,7 @@ import android.content.Context
44import android.graphics.Canvas
55import android.graphics.Color
66import android.graphics.Paint
7import android.graphics.Typeface
7import android.graphics.RectF
88import android.view.GestureDetector
99import android.view.MotionEvent
1010import android.view.VelocityTracker
......@@ -19,6 +19,7 @@ import org.json.JSONArray
1919import org.json.JSONObject
2020import java.util.concurrent.Executors
2121import kotlin.math.abs
22import kotlin.math.ceil
2223import kotlin.math.max
2324import kotlin.math.min
2425
......@@ -49,12 +50,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
4950 private val verticalScroller = OverScroller(context)
5051 private val horizontalScroller = OverScroller(context)
5152 private var dragAxis: DragAxis? = null
53 private var horizontalDragTarget: HorizontalPanTarget? = null
5254 private var lastTouchX = 0f
5355 private var lastTouchY = 0f
5456 private var velocityTracker: VelocityTracker? = null
5557
5658 init {
57 canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) }
59 canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) }
5860 canvasView.onVisibleRowsChanged = { first, last ->
5961 onDebug(
6062 mapOf(
......@@ -86,12 +88,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
8688 lastVisibleFileId = null
8789 pendingInitialScroll = true
8890 canvasView.setVerticalOffset(0)
89 canvasView.setHorizontalOffset(0)
91 canvasView.resetHorizontalOffsets()
9092 applyPendingInitialScroll()
9193 }
9294
9395 fun setCollapsedFileIdsJson(value: String) {
9496 collapsedFileIds = parseStringSet(value)
97 canvasView.collapsedFileIds = collapsedFileIds
9598 rebuildVisibleRows()
9699 }
97100
......@@ -107,6 +110,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
107110
108111 fun setCollapsedCommentIdsJson(value: String) {
109112 collapsedCommentIds = parseStringSet(value)
113 canvasView.collapsedCommentIds = collapsedCommentIds
110114 rebuildVisibleRows()
111115 }
112116
......@@ -202,6 +206,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
202206 verticalScroller.forceFinished(true)
203207 horizontalScroller.forceFinished(true)
204208 dragAxis = null
209 horizontalDragTarget = canvasView.horizontalPanTarget(event.y)
205210 lastTouchX = event.x
206211 lastTouchY = event.y
207212 parent?.requestDisallowInterceptTouchEvent(true)
......@@ -212,7 +217,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
212217 val deltaX = event.x - lastTouchX
213218 val deltaY = event.y - lastTouchY
214219 if (max(abs(deltaX), abs(deltaY)) > touchSlop) {
215 dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL
220 dragAxis = if (abs(deltaY) >= abs(deltaX)) {
221 DragAxis.VERTICAL
222 } else {
223 horizontalDragTarget
224 ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 }
225 ?.let { DragAxis.HORIZONTAL }
226 }
216227 }
217228 }
218229 return dragAxis != null
......@@ -238,6 +249,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
238249 ) {
239250 velocityTracker?.recycle()
240251 velocityTracker = null
252 horizontalDragTarget = null
241253 parent?.requestDisallowInterceptTouchEvent(false)
242254 }
243255 return handled
......@@ -253,7 +265,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
253265 if (axis == DragAxis.VERTICAL) {
254266 canvasView.scrollByVertical(deltaY)
255267 } else {
256 canvasView.scrollByHorizontal(deltaX)
268 horizontalDragTarget?.let { canvasView.scrollByHorizontal(deltaX, it) }
257269 }
258270 lastTouchX = event.x
259271 lastTouchY = event.y
......@@ -276,7 +288,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
276288 )
277289 postInvalidateOnAnimation()
278290 }
279 } else {
291 } else if (horizontalDragTarget?.kind == HorizontalPanKind.CODE) {
280292 val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt()
281293 if (abs(velocity) >= minimumFlingVelocity) {
282294 horizontalScroller.fling(
......@@ -294,6 +306,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
294306 }
295307 }
296308 dragAxis = null
309 horizontalDragTarget = null
297310 velocityTracker?.recycle()
298311 velocityTracker = null
299312 parent?.requestDisallowInterceptTouchEvent(false)
......@@ -325,11 +338,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
325338 currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId)
326339 filtered.add(row)
327340 } else if (!currentFileCollapsed) {
328 if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) {
329 filtered.add(row)
330 } else {
331 filtered.add(row.copy(commentText = "Comment collapsed"))
332 }
341 filtered.add(row)
333342 }
334343 }
335344 visibleRows = filtered
......@@ -339,10 +348,11 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
339348 applyPendingInitialScroll()
340349 }
341350
342 private fun handleRowTap(row: DiffRow, gesture: String) {
351 private fun handleRowTap(row: DiffRow, gesture: String, target: RowTapTarget) {
343352 when (row.kind) {
344353 "file" -> {
345 if (gesture == "longPress") {
354 if (gesture != "tap") return
355 if (target == RowTapTarget.VIEWED_CHECKBOX) {
346356 onToggleViewedFile(mapOf("fileId" to row.resolvedFileId))
347357 } else {
348358 onToggleFile(mapOf("fileId" to row.resolvedFileId))
......@@ -408,7 +418,22 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
408418 }
409419}
410420
411private data class DiffRow(
421private enum class RowTapTarget {
422 ROW,
423 VIEWED_CHECKBOX
424}
425
426private enum class HorizontalPanKind {
427 CODE,
428 FILE_HEADER_PATH
429}
430
431private data class HorizontalPanTarget(
432 val fileId: String,
433 val kind: HorizontalPanKind
434)
435
436internal data class DiffRow(
412437 val kind: String,
413438 val id: String,
414439 val fileId: String,
......@@ -422,6 +447,7 @@ private data class DiffRow(
422447 val change: String,
423448 val oldLineNumber: Int?,
424449 val newLineNumber: Int?,
450 val wordDiffRanges: List<DiffWordDiffRange>,
425451 val commentText: String,
426452 val commentRangeLabel: String,
427453 val commentSectionTitle: String
......@@ -429,13 +455,18 @@ private data class DiffRow(
429455 val resolvedFileId: String get() = fileId.ifEmpty { id }
430456}
431457
458internal data class DiffWordDiffRange(
459 val start: Int,
460 val end: Int
461)
462
432463private data class DiffToken(
433464 val content: String,
434465 val color: Int?,
435466 val fontStyle: Int
436467)
437468
438private data class DiffTheme(
469internal data class DiffTheme(
439470 val background: Int,
440471 val text: Int,
441472 val mutedText: Int,
......@@ -511,14 +542,25 @@ private data class DiffTheme(
511542 }
512543}
513544
514private data class DiffStyle(
545internal data class DiffStyle(
515546 val rowHeightPx: Float,
516547 val gutterWidthPx: Float,
517548 val codePaddingPx: Float,
518549 val changeBarWidthPx: Float,
519550 val fileHeaderHeightPx: Float,
551 val fileHeaderHorizontalPaddingPx: Float,
520552 val codeFontSizePx: Float,
521 val lineNumberFontSizePx: Float
553 val codeFontWeight: String,
554 val lineNumberFontSizePx: Float,
555 val lineNumberFontWeight: String,
556 val hunkFontSizePx: Float,
557 val hunkFontWeight: String,
558 val fileHeaderFontSizePx: Float,
559 val fileHeaderFontWeight: String,
560 val fileHeaderMetaFontSizePx: Float,
561 val fileHeaderMetaFontWeight: String,
562 val fileHeaderSubtextFontSizePx: Float,
563 val fileHeaderSubtextFontWeight: String
522564) {
523565 companion object {
524566 fun defaults(density: Float): DiffStyle = DiffStyle(
......@@ -527,8 +569,19 @@ private data class DiffStyle(
527569 codePaddingPx = 10f * density,
528570 changeBarWidthPx = 3f * density,
529571 fileHeaderHeightPx = 44f * density,
572 fileHeaderHorizontalPaddingPx = 10f * density,
530573 codeFontSizePx = 12f * density,
574 codeFontWeight = "regular",
531575 lineNumberFontSizePx = 10f * density,
576 lineNumberFontWeight = "regular",
577 hunkFontSizePx = 11f * density,
578 hunkFontWeight = "medium",
579 fileHeaderFontSizePx = 11f * density,
580 fileHeaderFontWeight = "semibold",
581 fileHeaderMetaFontSizePx = 10f * density,
582 fileHeaderMetaFontWeight = "semibold",
583 fileHeaderSubtextFontSizePx = 11f * density,
584 fileHeaderSubtextFontWeight = "medium",
532585 )
533586
534587 fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try {
......@@ -539,11 +592,50 @@ private data class DiffStyle(
539592 codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density),
540593 changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density),
541594 fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density),
595 fileHeaderHorizontalPaddingPx = json.floatDp(
596 "fileHeaderHorizontalPadding",
597 fallback.fileHeaderHorizontalPaddingPx,
598 density,
599 ),
542600 codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density),
601 codeFontWeight = json.optString("codeFontWeight", fallback.codeFontWeight),
543602 lineNumberFontSizePx = json.floatSp(
544603 "lineNumberFontSize",
545604 fallback.lineNumberFontSizePx,
546 density
605 density,
606 ),
607 lineNumberFontWeight = json.optString(
608 "lineNumberFontWeight",
609 fallback.lineNumberFontWeight,
610 ),
611 hunkFontSizePx = json.floatSp("hunkFontSize", fallback.hunkFontSizePx, density),
612 hunkFontWeight = json.optString("hunkFontWeight", fallback.hunkFontWeight),
613 fileHeaderFontSizePx = json.floatSp(
614 "fileHeaderFontSize",
615 fallback.fileHeaderFontSizePx,
616 density,
617 ),
618 fileHeaderFontWeight = json.optString(
619 "fileHeaderFontWeight",
620 fallback.fileHeaderFontWeight,
621 ),
622 fileHeaderMetaFontSizePx = json.floatSp(
623 "fileHeaderMetaFontSize",
624 fallback.fileHeaderMetaFontSizePx,
625 density,
626 ),
627 fileHeaderMetaFontWeight = json.optString(
628 "fileHeaderMetaFontWeight",
629 fallback.fileHeaderMetaFontWeight,
630 ),
631 fileHeaderSubtextFontSizePx = json.floatSp(
632 "fileHeaderSubtextFontSize",
633 fallback.fileHeaderSubtextFontSizePx,
634 density,
635 ),
636 fileHeaderSubtextFontWeight = json.optString(
637 "fileHeaderSubtextFontWeight",
638 fallback.fileHeaderSubtextFontWeight,
547639 ),
548640 )
549641 } catch (_: Exception) {
......@@ -554,35 +646,54 @@ private data class DiffStyle(
554646
555647private class DiffCanvasView(context: Context) : View(context) {
556648 private val density = resources.displayMetrics.density
557 private val backgroundPaint = Paint()
558 private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
559 private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
560 private val boldTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
561 typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
562 }
649 private val drawing = ReviewDiffCanvasDrawing(context)
650 private val backgroundPaint = drawing.backgroundPaint
651 private val borderPaint = drawing.borderPaint
652 private val textPaint = drawing.textPaint
653 private val boldTextPaint = drawing.uiPaint
563654 private val gestureDetector = GestureDetector(
564655 context,
565656 object : GestureDetector.SimpleOnGestureListener() {
566657 override fun onDown(event: MotionEvent): Boolean = true
567658
568659 override fun onSingleTapUp(event: MotionEvent): Boolean {
569 rowAt(event.y)?.let { onRowTap?.invoke(it, "tap") }
660 rowHitAt(event.y)?.let { hit ->
661 val target = if (
662 hit.row.kind == "file" &&
663 drawing.fileHeaderCheckboxRect(
664 hit.top,
665 hit.bottom,
666 width,
667 style
668 ).contains(event.x, event.y)
669 ) {
670 RowTapTarget.VIEWED_CHECKBOX
671 } else {
672 RowTapTarget.ROW
673 }
674 onRowTap?.invoke(hit.row, "tap", target)
675 }
570676 return true
571677 }
572678
573679 override fun onLongPress(event: MotionEvent) {
574 rowAt(event.y)?.let { onRowTap?.invoke(it, "longPress") }
680 rowHitAt(event.y)?.row
681 ?.takeIf { it.kind == "line" }
682 ?.let { onRowTap?.invoke(it, "longPress", RowTapTarget.ROW) }
575683 }
576684 },
577685 )
578686 private var rowOffsets = intArrayOf(0)
579687 private var verticalOffset = 0
580688 private var horizontalOffset = 0
689 private val headerPathOffsetsByFileId = mutableMapOf<String, Int>()
581690 private var lastVisibleRange: Pair<Int, Int>? = null
582
583691 var rows: List<DiffRow> = emptyList()
584692 set(value) {
585693 field = value
694 headerPathOffsetsByFileId.keys.retainAll(
695 value.asSequence().filter { it.kind == "file" }.map { it.resolvedFileId }.toSet(),
696 )
586697 rebuildOffsets()
587698 }
588699 var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()
......@@ -595,6 +706,16 @@ private class DiffCanvasView(context: Context) : View(context) {
595706 field = value
596707 invalidate()
597708 }
709 var collapsedFileIds: Set<String> = emptySet()
710 set(value) {
711 field = value
712 invalidate()
713 }
714 var collapsedCommentIds: Set<String> = emptySet()
715 set(value) {
716 field = value
717 rebuildOffsets()
718 }
598719 var selectedRowIds: Set<String> = emptySet()
599720 set(value) {
600721 field = value
......@@ -603,6 +724,7 @@ private class DiffCanvasView(context: Context) : View(context) {
603724 var theme: DiffTheme = DiffTheme.fallback("light")
604725 set(value) {
605726 field = value
727 drawing.theme = value
606728 invalidate()
607729 }
608730 var style: DiffStyle = DiffStyle.defaults(density)
......@@ -614,9 +736,10 @@ private class DiffCanvasView(context: Context) : View(context) {
614736 set(value) {
615737 field = max(value, suggestedMinimumWidth)
616738 setHorizontalOffset(horizontalOffset)
739 clampHeaderPathOffsets()
617740 invalidate()
618741 }
619 var onRowTap: ((DiffRow, String) -> Unit)? = null
742 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null
620743 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null
621744
622745 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
......@@ -630,6 +753,7 @@ private class DiffCanvasView(context: Context) : View(context) {
630753 super.onSizeChanged(width, height, oldWidth, oldHeight)
631754 setVerticalOffset(verticalOffset)
632755 setHorizontalOffset(horizontalOffset)
756 clampHeaderPathOffsets()
633757 }
634758
635759 override fun onDraw(canvas: Canvas) {
......@@ -681,27 +805,63 @@ private class DiffCanvasView(context: Context) : View(context) {
681805 invalidate()
682806 }
683807
684 fun scrollByHorizontal(delta: Int) {
685 setHorizontalOffset(horizontalOffset + delta)
808 fun scrollByHorizontal(delta: Int, target: HorizontalPanTarget) {
809 if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) {
810 setHeaderPathOffset(
811 target.fileId,
812 (headerPathOffsetsByFileId[target.fileId] ?: 0) + delta,
813 )
814 } else {
815 setHorizontalOffset(horizontalOffset + delta)
816 }
686817 }
687818
688819 fun horizontalOffset(): Int = horizontalOffset
689820
690821 fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width)
691822
823 fun maxHorizontalOffset(target: HorizontalPanTarget): Int =
824 if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) {
825 maxHeaderPathOffset(target.fileId)
826 } else {
827 maxHorizontalOffset()
828 }
829
830 fun horizontalPanTarget(y: Float): HorizontalPanTarget? {
831 val row = rowHitAt(y)?.row ?: return null
832 return HorizontalPanTarget(
833 fileId = row.resolvedFileId,
834 kind = if (row.kind == "file") HorizontalPanKind.FILE_HEADER_PATH else HorizontalPanKind.CODE,
835 )
836 }
837
838 fun resetHorizontalOffsets() {
839 setHorizontalOffset(0)
840 if (headerPathOffsetsByFileId.isNotEmpty()) {
841 headerPathOffsetsByFileId.clear()
842 invalidate()
843 }
844 }
845
692846 private fun rebuildOffsets() {
693847 rowOffsets = IntArray(rows.size + 1)
694848 rows.forEachIndexed { index, row ->
695849 rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row)
696850 }
697851 setVerticalOffset(verticalOffset)
852 clampHeaderPathOffsets()
698853 requestLayout()
699854 invalidate()
700855 }
701856
702857 private fun rowHeight(row: DiffRow): Int = when (row.kind) {
703858 "file" -> style.fileHeaderHeightPx.toInt()
704 "comment" -> max((style.rowHeightPx * 3.2f).toInt(), (56 * density).toInt())
859 "notice" -> max((style.rowHeightPx * 2f).toInt(), (44 * density).toInt())
860 "comment" -> if (collapsedCommentIds.contains(row.id)) {
861 (44 * density).toInt()
862 } else {
863 (124 * density).toInt()
864 }
705865 else -> style.rowHeightPx.toInt()
706866 }.coerceAtLeast(1)
707867
......@@ -721,13 +881,20 @@ private class DiffCanvasView(context: Context) : View(context) {
721881 return low.coerceIn(0, rows.lastIndex)
722882 }
723883
724 private fun rowAt(y: Float): DiffRow? {
884 private fun rowHitAt(y: Float): RowHit? {
725885 stickyFileHeader(firstVisibleRow())?.let { sticky ->
726886 if (y >= max(0, sticky.top).toFloat() && y < sticky.bottom.toFloat()) {
727 return rows.getOrNull(sticky.index)
887 return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) }
728888 }
729889 }
730 return rows.getOrNull(rowIndexAt(verticalOffset + y.toInt()))
890 val index = rowIndexAt(verticalOffset + y.toInt())
891 return rows.getOrNull(index)?.let {
892 RowHit(
893 row = it,
894 top = rowOffsets[index] - verticalOffset,
895 bottom = rowOffsets[index + 1] - verticalOffset,
896 )
897 }
731898 }
732899
733900 private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset)
......@@ -754,23 +921,140 @@ private class DiffCanvasView(context: Context) : View(context) {
754921
755922 private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
756923 fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
757 val baseline = centeredBaseline(top, bottom, boldTextPaint.apply { textSize = 13f * density })
758 boldTextPaint.color = theme.text
759 val marker = if (viewedFileIds.contains(row.resolvedFileId)) "[x] " else "[ ] "
760 textPaint.color = theme.mutedText
761 textPaint.textSize = 11f * density
762 val stats = "+${row.additions} -${row.deletions}"
763 val statsX = max(12f * density, width - textPaint.measureText(stats) - 16f * density)
764 val title = ellipsize(marker + row.filePath, boldTextPaint, statsX - 28f * density)
765 canvas.drawText(title, 12f * density, baseline, boldTextPaint)
766 canvas.drawText(stats, statsX, baseline, textPaint)
924
925 val chevronRect = drawing.fileHeaderChevronRect(top, bottom, style)
926 val iconRect = drawing.fileHeaderIconRect(top, bottom, style)
927 val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style)
928 drawing.drawDisclosureChevron(
929 canvas,
930 chevronRect,
931 theme.mutedText,
932 collapsed = collapsedFileIds.contains(row.resolvedFileId),
933 )
934 drawing.drawFileIcon(canvas, iconRect, row.changeType)
935 drawing.drawViewedCheckbox(
936 canvas,
937 checkboxRect,
938 viewedFileIds.contains(row.resolvedFileId),
939 )
940
941 drawing.configureUiPaint(
942 paint = boldTextPaint,
943 color = theme.deleteText,
944 size = style.fileHeaderMetaFontSizePx,
945 weight = style.fileHeaderMetaFontWeight,
946 )
947 val deleteText = "-${row.deletions}"
948 val deleteWidth = boldTextPaint.measureText(deleteText)
949 val addText = "+${row.additions}"
950 val addWidth = boldTextPaint.measureText(addText)
951 val countGap = 4f * density
952 val countsX = checkboxRect.left - 10f * density - deleteWidth - countGap - addWidth
953 val baseline = centeredBaseline(top, bottom, boldTextPaint)
954 canvas.drawText(deleteText, countsX, baseline, boldTextPaint)
955 boldTextPaint.color = theme.addText
956 canvas.drawText(addText, countsX + deleteWidth + countGap, baseline, boldTextPaint)
957
958 val pathLayout = fileHeaderPathLayout(row, top, bottom, iconRect, countsX)
959 val maxPathOffset = maxHeaderPathOffset(pathLayout)
960 val pathOffset = (headerPathOffsetsByFileId[row.resolvedFileId] ?: 0)
961 .coerceIn(0, maxPathOffset)
962 canvas.save()
963 canvas.clipRect(pathLayout.rect)
964 canvas.drawText(
965 pathLayout.displayPath,
966 pathLayout.rect.left - pathOffset,
967 centeredBaseline(top, bottom, boldTextPaint),
968 boldTextPaint,
969 )
970 canvas.restore()
971 drawing.drawFileHeaderPathScrollFades(
972 canvas,
973 pathLayout.rect,
974 pathOffset,
975 maxPathOffset,
976 )
767977 drawBottomBorder(canvas, bottom)
768978 }
769979
980 private fun fileHeaderPathLayout(
981 row: DiffRow,
982 top: Int,
983 bottom: Int,
984 iconRect: RectF,
985 countsX: Float
986 ): FileHeaderPathLayout {
987 drawing.configureUiPaint(
988 paint = boldTextPaint,
989 color = theme.text,
990 size = style.fileHeaderFontSizePx,
991 weight = style.fileHeaderFontWeight,
992 )
993 val pathX = iconRect.right + 10f * density
994 val pathWidth = max(24f * density, countsX - pathX - 12f * density)
995 val centerY = (top + bottom) / 2f
996 val displayPath = if (
997 !row.previousPath.isNullOrEmpty() && row.previousPath != row.filePath
998 ) {
999 "${row.previousPath} -> ${row.filePath}"
1000 } else {
1001 row.filePath
1002 }
1003 return FileHeaderPathLayout(
1004 displayPath = displayPath,
1005 rect = RectF(pathX, centerY - 10f * density, pathX + pathWidth, centerY + 10f * density),
1006 )
1007 }
1008
1009 private fun maxHeaderPathOffset(fileId: String): Int {
1010 val row = rows.firstOrNull { it.kind == "file" && it.resolvedFileId == fileId } ?: return 0
1011 val top = 0
1012 val bottom = rowHeight(row)
1013 val iconRect = drawing.fileHeaderIconRect(top, bottom, style)
1014 val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style)
1015 drawing.configureUiPaint(
1016 paint = boldTextPaint,
1017 color = theme.deleteText,
1018 size = style.fileHeaderMetaFontSizePx,
1019 weight = style.fileHeaderMetaFontWeight,
1020 )
1021 val countsX = checkboxRect.left - 10f * density -
1022 boldTextPaint.measureText("-${row.deletions}") - 4f * density -
1023 boldTextPaint.measureText("+${row.additions}")
1024 return maxHeaderPathOffset(fileHeaderPathLayout(row, top, bottom, iconRect, countsX))
1025 }
1026
1027 private fun maxHeaderPathOffset(layout: FileHeaderPathLayout): Int = max(
1028 0,
1029 ceil(boldTextPaint.measureText(layout.displayPath) - layout.rect.width()).toInt(),
1030 )
1031
1032 private fun setHeaderPathOffset(fileId: String, value: Int) {
1033 val nextOffset = value.coerceIn(0, maxHeaderPathOffset(fileId))
1034 if ((headerPathOffsetsByFileId[fileId] ?: 0) == nextOffset) return
1035 headerPathOffsetsByFileId[fileId] = nextOffset
1036 invalidate()
1037 }
1038
1039 private fun clampHeaderPathOffsets() {
1040 headerPathOffsetsByFileId.keys.toList().forEach { fileId ->
1041 val nextOffset = (headerPathOffsetsByFileId[fileId] ?: 0)
1042 .coerceIn(0, maxHeaderPathOffset(fileId))
1043 if (nextOffset == 0) {
1044 headerPathOffsetsByFileId.remove(fileId)
1045 } else {
1046 headerPathOffsetsByFileId[fileId] = nextOffset
1047 }
1048 }
1049 }
1050
7701051 private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
7711052 fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
772 textPaint.color = theme.hunkText
773 textPaint.textSize = style.codeFontSizePx
1053 drawing.configureMonospacePaint(
1054 color = theme.hunkText,
1055 size = style.hunkFontSizePx,
1056 weight = style.hunkFontWeight,
1057 )
7741058 drawScrollableCode(canvas, top, bottom) { codeX ->
7751059 canvas.drawText(
7761060 row.text.ifEmpty { row.content },
......@@ -782,36 +1066,82 @@ private class DiffCanvasView(context: Context) : View(context) {
7821066 }
7831067
7841068 private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
785 textPaint.color = theme.mutedText
786 textPaint.textSize = style.codeFontSizePx
787 drawScrollableCode(canvas, top, bottom) { codeX ->
788 canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint)
789 }
1069 fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
1070 val iconSize = 16f * density
1071 val iconRect = RectF(
1072 style.fileHeaderHorizontalPaddingPx + 2f * density,
1073 (top + bottom - iconSize) / 2f,
1074 style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize,
1075 (top + bottom + iconSize) / 2f,
1076 )
1077 drawing.drawNoticeIcon(canvas, iconRect, theme.mutedText)
1078 drawing.configureUiPaint(
1079 paint = textPaint,
1080 color = theme.mutedText,
1081 size = style.fileHeaderSubtextFontSizePx,
1082 weight = style.fileHeaderSubtextFontWeight,
1083 )
1084 val textX = iconRect.right + 10f * density
1085 canvas.drawText(
1086 ellipsize(row.text, textPaint, width - textX - style.fileHeaderHorizontalPaddingPx),
1087 textX,
1088 centeredBaseline(top, bottom, textPaint),
1089 textPaint,
1090 )
1091 drawBottomBorder(canvas, bottom)
7901092 }
7911093
7921094 private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
793 fill(
794 canvas,
795 theme.headerBackground,
796 style.gutterWidthPx,
797 top.toFloat(),
798 width.toFloat(),
799 bottom.toFloat()
1095 fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
1096 val cardRect = RectF(
1097 8f * density,
1098 top + 5f * density,
1099 width - 8f * density,
1100 bottom - 5f * density,
8001101 )
801 boldTextPaint.color = theme.text
802 boldTextPaint.textSize = 12f * density
803 drawScrollableCode(canvas, top, bottom) { codeX ->
1102 backgroundPaint.color = theme.headerBackground
1103 canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint)
1104 borderPaint.style = Paint.Style.STROKE
1105 borderPaint.color = withAlpha(theme.border, 217)
1106 borderPaint.strokeWidth = density
1107 canvas.drawRoundRect(cardRect, 10f * density, 10f * density, borderPaint)
1108 borderPaint.style = Paint.Style.FILL
1109
1110 val collapsed = collapsedCommentIds.contains(row.id)
1111 val chevronRect = RectF(
1112 cardRect.left + 10f * density,
1113 cardRect.top + 11f * density,
1114 cardRect.left + 26f * density,
1115 cardRect.top + 27f * density,
1116 )
1117 drawing.drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed)
1118 drawing.configureUiPaint(
1119 paint = textPaint,
1120 color = theme.mutedText,
1121 size = style.fileHeaderSubtextFontSizePx,
1122 weight = style.fileHeaderSubtextFontWeight,
1123 )
1124 val title = "Comment on ${row.commentRangeLabel.ifEmpty { "line" }}"
1125 canvas.drawText(
1126 ellipsize(title, textPaint, cardRect.right - chevronRect.right - 20f * density),
1127 chevronRect.right + 10f * density,
1128 cardRect.top + 22f * density,
1129 textPaint,
1130 )
1131 if (!collapsed) {
1132 textPaint.color = theme.text
1133 val bodyX = cardRect.left + 18f * density
8041134 canvas.drawText(
805 row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } },
806 codeX,
807 top + 20f * density,
808 boldTextPaint,
1135 ellipsize(
1136 row.commentText.ifEmpty { "Comment" },
1137 textPaint,
1138 cardRect.right - bodyX - 18f * density
1139 ),
1140 bodyX,
1141 cardRect.top + 58f * density,
1142 textPaint,
8091143 )
810 textPaint.color = theme.mutedText
811 textPaint.textSize = 12f * density
812 canvas.drawText(row.commentText, codeX, top + 42f * density, textPaint)
8131144 }
814 drawBottomBorder(canvas, bottom)
8151145 }
8161146
8171147 @Suppress("CyclomaticComplexMethod")
......@@ -822,65 +1152,79 @@ private class DiffCanvasView(context: Context) : View(context) {
8221152 else -> theme.background
8231153 }
8241154 fill(canvas, background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
1155 if (style.changeBarWidthPx > 0) {
1156 when (row.change) {
1157 "add" -> fill(
1158 canvas,
1159 theme.addBar,
1160 0f,
1161 top.toFloat(),
1162 style.changeBarWidthPx,
1163 bottom.toFloat()
1164 )
1165 "delete" -> drawing.drawDeleteStripes(
1166 canvas,
1167 top,
1168 bottom,
1169 style.changeBarWidthPx,
1170 theme.deleteBar,
1171 )
1172 }
1173 }
8251174 val selected = selectedRowIds.contains(row.id)
8261175 if (selected) {
8271176 fill(
8281177 canvas,
829 withAlpha(theme.hunkText, if (theme.background == Color.WHITE) 54 else 76),
1178 withAlpha(theme.hunkText, 56),
8301179 0f,
8311180 top.toFloat(),
8321181 width.toFloat(),
8331182 bottom.toFloat(),
8341183 )
835 }
836 val barColor = when (row.change) {
837 "add" -> theme.addBar
838 "delete" -> theme.deleteBar
839 else -> Color.TRANSPARENT
840 }
841 if (barColor != Color.TRANSPARENT && style.changeBarWidthPx > 0) {
842 fill(canvas, barColor, 0f, top.toFloat(), style.changeBarWidthPx, bottom.toFloat())
1184 fill(
1185 canvas,
1186 withAlpha(theme.hunkText, 242),
1187 0f,
1188 top.toFloat(),
1189 style.changeBarWidthPx,
1190 bottom.toFloat()
1191 )
8431192 }
8441193
8451194 val tokens = tokensByRowId[row.id]
8461195 drawScrollableCode(canvas, top, bottom) { codeX ->
1196 drawing.configureCodePaint(theme.text, 0, style)
1197 drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom)
8471198 if (tokens.isNullOrEmpty()) {
848 textPaint.textSize = style.codeFontSizePx
849 textPaint.color = when (row.change) {
850 "add" -> theme.addText
851 "delete" -> theme.deleteText
852 else -> theme.text
853 }
8541199 canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint)
8551200 } else {
8561201 var x = codeX
8571202 tokens.forEach { token ->
858 textPaint.textSize = style.codeFontSizePx
859 textPaint.color = token.color ?: theme.text
860 textPaint.typeface = when {
861 token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC)
862 token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
863 else -> Typeface.MONOSPACE
864 }
1203 drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style)
8651204 canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint)
8661205 x += textPaint.measureText(token.content)
8671206 }
868 textPaint.typeface = Typeface.MONOSPACE
8691207 }
8701208 }
8711209
872 textPaint.textSize = style.lineNumberFontSizePx
873 textPaint.color = if (selected) theme.text else theme.mutedText
874 val oldNumber = row.oldLineNumber?.toString().orEmpty()
875 val newNumber = row.newLineNumber?.toString().orEmpty()
876 val baseline = centeredBaseline(top, bottom, textPaint)
877 canvas.drawText(oldNumber, style.changeBarWidthPx + 6f * density, baseline, textPaint)
1210 drawLineNumber(canvas, row, top, bottom)
1211 }
1212
1213 private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
1214 val lineNumber = row.newLineNumber ?: row.oldLineNumber ?: return
1215 drawing.configureMonospacePaint(
1216 color = drawing.lineNumberColor(row.change),
1217 size = style.lineNumberFontSizePx,
1218 weight = style.lineNumberFontWeight,
1219 )
1220 textPaint.textAlign = Paint.Align.RIGHT
8781221 canvas.drawText(
879 newNumber,
880 style.changeBarWidthPx + style.gutterWidthPx / 2f,
881 baseline,
882 textPaint
1222 lineNumber.toString(),
1223 style.changeBarWidthPx + style.gutterWidthPx - style.codePaddingPx,
1224 centeredBaseline(top, bottom, textPaint),
1225 textPaint,
8831226 )
1227 textPaint.textAlign = Paint.Align.LEFT
8841228 }
8851229
8861230 private fun drawScrollableCode(
......@@ -972,6 +1316,17 @@ private class DiffCanvasView(context: Context) : View(context) {
9721316 val top: Int,
9731317 val bottom: Int
9741318 )
1319
1320 private data class RowHit(
1321 val row: DiffRow,
1322 val top: Int,
1323 val bottom: Int
1324 )
1325
1326 private data class FileHeaderPathLayout(
1327 val displayPath: String,
1328 val rect: RectF
1329 )
9751330}
9761331
9771332private fun withAlpha(color: Int, alpha: Int): Int =
......@@ -995,6 +1350,7 @@ private fun parseRows(value: String): List<DiffRow> = try {
9951350 change = row.optString("change", "context"),
9961351 oldLineNumber = row.optNullableInt("oldLineNumber"),
9971352 newLineNumber = row.optNullableInt("newLineNumber"),
1353 wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(),
9981354 commentText = row.optString("commentText"),
9991355 commentRangeLabel = row.optString("commentRangeLabel"),
10001356 commentSectionTitle = row.optString("commentSectionTitle"),
......@@ -1004,6 +1360,17 @@ private fun parseRows(value: String): List<DiffRow> = try {
10041360 emptyList()
10051361}
10061362
1363private fun parseWordDiffRanges(value: JSONArray): List<DiffWordDiffRange> = buildList {
1364 for (index in 0 until value.length()) {
1365 val range = value.optJSONObject(index) ?: continue
1366 val start = range.optInt("start", -1)
1367 val end = range.optInt("end", -1)
1368 if (start >= 0 && end > start) {
1369 add(DiffWordDiffRange(start = start, end = end))
1370 }
1371 }
1372}
1373
10071374private fun parseTokensObject(value: String): Map<String, List<DiffToken>> = try {
10081375 parseTokensObject(JSONObject(value))
10091376} catch (_: Exception) {
......@@ -1020,7 +1387,7 @@ private fun parseTokensObject(value: JSONObject): Map<String, List<DiffToken>> {
10201387 val token = array.getJSONObject(index)
10211388 DiffToken(
10221389 content = token.optString("content"),
1023 color = token.optNullableString("color")?.let { parseColor(it, Color.TRANSPARENT) },
1390 color = token.optNullableString("color")?.let(::parseColorOrNull),
10241391 fontStyle = token.optInt("fontStyle"),
10251392 )
10261393 }
......@@ -1043,6 +1410,12 @@ private fun parseColor(value: String, fallback: Int): Int = try {
10431410 fallback
10441411}
10451412
1413private fun parseColorOrNull(value: String): Int? = try {
1414 Color.parseColor(value)
1415} catch (_: Exception) {
1416 null
1417}
1418
10461419private fun JSONObject.optNullableString(key: String): String? =
10471420 if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
10481421
apps/mobile/src/Stack.tsx+3-3
......@@ -440,9 +440,9 @@ export const RootStack = createNativeStackNavigator({
440440 screen: ConnectOnboardingRouteScreen,
441441 linking: "connect-onboarding",
442442 options: {
443 // Root screenOptions hide headers; formSheets that want the native
444 // title bar opt back in with the sheet header preset.
445 ...SHEET_SOLID_HEADER_OPTIONS,
443 // A root-level Android formSheet does not host the native stack bar;
444 // the route renders an embedded AndroidSheetHeader instead.
445 ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS),
446446 title: "Set up T3 Connect",
447447 gestureEnabled: true,
448448 presentation: "formSheet",
apps/mobile/src/components/AndroidScreenHeader.tsx+8-1
......@@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: {
5151 readonly actions?: ReadonlyArray<AndroidHeaderAction>;
5252 readonly trailing?: ReactNode;
5353 readonly onBack?: () => void;
54 readonly embedded?: boolean;
5455}) {
5556 const insets = useSafeAreaInsets();
5657 const foregroundColor = useThemeColor("--color-foreground");
......@@ -59,7 +60,7 @@ export function AndroidScreenHeader(props: {
5960 <View
6061 className="border-b border-header-border bg-header px-3 pb-2.5"
6162 style={{
62 paddingTop: Math.max(insets.top, 12),
63 paddingTop: props.embedded ? 8 : Math.max(insets.top, 12),
6364 }}
6465 >
6566 <View className="min-h-12 flex-row items-center gap-2">
......@@ -108,3 +109,9 @@ export function AndroidScreenHeader(props: {
108109 </View>
109110 );
110111}
112
113export function AndroidSheetHeader(
114 props: Omit<Parameters<typeof AndroidScreenHeader>[0], "embedded">,
115) {
116 return <AndroidScreenHeader {...props} embedded />;
117}
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+12-4
......@@ -2,10 +2,11 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";
22import { useAuth } from "@clerk/expo";
33import { StackActions, useNavigation } from "@react-navigation/native";
44import { useCallback, useEffect, useState } from "react";
5import { Pressable, RefreshControl, ScrollView, View } from "react-native";
5import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
66import { useSafeAreaInsets } from "react-native-safe-area-context";
77
88import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
9import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
910import { AppText as Text } from "../../components/AppText";
1011import { useRemoteConnections } from "../../state/use-remote-environment-registry";
1112import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
......@@ -81,9 +82,16 @@ function ConfiguredConnectOnboardingRouteScreen() {
8182
8283 return (
8384 <View collapsable={false} className="flex-1 bg-sheet">
84 <NativeHeaderToolbar placement="right">
85 <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />
86 </NativeHeaderToolbar>
85 {Platform.OS === "android" ? (
86 <AndroidSheetHeader
87 title="Set up T3 Connect"
88 actions={[{ accessibilityLabel: "Close", icon: "xmark", onPress: handleClose }]}
89 />
90 ) : (
91 <NativeHeaderToolbar placement="right">
92 <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />
93 </NativeHeaderToolbar>
94 )}
8795 <ScrollView
8896 alwaysBounceVertical
8997 contentInsetAdjustmentBehavior="automatic"
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+118-107
......@@ -1,9 +1,10 @@
11import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
22import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
33import { useState } from "react";
4import { Pressable, ScrollView, View } from "react-native";
4import { Platform, Pressable, ScrollView, View } from "react-native";
55import { useSafeAreaInsets } from "react-native-safe-area-context";
66
7import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
78import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
89import { cn } from "../../../lib/cn";
910import { useEnvironmentQuery } from "../../../state/query";
......@@ -57,115 +58,125 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
5758 const disabledExistingBranches = new Set(disabledExistingBranchNames);
5859
5960 return (
60 <ScrollView
61 showsVerticalScrollIndicator={false}
62 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
63 contentContainerClassName="gap-4 px-5 pt-2"
64 >
65 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
66 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
67 New branch
68 </Text>
69 <TextInput
70 value={newBranchName}
71 onChangeText={setNewBranchName}
72 placeholder="feature/mobile-polish"
73 className="rounded-[18px]"
74 />
75 <SheetActionButton
76 icon="plus"
77 label="Create & checkout"
78 tone="primary"
79 disabled={busy || newBranchName.trim().length === 0}
80 onPress={() => {
81 const branch = sanitizeFeatureBranchName(newBranchName.trim());
82 if (branch.length === 0) return;
83 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
84 setNewBranchName("");
85 navigation.goBack();
86 });
87 }}
88 />
89 </View>
61 <View collapsable={false} className="flex-1 bg-sheet">
62 {Platform.OS === "android" ? (
63 <AndroidSheetHeader title="Branches & worktrees" onBack={() => navigation.goBack()} />
64 ) : null}
65 <ScrollView
66 className="flex-1"
67 showsVerticalScrollIndicator={false}
68 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
69 contentContainerClassName="gap-4 px-5 pt-2"
70 >
71 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
72 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
73 New branch
74 </Text>
75 <TextInput
76 value={newBranchName}
77 onChangeText={setNewBranchName}
78 placeholder="feature/mobile-polish"
79 className="rounded-[18px]"
80 />
81 <SheetActionButton
82 icon="plus"
83 label="Create & checkout"
84 tone="primary"
85 disabled={busy || newBranchName.trim().length === 0}
86 onPress={() => {
87 const branch = sanitizeFeatureBranchName(newBranchName.trim());
88 if (branch.length === 0) return;
89 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
90 setNewBranchName("");
91 navigation.goBack();
92 });
93 }}
94 />
95 </View>
9096
91 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
92 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
93 New worktree
94 </Text>
95 <TextInput
96 value={worktreeBaseBranch}
97 onChangeText={setWorktreeBaseBranch}
98 placeholder="main"
99 className="rounded-[18px]"
100 />
101 <TextInput
102 value={worktreeBranchName}
103 onChangeText={setWorktreeBranchName}
104 placeholder="feature/mobile-thread"
105 className="rounded-[18px]"
106 />
107 <SheetActionButton
108 icon="square.split.2x1"
109 label="Create worktree"
110 tone="primary"
111 disabled={
112 busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0
113 }
114 onPress={() => {
115 const baseBranch = worktreeBaseBranch.trim();
116 const newBranch = worktreeBranchName.trim();
117 if (baseBranch.length === 0 || newBranch.length === 0) return;
118 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
119 setWorktreeBranchName("");
120 navigation.goBack();
121 });
122 }}
123 />
124 </View>
97 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
98 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
99 New worktree
100 </Text>
101 <TextInput
102 value={worktreeBaseBranch}
103 onChangeText={setWorktreeBaseBranch}
104 placeholder="main"
105 className="rounded-[18px]"
106 />
107 <TextInput
108 value={worktreeBranchName}
109 onChangeText={setWorktreeBranchName}
110 placeholder="feature/mobile-thread"
111 className="rounded-[18px]"
112 />
113 <SheetActionButton
114 icon="square.split.2x1"
115 label="Create worktree"
116 tone="primary"
117 disabled={
118 busy ||
119 worktreeBaseBranch.trim().length === 0 ||
120 worktreeBranchName.trim().length === 0
121 }
122 onPress={() => {
123 const baseBranch = worktreeBaseBranch.trim();
124 const newBranch = worktreeBranchName.trim();
125 if (baseBranch.length === 0 || newBranch.length === 0) return;
126 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
127 setWorktreeBranchName("");
128 navigation.goBack();
129 });
130 }}
131 />
132 </View>
125133
126 <View className="gap-2">
127 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
128 Existing branches
129 </Text>
130 {branchesLoading ? (
131 <Text className="text-foreground-secondary text-sm font-medium">Loading branches...</Text>
132 ) : null}
133 {!branchesLoading && availableBranches.length === 0 ? (
134 <Text className="text-foreground-secondary text-sm font-medium">
135 No local branches found.
134 <View className="gap-2">
135 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
136 Existing branches
136137 </Text>
137 ) : null}
138 {availableBranches.map((branch) => {
139 const disabled = disabledExistingBranches.has(branch.name);
140 const subtitle = branch.worktreePath
141 ? branch.worktreePath === currentWorktreePath
142 ? "Checked out in this thread"
143 : "Checked out in another worktree"
144 : branch.isDefault
145 ? "Default branch"
146 : "Local branch";
138 {branchesLoading ? (
139 <Text className="text-foreground-secondary text-sm font-medium">
140 Loading branches...
141 </Text>
142 ) : null}
143 {!branchesLoading && availableBranches.length === 0 ? (
144 <Text className="text-foreground-secondary text-sm font-medium">
145 No local branches found.
146 </Text>
147 ) : null}
148 {availableBranches.map((branch) => {
149 const disabled = disabledExistingBranches.has(branch.name);
150 const subtitle = branch.worktreePath
151 ? branch.worktreePath === currentWorktreePath
152 ? "Checked out in this thread"
153 : "Checked out in another worktree"
154 : branch.isDefault
155 ? "Default branch"
156 : "Local branch";
147157
148 return (
149 <Pressable
150 key={branch.name}
151 className={cn(
152 "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]",
153 branch.current ? "border-subtle-strong" : "border-border",
154 )}
155 disabled={busy || disabled}
156 onPress={() => {
157 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
158 navigation.goBack();
159 });
160 }}
161 >
162 <View className="absolute inset-0 rounded-[18px] bg-card" />
163 <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text>
164 <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text>
165 </Pressable>
166 );
167 })}
168 </View>
169 </ScrollView>
158 return (
159 <Pressable
160 key={branch.name}
161 className={cn(
162 "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]",
163 branch.current ? "border-subtle-strong" : "border-border",
164 )}
165 disabled={busy || disabled}
166 onPress={() => {
167 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
168 navigation.goBack();
169 });
170 }}
171 >
172 <View className="absolute inset-0 rounded-[18px] bg-card" />
173 <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text>
174 <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text>
175 </Pressable>
176 );
177 })}
178 </View>
179 </ScrollView>
180 </View>
170181 );
171182}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+150-141
......@@ -1,8 +1,9 @@
11import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
22import { useCallback, useState } from "react";
3import { Pressable, ScrollView, View } from "react-native";
3import { Platform, Pressable, ScrollView, View } from "react-native";
44import { useSafeAreaInsets } from "react-native-safe-area-context";
55
6import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
67import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
78import { cn } from "../../../lib/cn";
89import { useEnvironmentQuery } from "../../../state/query";
......@@ -65,160 +66,168 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
6566 );
6667
6768 return (
68 <ScrollView
69 keyboardShouldPersistTaps="handled"
70 showsVerticalScrollIndicator={false}
71 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
72 contentContainerClassName="gap-4 px-5 pt-2"
73 >
74 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
75 <View className="flex-row items-center justify-between gap-3">
76 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>
77 <Text className="text-foreground text-base font-t3-bold">
78 {gitStatus.data?.refName ?? "(detached HEAD)"}
79 </Text>
80 </View>
81 {isDefaultRef ? (
82 <Text className="text-xs leading-normal text-amber-700 dark:text-amber-400">
83 Warning: this is the default branch.
84 </Text>
85 ) : null}
86 </View>
87
88 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
89 <View className="flex-row items-center justify-between gap-3">
90 <View className="gap-1">
91 <Text className="text-foreground text-base font-t3-bold">Files</Text>
92 <Text className="text-foreground-muted text-xs leading-normal">
93 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
69 <View collapsable={false} className="flex-1 bg-sheet">
70 {Platform.OS === "android" ? (
71 <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} />
72 ) : null}
73 <ScrollView
74 className="flex-1"
75 keyboardShouldPersistTaps="handled"
76 showsVerticalScrollIndicator={false}
77 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
78 contentContainerClassName="gap-4 px-5 pt-2"
79 >
80 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
81 <View className="flex-row items-center justify-between gap-3">
82 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>
83 <Text className="text-foreground text-base font-t3-bold">
84 {gitStatus.data?.refName ?? "(detached HEAD)"}
9485 </Text>
9586 </View>
96 <View className="flex-row items-center gap-2">
97 {!allSelected && isEditingFiles ? (
87 {isDefaultRef ? (
88 <Text className="text-xs leading-normal text-amber-700 dark:text-amber-400">
89 Warning: this is the default branch.
90 </Text>
91 ) : null}
92 </View>
93
94 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
95 <View className="flex-row items-center justify-between gap-3">
96 <View className="gap-1">
97 <Text className="text-foreground text-base font-t3-bold">Files</Text>
98 <Text className="text-foreground-muted text-xs leading-normal">
99 {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions}
100 </Text>
101 </View>
102 <View className="flex-row items-center gap-2">
103 {!allSelected && isEditingFiles ? (
104 <Pressable
105 className="bg-subtle rounded-full px-3 py-2"
106 onPress={() => setExcludedFiles(new Set())}
107 >
108 <Text className="text-foreground text-2xs font-t3-bold uppercase">Reset</Text>
109 </Pressable>
110 ) : null}
98111 <Pressable
99112 className="bg-subtle rounded-full px-3 py-2"
100 onPress={() => setExcludedFiles(new Set())}
113 onPress={() => setIsEditingFiles((current) => !current)}
101114 >
102 <Text className="text-foreground text-2xs font-t3-bold uppercase">Reset</Text>
115 <Text className="text-foreground text-2xs font-t3-bold uppercase">
116 {isEditingFiles ? "Done" : "Edit"}
117 </Text>
103118 </Pressable>
104 ) : null}
105 <Pressable
106 className="bg-subtle rounded-full px-3 py-2"
107 onPress={() => setIsEditingFiles((current) => !current)}
108 >
109 <Text className="text-foreground text-2xs font-t3-bold uppercase">
110 {isEditingFiles ? "Done" : "Edit"}
111 </Text>
112 </Pressable>
119 </View>
113120 </View>
114 </View>
115121
116 {allFiles.length === 0 ? (
117 <Text className="text-foreground-secondary text-sm leading-normal">
118 No changed files are available to commit.
119 </Text>
120 ) : !isEditingFiles ? (
121 <View className="gap-2">
122 {selectedFilePreview.map((file) => (
123 <View key={file.path} className="flex-row items-center justify-between gap-3">
124 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>
125 {file.path}
122 {allFiles.length === 0 ? (
123 <Text className="text-foreground-secondary text-sm leading-normal">
124 No changed files are available to commit.
125 </Text>
126 ) : !isEditingFiles ? (
127 <View className="gap-2">
128 {selectedFilePreview.map((file) => (
129 <View key={file.path} className="flex-row items-center justify-between gap-3">
130 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>
131 {file.path}
132 </Text>
133 <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text>
134 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>
135 </View>
136 ))}
137 {selectedFiles.length > selectedFilePreview.length ? (
138 <Text className="text-foreground-muted text-xs leading-snug">
139 +{selectedFiles.length - selectedFilePreview.length} more files
126140 </Text>
127 <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text>
128 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>
129 </View>
130 ))}
131 {selectedFiles.length > selectedFilePreview.length ? (
132 <Text className="text-foreground-muted text-xs leading-snug">
133 +{selectedFiles.length - selectedFilePreview.length} more files
134 </Text>
135 ) : null}
136 </View>
137 ) : (
138 <View className="gap-2">
139 {allFiles.map((file) => {
140 const included = !excludedFiles.has(file.path);
141 return (
142 <Pressable
143 key={file.path}
144 className={cn(
145 "rounded-[18px] border px-4 py-3",
146 included ? "border-border" : "border-border-subtle",
147 )}
148 onPress={() => {
149 setExcludedFiles((current) => {
150 const next = new Set(current);
151 if (next.has(file.path)) {
152 next.delete(file.path);
153 } else {
154 next.add(file.path);
155 }
156 return next;
157 });
158 }}
159 >
160 <View
161 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
162 />
163 <View className="flex-row items-start justify-between gap-3">
164 <View className="flex-1 gap-1">
165 <Text
166 selectable
167 className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
168 >
169 {file.path}
170 </Text>
171 {!included ? (
172 <Text className="text-foreground-muted text-2xs leading-normal">
173 Excluded from this commit
141 ) : null}
142 </View>
143 ) : (
144 <View className="gap-2">
145 {allFiles.map((file) => {
146 const included = !excludedFiles.has(file.path);
147 return (
148 <Pressable
149 key={file.path}
150 className={cn(
151 "rounded-[18px] border px-4 py-3",
152 included ? "border-border" : "border-border-subtle",
153 )}
154 onPress={() => {
155 setExcludedFiles((current) => {
156 const next = new Set(current);
157 if (next.has(file.path)) {
158 next.delete(file.path);
159 } else {
160 next.add(file.path);
161 }
162 return next;
163 });
164 }}
165 >
166 <View
167 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
168 />
169 <View className="flex-row items-start justify-between gap-3">
170 <View className="flex-1 gap-1">
171 <Text
172 selectable
173 className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
174 >
175 {file.path}
174176 </Text>
175 ) : null}
176 </View>
177 <View className="items-end gap-1">
178 <Text className="text-xs font-t3-bold text-emerald-500">
179 +{file.insertions}
180 </Text>
181 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>
177 {!included ? (
178 <Text className="text-foreground-muted text-2xs leading-normal">
179 Excluded from this commit
180 </Text>
181 ) : null}
182 </View>
183 <View className="items-end gap-1">
184 <Text className="text-xs font-t3-bold text-emerald-500">
185 +{file.insertions}
186 </Text>
187 <Text className="text-xs font-t3-bold text-rose-500">
188 -{file.deletions}
189 </Text>
190 </View>
182191 </View>
183 </View>
184 </Pressable>
185 );
186 })}
187 </View>
188 )}
189 </View>
190
191 <View className="gap-2">
192 <Text className="text-foreground text-sm font-t3-bold">Commit message</Text>
193 <TextInput
194 multiline
195 value={dialogCommitMessage}
196 onChangeText={setDialogCommitMessage}
197 placeholder="Leave empty to auto-generate"
198 textAlignVertical="top"
199 className="min-h-[128px] rounded-[20px] px-4 py-3.5"
200 />
201 </View>
192 </Pressable>
193 );
194 })}
195 </View>
196 )}
197 </View>
202198
203 <View className="flex-row gap-3">
204 <View className="flex-1">
205 <SheetActionButton
206 icon="arrow.branch"
207 label="Commit on new branch"
208 disabled={noneSelected || busy}
209 onPress={() => void runCommitAction(true)}
199 <View className="gap-2">
200 <Text className="text-foreground text-sm font-t3-bold">Commit message</Text>
201 <TextInput
202 multiline
203 value={dialogCommitMessage}
204 onChangeText={setDialogCommitMessage}
205 placeholder="Leave empty to auto-generate"
206 textAlignVertical="top"
207 className="min-h-[128px] rounded-[20px] px-4 py-3.5"
210208 />
211209 </View>
212 <View className="flex-1">
213 <SheetActionButton
214 icon="checkmark.circle"
215 label="Commit"
216 tone="primary"
217 disabled={noneSelected || busy}
218 onPress={() => void runCommitAction(false)}
219 />
210
211 <View className="flex-row gap-3">
212 <View className="flex-1">
213 <SheetActionButton
214 icon="arrow.branch"
215 label="Commit on new branch"
216 disabled={noneSelected || busy}
217 onPress={() => void runCommitAction(true)}
218 />
219 </View>
220 <View className="flex-1">
221 <SheetActionButton
222 icon="checkmark.circle"
223 label="Commit"
224 tone="primary"
225 disabled={noneSelected || busy}
226 onPress={() => void runCommitAction(false)}
227 />
228 </View>
220229 </View>
221 </View>
222 </ScrollView>
230 </ScrollView>
231 </View>
223232 );
224233}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+7-2
......@@ -4,10 +4,11 @@ import * as Arr from "effect/Array";
44import * as Result from "effect/Result";
55import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
66import { useCallback, useMemo } from "react";
7import { View } from "react-native";
7import { Platform, View } from "react-native";
88
99import { useSafeAreaInsets } from "react-native-safe-area-context";
1010
11import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
1112import { AppText as Text } from "../../../components/AppText";
1213import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
1314import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
......@@ -102,7 +103,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
102103
103104 return (
104105 <View collapsable={false} className="flex-1 bg-sheet">
105 <View className="min-h-4 pt-2" />
106 {Platform.OS === "android" ? (
107 <AndroidSheetHeader title="Confirm action" onBack={() => navigation.goBack()} />
108 ) : (
109 <View className="min-h-4 pt-2" />
110 )}
106111
107112 <View className="items-center gap-1 px-5 pb-3 pt-4">
108113 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+37-26
......@@ -5,7 +5,12 @@ import {
55 requiresDefaultBranchConfirmation,
66} from "@t3tools/client-runtime/state/vcs";
77import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
8import {
9 CommonActions,
10 StackActions,
11 useNavigation,
12 type StaticScreenProps,
13} from "@react-navigation/native";
914import { SymbolView } from "../../../components/AppSymbol";
1015import { useCallback, useEffect, useMemo, useState } from "react";
1116import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
......@@ -14,6 +19,7 @@ import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-scree
1419import { useSafeAreaInsets } from "react-native-safe-area-context";
1520import { useThemeColor } from "../../../lib/useThemeColor";
1621
22import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
1723import { AppText as Text } from "../../../components/AppText";
1824import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
1925import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
......@@ -23,6 +29,7 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-
2329import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
2430import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
2531import { vcsEnvironment } from "../../../state/vcs";
32import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
2633import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
2734
2835const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
......@@ -255,7 +262,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
255262 title="Review changes"
256263 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
257264 disabled={busy || !isRepo}
258 onPress={() => navigation.navigate("ThreadReview", { environmentId, threadId })}
265 onPress={() => {
266 const params = { environmentId, threadId };
267 navigation.dispatch(
268 resolveGitOverviewReviewNavigationAction(presentation) === "replace"
269 ? StackActions.replace("ThreadReview", params)
270 : CommonActions.navigate("ThreadReview", params),
271 );
272 }}
259273 />
260274 <View className="ml-12 h-px bg-border" />
261275 <SheetListRow
......@@ -348,12 +362,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
348362 collapsable={false}
349363 className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"}
350364 >
351 <View
352 style={{
353 minHeight: isInspector ? (props.headerInset ?? 0) : 16,
354 paddingTop: isInspector ? (props.headerInset ?? 0) : 8,
355 }}
356 />
365 {isInspector ? (
366 <View
367 style={{
368 minHeight: props.headerInset ?? 0,
369 paddingTop: props.headerInset ?? 0,
370 }}
371 />
372 ) : null}
357373
358374 {isInspector ? (
359375 <View className="gap-1 border-b border-border px-4 pb-4 pt-3">
......@@ -383,24 +399,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
383399 </Text>
384400 </View>
385401 ) : (
386 // Compact header row: labeled branch on the left, status summary at
387 // the trailing end. Horizontal padding lines the text up with the
388 // rows' icon column inside the card below (20 screen + 16 card + 4
389 // row). The sheet relies on pull-to-refresh instead of a corner
390 // refresh button.
391 <View className="flex-row items-end justify-between gap-3 px-10 pb-4 pt-4">
392 <View className="shrink gap-0.5">
393 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">
394 Branch
395 </Text>
396 <Text className="text-xl font-t3-bold" numberOfLines={1}>
397 {currentBranchLabel}
398 </Text>
399 </View>
400 <Text className="text-foreground-secondary pb-0.5 text-sm font-medium" numberOfLines={1}>
401 {currentStatusSummary}
402 </Text>
403 </View>
402 <AndroidSheetHeader
403 title={currentBranchLabel}
404 subtitle={currentStatusSummary}
405 onBack={() => navigation.goBack()}
406 actions={[
407 {
408 accessibilityLabel: "Refresh repository status",
409 disabled: busy,
410 icon: "arrow.clockwise",
411 onPress: () => void gitActions.refreshSelectedThreadGitStatus(),
412 },
413 ]}
414 />
404415 )}
405416
406417 {content}
apps/mobile/src/features/threads/git/git-overview-navigation.test.ts created+13
......@@ -0,0 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
4
5describe("resolveGitOverviewReviewNavigationAction", () => {
6 it("replaces the sheet so Back returns directly to the thread", () => {
7 expect(resolveGitOverviewReviewNavigationAction("sheet")).toBe("replace");
8 });
9
10 it("pushes Review normally when Git is a persistent inspector", () => {
11 expect(resolveGitOverviewReviewNavigationAction("inspector")).toBe("navigate");
12 });
13});
apps/mobile/src/features/threads/git/git-overview-navigation.ts created+5
......@@ -0,0 +1,5 @@
1export function resolveGitOverviewReviewNavigationAction(
2 presentation: "sheet" | "inspector",
3): "replace" | "navigate" {
4 return presentation === "sheet" ? "replace" : "navigate";
5}