| author | |
| committer | |
| log | 3513fa04fbf12c1d4fa2b8d07cfc7f0905714d31 |
| tree | 39bca758734118bb4a4081c2d1ad59b78e703a18 |
| parent | 735240f3162470032120603474605b8a6e42580b |
| signature | Signed by PGP key B5690EEEBB952194 |
20 files changed, 1191 insertions(+), 418 deletions(-)
apps/mobile/app.config.ts+47-31| ... | ... | @@ -23,46 +23,54 @@ if ( |
| 23 | 23 | ); |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | const VARIANT_CONFIG: Record< | |
| 27 | AppVariant, | |
| 28 | { | |
| 29 | readonly appName: string; | |
| 30 | readonly scheme: string; | |
| 31 | readonly iosIcon: string; | |
| 32 | readonly splashIcon: string; | |
| 33 | readonly iosBundleIdentifier: string; | |
| 34 | readonly androidPackage: string; | |
| 35 | readonly relyingParty?: string; | |
| 36 | } | |
| 37 | > = { | |
| 26 | const DEVELOPMENT_ASSETS = { | |
| 27 | appIcon: "./assets/splash-icon-dev.png", | |
| 28 | iosIcon: "./assets/icon-composer-dev.icon", | |
| 29 | splashIcon: "./assets/splash-icon-dev.png", | |
| 30 | androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png", | |
| 31 | androidAdaptiveBackgroundColor: "#00639B", | |
| 32 | androidMonochromeIcon: "./assets/android-icon-mark.png", | |
| 33 | androidNotificationIcon: "./assets/android-notification-icon.png", | |
| 34 | androidNotificationColor: "#00639B", | |
| 35 | } as const; | |
| 36 | ||
| 37 | const 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 | ||
| 48 | const VARIANT_CONFIG = { | |
| 38 | 49 | development: { |
| 39 | 50 | appName: "T3 Code Dev", |
| 40 | 51 | scheme: "t3code-dev", |
| 41 | iosIcon: "./assets/icon-composer-dev.icon", | |
| 42 | splashIcon: "./assets/splash-icon-dev.png", | |
| 43 | 52 | iosBundleIdentifier: "com.t3tools.t3code.dev", |
| 44 | 53 | androidPackage: "com.t3tools.t3code.dev", |
| 45 | 54 | relyingParty: "clerk.t3.codes", |
| 55 | assets: DEVELOPMENT_ASSETS, | |
| 46 | 56 | }, |
| 47 | 57 | preview: { |
| 48 | 58 | appName: "T3 Code Preview", |
| 49 | 59 | scheme: "t3code-preview", |
| 50 | iosIcon: "./assets/icon-composer-prod.icon", | |
| 51 | splashIcon: "./assets/splash-icon-prod.png", | |
| 52 | 60 | iosBundleIdentifier: "com.t3tools.t3code.preview", |
| 53 | 61 | androidPackage: "com.t3tools.t3code.preview", |
| 54 | 62 | relyingParty: "clerk.t3.codes", |
| 63 | assets: RELEASE_ASSETS, | |
| 55 | 64 | }, |
| 56 | 65 | production: { |
| 57 | 66 | appName: "T3 Code", |
| 58 | 67 | scheme: "t3code", |
| 59 | iosIcon: "./assets/icon-composer-prod.icon", | |
| 60 | splashIcon: "./assets/splash-icon-prod.png", | |
| 61 | 68 | iosBundleIdentifier: "com.t3tools.t3code", |
| 62 | 69 | androidPackage: "com.t3tools.t3code", |
| 63 | 70 | relyingParty: "clerk.t3.codes", |
| 71 | assets: RELEASE_ASSETS, | |
| 64 | 72 | }, |
| 65 | }; | |
| 73 | } as const; | |
| 66 | 74 | |
| 67 | 75 | function resolveAppVariant(value: string | undefined): AppVariant { |
| 68 | 76 | switch (value) { |
| ... | ... | @@ -121,7 +129,7 @@ const config: ExpoConfig = { |
| 121 | 129 | policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", |
| 122 | 130 | }, |
| 123 | 131 | orientation: "portrait", |
| 124 | icon: "./assets/icon.png", | |
| 132 | icon: variant.assets.appIcon, | |
| 125 | 133 | userInterfaceStyle: "automatic", |
| 126 | 134 | updates: { |
| 127 | 135 | enabled: true, |
| ... | ... | @@ -130,7 +138,7 @@ const config: ExpoConfig = { |
| 130 | 138 | fallbackToCacheTimeout: 0, |
| 131 | 139 | }, |
| 132 | 140 | ios: { |
| 133 | icon: variant.iosIcon, | |
| 141 | icon: variant.assets.iosIcon, | |
| 134 | 142 | supportsTablet: true, |
| 135 | 143 | bundleIdentifier: variant.iosBundleIdentifier, |
| 136 | 144 | // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` |
| ... | ... | @@ -151,13 +159,12 @@ const config: ExpoConfig = { |
| 151 | 159 | }, |
| 152 | 160 | }, |
| 153 | 161 | android: { |
| 154 | icon: "./assets/icon.png", | |
| 162 | icon: variant.assets.appIcon, | |
| 155 | 163 | package: variant.androidPackage, |
| 156 | 164 | adaptiveIcon: { |
| 157 | backgroundColor: "#E6F4FE", | |
| 158 | foregroundImage: "./assets/android-icon-foreground.png", | |
| 159 | backgroundImage: "./assets/android-icon-background.png", | |
| 160 | monochromeImage: "./assets/android-icon-monochrome.png", | |
| 165 | backgroundColor: variant.assets.androidAdaptiveBackgroundColor, | |
| 166 | foregroundImage: variant.assets.androidAdaptiveForeground, | |
| 167 | monochromeImage: variant.assets.androidMonochromeIcon, | |
| 161 | 168 | }, |
| 162 | 169 | // Opts into OnBackInvokedCallback-based back dispatch (Android 13+). |
| 163 | 170 | // JS back handling survives it via react-native's Android 16 shim plus |
| ... | ... | @@ -165,7 +172,7 @@ const config: ExpoConfig = { |
| 165 | 172 | predictiveBackGestureEnabled: true, |
| 166 | 173 | }, |
| 167 | 174 | web: { |
| 168 | favicon: "./assets/favicon.png", | |
| 175 | favicon: variant.assets.appIcon, | |
| 169 | 176 | }, |
| 170 | 177 | plugins: [ |
| 171 | 178 | "expo-asset", |
| ... | ... | @@ -195,6 +202,14 @@ const config: ExpoConfig = { |
| 195 | 202 | ], |
| 196 | 203 | "expo-secure-store", |
| 197 | 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 | 213 | // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before |
| 199 | 214 | // plugins earlier in this array, so it cannot strip the entitlement Clerk would add. |
| 200 | 215 | ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], |
| ... | ... | @@ -206,8 +221,8 @@ const config: ExpoConfig = { |
| 206 | 221 | // the shortcut items set in src/features/shortcuts. |
| 207 | 222 | androidIcons: { |
| 208 | 223 | shortcut_icon: { |
| 209 | foregroundImage: "./assets/android-icon-foreground.png", | |
| 210 | backgroundColor: "#E6F4FE", | |
| 224 | foregroundImage: variant.assets.androidAdaptiveForeground, | |
| 225 | backgroundColor: variant.assets.androidAdaptiveBackgroundColor, | |
| 211 | 226 | }, |
| 212 | 227 | }, |
| 213 | 228 | }, |
| ... | ... | @@ -217,17 +232,18 @@ const config: ExpoConfig = { |
| 217 | 232 | { |
| 218 | 233 | cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", |
| 219 | 234 | barcodeScannerEnabled: true, |
| 235 | recordAudioAndroid: false, | |
| 220 | 236 | }, |
| 221 | 237 | ], |
| 222 | 238 | [ |
| 223 | 239 | "expo-splash-screen", |
| 224 | 240 | { |
| 225 | image: variant.splashIcon, | |
| 241 | image: variant.assets.splashIcon, | |
| 226 | 242 | resizeMode: "contain", |
| 227 | 243 | backgroundColor: "#ffffff", |
| 228 | 244 | imageWidth: 220, |
| 229 | 245 | dark: { |
| 230 | image: variant.splashIcon, | |
| 246 | image: variant.assets.splashIcon, | |
| 231 | 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 @@ |
| 1 | package expo.modules.t3reviewdiff | |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import android.graphics.Canvas | |
| 5 | import android.graphics.Color | |
| 6 | import android.graphics.LinearGradient | |
| 7 | import android.graphics.Paint | |
| 8 | import android.graphics.Path | |
| 9 | import android.graphics.RectF | |
| 10 | import android.graphics.Shader | |
| 11 | import android.graphics.Typeface | |
| 12 | import kotlin.math.max | |
| 13 | import kotlin.math.min | |
| 14 | ||
| 15 | internal 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 | ||
| 305 | private 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 | 4 | import android.graphics.Canvas |
| 5 | 5 | import android.graphics.Color |
| 6 | 6 | import android.graphics.Paint |
| 7 | import android.graphics.Typeface | |
| 7 | import android.graphics.RectF | |
| 8 | 8 | import android.view.GestureDetector |
| 9 | 9 | import android.view.MotionEvent |
| 10 | 10 | import android.view.VelocityTracker |
| ... | ... | @@ -19,6 +19,7 @@ import org.json.JSONArray |
| 19 | 19 | import org.json.JSONObject |
| 20 | 20 | import java.util.concurrent.Executors |
| 21 | 21 | import kotlin.math.abs |
| 22 | import kotlin.math.ceil | |
| 22 | 23 | import kotlin.math.max |
| 23 | 24 | import kotlin.math.min |
| 24 | 25 | |
| ... | ... | @@ -49,12 +50,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 49 | 50 | private val verticalScroller = OverScroller(context) |
| 50 | 51 | private val horizontalScroller = OverScroller(context) |
| 51 | 52 | private var dragAxis: DragAxis? = null |
| 53 | private var horizontalDragTarget: HorizontalPanTarget? = null | |
| 52 | 54 | private var lastTouchX = 0f |
| 53 | 55 | private var lastTouchY = 0f |
| 54 | 56 | private var velocityTracker: VelocityTracker? = null |
| 55 | 57 | |
| 56 | 58 | init { |
| 57 | canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) } | |
| 59 | canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } | |
| 58 | 60 | canvasView.onVisibleRowsChanged = { first, last -> |
| 59 | 61 | onDebug( |
| 60 | 62 | mapOf( |
| ... | ... | @@ -86,12 +88,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 86 | 88 | lastVisibleFileId = null |
| 87 | 89 | pendingInitialScroll = true |
| 88 | 90 | canvasView.setVerticalOffset(0) |
| 89 | canvasView.setHorizontalOffset(0) | |
| 91 | canvasView.resetHorizontalOffsets() | |
| 90 | 92 | applyPendingInitialScroll() |
| 91 | 93 | } |
| 92 | 94 | |
| 93 | 95 | fun setCollapsedFileIdsJson(value: String) { |
| 94 | 96 | collapsedFileIds = parseStringSet(value) |
| 97 | canvasView.collapsedFileIds = collapsedFileIds | |
| 95 | 98 | rebuildVisibleRows() |
| 96 | 99 | } |
| 97 | 100 | |
| ... | ... | @@ -107,6 +110,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 107 | 110 | |
| 108 | 111 | fun setCollapsedCommentIdsJson(value: String) { |
| 109 | 112 | collapsedCommentIds = parseStringSet(value) |
| 113 | canvasView.collapsedCommentIds = collapsedCommentIds | |
| 110 | 114 | rebuildVisibleRows() |
| 111 | 115 | } |
| 112 | 116 | |
| ... | ... | @@ -202,6 +206,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 202 | 206 | verticalScroller.forceFinished(true) |
| 203 | 207 | horizontalScroller.forceFinished(true) |
| 204 | 208 | dragAxis = null |
| 209 | horizontalDragTarget = canvasView.horizontalPanTarget(event.y) | |
| 205 | 210 | lastTouchX = event.x |
| 206 | 211 | lastTouchY = event.y |
| 207 | 212 | parent?.requestDisallowInterceptTouchEvent(true) |
| ... | ... | @@ -212,7 +217,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 212 | 217 | val deltaX = event.x - lastTouchX |
| 213 | 218 | val deltaY = event.y - lastTouchY |
| 214 | 219 | if (max(abs(deltaX), abs(deltaY)) > touchSlop) { |
| 215 | dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL | |
| 220 | dragAxis = if (abs(deltaY) >= abs(deltaX)) { | |
| 221 | DragAxis.VERTICAL | |
| 222 | } else { | |
| 223 | horizontalDragTarget | |
| 224 | ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 } | |
| 225 | ?.let { DragAxis.HORIZONTAL } | |
| 226 | } | |
| 216 | 227 | } |
| 217 | 228 | } |
| 218 | 229 | return dragAxis != null |
| ... | ... | @@ -238,6 +249,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 238 | 249 | ) { |
| 239 | 250 | velocityTracker?.recycle() |
| 240 | 251 | velocityTracker = null |
| 252 | horizontalDragTarget = null | |
| 241 | 253 | parent?.requestDisallowInterceptTouchEvent(false) |
| 242 | 254 | } |
| 243 | 255 | return handled |
| ... | ... | @@ -253,7 +265,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 253 | 265 | if (axis == DragAxis.VERTICAL) { |
| 254 | 266 | canvasView.scrollByVertical(deltaY) |
| 255 | 267 | } else { |
| 256 | canvasView.scrollByHorizontal(deltaX) | |
| 268 | horizontalDragTarget?.let { canvasView.scrollByHorizontal(deltaX, it) } | |
| 257 | 269 | } |
| 258 | 270 | lastTouchX = event.x |
| 259 | 271 | lastTouchY = event.y |
| ... | ... | @@ -276,7 +288,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 276 | 288 | ) |
| 277 | 289 | postInvalidateOnAnimation() |
| 278 | 290 | } |
| 279 | } else { | |
| 291 | } else if (horizontalDragTarget?.kind == HorizontalPanKind.CODE) { | |
| 280 | 292 | val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt() |
| 281 | 293 | if (abs(velocity) >= minimumFlingVelocity) { |
| 282 | 294 | horizontalScroller.fling( |
| ... | ... | @@ -294,6 +306,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 294 | 306 | } |
| 295 | 307 | } |
| 296 | 308 | dragAxis = null |
| 309 | horizontalDragTarget = null | |
| 297 | 310 | velocityTracker?.recycle() |
| 298 | 311 | velocityTracker = null |
| 299 | 312 | parent?.requestDisallowInterceptTouchEvent(false) |
| ... | ... | @@ -325,11 +338,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 325 | 338 | currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId) |
| 326 | 339 | filtered.add(row) |
| 327 | 340 | } else if (!currentFileCollapsed) { |
| 328 | if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) { | |
| 329 | filtered.add(row) | |
| 330 | } else { | |
| 331 | filtered.add(row.copy(commentText = "Comment collapsed")) | |
| 332 | } | |
| 341 | filtered.add(row) | |
| 333 | 342 | } |
| 334 | 343 | } |
| 335 | 344 | visibleRows = filtered |
| ... | ... | @@ -339,10 +348,11 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 339 | 348 | applyPendingInitialScroll() |
| 340 | 349 | } |
| 341 | 350 | |
| 342 | private fun handleRowTap(row: DiffRow, gesture: String) { | |
| 351 | private fun handleRowTap(row: DiffRow, gesture: String, target: RowTapTarget) { | |
| 343 | 352 | when (row.kind) { |
| 344 | 353 | "file" -> { |
| 345 | if (gesture == "longPress") { | |
| 354 | if (gesture != "tap") return | |
| 355 | if (target == RowTapTarget.VIEWED_CHECKBOX) { | |
| 346 | 356 | onToggleViewedFile(mapOf("fileId" to row.resolvedFileId)) |
| 347 | 357 | } else { |
| 348 | 358 | onToggleFile(mapOf("fileId" to row.resolvedFileId)) |
| ... | ... | @@ -408,7 +418,22 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 408 | 418 | } |
| 409 | 419 | } |
| 410 | 420 | |
| 411 | private data class DiffRow( | |
| 421 | private enum class RowTapTarget { | |
| 422 | ROW, | |
| 423 | VIEWED_CHECKBOX | |
| 424 | } | |
| 425 | ||
| 426 | private enum class HorizontalPanKind { | |
| 427 | CODE, | |
| 428 | FILE_HEADER_PATH | |
| 429 | } | |
| 430 | ||
| 431 | private data class HorizontalPanTarget( | |
| 432 | val fileId: String, | |
| 433 | val kind: HorizontalPanKind | |
| 434 | ) | |
| 435 | ||
| 436 | internal data class DiffRow( | |
| 412 | 437 | val kind: String, |
| 413 | 438 | val id: String, |
| 414 | 439 | val fileId: String, |
| ... | ... | @@ -422,6 +447,7 @@ private data class DiffRow( |
| 422 | 447 | val change: String, |
| 423 | 448 | val oldLineNumber: Int?, |
| 424 | 449 | val newLineNumber: Int?, |
| 450 | val wordDiffRanges: List<DiffWordDiffRange>, | |
| 425 | 451 | val commentText: String, |
| 426 | 452 | val commentRangeLabel: String, |
| 427 | 453 | val commentSectionTitle: String |
| ... | ... | @@ -429,13 +455,18 @@ private data class DiffRow( |
| 429 | 455 | val resolvedFileId: String get() = fileId.ifEmpty { id } |
| 430 | 456 | } |
| 431 | 457 | |
| 458 | internal data class DiffWordDiffRange( | |
| 459 | val start: Int, | |
| 460 | val end: Int | |
| 461 | ) | |
| 462 | ||
| 432 | 463 | private data class DiffToken( |
| 433 | 464 | val content: String, |
| 434 | 465 | val color: Int?, |
| 435 | 466 | val fontStyle: Int |
| 436 | 467 | ) |
| 437 | 468 | |
| 438 | private data class DiffTheme( | |
| 469 | internal data class DiffTheme( | |
| 439 | 470 | val background: Int, |
| 440 | 471 | val text: Int, |
| 441 | 472 | val mutedText: Int, |
| ... | ... | @@ -511,14 +542,25 @@ private data class DiffTheme( |
| 511 | 542 | } |
| 512 | 543 | } |
| 513 | 544 | |
| 514 | private data class DiffStyle( | |
| 545 | internal data class DiffStyle( | |
| 515 | 546 | val rowHeightPx: Float, |
| 516 | 547 | val gutterWidthPx: Float, |
| 517 | 548 | val codePaddingPx: Float, |
| 518 | 549 | val changeBarWidthPx: Float, |
| 519 | 550 | val fileHeaderHeightPx: Float, |
| 551 | val fileHeaderHorizontalPaddingPx: Float, | |
| 520 | 552 | val codeFontSizePx: Float, |
| 521 | val lineNumberFontSizePx: Float | |
| 553 | val codeFontWeight: String, | |
| 554 | val lineNumberFontSizePx: Float, | |
| 555 | val lineNumberFontWeight: String, | |
| 556 | val hunkFontSizePx: Float, | |
| 557 | val hunkFontWeight: String, | |
| 558 | val fileHeaderFontSizePx: Float, | |
| 559 | val fileHeaderFontWeight: String, | |
| 560 | val fileHeaderMetaFontSizePx: Float, | |
| 561 | val fileHeaderMetaFontWeight: String, | |
| 562 | val fileHeaderSubtextFontSizePx: Float, | |
| 563 | val fileHeaderSubtextFontWeight: String | |
| 522 | 564 | ) { |
| 523 | 565 | companion object { |
| 524 | 566 | fun defaults(density: Float): DiffStyle = DiffStyle( |
| ... | ... | @@ -527,8 +569,19 @@ private data class DiffStyle( |
| 527 | 569 | codePaddingPx = 10f * density, |
| 528 | 570 | changeBarWidthPx = 3f * density, |
| 529 | 571 | fileHeaderHeightPx = 44f * density, |
| 572 | fileHeaderHorizontalPaddingPx = 10f * density, | |
| 530 | 573 | codeFontSizePx = 12f * density, |
| 574 | codeFontWeight = "regular", | |
| 531 | 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 | ) |
| 533 | 586 | |
| 534 | 587 | fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { |
| ... | ... | @@ -539,11 +592,50 @@ private data class DiffStyle( |
| 539 | 592 | codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), |
| 540 | 593 | changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density), |
| 541 | 594 | fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density), |
| 595 | fileHeaderHorizontalPaddingPx = json.floatDp( | |
| 596 | "fileHeaderHorizontalPadding", | |
| 597 | fallback.fileHeaderHorizontalPaddingPx, | |
| 598 | density, | |
| 599 | ), | |
| 542 | 600 | codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density), |
| 601 | codeFontWeight = json.optString("codeFontWeight", fallback.codeFontWeight), | |
| 543 | 602 | lineNumberFontSizePx = json.floatSp( |
| 544 | 603 | "lineNumberFontSize", |
| 545 | 604 | fallback.lineNumberFontSizePx, |
| 546 | density | |
| 605 | density, | |
| 606 | ), | |
| 607 | lineNumberFontWeight = json.optString( | |
| 608 | "lineNumberFontWeight", | |
| 609 | fallback.lineNumberFontWeight, | |
| 610 | ), | |
| 611 | hunkFontSizePx = json.floatSp("hunkFontSize", fallback.hunkFontSizePx, density), | |
| 612 | hunkFontWeight = json.optString("hunkFontWeight", fallback.hunkFontWeight), | |
| 613 | fileHeaderFontSizePx = json.floatSp( | |
| 614 | "fileHeaderFontSize", | |
| 615 | fallback.fileHeaderFontSizePx, | |
| 616 | density, | |
| 617 | ), | |
| 618 | fileHeaderFontWeight = json.optString( | |
| 619 | "fileHeaderFontWeight", | |
| 620 | fallback.fileHeaderFontWeight, | |
| 621 | ), | |
| 622 | fileHeaderMetaFontSizePx = json.floatSp( | |
| 623 | "fileHeaderMetaFontSize", | |
| 624 | fallback.fileHeaderMetaFontSizePx, | |
| 625 | density, | |
| 626 | ), | |
| 627 | fileHeaderMetaFontWeight = json.optString( | |
| 628 | "fileHeaderMetaFontWeight", | |
| 629 | fallback.fileHeaderMetaFontWeight, | |
| 630 | ), | |
| 631 | fileHeaderSubtextFontSizePx = json.floatSp( | |
| 632 | "fileHeaderSubtextFontSize", | |
| 633 | fallback.fileHeaderSubtextFontSizePx, | |
| 634 | density, | |
| 635 | ), | |
| 636 | fileHeaderSubtextFontWeight = json.optString( | |
| 637 | "fileHeaderSubtextFontWeight", | |
| 638 | fallback.fileHeaderSubtextFontWeight, | |
| 547 | 639 | ), |
| 548 | 640 | ) |
| 549 | 641 | } catch (_: Exception) { |
| ... | ... | @@ -554,35 +646,54 @@ private data class DiffStyle( |
| 554 | 646 | |
| 555 | 647 | private class DiffCanvasView(context: Context) : View(context) { |
| 556 | 648 | private val density = resources.displayMetrics.density |
| 557 | private val backgroundPaint = Paint() | |
| 558 | private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) | |
| 559 | private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } | |
| 560 | private val boldTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
| 561 | typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) | |
| 562 | } | |
| 649 | private val drawing = ReviewDiffCanvasDrawing(context) | |
| 650 | private val backgroundPaint = drawing.backgroundPaint | |
| 651 | private val borderPaint = drawing.borderPaint | |
| 652 | private val textPaint = drawing.textPaint | |
| 653 | private val boldTextPaint = drawing.uiPaint | |
| 563 | 654 | private val gestureDetector = GestureDetector( |
| 564 | 655 | context, |
| 565 | 656 | object : GestureDetector.SimpleOnGestureListener() { |
| 566 | 657 | override fun onDown(event: MotionEvent): Boolean = true |
| 567 | 658 | |
| 568 | 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 | 676 | return true |
| 571 | 677 | } |
| 572 | 678 | |
| 573 | 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 | 686 | private var rowOffsets = intArrayOf(0) |
| 579 | 687 | private var verticalOffset = 0 |
| 580 | 688 | private var horizontalOffset = 0 |
| 689 | private val headerPathOffsetsByFileId = mutableMapOf<String, Int>() | |
| 581 | 690 | private var lastVisibleRange: Pair<Int, Int>? = null |
| 582 | ||
| 583 | 691 | var rows: List<DiffRow> = emptyList() |
| 584 | 692 | set(value) { |
| 585 | 693 | field = value |
| 694 | headerPathOffsetsByFileId.keys.retainAll( | |
| 695 | value.asSequence().filter { it.kind == "file" }.map { it.resolvedFileId }.toSet(), | |
| 696 | ) | |
| 586 | 697 | rebuildOffsets() |
| 587 | 698 | } |
| 588 | 699 | var tokensByRowId: Map<String, List<DiffToken>> = emptyMap() |
| ... | ... | @@ -595,6 +706,16 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 595 | 706 | field = value |
| 596 | 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 | 719 | var selectedRowIds: Set<String> = emptySet() |
| 599 | 720 | set(value) { |
| 600 | 721 | field = value |
| ... | ... | @@ -603,6 +724,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 603 | 724 | var theme: DiffTheme = DiffTheme.fallback("light") |
| 604 | 725 | set(value) { |
| 605 | 726 | field = value |
| 727 | drawing.theme = value | |
| 606 | 728 | invalidate() |
| 607 | 729 | } |
| 608 | 730 | var style: DiffStyle = DiffStyle.defaults(density) |
| ... | ... | @@ -614,9 +736,10 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 614 | 736 | set(value) { |
| 615 | 737 | field = max(value, suggestedMinimumWidth) |
| 616 | 738 | setHorizontalOffset(horizontalOffset) |
| 739 | clampHeaderPathOffsets() | |
| 617 | 740 | invalidate() |
| 618 | 741 | } |
| 619 | var onRowTap: ((DiffRow, String) -> Unit)? = null | |
| 742 | var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null | |
| 620 | 743 | var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null |
| 621 | 744 | |
| 622 | 745 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { |
| ... | ... | @@ -630,6 +753,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 630 | 753 | super.onSizeChanged(width, height, oldWidth, oldHeight) |
| 631 | 754 | setVerticalOffset(verticalOffset) |
| 632 | 755 | setHorizontalOffset(horizontalOffset) |
| 756 | clampHeaderPathOffsets() | |
| 633 | 757 | } |
| 634 | 758 | |
| 635 | 759 | override fun onDraw(canvas: Canvas) { |
| ... | ... | @@ -681,27 +805,63 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 681 | 805 | invalidate() |
| 682 | 806 | } |
| 683 | 807 | |
| 684 | fun scrollByHorizontal(delta: Int) { | |
| 685 | setHorizontalOffset(horizontalOffset + delta) | |
| 808 | fun scrollByHorizontal(delta: Int, target: HorizontalPanTarget) { | |
| 809 | if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { | |
| 810 | setHeaderPathOffset( | |
| 811 | target.fileId, | |
| 812 | (headerPathOffsetsByFileId[target.fileId] ?: 0) + delta, | |
| 813 | ) | |
| 814 | } else { | |
| 815 | setHorizontalOffset(horizontalOffset + delta) | |
| 816 | } | |
| 686 | 817 | } |
| 687 | 818 | |
| 688 | 819 | fun horizontalOffset(): Int = horizontalOffset |
| 689 | 820 | |
| 690 | 821 | fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) |
| 691 | 822 | |
| 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 | 846 | private fun rebuildOffsets() { |
| 693 | 847 | rowOffsets = IntArray(rows.size + 1) |
| 694 | 848 | rows.forEachIndexed { index, row -> |
| 695 | 849 | rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) |
| 696 | 850 | } |
| 697 | 851 | setVerticalOffset(verticalOffset) |
| 852 | clampHeaderPathOffsets() | |
| 698 | 853 | requestLayout() |
| 699 | 854 | invalidate() |
| 700 | 855 | } |
| 701 | 856 | |
| 702 | 857 | private fun rowHeight(row: DiffRow): Int = when (row.kind) { |
| 703 | 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 | 865 | else -> style.rowHeightPx.toInt() |
| 706 | 866 | }.coerceAtLeast(1) |
| 707 | 867 | |
| ... | ... | @@ -721,13 +881,20 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 721 | 881 | return low.coerceIn(0, rows.lastIndex) |
| 722 | 882 | } |
| 723 | 883 | |
| 724 | private fun rowAt(y: Float): DiffRow? { | |
| 884 | private fun rowHitAt(y: Float): RowHit? { | |
| 725 | 885 | stickyFileHeader(firstVisibleRow())?.let { sticky -> |
| 726 | 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 | } |
| 732 | 899 | |
| 733 | 900 | private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset) |
| ... | ... | @@ -754,23 +921,140 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 754 | 921 | |
| 755 | 922 | private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { |
| 756 | 923 | fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) |
| 757 | val baseline = centeredBaseline(top, bottom, boldTextPaint.apply { textSize = 13f * density }) | |
| 758 | boldTextPaint.color = theme.text | |
| 759 | val marker = if (viewedFileIds.contains(row.resolvedFileId)) "[x] " else "[ ] " | |
| 760 | textPaint.color = theme.mutedText | |
| 761 | textPaint.textSize = 11f * density | |
| 762 | val stats = "+${row.additions} -${row.deletions}" | |
| 763 | val statsX = max(12f * density, width - textPaint.measureText(stats) - 16f * density) | |
| 764 | val title = ellipsize(marker + row.filePath, boldTextPaint, statsX - 28f * density) | |
| 765 | canvas.drawText(title, 12f * density, baseline, boldTextPaint) | |
| 766 | canvas.drawText(stats, statsX, baseline, textPaint) | |
| 924 | ||
| 925 | val chevronRect = drawing.fileHeaderChevronRect(top, bottom, style) | |
| 926 | val iconRect = drawing.fileHeaderIconRect(top, bottom, style) | |
| 927 | val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) | |
| 928 | drawing.drawDisclosureChevron( | |
| 929 | canvas, | |
| 930 | chevronRect, | |
| 931 | theme.mutedText, | |
| 932 | collapsed = collapsedFileIds.contains(row.resolvedFileId), | |
| 933 | ) | |
| 934 | drawing.drawFileIcon(canvas, iconRect, row.changeType) | |
| 935 | drawing.drawViewedCheckbox( | |
| 936 | canvas, | |
| 937 | checkboxRect, | |
| 938 | viewedFileIds.contains(row.resolvedFileId), | |
| 939 | ) | |
| 940 | ||
| 941 | drawing.configureUiPaint( | |
| 942 | paint = boldTextPaint, | |
| 943 | color = theme.deleteText, | |
| 944 | size = style.fileHeaderMetaFontSizePx, | |
| 945 | weight = style.fileHeaderMetaFontWeight, | |
| 946 | ) | |
| 947 | val deleteText = "-${row.deletions}" | |
| 948 | val deleteWidth = boldTextPaint.measureText(deleteText) | |
| 949 | val addText = "+${row.additions}" | |
| 950 | val addWidth = boldTextPaint.measureText(addText) | |
| 951 | val countGap = 4f * density | |
| 952 | val countsX = checkboxRect.left - 10f * density - deleteWidth - countGap - addWidth | |
| 953 | val baseline = centeredBaseline(top, bottom, boldTextPaint) | |
| 954 | canvas.drawText(deleteText, countsX, baseline, boldTextPaint) | |
| 955 | boldTextPaint.color = theme.addText | |
| 956 | canvas.drawText(addText, countsX + deleteWidth + countGap, baseline, boldTextPaint) | |
| 957 | ||
| 958 | val pathLayout = fileHeaderPathLayout(row, top, bottom, iconRect, countsX) | |
| 959 | val maxPathOffset = maxHeaderPathOffset(pathLayout) | |
| 960 | val pathOffset = (headerPathOffsetsByFileId[row.resolvedFileId] ?: 0) | |
| 961 | .coerceIn(0, maxPathOffset) | |
| 962 | canvas.save() | |
| 963 | canvas.clipRect(pathLayout.rect) | |
| 964 | canvas.drawText( | |
| 965 | pathLayout.displayPath, | |
| 966 | pathLayout.rect.left - pathOffset, | |
| 967 | centeredBaseline(top, bottom, boldTextPaint), | |
| 968 | boldTextPaint, | |
| 969 | ) | |
| 970 | canvas.restore() | |
| 971 | drawing.drawFileHeaderPathScrollFades( | |
| 972 | canvas, | |
| 973 | pathLayout.rect, | |
| 974 | pathOffset, | |
| 975 | maxPathOffset, | |
| 976 | ) | |
| 767 | 977 | drawBottomBorder(canvas, bottom) |
| 768 | 978 | } |
| 769 | 979 | |
| 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 | 1051 | private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { |
| 771 | 1052 | fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) |
| 772 | textPaint.color = theme.hunkText | |
| 773 | textPaint.textSize = style.codeFontSizePx | |
| 1053 | drawing.configureMonospacePaint( | |
| 1054 | color = theme.hunkText, | |
| 1055 | size = style.hunkFontSizePx, | |
| 1056 | weight = style.hunkFontWeight, | |
| 1057 | ) | |
| 774 | 1058 | drawScrollableCode(canvas, top, bottom) { codeX -> |
| 775 | 1059 | canvas.drawText( |
| 776 | 1060 | row.text.ifEmpty { row.content }, |
| ... | ... | @@ -782,36 +1066,82 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 782 | 1066 | } |
| 783 | 1067 | |
| 784 | 1068 | private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { |
| 785 | textPaint.color = theme.mutedText | |
| 786 | textPaint.textSize = style.codeFontSizePx | |
| 787 | drawScrollableCode(canvas, top, bottom) { codeX -> | |
| 788 | canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint) | |
| 789 | } | |
| 1069 | fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) | |
| 1070 | val iconSize = 16f * density | |
| 1071 | val iconRect = RectF( | |
| 1072 | style.fileHeaderHorizontalPaddingPx + 2f * density, | |
| 1073 | (top + bottom - iconSize) / 2f, | |
| 1074 | style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize, | |
| 1075 | (top + bottom + iconSize) / 2f, | |
| 1076 | ) | |
| 1077 | drawing.drawNoticeIcon(canvas, iconRect, theme.mutedText) | |
| 1078 | drawing.configureUiPaint( | |
| 1079 | paint = textPaint, | |
| 1080 | color = theme.mutedText, | |
| 1081 | size = style.fileHeaderSubtextFontSizePx, | |
| 1082 | weight = style.fileHeaderSubtextFontWeight, | |
| 1083 | ) | |
| 1084 | val textX = iconRect.right + 10f * density | |
| 1085 | canvas.drawText( | |
| 1086 | ellipsize(row.text, textPaint, width - textX - style.fileHeaderHorizontalPaddingPx), | |
| 1087 | textX, | |
| 1088 | centeredBaseline(top, bottom, textPaint), | |
| 1089 | textPaint, | |
| 1090 | ) | |
| 1091 | drawBottomBorder(canvas, bottom) | |
| 790 | 1092 | } |
| 791 | 1093 | |
| 792 | 1094 | private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { |
| 793 | fill( | |
| 794 | canvas, | |
| 795 | theme.headerBackground, | |
| 796 | style.gutterWidthPx, | |
| 797 | top.toFloat(), | |
| 798 | width.toFloat(), | |
| 799 | bottom.toFloat() | |
| 1095 | fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) | |
| 1096 | val cardRect = RectF( | |
| 1097 | 8f * density, | |
| 1098 | top + 5f * density, | |
| 1099 | width - 8f * density, | |
| 1100 | bottom - 5f * density, | |
| 800 | 1101 | ) |
| 801 | boldTextPaint.color = theme.text | |
| 802 | boldTextPaint.textSize = 12f * density | |
| 803 | drawScrollableCode(canvas, top, bottom) { codeX -> | |
| 1102 | backgroundPaint.color = theme.headerBackground | |
| 1103 | canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint) | |
| 1104 | borderPaint.style = Paint.Style.STROKE | |
| 1105 | borderPaint.color = withAlpha(theme.border, 217) | |
| 1106 | borderPaint.strokeWidth = density | |
| 1107 | canvas.drawRoundRect(cardRect, 10f * density, 10f * density, borderPaint) | |
| 1108 | borderPaint.style = Paint.Style.FILL | |
| 1109 | ||
| 1110 | val collapsed = collapsedCommentIds.contains(row.id) | |
| 1111 | val chevronRect = RectF( | |
| 1112 | cardRect.left + 10f * density, | |
| 1113 | cardRect.top + 11f * density, | |
| 1114 | cardRect.left + 26f * density, | |
| 1115 | cardRect.top + 27f * density, | |
| 1116 | ) | |
| 1117 | drawing.drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed) | |
| 1118 | drawing.configureUiPaint( | |
| 1119 | paint = textPaint, | |
| 1120 | color = theme.mutedText, | |
| 1121 | size = style.fileHeaderSubtextFontSizePx, | |
| 1122 | weight = style.fileHeaderSubtextFontWeight, | |
| 1123 | ) | |
| 1124 | val title = "Comment on ${row.commentRangeLabel.ifEmpty { "line" }}" | |
| 1125 | canvas.drawText( | |
| 1126 | ellipsize(title, textPaint, cardRect.right - chevronRect.right - 20f * density), | |
| 1127 | chevronRect.right + 10f * density, | |
| 1128 | cardRect.top + 22f * density, | |
| 1129 | textPaint, | |
| 1130 | ) | |
| 1131 | if (!collapsed) { | |
| 1132 | textPaint.color = theme.text | |
| 1133 | val bodyX = cardRect.left + 18f * density | |
| 804 | 1134 | canvas.drawText( |
| 805 | row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } }, | |
| 806 | codeX, | |
| 807 | top + 20f * density, | |
| 808 | boldTextPaint, | |
| 1135 | ellipsize( | |
| 1136 | row.commentText.ifEmpty { "Comment" }, | |
| 1137 | textPaint, | |
| 1138 | cardRect.right - bodyX - 18f * density | |
| 1139 | ), | |
| 1140 | bodyX, | |
| 1141 | cardRect.top + 58f * density, | |
| 1142 | textPaint, | |
| 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 | } |
| 816 | 1146 | |
| 817 | 1147 | @Suppress("CyclomaticComplexMethod") |
| ... | ... | @@ -822,65 +1152,79 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 822 | 1152 | else -> theme.background |
| 823 | 1153 | } |
| 824 | 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 | 1174 | val selected = selectedRowIds.contains(row.id) |
| 826 | 1175 | if (selected) { |
| 827 | 1176 | fill( |
| 828 | 1177 | canvas, |
| 829 | withAlpha(theme.hunkText, if (theme.background == Color.WHITE) 54 else 76), | |
| 1178 | withAlpha(theme.hunkText, 56), | |
| 830 | 1179 | 0f, |
| 831 | 1180 | top.toFloat(), |
| 832 | 1181 | width.toFloat(), |
| 833 | 1182 | bottom.toFloat(), |
| 834 | 1183 | ) |
| 835 | } | |
| 836 | val barColor = when (row.change) { | |
| 837 | "add" -> theme.addBar | |
| 838 | "delete" -> theme.deleteBar | |
| 839 | else -> Color.TRANSPARENT | |
| 840 | } | |
| 841 | if (barColor != Color.TRANSPARENT && style.changeBarWidthPx > 0) { | |
| 842 | fill(canvas, barColor, 0f, top.toFloat(), style.changeBarWidthPx, bottom.toFloat()) | |
| 1184 | fill( | |
| 1185 | canvas, | |
| 1186 | withAlpha(theme.hunkText, 242), | |
| 1187 | 0f, | |
| 1188 | top.toFloat(), | |
| 1189 | style.changeBarWidthPx, | |
| 1190 | bottom.toFloat() | |
| 1191 | ) | |
| 843 | 1192 | } |
| 844 | 1193 | |
| 845 | 1194 | val tokens = tokensByRowId[row.id] |
| 846 | 1195 | drawScrollableCode(canvas, top, bottom) { codeX -> |
| 1196 | drawing.configureCodePaint(theme.text, 0, style) | |
| 1197 | drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) | |
| 847 | 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 | 1199 | canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) |
| 855 | 1200 | } else { |
| 856 | 1201 | var x = codeX |
| 857 | 1202 | tokens.forEach { token -> |
| 858 | textPaint.textSize = style.codeFontSizePx | |
| 859 | textPaint.color = token.color ?: theme.text | |
| 860 | textPaint.typeface = when { | |
| 861 | token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC) | |
| 862 | token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) | |
| 863 | else -> Typeface.MONOSPACE | |
| 864 | } | |
| 1203 | drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) | |
| 865 | 1204 | canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) |
| 866 | 1205 | x += textPaint.measureText(token.content) |
| 867 | 1206 | } |
| 868 | textPaint.typeface = Typeface.MONOSPACE | |
| 869 | 1207 | } |
| 870 | 1208 | } |
| 871 | 1209 | |
| 872 | textPaint.textSize = style.lineNumberFontSizePx | |
| 873 | textPaint.color = if (selected) theme.text else theme.mutedText | |
| 874 | val oldNumber = row.oldLineNumber?.toString().orEmpty() | |
| 875 | val newNumber = row.newLineNumber?.toString().orEmpty() | |
| 876 | val baseline = centeredBaseline(top, bottom, textPaint) | |
| 877 | canvas.drawText(oldNumber, style.changeBarWidthPx + 6f * density, baseline, textPaint) | |
| 1210 | drawLineNumber(canvas, row, top, bottom) | |
| 1211 | } | |
| 1212 | ||
| 1213 | private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { | |
| 1214 | val lineNumber = row.newLineNumber ?: row.oldLineNumber ?: return | |
| 1215 | drawing.configureMonospacePaint( | |
| 1216 | color = drawing.lineNumberColor(row.change), | |
| 1217 | size = style.lineNumberFontSizePx, | |
| 1218 | weight = style.lineNumberFontWeight, | |
| 1219 | ) | |
| 1220 | textPaint.textAlign = Paint.Align.RIGHT | |
| 878 | 1221 | canvas.drawText( |
| 879 | newNumber, | |
| 880 | style.changeBarWidthPx + style.gutterWidthPx / 2f, | |
| 881 | baseline, | |
| 882 | textPaint | |
| 1222 | lineNumber.toString(), | |
| 1223 | style.changeBarWidthPx + style.gutterWidthPx - style.codePaddingPx, | |
| 1224 | centeredBaseline(top, bottom, textPaint), | |
| 1225 | textPaint, | |
| 883 | 1226 | ) |
| 1227 | textPaint.textAlign = Paint.Align.LEFT | |
| 884 | 1228 | } |
| 885 | 1229 | |
| 886 | 1230 | private fun drawScrollableCode( |
| ... | ... | @@ -972,6 +1316,17 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 972 | 1316 | val top: Int, |
| 973 | 1317 | 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 | } |
| 976 | 1331 | |
| 977 | 1332 | private fun withAlpha(color: Int, alpha: Int): Int = |
| ... | ... | @@ -995,6 +1350,7 @@ private fun parseRows(value: String): List<DiffRow> = try { |
| 995 | 1350 | change = row.optString("change", "context"), |
| 996 | 1351 | oldLineNumber = row.optNullableInt("oldLineNumber"), |
| 997 | 1352 | newLineNumber = row.optNullableInt("newLineNumber"), |
| 1353 | wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(), | |
| 998 | 1354 | commentText = row.optString("commentText"), |
| 999 | 1355 | commentRangeLabel = row.optString("commentRangeLabel"), |
| 1000 | 1356 | commentSectionTitle = row.optString("commentSectionTitle"), |
| ... | ... | @@ -1004,6 +1360,17 @@ private fun parseRows(value: String): List<DiffRow> = try { |
| 1004 | 1360 | emptyList() |
| 1005 | 1361 | } |
| 1006 | 1362 | |
| 1363 | private 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 | ||
| 1007 | 1374 | private fun parseTokensObject(value: String): Map<String, List<DiffToken>> = try { |
| 1008 | 1375 | parseTokensObject(JSONObject(value)) |
| 1009 | 1376 | } catch (_: Exception) { |
| ... | ... | @@ -1020,7 +1387,7 @@ private fun parseTokensObject(value: JSONObject): Map<String, List<DiffToken>> { |
| 1020 | 1387 | val token = array.getJSONObject(index) |
| 1021 | 1388 | DiffToken( |
| 1022 | 1389 | content = token.optString("content"), |
| 1023 | color = token.optNullableString("color")?.let { parseColor(it, Color.TRANSPARENT) }, | |
| 1390 | color = token.optNullableString("color")?.let(::parseColorOrNull), | |
| 1024 | 1391 | fontStyle = token.optInt("fontStyle"), |
| 1025 | 1392 | ) |
| 1026 | 1393 | } |
| ... | ... | @@ -1043,6 +1410,12 @@ private fun parseColor(value: String, fallback: Int): Int = try { |
| 1043 | 1410 | fallback |
| 1044 | 1411 | } |
| 1045 | 1412 | |
| 1413 | private fun parseColorOrNull(value: String): Int? = try { | |
| 1414 | Color.parseColor(value) | |
| 1415 | } catch (_: Exception) { | |
| 1416 | null | |
| 1417 | } | |
| 1418 | ||
| 1046 | 1419 | private fun JSONObject.optNullableString(key: String): String? = |
| 1047 | 1420 | if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } |
| 1048 | 1421 |
apps/mobile/src/Stack.tsx+3-3| ... | ... | @@ -440,9 +440,9 @@ export const RootStack = createNativeStackNavigator({ |
| 440 | 440 | screen: ConnectOnboardingRouteScreen, |
| 441 | 441 | linking: "connect-onboarding", |
| 442 | 442 | options: { |
| 443 | // Root screenOptions hide headers; formSheets that want the native | |
| 444 | // title bar opt back in with the sheet header preset. | |
| 445 | ...SHEET_SOLID_HEADER_OPTIONS, | |
| 443 | // A root-level Android formSheet does not host the native stack bar; | |
| 444 | // the route renders an embedded AndroidSheetHeader instead. | |
| 445 | ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), | |
| 446 | 446 | title: "Set up T3 Connect", |
| 447 | 447 | gestureEnabled: true, |
| 448 | 448 | presentation: "formSheet", |
apps/mobile/src/components/AndroidScreenHeader.tsx+8-1| ... | ... | @@ -51,6 +51,7 @@ export function AndroidScreenHeader(props: { |
| 51 | 51 | readonly actions?: ReadonlyArray<AndroidHeaderAction>; |
| 52 | 52 | readonly trailing?: ReactNode; |
| 53 | 53 | readonly onBack?: () => void; |
| 54 | readonly embedded?: boolean; | |
| 54 | 55 | }) { |
| 55 | 56 | const insets = useSafeAreaInsets(); |
| 56 | 57 | const foregroundColor = useThemeColor("--color-foreground"); |
| ... | ... | @@ -59,7 +60,7 @@ export function AndroidScreenHeader(props: { |
| 59 | 60 | <View |
| 60 | 61 | className="border-b border-header-border bg-header px-3 pb-2.5" |
| 61 | 62 | style={{ |
| 62 | paddingTop: Math.max(insets.top, 12), | |
| 63 | paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), | |
| 63 | 64 | }} |
| 64 | 65 | > |
| 65 | 66 | <View className="min-h-12 flex-row items-center gap-2"> |
| ... | ... | @@ -108,3 +109,9 @@ export function AndroidScreenHeader(props: { |
| 108 | 109 | </View> |
| 109 | 110 | ); |
| 110 | 111 | } |
| 112 | ||
| 113 | export 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 | 2 | import { useAuth } from "@clerk/expo"; |
| 3 | 3 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 4 | 4 | import { useCallback, useEffect, useState } from "react"; |
| 5 | import { Pressable, RefreshControl, ScrollView, View } from "react-native"; | |
| 5 | import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; | |
| 6 | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | |
| 8 | 8 | import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; | |
| 9 | 10 | import { AppText as Text } from "../../components/AppText"; |
| 10 | 11 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 11 | 12 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; |
| ... | ... | @@ -81,9 +82,16 @@ function ConfiguredConnectOnboardingRouteScreen() { |
| 81 | 82 | |
| 82 | 83 | return ( |
| 83 | 84 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 84 | <NativeHeaderToolbar placement="right"> | |
| 85 | <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground /> | |
| 86 | </NativeHeaderToolbar> | |
| 85 | {Platform.OS === "android" ? ( | |
| 86 | <AndroidSheetHeader | |
| 87 | title="Set up T3 Connect" | |
| 88 | actions={[{ accessibilityLabel: "Close", icon: "xmark", onPress: handleClose }]} | |
| 89 | /> | |
| 90 | ) : ( | |
| 91 | <NativeHeaderToolbar placement="right"> | |
| 92 | <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground /> | |
| 93 | </NativeHeaderToolbar> | |
| 94 | )} | |
| 87 | 95 | <ScrollView |
| 88 | 96 | alwaysBounceVertical |
| 89 | 97 | contentInsetAdjustmentBehavior="automatic" |
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+118-107| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 2 | 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | 3 | import { useState } from "react"; |
| 4 | import { Pressable, ScrollView, View } from "react-native"; | |
| 4 | import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | |
| 7 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | |
| 7 | 8 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 8 | 9 | import { cn } from "../../../lib/cn"; |
| 9 | 10 | import { useEnvironmentQuery } from "../../../state/query"; |
| ... | ... | @@ -57,115 +58,125 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 57 | 58 | const disabledExistingBranches = new Set(disabledExistingBranchNames); |
| 58 | 59 | |
| 59 | 60 | return ( |
| 60 | <ScrollView | |
| 61 | showsVerticalScrollIndicator={false} | |
| 62 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | |
| 63 | contentContainerClassName="gap-4 px-5 pt-2" | |
| 64 | > | |
| 65 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | |
| 66 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | |
| 67 | New branch | |
| 68 | </Text> | |
| 69 | <TextInput | |
| 70 | value={newBranchName} | |
| 71 | onChangeText={setNewBranchName} | |
| 72 | placeholder="feature/mobile-polish" | |
| 73 | className="rounded-[18px]" | |
| 74 | /> | |
| 75 | <SheetActionButton | |
| 76 | icon="plus" | |
| 77 | label="Create & checkout" | |
| 78 | tone="primary" | |
| 79 | disabled={busy || newBranchName.trim().length === 0} | |
| 80 | onPress={() => { | |
| 81 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | |
| 82 | if (branch.length === 0) return; | |
| 83 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { | |
| 84 | setNewBranchName(""); | |
| 85 | navigation.goBack(); | |
| 86 | }); | |
| 87 | }} | |
| 88 | /> | |
| 89 | </View> | |
| 61 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 62 | {Platform.OS === "android" ? ( | |
| 63 | <AndroidSheetHeader title="Branches & worktrees" onBack={() => navigation.goBack()} /> | |
| 64 | ) : null} | |
| 65 | <ScrollView | |
| 66 | className="flex-1" | |
| 67 | showsVerticalScrollIndicator={false} | |
| 68 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | |
| 69 | contentContainerClassName="gap-4 px-5 pt-2" | |
| 70 | > | |
| 71 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | |
| 72 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | |
| 73 | New branch | |
| 74 | </Text> | |
| 75 | <TextInput | |
| 76 | value={newBranchName} | |
| 77 | onChangeText={setNewBranchName} | |
| 78 | placeholder="feature/mobile-polish" | |
| 79 | className="rounded-[18px]" | |
| 80 | /> | |
| 81 | <SheetActionButton | |
| 82 | icon="plus" | |
| 83 | label="Create & checkout" | |
| 84 | tone="primary" | |
| 85 | disabled={busy || newBranchName.trim().length === 0} | |
| 86 | onPress={() => { | |
| 87 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | |
| 88 | if (branch.length === 0) return; | |
| 89 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { | |
| 90 | setNewBranchName(""); | |
| 91 | navigation.goBack(); | |
| 92 | }); | |
| 93 | }} | |
| 94 | /> | |
| 95 | </View> | |
| 90 | 96 | |
| 91 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | |
| 92 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | |
| 93 | New worktree | |
| 94 | </Text> | |
| 95 | <TextInput | |
| 96 | value={worktreeBaseBranch} | |
| 97 | onChangeText={setWorktreeBaseBranch} | |
| 98 | placeholder="main" | |
| 99 | className="rounded-[18px]" | |
| 100 | /> | |
| 101 | <TextInput | |
| 102 | value={worktreeBranchName} | |
| 103 | onChangeText={setWorktreeBranchName} | |
| 104 | placeholder="feature/mobile-thread" | |
| 105 | className="rounded-[18px]" | |
| 106 | /> | |
| 107 | <SheetActionButton | |
| 108 | icon="square.split.2x1" | |
| 109 | label="Create worktree" | |
| 110 | tone="primary" | |
| 111 | disabled={ | |
| 112 | busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 | |
| 113 | } | |
| 114 | onPress={() => { | |
| 115 | const baseBranch = worktreeBaseBranch.trim(); | |
| 116 | const newBranch = worktreeBranchName.trim(); | |
| 117 | if (baseBranch.length === 0 || newBranch.length === 0) return; | |
| 118 | void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { | |
| 119 | setWorktreeBranchName(""); | |
| 120 | navigation.goBack(); | |
| 121 | }); | |
| 122 | }} | |
| 123 | /> | |
| 124 | </View> | |
| 97 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | |
| 98 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | |
| 99 | New worktree | |
| 100 | </Text> | |
| 101 | <TextInput | |
| 102 | value={worktreeBaseBranch} | |
| 103 | onChangeText={setWorktreeBaseBranch} | |
| 104 | placeholder="main" | |
| 105 | className="rounded-[18px]" | |
| 106 | /> | |
| 107 | <TextInput | |
| 108 | value={worktreeBranchName} | |
| 109 | onChangeText={setWorktreeBranchName} | |
| 110 | placeholder="feature/mobile-thread" | |
| 111 | className="rounded-[18px]" | |
| 112 | /> | |
| 113 | <SheetActionButton | |
| 114 | icon="square.split.2x1" | |
| 115 | label="Create worktree" | |
| 116 | tone="primary" | |
| 117 | disabled={ | |
| 118 | busy || | |
| 119 | worktreeBaseBranch.trim().length === 0 || | |
| 120 | worktreeBranchName.trim().length === 0 | |
| 121 | } | |
| 122 | onPress={() => { | |
| 123 | const baseBranch = worktreeBaseBranch.trim(); | |
| 124 | const newBranch = worktreeBranchName.trim(); | |
| 125 | if (baseBranch.length === 0 || newBranch.length === 0) return; | |
| 126 | void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { | |
| 127 | setWorktreeBranchName(""); | |
| 128 | navigation.goBack(); | |
| 129 | }); | |
| 130 | }} | |
| 131 | /> | |
| 132 | </View> | |
| 125 | 133 | |
| 126 | <View className="gap-2"> | |
| 127 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | |
| 128 | Existing branches | |
| 129 | </Text> | |
| 130 | {branchesLoading ? ( | |
| 131 | <Text className="text-foreground-secondary text-sm font-medium">Loading branches...</Text> | |
| 132 | ) : null} | |
| 133 | {!branchesLoading && availableBranches.length === 0 ? ( | |
| 134 | <Text className="text-foreground-secondary text-sm font-medium"> | |
| 135 | No local branches found. | |
| 134 | <View className="gap-2"> | |
| 135 | <Text className="text-foreground-secondary text-2xs font-t3-bold tracking-[1px] uppercase"> | |
| 136 | Existing branches | |
| 136 | 137 | </Text> |
| 137 | ) : null} | |
| 138 | {availableBranches.map((branch) => { | |
| 139 | const disabled = disabledExistingBranches.has(branch.name); | |
| 140 | const subtitle = branch.worktreePath | |
| 141 | ? branch.worktreePath === currentWorktreePath | |
| 142 | ? "Checked out in this thread" | |
| 143 | : "Checked out in another worktree" | |
| 144 | : branch.isDefault | |
| 145 | ? "Default branch" | |
| 146 | : "Local branch"; | |
| 138 | {branchesLoading ? ( | |
| 139 | <Text className="text-foreground-secondary text-sm font-medium"> | |
| 140 | Loading branches... | |
| 141 | </Text> | |
| 142 | ) : null} | |
| 143 | {!branchesLoading && availableBranches.length === 0 ? ( | |
| 144 | <Text className="text-foreground-secondary text-sm font-medium"> | |
| 145 | No local branches found. | |
| 146 | </Text> | |
| 147 | ) : null} | |
| 148 | {availableBranches.map((branch) => { | |
| 149 | const disabled = disabledExistingBranches.has(branch.name); | |
| 150 | const subtitle = branch.worktreePath | |
| 151 | ? branch.worktreePath === currentWorktreePath | |
| 152 | ? "Checked out in this thread" | |
| 153 | : "Checked out in another worktree" | |
| 154 | : branch.isDefault | |
| 155 | ? "Default branch" | |
| 156 | : "Local branch"; | |
| 147 | 157 | |
| 148 | return ( | |
| 149 | <Pressable | |
| 150 | key={branch.name} | |
| 151 | className={cn( | |
| 152 | "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]", | |
| 153 | branch.current ? "border-subtle-strong" : "border-border", | |
| 154 | )} | |
| 155 | disabled={busy || disabled} | |
| 156 | onPress={() => { | |
| 157 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | |
| 158 | navigation.goBack(); | |
| 159 | }); | |
| 160 | }} | |
| 161 | > | |
| 162 | <View className="absolute inset-0 rounded-[18px] bg-card" /> | |
| 163 | <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text> | |
| 164 | <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text> | |
| 165 | </Pressable> | |
| 166 | ); | |
| 167 | })} | |
| 168 | </View> | |
| 169 | </ScrollView> | |
| 158 | return ( | |
| 159 | <Pressable | |
| 160 | key={branch.name} | |
| 161 | className={cn( | |
| 162 | "gap-1 rounded-[18px] border px-4 py-3 disabled:opacity-[0.45]", | |
| 163 | branch.current ? "border-subtle-strong" : "border-border", | |
| 164 | )} | |
| 165 | disabled={busy || disabled} | |
| 166 | onPress={() => { | |
| 167 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | |
| 168 | navigation.goBack(); | |
| 169 | }); | |
| 170 | }} | |
| 171 | > | |
| 172 | <View className="absolute inset-0 rounded-[18px] bg-card" /> | |
| 173 | <Text className="text-foreground text-base font-t3-bold">{branch.name}</Text> | |
| 174 | <Text className="text-foreground-secondary text-xs font-medium">{subtitle}</Text> | |
| 175 | </Pressable> | |
| 176 | ); | |
| 177 | })} | |
| 178 | </View> | |
| 179 | </ScrollView> | |
| 180 | </View> | |
| 170 | 181 | ); |
| 171 | 182 | } |
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+150-141| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 2 | 2 | import { useCallback, useState } from "react"; |
| 3 | import { Pressable, ScrollView, View } from "react-native"; | |
| 3 | import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 4 | 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 5 | 5 | |
| 6 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | |
| 6 | 7 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; |
| 7 | 8 | import { cn } from "../../../lib/cn"; |
| 8 | 9 | import { useEnvironmentQuery } from "../../../state/query"; |
| ... | ... | @@ -65,160 +66,168 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 65 | 66 | ); |
| 66 | 67 | |
| 67 | 68 | return ( |
| 68 | <ScrollView | |
| 69 | keyboardShouldPersistTaps="handled" | |
| 70 | showsVerticalScrollIndicator={false} | |
| 71 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | |
| 72 | contentContainerClassName="gap-4 px-5 pt-2" | |
| 73 | > | |
| 74 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | |
| 75 | <View className="flex-row items-center justify-between gap-3"> | |
| 76 | <Text className="text-foreground-muted text-sm font-medium">Branch</Text> | |
| 77 | <Text className="text-foreground text-base font-t3-bold"> | |
| 78 | {gitStatus.data?.refName ?? "(detached HEAD)"} | |
| 79 | </Text> | |
| 80 | </View> | |
| 81 | {isDefaultRef ? ( | |
| 82 | <Text className="text-xs leading-normal text-amber-700 dark:text-amber-400"> | |
| 83 | Warning: this is the default branch. | |
| 84 | </Text> | |
| 85 | ) : null} | |
| 86 | </View> | |
| 87 | ||
| 88 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | |
| 89 | <View className="flex-row items-center justify-between gap-3"> | |
| 90 | <View className="gap-1"> | |
| 91 | <Text className="text-foreground text-base font-t3-bold">Files</Text> | |
| 92 | <Text className="text-foreground-muted text-xs leading-normal"> | |
| 93 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} | |
| 69 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 70 | {Platform.OS === "android" ? ( | |
| 71 | <AndroidSheetHeader title="Commit changes" onBack={() => navigation.goBack()} /> | |
| 72 | ) : null} | |
| 73 | <ScrollView | |
| 74 | className="flex-1" | |
| 75 | keyboardShouldPersistTaps="handled" | |
| 76 | showsVerticalScrollIndicator={false} | |
| 77 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | |
| 78 | contentContainerClassName="gap-4 px-5 pt-2" | |
| 79 | > | |
| 80 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | |
| 81 | <View className="flex-row items-center justify-between gap-3"> | |
| 82 | <Text className="text-foreground-muted text-sm font-medium">Branch</Text> | |
| 83 | <Text className="text-foreground text-base font-t3-bold"> | |
| 84 | {gitStatus.data?.refName ?? "(detached HEAD)"} | |
| 94 | 85 | </Text> |
| 95 | 86 | </View> |
| 96 | <View className="flex-row items-center gap-2"> | |
| 97 | {!allSelected && isEditingFiles ? ( | |
| 87 | {isDefaultRef ? ( | |
| 88 | <Text className="text-xs leading-normal text-amber-700 dark:text-amber-400"> | |
| 89 | Warning: this is the default branch. | |
| 90 | </Text> | |
| 91 | ) : null} | |
| 92 | </View> | |
| 93 | ||
| 94 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | |
| 95 | <View className="flex-row items-center justify-between gap-3"> | |
| 96 | <View className="gap-1"> | |
| 97 | <Text className="text-foreground text-base font-t3-bold">Files</Text> | |
| 98 | <Text className="text-foreground-muted text-xs leading-normal"> | |
| 99 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} | |
| 100 | </Text> | |
| 101 | </View> | |
| 102 | <View className="flex-row items-center gap-2"> | |
| 103 | {!allSelected && isEditingFiles ? ( | |
| 104 | <Pressable | |
| 105 | className="bg-subtle rounded-full px-3 py-2" | |
| 106 | onPress={() => setExcludedFiles(new Set())} | |
| 107 | > | |
| 108 | <Text className="text-foreground text-2xs font-t3-bold uppercase">Reset</Text> | |
| 109 | </Pressable> | |
| 110 | ) : null} | |
| 98 | 111 | <Pressable |
| 99 | 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 | 118 | </Pressable> |
| 104 | ) : null} | |
| 105 | <Pressable | |
| 106 | className="bg-subtle rounded-full px-3 py-2" | |
| 107 | onPress={() => setIsEditingFiles((current) => !current)} | |
| 108 | > | |
| 109 | <Text className="text-foreground text-2xs font-t3-bold uppercase"> | |
| 110 | {isEditingFiles ? "Done" : "Edit"} | |
| 111 | </Text> | |
| 112 | </Pressable> | |
| 119 | </View> | |
| 113 | 120 | </View> |
| 114 | </View> | |
| 115 | 121 | |
| 116 | {allFiles.length === 0 ? ( | |
| 117 | <Text className="text-foreground-secondary text-sm leading-normal"> | |
| 118 | No changed files are available to commit. | |
| 119 | </Text> | |
| 120 | ) : !isEditingFiles ? ( | |
| 121 | <View className="gap-2"> | |
| 122 | {selectedFilePreview.map((file) => ( | |
| 123 | <View key={file.path} className="flex-row items-center justify-between gap-3"> | |
| 124 | <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}> | |
| 125 | {file.path} | |
| 122 | {allFiles.length === 0 ? ( | |
| 123 | <Text className="text-foreground-secondary text-sm leading-normal"> | |
| 124 | No changed files are available to commit. | |
| 125 | </Text> | |
| 126 | ) : !isEditingFiles ? ( | |
| 127 | <View className="gap-2"> | |
| 128 | {selectedFilePreview.map((file) => ( | |
| 129 | <View key={file.path} className="flex-row items-center justify-between gap-3"> | |
| 130 | <Text className="text-foreground flex-1 text-sm font-medium" numberOfLines={1}> | |
| 131 | {file.path} | |
| 132 | </Text> | |
| 133 | <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text> | |
| 134 | <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text> | |
| 135 | </View> | |
| 136 | ))} | |
| 137 | {selectedFiles.length > selectedFilePreview.length ? ( | |
| 138 | <Text className="text-foreground-muted text-xs leading-snug"> | |
| 139 | +{selectedFiles.length - selectedFilePreview.length} more files | |
| 126 | 140 | </Text> |
| 127 | <Text className="text-xs font-t3-bold text-emerald-500">+{file.insertions}</Text> | |
| 128 | <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text> | |
| 129 | </View> | |
| 130 | ))} | |
| 131 | {selectedFiles.length > selectedFilePreview.length ? ( | |
| 132 | <Text className="text-foreground-muted text-xs leading-snug"> | |
| 133 | +{selectedFiles.length - selectedFilePreview.length} more files | |
| 134 | </Text> | |
| 135 | ) : null} | |
| 136 | </View> | |
| 137 | ) : ( | |
| 138 | <View className="gap-2"> | |
| 139 | {allFiles.map((file) => { | |
| 140 | const included = !excludedFiles.has(file.path); | |
| 141 | return ( | |
| 142 | <Pressable | |
| 143 | key={file.path} | |
| 144 | className={cn( | |
| 145 | "rounded-[18px] border px-4 py-3", | |
| 146 | included ? "border-border" : "border-border-subtle", | |
| 147 | )} | |
| 148 | onPress={() => { | |
| 149 | setExcludedFiles((current) => { | |
| 150 | const next = new Set(current); | |
| 151 | if (next.has(file.path)) { | |
| 152 | next.delete(file.path); | |
| 153 | } else { | |
| 154 | next.add(file.path); | |
| 155 | } | |
| 156 | return next; | |
| 157 | }); | |
| 158 | }} | |
| 159 | > | |
| 160 | <View | |
| 161 | className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`} | |
| 162 | /> | |
| 163 | <View className="flex-row items-start justify-between gap-3"> | |
| 164 | <View className="flex-1 gap-1"> | |
| 165 | <Text | |
| 166 | selectable | |
| 167 | className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`} | |
| 168 | > | |
| 169 | {file.path} | |
| 170 | </Text> | |
| 171 | {!included ? ( | |
| 172 | <Text className="text-foreground-muted text-2xs leading-normal"> | |
| 173 | Excluded from this commit | |
| 141 | ) : null} | |
| 142 | </View> | |
| 143 | ) : ( | |
| 144 | <View className="gap-2"> | |
| 145 | {allFiles.map((file) => { | |
| 146 | const included = !excludedFiles.has(file.path); | |
| 147 | return ( | |
| 148 | <Pressable | |
| 149 | key={file.path} | |
| 150 | className={cn( | |
| 151 | "rounded-[18px] border px-4 py-3", | |
| 152 | included ? "border-border" : "border-border-subtle", | |
| 153 | )} | |
| 154 | onPress={() => { | |
| 155 | setExcludedFiles((current) => { | |
| 156 | const next = new Set(current); | |
| 157 | if (next.has(file.path)) { | |
| 158 | next.delete(file.path); | |
| 159 | } else { | |
| 160 | next.add(file.path); | |
| 161 | } | |
| 162 | return next; | |
| 163 | }); | |
| 164 | }} | |
| 165 | > | |
| 166 | <View | |
| 167 | className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`} | |
| 168 | /> | |
| 169 | <View className="flex-row items-start justify-between gap-3"> | |
| 170 | <View className="flex-1 gap-1"> | |
| 171 | <Text | |
| 172 | selectable | |
| 173 | className={`text-sm font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`} | |
| 174 | > | |
| 175 | {file.path} | |
| 174 | 176 | </Text> |
| 175 | ) : null} | |
| 176 | </View> | |
| 177 | <View className="items-end gap-1"> | |
| 178 | <Text className="text-xs font-t3-bold text-emerald-500"> | |
| 179 | +{file.insertions} | |
| 180 | </Text> | |
| 181 | <Text className="text-xs font-t3-bold text-rose-500">-{file.deletions}</Text> | |
| 177 | {!included ? ( | |
| 178 | <Text className="text-foreground-muted text-2xs leading-normal"> | |
| 179 | Excluded from this commit | |
| 180 | </Text> | |
| 181 | ) : null} | |
| 182 | </View> | |
| 183 | <View className="items-end gap-1"> | |
| 184 | <Text className="text-xs font-t3-bold text-emerald-500"> | |
| 185 | +{file.insertions} | |
| 186 | </Text> | |
| 187 | <Text className="text-xs font-t3-bold text-rose-500"> | |
| 188 | -{file.deletions} | |
| 189 | </Text> | |
| 190 | </View> | |
| 182 | 191 | </View> |
| 183 | </View> | |
| 184 | </Pressable> | |
| 185 | ); | |
| 186 | })} | |
| 187 | </View> | |
| 188 | )} | |
| 189 | </View> | |
| 190 | ||
| 191 | <View className="gap-2"> | |
| 192 | <Text className="text-foreground text-sm font-t3-bold">Commit message</Text> | |
| 193 | <TextInput | |
| 194 | multiline | |
| 195 | value={dialogCommitMessage} | |
| 196 | onChangeText={setDialogCommitMessage} | |
| 197 | placeholder="Leave empty to auto-generate" | |
| 198 | textAlignVertical="top" | |
| 199 | className="min-h-[128px] rounded-[20px] px-4 py-3.5" | |
| 200 | /> | |
| 201 | </View> | |
| 192 | </Pressable> | |
| 193 | ); | |
| 194 | })} | |
| 195 | </View> | |
| 196 | )} | |
| 197 | </View> | |
| 202 | 198 | |
| 203 | <View className="flex-row gap-3"> | |
| 204 | <View className="flex-1"> | |
| 205 | <SheetActionButton | |
| 206 | icon="arrow.branch" | |
| 207 | label="Commit on new branch" | |
| 208 | disabled={noneSelected || busy} | |
| 209 | onPress={() => void runCommitAction(true)} | |
| 199 | <View className="gap-2"> | |
| 200 | <Text className="text-foreground text-sm font-t3-bold">Commit message</Text> | |
| 201 | <TextInput | |
| 202 | multiline | |
| 203 | value={dialogCommitMessage} | |
| 204 | onChangeText={setDialogCommitMessage} | |
| 205 | placeholder="Leave empty to auto-generate" | |
| 206 | textAlignVertical="top" | |
| 207 | className="min-h-[128px] rounded-[20px] px-4 py-3.5" | |
| 210 | 208 | /> |
| 211 | 209 | </View> |
| 212 | <View className="flex-1"> | |
| 213 | <SheetActionButton | |
| 214 | icon="checkmark.circle" | |
| 215 | label="Commit" | |
| 216 | tone="primary" | |
| 217 | disabled={noneSelected || busy} | |
| 218 | onPress={() => void runCommitAction(false)} | |
| 219 | /> | |
| 210 | ||
| 211 | <View className="flex-row gap-3"> | |
| 212 | <View className="flex-1"> | |
| 213 | <SheetActionButton | |
| 214 | icon="arrow.branch" | |
| 215 | label="Commit on new branch" | |
| 216 | disabled={noneSelected || busy} | |
| 217 | onPress={() => void runCommitAction(true)} | |
| 218 | /> | |
| 219 | </View> | |
| 220 | <View className="flex-1"> | |
| 221 | <SheetActionButton | |
| 222 | icon="checkmark.circle" | |
| 223 | label="Commit" | |
| 224 | tone="primary" | |
| 225 | disabled={noneSelected || busy} | |
| 226 | onPress={() => void runCommitAction(false)} | |
| 227 | /> | |
| 228 | </View> | |
| 220 | 229 | </View> |
| 221 | </View> | |
| 222 | </ScrollView> | |
| 230 | </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 | 4 | import * as Result from "effect/Result"; |
| 5 | 5 | import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 6 | 6 | import { useCallback, useMemo } from "react"; |
| 7 | import { View } from "react-native"; | |
| 7 | import { Platform, View } from "react-native"; | |
| 8 | 8 | |
| 9 | 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 10 | 10 | |
| 11 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | |
| 11 | 12 | import { AppText as Text } from "../../../components/AppText"; |
| 12 | 13 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; |
| 13 | 14 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| ... | ... | @@ -102,7 +103,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 102 | 103 | |
| 103 | 104 | return ( |
| 104 | 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 | )} | |
| 106 | 111 | |
| 107 | 112 | <View className="items-center gap-1 px-5 pb-3 pt-4"> |
| 108 | 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 | 5 | requiresDefaultBranchConfirmation, |
| 6 | 6 | } from "@t3tools/client-runtime/state/vcs"; |
| 7 | 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 8 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | |
| 8 | import { | |
| 9 | CommonActions, | |
| 10 | StackActions, | |
| 11 | useNavigation, | |
| 12 | type StaticScreenProps, | |
| 13 | } from "@react-navigation/native"; | |
| 9 | 14 | import { SymbolView } from "../../../components/AppSymbol"; |
| 10 | 15 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 11 | 16 | import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; |
| ... | ... | @@ -14,6 +19,7 @@ import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-scree |
| 14 | 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | 20 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 16 | 21 | |
| 22 | import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; | |
| 17 | 23 | import { AppText as Text } from "../../../components/AppText"; |
| 18 | 24 | import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader"; |
| 19 | 25 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; |
| ... | ... | @@ -23,6 +29,7 @@ import { useSelectedThreadGitActions } from "../../../state/use-selected-thread- |
| 23 | 29 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; |
| 24 | 30 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; |
| 25 | 31 | import { vcsEnvironment } from "../../../state/vcs"; |
| 32 | import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; | |
| 26 | 33 | import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; |
| 27 | 34 | |
| 28 | 35 | const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); |
| ... | ... | @@ -255,7 +262,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 255 | 262 | title="Review changes" |
| 256 | 263 | subtitle="Inspect turn diffs, worktree changes, and base branch diff" |
| 257 | 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 | 274 | <View className="ml-12 h-px bg-border" /> |
| 261 | 275 | <SheetListRow |
| ... | ... | @@ -348,12 +362,14 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 348 | 362 | collapsable={false} |
| 349 | 363 | className={isInspector ? "flex-1 border-l border-border bg-sheet" : "flex-1 bg-sheet"} |
| 350 | 364 | > |
| 351 | <View | |
| 352 | style={{ | |
| 353 | minHeight: isInspector ? (props.headerInset ?? 0) : 16, | |
| 354 | paddingTop: isInspector ? (props.headerInset ?? 0) : 8, | |
| 355 | }} | |
| 356 | /> | |
| 365 | {isInspector ? ( | |
| 366 | <View | |
| 367 | style={{ | |
| 368 | minHeight: props.headerInset ?? 0, | |
| 369 | paddingTop: props.headerInset ?? 0, | |
| 370 | }} | |
| 371 | /> | |
| 372 | ) : null} | |
| 357 | 373 | |
| 358 | 374 | {isInspector ? ( |
| 359 | 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 | 399 | </Text> |
| 384 | 400 | </View> |
| 385 | 401 | ) : ( |
| 386 | // Compact header row: labeled branch on the left, status summary at | |
| 387 | // the trailing end. Horizontal padding lines the text up with the | |
| 388 | // rows' icon column inside the card below (20 screen + 16 card + 4 | |
| 389 | // row). The sheet relies on pull-to-refresh instead of a corner | |
| 390 | // refresh button. | |
| 391 | <View className="flex-row items-end justify-between gap-3 px-10 pb-4 pt-4"> | |
| 392 | <View className="shrink gap-0.5"> | |
| 393 | <Text className="text-xs font-t3-bold tracking-[1px] uppercase text-foreground-muted"> | |
| 394 | Branch | |
| 395 | </Text> | |
| 396 | <Text className="text-xl font-t3-bold" numberOfLines={1}> | |
| 397 | {currentBranchLabel} | |
| 398 | </Text> | |
| 399 | </View> | |
| 400 | <Text className="text-foreground-secondary pb-0.5 text-sm font-medium" numberOfLines={1}> | |
| 401 | {currentStatusSummary} | |
| 402 | </Text> | |
| 403 | </View> | |
| 402 | <AndroidSheetHeader | |
| 403 | title={currentBranchLabel} | |
| 404 | subtitle={currentStatusSummary} | |
| 405 | onBack={() => navigation.goBack()} | |
| 406 | actions={[ | |
| 407 | { | |
| 408 | accessibilityLabel: "Refresh repository status", | |
| 409 | disabled: busy, | |
| 410 | icon: "arrow.clockwise", | |
| 411 | onPress: () => void gitActions.refreshSelectedThreadGitStatus(), | |
| 412 | }, | |
| 413 | ]} | |
| 414 | /> | |
| 404 | 415 | )} |
| 405 | 416 | |
| 406 | 417 | {content} |
apps/mobile/src/features/threads/git/git-overview-navigation.test.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | export function resolveGitOverviewReviewNavigationAction( | |
| 2 | presentation: "sheet" | "inspector", | |
| 3 | ): "replace" | "navigate" { | |
| 4 | return presentation === "sheet" ? "replace" : "navigate"; | |
| 5 | } |