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 (...@@ -23,46 +23,54 @@ if (
23 );23 );
24}24}
2525
26const VARIANT_CONFIG: Record<26const DEVELOPMENT_ASSETS = {
27 AppVariant,27 appIcon: "./assets/splash-icon-dev.png",
28 {28 iosIcon: "./assets/icon-composer-dev.icon",
29 readonly appName: string;29 splashIcon: "./assets/splash-icon-dev.png",
30 readonly scheme: string;30 androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png",
31 readonly iosIcon: string;31 androidAdaptiveBackgroundColor: "#00639B",
32 readonly splashIcon: string;32 androidMonochromeIcon: "./assets/android-icon-mark.png",
33 readonly iosBundleIdentifier: string;33 androidNotificationIcon: "./assets/android-notification-icon.png",
34 readonly androidPackage: string;34 androidNotificationColor: "#00639B",
35 readonly relyingParty?: string;35} as const;
36 }36
37> = {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 = {
38 development: {49 development: {
39 appName: "T3 Code Dev",50 appName: "T3 Code Dev",
40 scheme: "t3code-dev",51 scheme: "t3code-dev",
41 iosIcon: "./assets/icon-composer-dev.icon",
42 splashIcon: "./assets/splash-icon-dev.png",
43 iosBundleIdentifier: "com.t3tools.t3code.dev",52 iosBundleIdentifier: "com.t3tools.t3code.dev",
44 androidPackage: "com.t3tools.t3code.dev",53 androidPackage: "com.t3tools.t3code.dev",
45 relyingParty: "clerk.t3.codes",54 relyingParty: "clerk.t3.codes",
55 assets: DEVELOPMENT_ASSETS,
46 },56 },
47 preview: {57 preview: {
48 appName: "T3 Code Preview",58 appName: "T3 Code Preview",
49 scheme: "t3code-preview",59 scheme: "t3code-preview",
50 iosIcon: "./assets/icon-composer-prod.icon",
51 splashIcon: "./assets/splash-icon-prod.png",
52 iosBundleIdentifier: "com.t3tools.t3code.preview",60 iosBundleIdentifier: "com.t3tools.t3code.preview",
53 androidPackage: "com.t3tools.t3code.preview",61 androidPackage: "com.t3tools.t3code.preview",
54 relyingParty: "clerk.t3.codes",62 relyingParty: "clerk.t3.codes",
63 assets: RELEASE_ASSETS,
55 },64 },
56 production: {65 production: {
57 appName: "T3 Code",66 appName: "T3 Code",
58 scheme: "t3code",67 scheme: "t3code",
59 iosIcon: "./assets/icon-composer-prod.icon",
60 splashIcon: "./assets/splash-icon-prod.png",
61 iosBundleIdentifier: "com.t3tools.t3code",68 iosBundleIdentifier: "com.t3tools.t3code",
62 androidPackage: "com.t3tools.t3code",69 androidPackage: "com.t3tools.t3code",
63 relyingParty: "clerk.t3.codes",70 relyingParty: "clerk.t3.codes",
71 assets: RELEASE_ASSETS,
64 },72 },
65};73} as const;
6674
67function resolveAppVariant(value: string | undefined): AppVariant {75function resolveAppVariant(value: string | undefined): AppVariant {
68 switch (value) {76 switch (value) {
...@@ -121,7 +129,7 @@ const config: ExpoConfig = {...@@ -121,7 +129,7 @@ const config: ExpoConfig = {
121 policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint",129 policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint",
122 },130 },
123 orientation: "portrait",131 orientation: "portrait",
124 icon: "./assets/icon.png",132 icon: variant.assets.appIcon,
125 userInterfaceStyle: "automatic",133 userInterfaceStyle: "automatic",
126 updates: {134 updates: {
127 enabled: true,135 enabled: true,
...@@ -130,7 +138,7 @@ const config: ExpoConfig = {...@@ -130,7 +138,7 @@ const config: ExpoConfig = {
130 fallbackToCacheTimeout: 0,138 fallbackToCacheTimeout: 0,
131 },139 },
132 ios: {140 ios: {
133 icon: variant.iosIcon,141 icon: variant.assets.iosIcon,
134 supportsTablet: true,142 supportsTablet: true,
135 bundleIdentifier: variant.iosBundleIdentifier,143 bundleIdentifier: variant.iosBundleIdentifier,
136 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`144 // Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
...@@ -151,13 +159,12 @@ const config: ExpoConfig = {...@@ -151,13 +159,12 @@ const config: ExpoConfig = {
151 },159 },
152 },160 },
153 android: {161 android: {
154 icon: "./assets/icon.png",162 icon: variant.assets.appIcon,
155 package: variant.androidPackage,163 package: variant.androidPackage,
156 adaptiveIcon: {164 adaptiveIcon: {
157 backgroundColor: "#E6F4FE",165 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
158 foregroundImage: "./assets/android-icon-foreground.png",166 foregroundImage: variant.assets.androidAdaptiveForeground,
159 backgroundImage: "./assets/android-icon-background.png",167 monochromeImage: variant.assets.androidMonochromeIcon,
160 monochromeImage: "./assets/android-icon-monochrome.png",
161 },168 },
162 // Opts into OnBackInvokedCallback-based back dispatch (Android 13+).169 // Opts into OnBackInvokedCallback-based back dispatch (Android 13+).
163 // JS back handling survives it via react-native's Android 16 shim plus170 // JS back handling survives it via react-native's Android 16 shim plus
...@@ -165,7 +172,7 @@ const config: ExpoConfig = {...@@ -165,7 +172,7 @@ const config: ExpoConfig = {
165 predictiveBackGestureEnabled: true,172 predictiveBackGestureEnabled: true,
166 },173 },
167 web: {174 web: {
168 favicon: "./assets/favicon.png",175 favicon: variant.assets.appIcon,
169 },176 },
170 plugins: [177 plugins: [
171 "expo-asset",178 "expo-asset",
...@@ -195,6 +202,14 @@ const config: ExpoConfig = {...@@ -195,6 +202,14 @@ const config: ExpoConfig = {
195 ],202 ],
196 "expo-secure-store",203 "expo-secure-store",
197 "expo-sqlite",204 "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 ],
198 // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before213 // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before
199 // plugins earlier in this array, so it cannot strip the entitlement Clerk would add.214 // plugins earlier in this array, so it cannot strip the entitlement Clerk would add.
200 ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],215 ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],
...@@ -206,8 +221,8 @@ const config: ExpoConfig = {...@@ -206,8 +221,8 @@ const config: ExpoConfig = {
206 // the shortcut items set in src/features/shortcuts.221 // the shortcut items set in src/features/shortcuts.
207 androidIcons: {222 androidIcons: {
208 shortcut_icon: {223 shortcut_icon: {
209 foregroundImage: "./assets/android-icon-foreground.png",224 foregroundImage: variant.assets.androidAdaptiveForeground,
210 backgroundColor: "#E6F4FE",225 backgroundColor: variant.assets.androidAdaptiveBackgroundColor,
211 },226 },
212 },227 },
213 },228 },
...@@ -217,17 +232,18 @@ const config: ExpoConfig = {...@@ -217,17 +232,18 @@ const config: ExpoConfig = {
217 {232 {
218 cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.",233 cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.",
219 barcodeScannerEnabled: true,234 barcodeScannerEnabled: true,
235 recordAudioAndroid: false,
220 },236 },
221 ],237 ],
222 [238 [
223 "expo-splash-screen",239 "expo-splash-screen",
224 {240 {
225 image: variant.splashIcon,241 image: variant.assets.splashIcon,
226 resizeMode: "contain",242 resizeMode: "contain",
227 backgroundColor: "#ffffff",243 backgroundColor: "#ffffff",
228 imageWidth: 220,244 imageWidth: 220,
229 dark: {245 dark: {
230 image: variant.splashIcon,246 image: variant.assets.splashIcon,
231 backgroundColor: "#0a0a0a",247 backgroundColor: "#0a0a0a",
232 },248 },
233 },249 },
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...@@ -4,7 +4,7 @@ import android.content.Context
4import android.graphics.Canvas4import android.graphics.Canvas
5import android.graphics.Color5import android.graphics.Color
6import android.graphics.Paint6import android.graphics.Paint
7import android.graphics.Typeface7import android.graphics.RectF
8import android.view.GestureDetector8import android.view.GestureDetector
9import android.view.MotionEvent9import android.view.MotionEvent
10import android.view.VelocityTracker10import android.view.VelocityTracker
...@@ -19,6 +19,7 @@ import org.json.JSONArray...@@ -19,6 +19,7 @@ import org.json.JSONArray
19import org.json.JSONObject19import org.json.JSONObject
20import java.util.concurrent.Executors20import java.util.concurrent.Executors
21import kotlin.math.abs21import kotlin.math.abs
22import kotlin.math.ceil
22import kotlin.math.max23import kotlin.math.max
23import kotlin.math.min24import kotlin.math.min
2425
...@@ -49,12 +50,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -49,12 +50,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
49 private val verticalScroller = OverScroller(context)50 private val verticalScroller = OverScroller(context)
50 private val horizontalScroller = OverScroller(context)51 private val horizontalScroller = OverScroller(context)
51 private var dragAxis: DragAxis? = null52 private var dragAxis: DragAxis? = null
53 private var horizontalDragTarget: HorizontalPanTarget? = null
52 private var lastTouchX = 0f54 private var lastTouchX = 0f
53 private var lastTouchY = 0f55 private var lastTouchY = 0f
54 private var velocityTracker: VelocityTracker? = null56 private var velocityTracker: VelocityTracker? = null
5557
56 init {58 init {
57 canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) }59 canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) }
58 canvasView.onVisibleRowsChanged = { first, last ->60 canvasView.onVisibleRowsChanged = { first, last ->
59 onDebug(61 onDebug(
60 mapOf(62 mapOf(
...@@ -86,12 +88,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -86,12 +88,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
86 lastVisibleFileId = null88 lastVisibleFileId = null
87 pendingInitialScroll = true89 pendingInitialScroll = true
88 canvasView.setVerticalOffset(0)90 canvasView.setVerticalOffset(0)
89 canvasView.setHorizontalOffset(0)91 canvasView.resetHorizontalOffsets()
90 applyPendingInitialScroll()92 applyPendingInitialScroll()
91 }93 }
9294
93 fun setCollapsedFileIdsJson(value: String) {95 fun setCollapsedFileIdsJson(value: String) {
94 collapsedFileIds = parseStringSet(value)96 collapsedFileIds = parseStringSet(value)
97 canvasView.collapsedFileIds = collapsedFileIds
95 rebuildVisibleRows()98 rebuildVisibleRows()
96 }99 }
97100
...@@ -107,6 +110,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -107,6 +110,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
107110
108 fun setCollapsedCommentIdsJson(value: String) {111 fun setCollapsedCommentIdsJson(value: String) {
109 collapsedCommentIds = parseStringSet(value)112 collapsedCommentIds = parseStringSet(value)
113 canvasView.collapsedCommentIds = collapsedCommentIds
110 rebuildVisibleRows()114 rebuildVisibleRows()
111 }115 }
112116
...@@ -202,6 +206,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -202,6 +206,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
202 verticalScroller.forceFinished(true)206 verticalScroller.forceFinished(true)
203 horizontalScroller.forceFinished(true)207 horizontalScroller.forceFinished(true)
204 dragAxis = null208 dragAxis = null
209 horizontalDragTarget = canvasView.horizontalPanTarget(event.y)
205 lastTouchX = event.x210 lastTouchX = event.x
206 lastTouchY = event.y211 lastTouchY = event.y
207 parent?.requestDisallowInterceptTouchEvent(true)212 parent?.requestDisallowInterceptTouchEvent(true)
...@@ -212,7 +217,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -212,7 +217,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
212 val deltaX = event.x - lastTouchX217 val deltaX = event.x - lastTouchX
213 val deltaY = event.y - lastTouchY218 val deltaY = event.y - lastTouchY
214 if (max(abs(deltaX), abs(deltaY)) > touchSlop) {219 if (max(abs(deltaX), abs(deltaY)) > touchSlop) {
215 dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL220 dragAxis = if (abs(deltaY) >= abs(deltaX)) {
221 DragAxis.VERTICAL
222 } else {
223 horizontalDragTarget
224 ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 }
225 ?.let { DragAxis.HORIZONTAL }
226 }
216 }227 }
217 }228 }
218 return dragAxis != null229 return dragAxis != null
...@@ -238,6 +249,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -238,6 +249,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
238 ) {249 ) {
239 velocityTracker?.recycle()250 velocityTracker?.recycle()
240 velocityTracker = null251 velocityTracker = null
252 horizontalDragTarget = null
241 parent?.requestDisallowInterceptTouchEvent(false)253 parent?.requestDisallowInterceptTouchEvent(false)
242 }254 }
243 return handled255 return handled
...@@ -253,7 +265,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -253,7 +265,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
253 if (axis == DragAxis.VERTICAL) {265 if (axis == DragAxis.VERTICAL) {
254 canvasView.scrollByVertical(deltaY)266 canvasView.scrollByVertical(deltaY)
255 } else {267 } else {
256 canvasView.scrollByHorizontal(deltaX)268 horizontalDragTarget?.let { canvasView.scrollByHorizontal(deltaX, it) }
257 }269 }
258 lastTouchX = event.x270 lastTouchX = event.x
259 lastTouchY = event.y271 lastTouchY = event.y
...@@ -276,7 +288,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -276,7 +288,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
276 )288 )
277 postInvalidateOnAnimation()289 postInvalidateOnAnimation()
278 }290 }
279 } else {291 } else if (horizontalDragTarget?.kind == HorizontalPanKind.CODE) {
280 val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt()292 val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt()
281 if (abs(velocity) >= minimumFlingVelocity) {293 if (abs(velocity) >= minimumFlingVelocity) {
282 horizontalScroller.fling(294 horizontalScroller.fling(
...@@ -294,6 +306,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -294,6 +306,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
294 }306 }
295 }307 }
296 dragAxis = null308 dragAxis = null
309 horizontalDragTarget = null
297 velocityTracker?.recycle()310 velocityTracker?.recycle()
298 velocityTracker = null311 velocityTracker = null
299 parent?.requestDisallowInterceptTouchEvent(false)312 parent?.requestDisallowInterceptTouchEvent(false)
...@@ -325,11 +338,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -325,11 +338,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
325 currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId)338 currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId)
326 filtered.add(row)339 filtered.add(row)
327 } else if (!currentFileCollapsed) {340 } else if (!currentFileCollapsed) {
328 if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) {341 filtered.add(row)
329 filtered.add(row)
330 } else {
331 filtered.add(row.copy(commentText = "Comment collapsed"))
332 }
333 }342 }
334 }343 }
335 visibleRows = filtered344 visibleRows = filtered
...@@ -339,10 +348,11 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -339,10 +348,11 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
339 applyPendingInitialScroll()348 applyPendingInitialScroll()
340 }349 }
341350
342 private fun handleRowTap(row: DiffRow, gesture: String) {351 private fun handleRowTap(row: DiffRow, gesture: String, target: RowTapTarget) {
343 when (row.kind) {352 when (row.kind) {
344 "file" -> {353 "file" -> {
345 if (gesture == "longPress") {354 if (gesture != "tap") return
355 if (target == RowTapTarget.VIEWED_CHECKBOX) {
346 onToggleViewedFile(mapOf("fileId" to row.resolvedFileId))356 onToggleViewedFile(mapOf("fileId" to row.resolvedFileId))
347 } else {357 } else {
348 onToggleFile(mapOf("fileId" to row.resolvedFileId))358 onToggleFile(mapOf("fileId" to row.resolvedFileId))
...@@ -408,7 +418,22 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -408,7 +418,22 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
408 }418 }
409}419}
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(
412 val kind: String,437 val kind: String,
413 val id: String,438 val id: String,
414 val fileId: String,439 val fileId: String,
...@@ -422,6 +447,7 @@ private data class DiffRow(...@@ -422,6 +447,7 @@ private data class DiffRow(
422 val change: String,447 val change: String,
423 val oldLineNumber: Int?,448 val oldLineNumber: Int?,
424 val newLineNumber: Int?,449 val newLineNumber: Int?,
450 val wordDiffRanges: List<DiffWordDiffRange>,
425 val commentText: String,451 val commentText: String,
426 val commentRangeLabel: String,452 val commentRangeLabel: String,
427 val commentSectionTitle: String453 val commentSectionTitle: String
...@@ -429,13 +455,18 @@ private data class DiffRow(...@@ -429,13 +455,18 @@ private data class DiffRow(
429 val resolvedFileId: String get() = fileId.ifEmpty { id }455 val resolvedFileId: String get() = fileId.ifEmpty { id }
430}456}
431457
458internal data class DiffWordDiffRange(
459 val start: Int,
460 val end: Int
461)
462
432private data class DiffToken(463private data class DiffToken(
433 val content: String,464 val content: String,
434 val color: Int?,465 val color: Int?,
435 val fontStyle: Int466 val fontStyle: Int
436)467)
437468
438private data class DiffTheme(469internal data class DiffTheme(
439 val background: Int,470 val background: Int,
440 val text: Int,471 val text: Int,
441 val mutedText: Int,472 val mutedText: Int,
...@@ -511,14 +542,25 @@ private data class DiffTheme(...@@ -511,14 +542,25 @@ private data class DiffTheme(
511 }542 }
512}543}
513544
514private data class DiffStyle(545internal data class DiffStyle(
515 val rowHeightPx: Float,546 val rowHeightPx: Float,
516 val gutterWidthPx: Float,547 val gutterWidthPx: Float,
517 val codePaddingPx: Float,548 val codePaddingPx: Float,
518 val changeBarWidthPx: Float,549 val changeBarWidthPx: Float,
519 val fileHeaderHeightPx: Float,550 val fileHeaderHeightPx: Float,
551 val fileHeaderHorizontalPaddingPx: Float,
520 val codeFontSizePx: Float,552 val codeFontSizePx: Float,
521 val lineNumberFontSizePx: Float553 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
522) {564) {
523 companion object {565 companion object {
524 fun defaults(density: Float): DiffStyle = DiffStyle(566 fun defaults(density: Float): DiffStyle = DiffStyle(
...@@ -527,8 +569,19 @@ private data class DiffStyle(...@@ -527,8 +569,19 @@ private data class DiffStyle(
527 codePaddingPx = 10f * density,569 codePaddingPx = 10f * density,
528 changeBarWidthPx = 3f * density,570 changeBarWidthPx = 3f * density,
529 fileHeaderHeightPx = 44f * density,571 fileHeaderHeightPx = 44f * density,
572 fileHeaderHorizontalPaddingPx = 10f * density,
530 codeFontSizePx = 12f * density,573 codeFontSizePx = 12f * density,
574 codeFontWeight = "regular",
531 lineNumberFontSizePx = 10f * density,575 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",
532 )585 )
533586
534 fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try {587 fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try {
...@@ -539,11 +592,50 @@ private data class DiffStyle(...@@ -539,11 +592,50 @@ private data class DiffStyle(
539 codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density),592 codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density),
540 changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density),593 changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density),
541 fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density),594 fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density),
595 fileHeaderHorizontalPaddingPx = json.floatDp(
596 "fileHeaderHorizontalPadding",
597 fallback.fileHeaderHorizontalPaddingPx,
598 density,
599 ),
542 codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density),600 codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density),
601 codeFontWeight = json.optString("codeFontWeight", fallback.codeFontWeight),
543 lineNumberFontSizePx = json.floatSp(602 lineNumberFontSizePx = json.floatSp(
544 "lineNumberFontSize",603 "lineNumberFontSize",
545 fallback.lineNumberFontSizePx,604 fallback.lineNumberFontSizePx,
546 density605 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,
547 ),639 ),
548 )640 )
549 } catch (_: Exception) {641 } catch (_: Exception) {
...@@ -554,35 +646,54 @@ private data class DiffStyle(...@@ -554,35 +646,54 @@ private data class DiffStyle(
554646
555private class DiffCanvasView(context: Context) : View(context) {647private class DiffCanvasView(context: Context) : View(context) {
556 private val density = resources.displayMetrics.density648 private val density = resources.displayMetrics.density
557 private val backgroundPaint = Paint()649 private val drawing = ReviewDiffCanvasDrawing(context)
558 private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG)650 private val backgroundPaint = drawing.backgroundPaint
559 private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }651 private val borderPaint = drawing.borderPaint
560 private val boldTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {652 private val textPaint = drawing.textPaint
561 typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)653 private val boldTextPaint = drawing.uiPaint
562 }
563 private val gestureDetector = GestureDetector(654 private val gestureDetector = GestureDetector(
564 context,655 context,
565 object : GestureDetector.SimpleOnGestureListener() {656 object : GestureDetector.SimpleOnGestureListener() {
566 override fun onDown(event: MotionEvent): Boolean = true657 override fun onDown(event: MotionEvent): Boolean = true
567658
568 override fun onSingleTapUp(event: MotionEvent): Boolean {659 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 }
570 return true676 return true
571 }677 }
572678
573 override fun onLongPress(event: MotionEvent) {679 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) }
575 }683 }
576 },684 },
577 )685 )
578 private var rowOffsets = intArrayOf(0)686 private var rowOffsets = intArrayOf(0)
579 private var verticalOffset = 0687 private var verticalOffset = 0
580 private var horizontalOffset = 0688 private var horizontalOffset = 0
689 private val headerPathOffsetsByFileId = mutableMapOf<String, Int>()
581 private var lastVisibleRange: Pair<Int, Int>? = null690 private var lastVisibleRange: Pair<Int, Int>? = null
582
583 var rows: List<DiffRow> = emptyList()691 var rows: List<DiffRow> = emptyList()
584 set(value) {692 set(value) {
585 field = value693 field = value
694 headerPathOffsetsByFileId.keys.retainAll(
695 value.asSequence().filter { it.kind == "file" }.map { it.resolvedFileId }.toSet(),
696 )
586 rebuildOffsets()697 rebuildOffsets()
587 }698 }
588 var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()699 var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()
...@@ -595,6 +706,16 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -595,6 +706,16 @@ private class DiffCanvasView(context: Context) : View(context) {
595 field = value706 field = value
596 invalidate()707 invalidate()
597 }708 }
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 }
598 var selectedRowIds: Set<String> = emptySet()719 var selectedRowIds: Set<String> = emptySet()
599 set(value) {720 set(value) {
600 field = value721 field = value
...@@ -603,6 +724,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -603,6 +724,7 @@ private class DiffCanvasView(context: Context) : View(context) {
603 var theme: DiffTheme = DiffTheme.fallback("light")724 var theme: DiffTheme = DiffTheme.fallback("light")
604 set(value) {725 set(value) {
605 field = value726 field = value
727 drawing.theme = value
606 invalidate()728 invalidate()
607 }729 }
608 var style: DiffStyle = DiffStyle.defaults(density)730 var style: DiffStyle = DiffStyle.defaults(density)
...@@ -614,9 +736,10 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -614,9 +736,10 @@ private class DiffCanvasView(context: Context) : View(context) {
614 set(value) {736 set(value) {
615 field = max(value, suggestedMinimumWidth)737 field = max(value, suggestedMinimumWidth)
616 setHorizontalOffset(horizontalOffset)738 setHorizontalOffset(horizontalOffset)
739 clampHeaderPathOffsets()
617 invalidate()740 invalidate()
618 }741 }
619 var onRowTap: ((DiffRow, String) -> Unit)? = null742 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null
620 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null743 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null
621744
622 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {745 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
...@@ -630,6 +753,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -630,6 +753,7 @@ private class DiffCanvasView(context: Context) : View(context) {
630 super.onSizeChanged(width, height, oldWidth, oldHeight)753 super.onSizeChanged(width, height, oldWidth, oldHeight)
631 setVerticalOffset(verticalOffset)754 setVerticalOffset(verticalOffset)
632 setHorizontalOffset(horizontalOffset)755 setHorizontalOffset(horizontalOffset)
756 clampHeaderPathOffsets()
633 }757 }
634758
635 override fun onDraw(canvas: Canvas) {759 override fun onDraw(canvas: Canvas) {
...@@ -681,27 +805,63 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -681,27 +805,63 @@ private class DiffCanvasView(context: Context) : View(context) {
681 invalidate()805 invalidate()
682 }806 }
683807
684 fun scrollByHorizontal(delta: Int) {808 fun scrollByHorizontal(delta: Int, target: HorizontalPanTarget) {
685 setHorizontalOffset(horizontalOffset + delta)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 }
686 }817 }
687818
688 fun horizontalOffset(): Int = horizontalOffset819 fun horizontalOffset(): Int = horizontalOffset
689820
690 fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width)821 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
692 private fun rebuildOffsets() {846 private fun rebuildOffsets() {
693 rowOffsets = IntArray(rows.size + 1)847 rowOffsets = IntArray(rows.size + 1)
694 rows.forEachIndexed { index, row ->848 rows.forEachIndexed { index, row ->
695 rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row)849 rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row)
696 }850 }
697 setVerticalOffset(verticalOffset)851 setVerticalOffset(verticalOffset)
852 clampHeaderPathOffsets()
698 requestLayout()853 requestLayout()
699 invalidate()854 invalidate()
700 }855 }
701856
702 private fun rowHeight(row: DiffRow): Int = when (row.kind) {857 private fun rowHeight(row: DiffRow): Int = when (row.kind) {
703 "file" -> style.fileHeaderHeightPx.toInt()858 "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 }
705 else -> style.rowHeightPx.toInt()865 else -> style.rowHeightPx.toInt()
706 }.coerceAtLeast(1)866 }.coerceAtLeast(1)
707867
...@@ -721,13 +881,20 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -721,13 +881,20 @@ private class DiffCanvasView(context: Context) : View(context) {
721 return low.coerceIn(0, rows.lastIndex)881 return low.coerceIn(0, rows.lastIndex)
722 }882 }
723883
724 private fun rowAt(y: Float): DiffRow? {884 private fun rowHitAt(y: Float): RowHit? {
725 stickyFileHeader(firstVisibleRow())?.let { sticky ->885 stickyFileHeader(firstVisibleRow())?.let { sticky ->
726 if (y >= max(0, sticky.top).toFloat() && y < sticky.bottom.toFloat()) {886 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) }
728 }888 }
729 }889 }
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 }
731 }898 }
732899
733 private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset)900 private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset)
...@@ -754,23 +921,140 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -754,23 +921,140 @@ private class DiffCanvasView(context: Context) : View(context) {
754921
755 private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {922 private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
756 fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())923 fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
757 val baseline = centeredBaseline(top, bottom, boldTextPaint.apply { textSize = 13f * density })924
758 boldTextPaint.color = theme.text925 val chevronRect = drawing.fileHeaderChevronRect(top, bottom, style)
759 val marker = if (viewedFileIds.contains(row.resolvedFileId)) "[x] " else "[ ] "926 val iconRect = drawing.fileHeaderIconRect(top, bottom, style)
760 textPaint.color = theme.mutedText927 val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style)
761 textPaint.textSize = 11f * density928 drawing.drawDisclosureChevron(
762 val stats = "+${row.additions} -${row.deletions}"929 canvas,
763 val statsX = max(12f * density, width - textPaint.measureText(stats) - 16f * density)930 chevronRect,
764 val title = ellipsize(marker + row.filePath, boldTextPaint, statsX - 28f * density)931 theme.mutedText,
765 canvas.drawText(title, 12f * density, baseline, boldTextPaint)932 collapsed = collapsedFileIds.contains(row.resolvedFileId),
766 canvas.drawText(stats, statsX, baseline, textPaint)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 )
767 drawBottomBorder(canvas, bottom)977 drawBottomBorder(canvas, bottom)
768 }978 }
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
770 private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {1051 private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
771 fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())1052 fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
772 textPaint.color = theme.hunkText1053 drawing.configureMonospacePaint(
773 textPaint.textSize = style.codeFontSizePx1054 color = theme.hunkText,
1055 size = style.hunkFontSizePx,
1056 weight = style.hunkFontWeight,
1057 )
774 drawScrollableCode(canvas, top, bottom) { codeX ->1058 drawScrollableCode(canvas, top, bottom) { codeX ->
775 canvas.drawText(1059 canvas.drawText(
776 row.text.ifEmpty { row.content },1060 row.text.ifEmpty { row.content },
...@@ -782,36 +1066,82 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -782,36 +1066,82 @@ private class DiffCanvasView(context: Context) : View(context) {
782 }1066 }
7831067
784 private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {1068 private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
785 textPaint.color = theme.mutedText1069 fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
786 textPaint.textSize = style.codeFontSizePx1070 val iconSize = 16f * density
787 drawScrollableCode(canvas, top, bottom) { codeX ->1071 val iconRect = RectF(
788 canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint)1072 style.fileHeaderHorizontalPaddingPx + 2f * density,
789 }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)
790 }1092 }
7911093
792 private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {1094 private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
793 fill(1095 fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
794 canvas,1096 val cardRect = RectF(
795 theme.headerBackground,1097 8f * density,
796 style.gutterWidthPx,1098 top + 5f * density,
797 top.toFloat(),1099 width - 8f * density,
798 width.toFloat(),1100 bottom - 5f * density,
799 bottom.toFloat()
800 )1101 )
801 boldTextPaint.color = theme.text1102 backgroundPaint.color = theme.headerBackground
802 boldTextPaint.textSize = 12f * density1103 canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint)
803 drawScrollableCode(canvas, top, bottom) { codeX ->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
804 canvas.drawText(1134 canvas.drawText(
805 row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } },1135 ellipsize(
806 codeX,1136 row.commentText.ifEmpty { "Comment" },
807 top + 20f * density,1137 textPaint,
808 boldTextPaint,1138 cardRect.right - bodyX - 18f * density
1139 ),
1140 bodyX,
1141 cardRect.top + 58f * density,
1142 textPaint,
809 )1143 )
810 textPaint.color = theme.mutedText
811 textPaint.textSize = 12f * density
812 canvas.drawText(row.commentText, codeX, top + 42f * density, textPaint)
813 }1144 }
814 drawBottomBorder(canvas, bottom)
815 }1145 }
8161146
817 @Suppress("CyclomaticComplexMethod")1147 @Suppress("CyclomaticComplexMethod")
...@@ -822,65 +1152,79 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -822,65 +1152,79 @@ private class DiffCanvasView(context: Context) : View(context) {
822 else -> theme.background1152 else -> theme.background
823 }1153 }
824 fill(canvas, background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())1154 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 }
825 val selected = selectedRowIds.contains(row.id)1174 val selected = selectedRowIds.contains(row.id)
826 if (selected) {1175 if (selected) {
827 fill(1176 fill(
828 canvas,1177 canvas,
829 withAlpha(theme.hunkText, if (theme.background == Color.WHITE) 54 else 76),1178 withAlpha(theme.hunkText, 56),
830 0f,1179 0f,
831 top.toFloat(),1180 top.toFloat(),
832 width.toFloat(),1181 width.toFloat(),
833 bottom.toFloat(),1182 bottom.toFloat(),
834 )1183 )
835 }1184 fill(
836 val barColor = when (row.change) {1185 canvas,
837 "add" -> theme.addBar1186 withAlpha(theme.hunkText, 242),
838 "delete" -> theme.deleteBar1187 0f,
839 else -> Color.TRANSPARENT1188 top.toFloat(),
840 }1189 style.changeBarWidthPx,
841 if (barColor != Color.TRANSPARENT && style.changeBarWidthPx > 0) {1190 bottom.toFloat()
842 fill(canvas, barColor, 0f, top.toFloat(), style.changeBarWidthPx, bottom.toFloat())1191 )
843 }1192 }
8441193
845 val tokens = tokensByRowId[row.id]1194 val tokens = tokensByRowId[row.id]
846 drawScrollableCode(canvas, top, bottom) { codeX ->1195 drawScrollableCode(canvas, top, bottom) { codeX ->
1196 drawing.configureCodePaint(theme.text, 0, style)
1197 drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom)
847 if (tokens.isNullOrEmpty()) {1198 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 }
854 canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint)1199 canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint)
855 } else {1200 } else {
856 var x = codeX1201 var x = codeX
857 tokens.forEach { token ->1202 tokens.forEach { token ->
858 textPaint.textSize = style.codeFontSizePx1203 drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style)
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 }
865 canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint)1204 canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint)
866 x += textPaint.measureText(token.content)1205 x += textPaint.measureText(token.content)
867 }1206 }
868 textPaint.typeface = Typeface.MONOSPACE
869 }1207 }
870 }1208 }
8711209
872 textPaint.textSize = style.lineNumberFontSizePx1210 drawLineNumber(canvas, row, top, bottom)
873 textPaint.color = if (selected) theme.text else theme.mutedText1211 }
874 val oldNumber = row.oldLineNumber?.toString().orEmpty()1212
875 val newNumber = row.newLineNumber?.toString().orEmpty()1213 private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
876 val baseline = centeredBaseline(top, bottom, textPaint)1214 val lineNumber = row.newLineNumber ?: row.oldLineNumber ?: return
877 canvas.drawText(oldNumber, style.changeBarWidthPx + 6f * density, baseline, textPaint)1215 drawing.configureMonospacePaint(
1216 color = drawing.lineNumberColor(row.change),
1217 size = style.lineNumberFontSizePx,
1218 weight = style.lineNumberFontWeight,
1219 )
1220 textPaint.textAlign = Paint.Align.RIGHT
878 canvas.drawText(1221 canvas.drawText(
879 newNumber,1222 lineNumber.toString(),
880 style.changeBarWidthPx + style.gutterWidthPx / 2f,1223 style.changeBarWidthPx + style.gutterWidthPx - style.codePaddingPx,
881 baseline,1224 centeredBaseline(top, bottom, textPaint),
882 textPaint1225 textPaint,
883 )1226 )
1227 textPaint.textAlign = Paint.Align.LEFT
884 }1228 }
8851229
886 private fun drawScrollableCode(1230 private fun drawScrollableCode(
...@@ -972,6 +1316,17 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -972,6 +1316,17 @@ private class DiffCanvasView(context: Context) : View(context) {
972 val top: Int,1316 val top: Int,
973 val bottom: Int1317 val bottom: Int
974 )1318 )
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 )
975}1330}
9761331
977private fun withAlpha(color: Int, alpha: Int): Int =1332private fun withAlpha(color: Int, alpha: Int): Int =
...@@ -995,6 +1350,7 @@ private fun parseRows(value: String): List<DiffRow> = try {...@@ -995,6 +1350,7 @@ private fun parseRows(value: String): List<DiffRow> = try {
995 change = row.optString("change", "context"),1350 change = row.optString("change", "context"),
996 oldLineNumber = row.optNullableInt("oldLineNumber"),1351 oldLineNumber = row.optNullableInt("oldLineNumber"),
997 newLineNumber = row.optNullableInt("newLineNumber"),1352 newLineNumber = row.optNullableInt("newLineNumber"),
1353 wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(),
998 commentText = row.optString("commentText"),1354 commentText = row.optString("commentText"),
999 commentRangeLabel = row.optString("commentRangeLabel"),1355 commentRangeLabel = row.optString("commentRangeLabel"),
1000 commentSectionTitle = row.optString("commentSectionTitle"),1356 commentSectionTitle = row.optString("commentSectionTitle"),
...@@ -1004,6 +1360,17 @@ private fun parseRows(value: String): List<DiffRow> = try {...@@ -1004,6 +1360,17 @@ private fun parseRows(value: String): List<DiffRow> = try {
1004 emptyList()1360 emptyList()
1005}1361}
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
1007private fun parseTokensObject(value: String): Map<String, List<DiffToken>> = try {1374private fun parseTokensObject(value: String): Map<String, List<DiffToken>> = try {
1008 parseTokensObject(JSONObject(value))1375 parseTokensObject(JSONObject(value))
1009} catch (_: Exception) {1376} catch (_: Exception) {
...@@ -1020,7 +1387,7 @@ private fun parseTokensObject(value: JSONObject): Map<String, List<DiffToken>> {...@@ -1020,7 +1387,7 @@ private fun parseTokensObject(value: JSONObject): Map<String, List<DiffToken>> {
1020 val token = array.getJSONObject(index)1387 val token = array.getJSONObject(index)
1021 DiffToken(1388 DiffToken(
1022 content = token.optString("content"),1389 content = token.optString("content"),
1023 color = token.optNullableString("color")?.let { parseColor(it, Color.TRANSPARENT) },1390 color = token.optNullableString("color")?.let(::parseColorOrNull),
1024 fontStyle = token.optInt("fontStyle"),1391 fontStyle = token.optInt("fontStyle"),
1025 )1392 )
1026 }1393 }
...@@ -1043,6 +1410,12 @@ private fun parseColor(value: String, fallback: Int): Int = try {...@@ -1043,6 +1410,12 @@ private fun parseColor(value: String, fallback: Int): Int = try {
1043 fallback1410 fallback
1044}1411}
10451412
1413private fun parseColorOrNull(value: String): Int? = try {
1414 Color.parseColor(value)
1415} catch (_: Exception) {
1416 null
1417}
1418
1046private fun JSONObject.optNullableString(key: String): String? =1419private fun JSONObject.optNullableString(key: String): String? =
1047 if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }1420 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({...@@ -440,9 +440,9 @@ export const RootStack = createNativeStackNavigator({
440 screen: ConnectOnboardingRouteScreen,440 screen: ConnectOnboardingRouteScreen,
441 linking: "connect-onboarding",441 linking: "connect-onboarding",
442 options: {442 options: {
443 // Root screenOptions hide headers; formSheets that want the native443 // A root-level Android formSheet does not host the native stack bar;
444 // title bar opt back in with the sheet header preset.444 // the route renders an embedded AndroidSheetHeader instead.
445 ...SHEET_SOLID_HEADER_OPTIONS,445 ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS),
446 title: "Set up T3 Connect",446 title: "Set up T3 Connect",
447 gestureEnabled: true,447 gestureEnabled: true,
448 presentation: "formSheet",448 presentation: "formSheet",
apps/mobile/src/components/AndroidScreenHeader.tsx+8-1
...@@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: {...@@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: {
51 readonly actions?: ReadonlyArray<AndroidHeaderAction>;51 readonly actions?: ReadonlyArray<AndroidHeaderAction>;
52 readonly trailing?: ReactNode;52 readonly trailing?: ReactNode;
53 readonly onBack?: () => void;53 readonly onBack?: () => void;
54 readonly embedded?: boolean;
54}) {55}) {
55 const insets = useSafeAreaInsets();56 const insets = useSafeAreaInsets();
56 const foregroundColor = useThemeColor("--color-foreground");57 const foregroundColor = useThemeColor("--color-foreground");
...@@ -59,7 +60,7 @@ export function AndroidScreenHeader(props: {...@@ -59,7 +60,7 @@ export function AndroidScreenHeader(props: {
59 <View60 <View
60 className="border-b border-header-border bg-header px-3 pb-2.5"61 className="border-b border-header-border bg-header px-3 pb-2.5"
61 style={{62 style={{
62 paddingTop: Math.max(insets.top, 12),63 paddingTop: props.embedded ? 8 : Math.max(insets.top, 12),
63 }}64 }}
64 >65 >
65 <View className="min-h-12 flex-row items-center gap-2">66 <View className="min-h-12 flex-row items-center gap-2">
...@@ -108,3 +109,9 @@ export function AndroidScreenHeader(props: {...@@ -108,3 +109,9 @@ export function AndroidScreenHeader(props: {
108 </View>109 </View>
109 );110 );
110}111}
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";...@@ -2,10 +2,11 @@ import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useAuth } from "@clerk/expo";2import { useAuth } from "@clerk/expo";
3import { StackActions, useNavigation } from "@react-navigation/native";3import { StackActions, useNavigation } from "@react-navigation/native";
4import { useCallback, useEffect, useState } from "react";4import { useCallback, useEffect, useState } from "react";
5import { Pressable, RefreshControl, ScrollView, View } from "react-native";5import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
77
8import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";8import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
9import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
9import { AppText as Text } from "../../components/AppText";10import { AppText as Text } from "../../components/AppText";
10import { useRemoteConnections } from "../../state/use-remote-environment-registry";11import { useRemoteConnections } from "../../state/use-remote-environment-registry";
11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";12import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
...@@ -81,9 +82,16 @@ function ConfiguredConnectOnboardingRouteScreen() {...@@ -81,9 +82,16 @@ function ConfiguredConnectOnboardingRouteScreen() {
8182
82 return (83 return (
83 <View collapsable={false} className="flex-1 bg-sheet">84 <View collapsable={false} className="flex-1 bg-sheet">
84 <NativeHeaderToolbar placement="right">85 {Platform.OS === "android" ? (
85 <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />86 <AndroidSheetHeader
86 </NativeHeaderToolbar>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 )}
87 <ScrollView95 <ScrollView
88 alwaysBounceVertical96 alwaysBounceVertical
89 contentInsetAdjustmentBehavior="automatic"97 contentInsetAdjustmentBehavior="automatic"
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+118-107
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useState } from "react";3import { useState } from "react";
4import { Pressable, ScrollView, View } from "react-native";4import { Platform, Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
66
7import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";8import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
8import { cn } from "../../../lib/cn";9import { cn } from "../../../lib/cn";
9import { useEnvironmentQuery } from "../../../state/query";10import { useEnvironmentQuery } from "../../../state/query";
...@@ -57,115 +58,125 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {...@@ -57,115 +58,125 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
57 const disabledExistingBranches = new Set(disabledExistingBranchNames);58 const disabledExistingBranches = new Set(disabledExistingBranchNames);
5859
59 return (60 return (
60 <ScrollView61 <View collapsable={false} className="flex-1 bg-sheet">
61 showsVerticalScrollIndicator={false}62 {Platform.OS === "android" ? (
62 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}63 <AndroidSheetHeader title="Branches & worktrees" onBack={() => navigation.goBack()} />
63 contentContainerClassName="gap-4 px-5 pt-2"64 ) : null}
64 >65 <ScrollView
65 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">66 className="flex-1"
66 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">67 showsVerticalScrollIndicator={false}
67 New branch68 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
68 </Text>69 contentContainerClassName="gap-4 px-5 pt-2"
69 <TextInput70 >
70 value={newBranchName}71 <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4">
71 onChangeText={setNewBranchName}72 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
72 placeholder="feature/mobile-polish"73 New branch
73 className="rounded-[18px]"74 </Text>
74 />75 <TextInput
75 <SheetActionButton76 value={newBranchName}
76 icon="plus"77 onChangeText={setNewBranchName}
77 label="Create & checkout"78 placeholder="feature/mobile-polish"
78 tone="primary"79 className="rounded-[18px]"
79 disabled={busy || newBranchName.trim().length === 0}80 />
80 onPress={() => {81 <SheetActionButton
81 const branch = sanitizeFeatureBranchName(newBranchName.trim());82 icon="plus"
82 if (branch.length === 0) return;83 label="Create & checkout"
83 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {84 tone="primary"
84 setNewBranchName("");85 disabled={busy || newBranchName.trim().length === 0}
85 navigation.goBack();86 onPress={() => {
86 });87 const branch = sanitizeFeatureBranchName(newBranchName.trim());
87 }}88 if (branch.length === 0) return;
88 />89 void gitActions.onCreateSelectedThreadBranch(branch).then(() => {
89 </View>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">97 <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">98 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
93 New worktree99 New worktree
94 </Text>100 </Text>
95 <TextInput101 <TextInput
96 value={worktreeBaseBranch}102 value={worktreeBaseBranch}
97 onChangeText={setWorktreeBaseBranch}103 onChangeText={setWorktreeBaseBranch}
98 placeholder="main"104 placeholder="main"
99 className="rounded-[18px]"105 className="rounded-[18px]"
100 />106 />
101 <TextInput107 <TextInput
102 value={worktreeBranchName}108 value={worktreeBranchName}
103 onChangeText={setWorktreeBranchName}109 onChangeText={setWorktreeBranchName}
104 placeholder="feature/mobile-thread"110 placeholder="feature/mobile-thread"
105 className="rounded-[18px]"111 className="rounded-[18px]"
106 />112 />
107 <SheetActionButton113 <SheetActionButton
108 icon="square.split.2x1"114 icon="square.split.2x1"
109 label="Create worktree"115 label="Create worktree"
110 tone="primary"116 tone="primary"
111 disabled={117 disabled={
112 busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0118 busy ||
113 }119 worktreeBaseBranch.trim().length === 0 ||
114 onPress={() => {120 worktreeBranchName.trim().length === 0
115 const baseBranch = worktreeBaseBranch.trim();121 }
116 const newBranch = worktreeBranchName.trim();122 onPress={() => {
117 if (baseBranch.length === 0 || newBranch.length === 0) return;123 const baseBranch = worktreeBaseBranch.trim();
118 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {124 const newBranch = worktreeBranchName.trim();
119 setWorktreeBranchName("");125 if (baseBranch.length === 0 || newBranch.length === 0) return;
120 navigation.goBack();126 void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => {
121 });127 setWorktreeBranchName("");
122 }}128 navigation.goBack();
123 />129 });
124 </View>130 }}
131 />
132 </View>
125133
126 <View className="gap-2">134 <View className="gap-2">
127 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">135 <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase">
128 Existing branches136 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.
136 </Text>137 </Text>
137 ) : null}138 {branchesLoading ? (
138 {availableBranches.map((branch) => {139 <Text className="text-foreground-secondary text-sm font-medium">
139 const disabled = disabledExistingBranches.has(branch.name);140 Loading branches...
140 const subtitle = branch.worktreePath141 </Text>
141 ? branch.worktreePath === currentWorktreePath142 ) : null}
142 ? "Checked out in this thread"143 {!branchesLoading && availableBranches.length === 0 ? (
143 : "Checked out in another worktree"144 <Text className="text-foreground-secondary text-sm font-medium">
144 : branch.isDefault145 No local branches found.
145 ? "Default branch"146 </Text>
146 : "Local branch";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 (158 return (
149 <Pressable159 <Pressable
150 key={branch.name}160 key={branch.name}
151 className={cn(161 className={cn(
152 "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]",162 "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]",
153 branch.current ? "border-subtle-strong" : "border-border",163 branch.current ? "border-subtle-strong" : "border-border",
154 )}164 )}
155 disabled={busy || disabled}165 disabled={busy || disabled}
156 onPress={() => {166 onPress={() => {
157 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {167 void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
158 navigation.goBack();168 navigation.goBack();
159 });169 });
160 }}170 }}
161 >171 >
162 <View className="absolute inset-0 rounded-[18px] bg-card" />172 <View className="absolute inset-0 rounded-[18px] bg-card" />
163 <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text>173 <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text>
164 <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text>174 <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text>
165 </Pressable>175 </Pressable>
166 );176 );
167 })}177 })}
168 </View>178 </View>
169 </ScrollView>179 </ScrollView>
180 </View>
170 );181 );
171}182}
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+150-141
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";1import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { useCallback, useState } from "react";2import { useCallback, useState } from "react";
3import { Pressable, ScrollView, View } from "react-native";3import { Platform, Pressable, ScrollView, View } from "react-native";
4import { useSafeAreaInsets } from "react-native-safe-area-context";4import { useSafeAreaInsets } from "react-native-safe-area-context";
55
6import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
6import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";7import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
7import { cn } from "../../../lib/cn";8import { cn } from "../../../lib/cn";
8import { useEnvironmentQuery } from "../../../state/query";9import { useEnvironmentQuery } from "../../../state/query";
...@@ -65,160 +66,168 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {...@@ -65,160 +66,168 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
65 );66 );
6667
67 return (68 return (
68 <ScrollView69 <View collapsable={false} className="flex-1 bg-sheet">
69 keyboardShouldPersistTaps="handled"70 {Platform.OS === "android" ? (
70 showsVerticalScrollIndicator={false}71 <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} />
71 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}72 ) : null}
72 contentContainerClassName="gap-4 px-5 pt-2"73 <ScrollView
73 >74 className="flex-1"
74 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">75 keyboardShouldPersistTaps="handled"
75 <View className="flex-row items-center justify-between gap-3">76 showsVerticalScrollIndicator={false}
76 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>77 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
77 <Text className="text-foreground text-base font-t3-bold">78 contentContainerClassName="gap-4 px-5 pt-2"
78 {gitStatus.data?.refName ?? "(detached HEAD)"}79 >
79 </Text>80 <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4">
80 </View>81 <View className="flex-row items-center justify-between gap-3">
81 {isDefaultRef ? (82 <Text className="text-foreground-muted text-sm font-medium">Branch</Text>
82 <Text className="text-xs leading-normal text-amber-700 dark:text-amber-400">83 <Text className="text-foreground text-base font-t3-bold">
83 Warning: this is the default branch.84 {gitStatus.data?.refName ?? "(detached HEAD)"}
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}
94 </Text>85 </Text>
95 </View>86 </View>
96 <View className="flex-row items-center gap-2">87 {isDefaultRef ? (
97 {!allSelected && isEditingFiles ? (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}
98 <Pressable111 <Pressable
99 className="bg-subtle rounded-full px-3 py-2"112 className="bg-subtle rounded-full px-3 py-2"
100 onPress={() => setExcludedFiles(new Set())}113 onPress={() => setIsEditingFiles((current) => !current)}
101 >114 >
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>
103 </Pressable>118 </Pressable>
104 ) : null}119 </View>
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>
113 </View>120 </View>
114 </View>
115121
116 {allFiles.length === 0 ? (122 {allFiles.length === 0 ? (
117 <Text className="text-foreground-secondary text-sm leading-normal">123 <Text className="text-foreground-secondary text-sm leading-normal">
118 No changed files are available to commit.124 No changed files are available to commit.
119 </Text>125 </Text>
120 ) : !isEditingFiles ? (126 ) : !isEditingFiles ? (
121 <View className="gap-2">127 <View className="gap-2">
122 {selectedFilePreview.map((file) => (128 {selectedFilePreview.map((file) => (
123 <View key={file.path} className="flex-row items-center justify-between gap-3">129 <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}>130 <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}>
125 {file.path}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
126 </Text>140 </Text>
127 <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text>141 ) : null}
128 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>142 </View>
129 </View>143 ) : (
130 ))}144 <View className="gap-2">
131 {selectedFiles.length > selectedFilePreview.length ? (145 {allFiles.map((file) => {
132 <Text className="text-foreground-muted text-xs leading-snug">146 const included = !excludedFiles.has(file.path);
133 +{selectedFiles.length - selectedFilePreview.length} more files147 return (
134 </Text>148 <Pressable
135 ) : null}149 key={file.path}
136 </View>150 className={cn(
137 ) : (151 "rounded-[18px] border px-4 py-3",
138 <View className="gap-2">152 included ? "border-border" : "border-border-subtle",
139 {allFiles.map((file) => {153 )}
140 const included = !excludedFiles.has(file.path);154 onPress={() => {
141 return (155 setExcludedFiles((current) => {
142 <Pressable156 const next = new Set(current);
143 key={file.path}157 if (next.has(file.path)) {
144 className={cn(158 next.delete(file.path);
145 "rounded-[18px] border px-4 py-3",159 } else {
146 included ? "border-border" : "border-border-subtle",160 next.add(file.path);
147 )}161 }
148 onPress={() => {162 return next;
149 setExcludedFiles((current) => {163 });
150 const next = new Set(current);164 }}
151 if (next.has(file.path)) {165 >
152 next.delete(file.path);166 <View
153 } else {167 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}
154 next.add(file.path);168 />
155 }169 <View className="flex-row items-start justify-between gap-3">
156 return next;170 <View className="flex-1 gap-1">
157 });171 <Text
158 }}172 selectable
159 >173 className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`}
160 <View174 >
161 className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`}175 {file.path}
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
174 </Text>176 </Text>
175 ) : null}177 {!included ? (
176 </View>178 <Text className="text-foreground-muted text-2xs leading-normal">
177 <View className="items-end gap-1">179 Excluded from this commit
178 <Text className="text-xs font-t3-bold text-emerald-500">180 </Text>
179 +{file.insertions}181 ) : null}
180 </Text>182 </View>
181 <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text>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>
182 </View>191 </View>
183 </View>192 </Pressable>
184 </Pressable>193 );
185 );194 })}
186 })}195 </View>
187 </View>196 )}
188 )}197 </View>
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>
202198
203 <View className="flex-row gap-3">199 <View className="gap-2">
204 <View className="flex-1">200 <Text className="text-foreground text-sm font-t3-bold">Commit message</Text>
205 <SheetActionButton201 <TextInput
206 icon="arrow.branch"202 multiline
207 label="Commit on new branch"203 value={dialogCommitMessage}
208 disabled={noneSelected || busy}204 onChangeText={setDialogCommitMessage}
209 onPress={() => void runCommitAction(true)}205 placeholder="Leave empty to auto-generate"
206 textAlignVertical="top"
207 className="min-h-[128px] rounded-[20px] px-4 py-3.5"
210 />208 />
211 </View>209 </View>
212 <View className="flex-1">210
213 <SheetActionButton211 <View className="flex-row gap-3">
214 icon="checkmark.circle"212 <View className="flex-1">
215 label="Commit"213 <SheetActionButton
216 tone="primary"214 icon="arrow.branch"
217 disabled={noneSelected || busy}215 label="Commit on new branch"
218 onPress={() => void runCommitAction(false)}216 disabled={noneSelected || busy}
219 />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>
220 </View>229 </View>
221 </View>230 </ScrollView>
222 </ScrollView>231 </View>
223 );232 );
224}233}
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+7-2
...@@ -4,10 +4,11 @@ import * as Arr from "effect/Array";...@@ -4,10 +4,11 @@ import * as Arr from "effect/Array";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";5import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
6import { useCallback, useMemo } from "react";6import { useCallback, useMemo } from "react";
7import { View } from "react-native";7import { Platform, View } from "react-native";
88
9import { useSafeAreaInsets } from "react-native-safe-area-context";9import { useSafeAreaInsets } from "react-native-safe-area-context";
1010
11import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
11import { AppText as Text } from "../../../components/AppText";12import { AppText as Text } from "../../../components/AppText";
12import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";13import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
13import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";14import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
...@@ -102,7 +103,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {...@@ -102,7 +103,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) {
102103
103 return (104 return (
104 <View collapsable={false} className="flex-1 bg-sheet">105 <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
107 <View className="items-center gap-1 px-5 pb-3 pt-4">112 <View className="items-center gap-1 px-5 pb-3 pt-4">
108 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">113 <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 {...@@ -5,7 +5,12 @@ import {
5 requiresDefaultBranchConfirmation,5 requiresDefaultBranchConfirmation,
6} from "@t3tools/client-runtime/state/vcs";6} from "@t3tools/client-runtime/state/vcs";
7import { EnvironmentId, ThreadId } from "@t3tools/contracts";7import { 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";
9import { SymbolView } from "../../../components/AppSymbol";14import { SymbolView } from "../../../components/AppSymbol";
10import { useCallback, useEffect, useMemo, useState } from "react";15import { useCallback, useEffect, useMemo, useState } from "react";
11import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";16import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native";
...@@ -14,6 +19,7 @@ import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-scree...@@ -14,6 +19,7 @@ import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-scree
14import { useSafeAreaInsets } from "react-native-safe-area-context";19import { useSafeAreaInsets } from "react-native-safe-area-context";
15import { useThemeColor } from "../../../lib/useThemeColor";20import { useThemeColor } from "../../../lib/useThemeColor";
1621
22import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
17import { AppText as Text } from "../../../components/AppText";23import { AppText as Text } from "../../../components/AppText";
18import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";24import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
19import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";25import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
...@@ -23,6 +29,7 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-...@@ -23,6 +29,7 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-
23import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";29import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
24import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";30import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
25import { vcsEnvironment } from "../../../state/vcs";31import { vcsEnvironment } from "../../../state/vcs";
32import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
26import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";33import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
2734
28const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);35const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
...@@ -255,7 +262,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -255,7 +262,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
255 title="Review changes"262 title="Review changes"
256 subtitle="Inspect turn diffs, worktree changes, and base branch diff"263 subtitle="Inspect turn diffs, worktree changes, and base branch diff"
257 disabled={busy || !isRepo}264 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 }}
259 />273 />
260 <View className="ml-12 h-px bg-border" />274 <View className="ml-12 h-px bg-border" />
261 <SheetListRow275 <SheetListRow
...@@ -348,12 +362,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -348,12 +362,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
348 collapsable={false}362 collapsable={false}
349 className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"}363 className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"}
350 >364 >
351 <View365 {isInspector ? (
352 style={{366 <View
353 minHeight: isInspector ? (props.headerInset ?? 0) : 16,367 style={{
354 paddingTop: isInspector ? (props.headerInset ?? 0) : 8,368 minHeight: props.headerInset ?? 0,
355 }}369 paddingTop: props.headerInset ?? 0,
356 />370 }}
371 />
372 ) : null}
357373
358 {isInspector ? (374 {isInspector ? (
359 <View className="gap-1 border-b border-border px-4 pb-4 pt-3">375 <View className="gap-1 border-b border-border px-4 pb-4 pt-3">
...@@ -383,24 +399,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {...@@ -383,24 +399,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
383 </Text>399 </Text>
384 </View>400 </View>
385 ) : (401 ) : (
386 // Compact header row: labeled branch on the left, status summary at402 <AndroidSheetHeader
387 // the trailing end. Horizontal padding lines the text up with the403 title={currentBranchLabel}
388 // rows' icon column inside the card below (20 screen + 16 card + 4404 subtitle={currentStatusSummary}
389 // row). The sheet relies on pull-to-refresh instead of a corner405 onBack={() => navigation.goBack()}
390 // refresh button.406 actions={[
391 <View className="flex-row items-end justify-between gap-3 px-10 pb-4 pt-4">407 {
392 <View className="shrink gap-0.5">408 accessibilityLabel: "Refresh repository status",
393 <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted">409 disabled: busy,
394 Branch410 icon: "arrow.clockwise",
395 </Text>411 onPress: () => void gitActions.refreshSelectedThreadGitStatus(),
396 <Text className="text-xl font-t3-bold" numberOfLines={1}>412 },
397 {currentBranchLabel}413 ]}
398 </Text>414 />
399 </View>
400 <Text className="text-foreground-secondary pb-0.5 text-sm font-medium" numberOfLines={1}>
401 {currentStatusSummary}
402 </Text>
403 </View>
404 )}415 )}
405416
406 {content}417 {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}