| author | |
| committer | |
| log | 397a46123999ada6b9ddcbe85534631a241bb0ed |
| tree | f43751bd71c0d65c88dc263de6396aa561d27fa5 |
| parent | f79f37a637bda257faa06a04184e368f78650d3d |
| signature | Signed by PGP key B5690EEEBB952194 |
47 files changed, 1399 insertions(+), 222 deletions(-)
apps/mobile/assets/icons/file-diff.png created| Binary files /dev/null and b/apps/mobile/assets/icons/file-diff.png differ | |||
apps/mobile/assets/icons/file-diff@2x.png created| Binary files /dev/null and b/apps/mobile/assets/icons/file-diff@2x.png differ | |||
apps/mobile/assets/icons/file-diff@3x.png created| Binary files /dev/null and b/apps/mobile/assets/icons/file-diff@3x.png differ | |||
apps/mobile/modules/t3-native-controls/android/build.gradle+11| ... | @@ -1,5 +1,13 @@ | ... | @@ -1,5 +1,13 @@ |
| 1 | buildscript { | ||
| 2 | repositories { mavenCentral() } | ||
| 3 | dependencies { | ||
| 4 | classpath("org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:${kotlinVersion}") | ||
| 5 | } | ||
| 6 | } | ||
| 7 | |||
| 1 | apply plugin: 'com.android.library' | 8 | apply plugin: 'com.android.library' |
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | 9 | apply plugin: 'org.jetbrains.kotlin.android' |
| 10 | apply plugin: 'org.jetbrains.kotlin.plugin.compose' | ||
| 3 | 11 | ||
| 4 | group = 'com.t3tools.nativecontrols' | 12 | group = 'com.t3tools.nativecontrols' |
| 5 | version = '0.0.0' | 13 | version = '0.0.0' |
| ... | @@ -12,8 +20,11 @@ android { | ... | @@ -12,8 +20,11 @@ android { |
| 12 | minSdkVersion rootProject.ext.minSdkVersion | 20 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | targetSdkVersion rootProject.ext.targetSdkVersion | 21 | targetSdkVersion rootProject.ext.targetSdkVersion |
| 14 | } | 22 | } |
| 23 | buildFeatures { compose true } | ||
| 15 | } | 24 | } |
| 16 | 25 | ||
| 17 | dependencies { | 26 | dependencies { |
| 18 | implementation project(':expo-modules-core') | 27 | implementation project(':expo-modules-core') |
| 28 | implementation 'androidx.compose.ui:ui-android:1.10.6' | ||
| 29 | implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17' | ||
| 19 | } | 30 | } |
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | package expo.modules.t3nativecontrols | ||
| 2 | |||
| 3 | import android.content.Context | ||
| 4 | import android.view.ViewGroup | ||
| 5 | import androidx.compose.foundation.layout.WindowInsets | ||
| 6 | import androidx.compose.foundation.layout.Spacer | ||
| 7 | import androidx.compose.foundation.layout.fillMaxSize | ||
| 8 | import androidx.compose.material3.Icon | ||
| 9 | import androidx.compose.material3.MaterialTheme | ||
| 10 | import androidx.compose.material3.NavigationBar | ||
| 11 | import androidx.compose.material3.NavigationBarItem | ||
| 12 | import androidx.compose.material3.NavigationBarItemDefaults | ||
| 13 | import androidx.compose.material3.NavigationRail | ||
| 14 | import androidx.compose.material3.NavigationRailItem | ||
| 15 | import androidx.compose.material3.NavigationRailItemDefaults | ||
| 16 | import androidx.compose.material3.Text | ||
| 17 | import androidx.compose.material3.darkColorScheme | ||
| 18 | import androidx.compose.material3.lightColorScheme | ||
| 19 | import androidx.compose.runtime.Composable | ||
| 20 | import androidx.compose.runtime.getValue | ||
| 21 | import androidx.compose.runtime.mutableStateOf | ||
| 22 | import androidx.compose.runtime.setValue | ||
| 23 | import androidx.compose.ui.Modifier | ||
| 24 | import androidx.compose.ui.graphics.Color | ||
| 25 | import androidx.compose.ui.platform.ComposeView | ||
| 26 | import androidx.compose.ui.platform.ViewCompositionStrategy | ||
| 27 | import androidx.compose.ui.res.painterResource | ||
| 28 | import androidx.compose.ui.unit.sp | ||
| 29 | import expo.modules.kotlin.AppContext | ||
| 30 | import expo.modules.kotlin.modules.Module | ||
| 31 | import expo.modules.kotlin.modules.ModuleDefinition | ||
| 32 | import expo.modules.kotlin.viewevent.EventDispatcher | ||
| 33 | import expo.modules.kotlin.views.ExpoView | ||
| 34 | |||
| 35 | class T3WorkspaceNavigationModule : Module() { | ||
| 36 | override fun definition() = ModuleDefinition { | ||
| 37 | Name("T3WorkspaceNavigation") | ||
| 38 | View(T3WorkspaceNavigationView::class) { | ||
| 39 | Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> | ||
| 40 | view.selectedTab = value | ||
| 41 | } | ||
| 42 | Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value } | ||
| 43 | Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value } | ||
| 44 | Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value } | ||
| 45 | Prop("containerColor") { view: T3WorkspaceNavigationView, value: Int -> | ||
| 46 | view.containerColor = value | ||
| 47 | } | ||
| 48 | Prop("selectedColor") { view: T3WorkspaceNavigationView, value: Int -> | ||
| 49 | view.selectedColor = value | ||
| 50 | } | ||
| 51 | Prop("indicatorColor") { view: T3WorkspaceNavigationView, value: Int -> | ||
| 52 | view.indicatorColor = value | ||
| 53 | } | ||
| 54 | Prop("unselectedColor") { view: T3WorkspaceNavigationView, value: Int -> | ||
| 55 | view.unselectedColor = value | ||
| 56 | } | ||
| 57 | Events("onTabSelected") | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | /** One Material3 selection surface moves from below the list to its leading edge. */ | ||
| 63 | class T3WorkspaceNavigationView( | ||
| 64 | context: Context, | ||
| 65 | appContext: AppContext | ||
| 66 | ) : ExpoView(context, appContext) { | ||
| 67 | // Compose must remeasure native children when Yoga resizes the folded window. | ||
| 68 | override val shouldUseAndroidLayout = true | ||
| 69 | |||
| 70 | private val onTabSelected by EventDispatcher() | ||
| 71 | var selectedTab by mutableStateOf("threads") | ||
| 72 | var rail by mutableStateOf(false) | ||
| 73 | var dark by mutableStateOf(false) | ||
| 74 | var textScale by mutableStateOf(1f) | ||
| 75 | var containerColor by mutableStateOf(0) | ||
| 76 | var selectedColor by mutableStateOf(0) | ||
| 77 | var indicatorColor by mutableStateOf(0) | ||
| 78 | var unselectedColor by mutableStateOf(0) | ||
| 79 | |||
| 80 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { | ||
| 81 | // Fabric measures the replacement bar before attaching it on a fold. | ||
| 82 | // Compose needs an attached window to obtain its lifecycle recomposer. | ||
| 83 | if (!isAttachedToWindow) { | ||
| 84 | setMeasuredDimension( | ||
| 85 | MeasureSpec.getSize(widthMeasureSpec), | ||
| 86 | MeasureSpec.getSize(heightMeasureSpec) | ||
| 87 | ) | ||
| 88 | return | ||
| 89 | } | ||
| 90 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) | ||
| 91 | } | ||
| 92 | |||
| 93 | override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { | ||
| 94 | if (isAttachedToWindow) super.onLayout(changed, left, top, right, bottom) | ||
| 95 | } | ||
| 96 | |||
| 97 | override fun onAttachedToWindow() { | ||
| 98 | super.onAttachedToWindow() | ||
| 99 | requestLayout() | ||
| 100 | } | ||
| 101 | |||
| 102 | init { | ||
| 103 | addView( | ||
| 104 | ComposeView(context).apply { | ||
| 105 | setViewCompositionStrategy( | ||
| 106 | ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool | ||
| 107 | ) | ||
| 108 | setContent { | ||
| 109 | MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { | ||
| 110 | val tabs = listOf("threads", "pullRequests") | ||
| 111 | if (rail) { | ||
| 112 | NavigationRail( | ||
| 113 | modifier = Modifier.fillMaxSize(), | ||
| 114 | containerColor = Color(containerColor), | ||
| 115 | windowInsets = WindowInsets(0), | ||
| 116 | ) { | ||
| 117 | Spacer(Modifier.weight(1f)) | ||
| 118 | tabs.forEach { tab -> | ||
| 119 | NavigationRailItem( | ||
| 120 | selected = selectedTab == tab, | ||
| 121 | onClick = { onTabSelected(mapOf("tab" to tab)) }, | ||
| 122 | icon = { tabIcon(tab) }, | ||
| 123 | label = { | ||
| 124 | val title = if (tab == "threads") "Chats" else "PRs" | ||
| 125 | Text(title, fontSize = (12 * textScale).sp) | ||
| 126 | }, | ||
| 127 | colors = NavigationRailItemDefaults.colors( | ||
| 128 | selectedIconColor = Color(selectedColor), | ||
| 129 | selectedTextColor = Color(selectedColor), | ||
| 130 | indicatorColor = Color(indicatorColor), | ||
| 131 | unselectedIconColor = Color(unselectedColor), | ||
| 132 | unselectedTextColor = Color(unselectedColor), | ||
| 133 | ), | ||
| 134 | ) | ||
| 135 | } | ||
| 136 | Spacer(Modifier.weight(1f)) | ||
| 137 | } | ||
| 138 | } else { | ||
| 139 | NavigationBar( | ||
| 140 | modifier = Modifier.fillMaxSize(), | ||
| 141 | containerColor = Color(containerColor), | ||
| 142 | tonalElevation = androidx.compose.ui.unit.Dp(0f), | ||
| 143 | windowInsets = WindowInsets(0), | ||
| 144 | ) { | ||
| 145 | tabs.forEach { tab -> | ||
| 146 | NavigationBarItem( | ||
| 147 | selected = selectedTab == tab, | ||
| 148 | onClick = { onTabSelected(mapOf("tab" to tab)) }, | ||
| 149 | icon = { tabIcon(tab) }, | ||
| 150 | label = { | ||
| 151 | val title = if (tab == "threads") "Chats" else "Pull requests" | ||
| 152 | Text(title, fontSize = (12 * textScale).sp) | ||
| 153 | }, | ||
| 154 | colors = NavigationBarItemDefaults.colors( | ||
| 155 | selectedIconColor = Color(selectedColor), | ||
| 156 | selectedTextColor = Color(selectedColor), | ||
| 157 | indicatorColor = Color(indicatorColor), | ||
| 158 | unselectedIconColor = Color(unselectedColor), | ||
| 159 | unselectedTextColor = Color(unselectedColor), | ||
| 160 | ), | ||
| 161 | ) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | } | ||
| 165 | } | ||
| 166 | } | ||
| 167 | }, | ||
| 168 | LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) | ||
| 169 | ) | ||
| 170 | } | ||
| 171 | |||
| 172 | @Composable | ||
| 173 | private fun tabIcon(tab: String) { | ||
| 174 | val drawable = if (tab == "threads") { | ||
| 175 | R.drawable.t3_chats | ||
| 176 | } else { | ||
| 177 | R.drawable.t3_pull_requests | ||
| 178 | } | ||
| 179 | Icon( | ||
| 180 | painter = painterResource(drawable), | ||
| 181 | contentDescription = if (tab == "threads") "Chats" else "Pull requests", | ||
| 182 | ) | ||
| 183 | } | ||
| 184 | } | ||
apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_chats.xml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> | ||
| 2 | <path android:fillColor="#000000" android:pathData="M20,2H4C2.9,2 2,2.9 2,4V22L6,18H20C21.1,18 22,17.1 22,16V4C22,2.9 21.1,2 20,2ZM20,16H5.17L4,17.17V4H20V16ZM6,6H18V8H6V6ZM6,10H16V12H6V10Z" /> | ||
| 3 | </vector> | ||
apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_pull_requests.xml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> | ||
| 2 | <path android:fillColor="#000000" android:pathData="M6,2C4.34,2 3,3.34 3,5C3,6.31 3.84,7.42 5,7.83V16.17C3.84,16.58 3,17.69 3,19C3,20.66 4.34,22 6,22C7.66,22 9,20.66 9,19C9,17.69 8.16,16.58 7,16.17V7.83C8.16,7.42 9,6.31 9,5C9,3.34 7.66,2 6,2ZM6,4C6.55,4 7,4.45 7,5C7,5.55 6.55,6 6,6C5.45,6 5,5.55 5,5C5,4.45 5.45,4 6,4ZM6,18C6.55,18 7,18.45 7,19C7,19.55 6.55,20 6,20C5.45,20 5,19.55 5,19C5,18.45 5.45,18 6,18ZM14,2L10,6L14,10V7H15C16.1,7 17,7.9 17,9V16.17C15.84,16.58 15,17.69 15,19C15,20.66 16.34,22 18,22C19.66,22 21,20.66 21,19C21,17.69 20.16,16.58 19,16.17V9C19,6.79 17.21,5 15,5H14V2ZM18,18C18.55,18 19,18.45 19,19C19,19.55 18.55,20 18,20C17.45,20 17,19.55 17,19C17,18.45 17.45,18 18,18Z" /> | ||
| 3 | </vector> | ||
apps/mobile/modules/t3-native-controls/expo-module.config.json+1| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | "android": { | 7 | "android": { |
| 8 | "modules": [ | 8 | "modules": [ |
| 9 | "expo.modules.t3nativecontrols.T3NativeControlsModule", | 9 | "expo.modules.t3nativecontrols.T3NativeControlsModule", |
| 10 | "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule", | ||
| 10 | "expo.modules.t3nativecontrols.T3KeyboardCommandsModule" | 11 | "expo.modules.t3nativecontrols.T3KeyboardCommandsModule" |
| 11 | ] | 12 | ] |
| 12 | } | 13 | } |
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt+3| ... | @@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() { | ... | @@ -41,6 +41,9 @@ class T3ReviewDiffModule : Module() { |
| 41 | Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> | 41 | Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> |
| 42 | view.setContentWidth(contentWidth.toFloat()) | 42 | view.setContentWidth(contentWidth.toFloat()) |
| 43 | } | 43 | } |
| 44 | Prop("contentInsetBottom") { view: T3ReviewDiffView, value: Double -> | ||
| 45 | view.setContentInsetBottom(value.toFloat()) | ||
| 46 | } | ||
| 44 | Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean -> | 47 | Prop("scrollEnabled") { view: T3ReviewDiffView, enabled: Boolean -> |
| 45 | view.setScrollEnabled(enabled) | 48 | view.setScrollEnabled(enabled) |
| 46 | } | 49 | } |
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt+12-4| ... | @@ -143,6 +143,12 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont | ... | @@ -143,6 +143,12 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 143 | canvasView.contentWidthPx = max(width, dp(value).toInt()) | 143 | canvasView.contentWidthPx = max(width, dp(value).toInt()) |
| 144 | } | 144 | } |
| 145 | 145 | ||
| 146 | fun setContentInsetBottom(value: Float) { | ||
| 147 | canvasView.setPadding(0, 0, 0, max(0, dp(value).toInt())) | ||
| 148 | canvasView.setVerticalOffset(canvasView.verticalOffset()) | ||
| 149 | canvasView.invalidate() | ||
| 150 | } | ||
| 151 | |||
| 146 | fun setInitialRowIndex(value: Double) { | 152 | fun setInitialRowIndex(value: Double) { |
| 147 | initialRowIndex = value.toInt().coerceAtLeast(0) | 153 | initialRowIndex = value.toInt().coerceAtLeast(0) |
| 148 | pendingInitialScroll = true | 154 | pendingInitialScroll = true |
| ... | @@ -818,7 +824,7 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -818,7 +824,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 818 | fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))] | 824 | fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))] |
| 819 | 825 | ||
| 820 | fun setVerticalOffset(value: Int) { | 826 | fun setVerticalOffset(value: Int) { |
| 821 | val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height) | 827 | val maxOffset = maxVerticalOffset() |
| 822 | val nextOffset = value.coerceIn(0, maxOffset) | 828 | val nextOffset = value.coerceIn(0, maxOffset) |
| 823 | if (verticalOffset == nextOffset) return | 829 | if (verticalOffset == nextOffset) return |
| 824 | verticalOffset = nextOffset | 830 | verticalOffset = nextOffset |
| ... | @@ -832,7 +838,8 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -832,7 +838,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 832 | 838 | ||
| 833 | fun verticalOffset(): Int = verticalOffset | 839 | fun verticalOffset(): Int = verticalOffset |
| 834 | 840 | ||
| 835 | fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height) | 841 | fun maxVerticalOffset(): Int = |
| 842 | max(0, (rowOffsets.lastOrNull() ?: 0) + paddingBottom - height) | ||
| 836 | 843 | ||
| 837 | fun setHorizontalOffset(value: Int) { | 844 | fun setHorizontalOffset(value: Int) { |
| 838 | val nextOffset = value.coerceIn(0, maxHorizontalOffset()) | 845 | val nextOffset = value.coerceIn(0, maxHorizontalOffset()) |
| ... | @@ -932,8 +939,9 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -932,8 +939,9 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 932 | return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) } | 939 | return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) } |
| 933 | } | 940 | } |
| 934 | } | 941 | } |
| 935 | val index = rowIndexAt(verticalOffset + y.toInt()) | 942 | val contentY = verticalOffset + y.toInt() |
| 936 | return rows.getOrNull(index)?.let { | 943 | val index = rowIndexAt(contentY) |
| 944 | return rows.getOrNull(index)?.takeIf { contentY in 0 until rowOffsets.last() }?.let { | ||
| 937 | RowHit( | 945 | RowHit( |
| 938 | row = it, | 946 | row = it, |
| 939 | top = rowOffsets[index] - verticalOffset, | 947 | top = rowOffsets[index] - verticalOffset, |
apps/mobile/src/Stack.tsx+21-12| ... | @@ -31,6 +31,7 @@ import { useResolveClassNames } from "uniwind"; | ... | @@ -31,6 +31,7 @@ import { useResolveClassNames } from "uniwind"; |
| 31 | import { AppText as Text } from "./components/AppText"; | 31 | import { AppText as Text } from "./components/AppText"; |
| 32 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; | 32 | import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; |
| 33 | import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; | 33 | import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; |
| 34 | import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; | ||
| 34 | import { | 35 | import { |
| 35 | PullRequestRouteScreen, | 36 | PullRequestRouteScreen, |
| 36 | PullRequestFileRouteScreen, | 37 | PullRequestFileRouteScreen, |
| ... | @@ -170,6 +171,12 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { | ... | @@ -170,6 +171,12 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { |
| 170 | unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, | 171 | unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, |
| 171 | }; | 172 | }; |
| 172 | 173 | ||
| 174 | // Android PR screens render their own Material toolbar. | ||
| 175 | const PULL_REQUEST_HEADER_OPTIONS: AppScreenOptions = { | ||
| 176 | ...GLASS_HEADER_OPTIONS, | ||
| 177 | headerShown: Platform.OS !== "android", | ||
| 178 | }; | ||
| 179 | |||
| 173 | // SOLID: opaque sheet-colored header for surfaces that manage their own viewport. | 180 | // SOLID: opaque sheet-colored header for surfaces that manage their own viewport. |
| 174 | const SOLID_HEADER_OPTIONS: AppScreenOptions = { | 181 | const SOLID_HEADER_OPTIONS: AppScreenOptions = { |
| 175 | headerBackButtonDisplayMode: "minimal", | 182 | headerBackButtonDisplayMode: "minimal", |
| ... | @@ -647,13 +654,15 @@ function RootStackLayout(props: { | ... | @@ -647,13 +654,15 @@ function RootStackLayout(props: { |
| 647 | <ThreadOutboxDrainWorker /> | 654 | <ThreadOutboxDrainWorker /> |
| 648 | <ShowcaseCaptureCoordinator pathname={pathname} /> | 655 | <ShowcaseCaptureCoordinator pathname={pathname} /> |
| 649 | <ExistingThreadSettingsRouteProvider> | 656 | <ExistingThreadSettingsRouteProvider> |
| 650 | <AdaptiveWorkspaceLayout | 657 | <AndroidWorkspaceTabsProvider> |
| 651 | pathname={workspaceLocation.pathname} | 658 | <AdaptiveWorkspaceLayout |
| 652 | workspaceRouteKey={workspaceLocation.routeKey} | 659 | pathname={workspaceLocation.pathname} |
| 653 | > | 660 | workspaceRouteKey={workspaceLocation.routeKey} |
| 654 | {props.children} | 661 | > |
| 655 | <HardwareKeyboardCommandOverlay /> | 662 | {props.children} |
| 656 | </AdaptiveWorkspaceLayout> | 663 | <HardwareKeyboardCommandOverlay /> |
| 664 | </AdaptiveWorkspaceLayout> | ||
| 665 | </AndroidWorkspaceTabsProvider> | ||
| 657 | </ExistingThreadSettingsRouteProvider> | 666 | </ExistingThreadSettingsRouteProvider> |
| 658 | </HardwareKeyboardCommandProvider> | 667 | </HardwareKeyboardCommandProvider> |
| 659 | ); | 668 | ); |
| ... | @@ -721,15 +730,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ | ... | @@ -721,15 +730,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 721 | path: "pull-requests/:environmentId/:projectId/:repository/:number", | 730 | path: "pull-requests/:environmentId/:projectId/:repository/:number", |
| 722 | parse: { number: Number }, | 731 | parse: { number: Number }, |
| 723 | }, | 732 | }, |
| 724 | options: GLASS_HEADER_OPTIONS, | 733 | options: PULL_REQUEST_HEADER_OPTIONS, |
| 725 | }), | 734 | }), |
| 726 | PullRequestGuideStop: createNativeStackScreen({ | 735 | PullRequestGuideStop: createNativeStackScreen({ |
| 727 | screen: PullRequestGuideStopScreen, | 736 | screen: PullRequestGuideStopScreen, |
| 728 | options: GLASS_HEADER_OPTIONS, | 737 | options: PULL_REQUEST_HEADER_OPTIONS, |
| 729 | }), | 738 | }), |
| 730 | PullRequestFile: createNativeStackScreen({ | 739 | PullRequestFile: createNativeStackScreen({ |
| 731 | screen: PullRequestFileRouteScreen, | 740 | screen: PullRequestFileRouteScreen, |
| 732 | options: GLASS_HEADER_OPTIONS, | 741 | options: PULL_REQUEST_HEADER_OPTIONS, |
| 733 | }), | 742 | }), |
| 734 | PullRequestFiles: createNativeStackScreen({ | 743 | PullRequestFiles: createNativeStackScreen({ |
| 735 | screen: PullRequestFilesRouteScreen, | 744 | screen: PullRequestFilesRouteScreen, |
| ... | @@ -743,7 +752,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ | ... | @@ -743,7 +752,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 743 | PullRequestReviewers: createNativeStackScreen({ | 752 | PullRequestReviewers: createNativeStackScreen({ |
| 744 | screen: PullRequestReviewersScreen, | 753 | screen: PullRequestReviewersScreen, |
| 745 | options: { | 754 | options: { |
| 746 | ...GLASS_HEADER_OPTIONS, | 755 | ...PULL_REQUEST_HEADER_OPTIONS, |
| 747 | ...FORM_SHEET_PRESENTATION_OPTIONS, | 756 | ...FORM_SHEET_PRESENTATION_OPTIONS, |
| 748 | sheetAllowedDetents: [0.75, 0.92], | 757 | sheetAllowedDetents: [0.75, 0.92], |
| 749 | sheetGrabberVisible: true, | 758 | sheetGrabberVisible: true, |
| ... | @@ -752,7 +761,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ | ... | @@ -752,7 +761,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ |
| 752 | }), | 761 | }), |
| 753 | PullRequestDiscussion: createNativeStackScreen({ | 762 | PullRequestDiscussion: createNativeStackScreen({ |
| 754 | screen: PullRequestDiscussionScreen, | 763 | screen: PullRequestDiscussionScreen, |
| 755 | options: GLASS_HEADER_OPTIONS, | 764 | options: PULL_REQUEST_HEADER_OPTIONS, |
| 756 | }), | 765 | }), |
| 757 | PullRequestInlineComment: createNativeStackScreen({ | 766 | PullRequestInlineComment: createNativeStackScreen({ |
| 758 | screen: PullRequestInlineCommentScreen, | 767 | screen: PullRequestInlineCommentScreen, |
apps/mobile/src/components/AppSymbol.ios.tsx+6-4| ... | @@ -5,6 +5,7 @@ import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequ | ... | @@ -5,6 +5,7 @@ import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequ |
| 5 | import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; | 5 | import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; |
| 6 | import type { SFSymbol } from "expo-symbols"; | 6 | import type { SFSymbol } from "expo-symbols"; |
| 7 | import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; | 7 | import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; |
| 8 | import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; | ||
| 8 | import { SymbolView as ExpoSymbolView } from "expo-symbols"; | 9 | import { SymbolView as ExpoSymbolView } from "expo-symbols"; |
| 9 | import { withUniwind } from "uniwind"; | 10 | import { withUniwind } from "uniwind"; |
| 10 | import type { AppSymbolViewProps } from "./AppSymbol"; | 11 | import type { AppSymbolViewProps } from "./AppSymbol"; |
| ... | @@ -12,7 +13,8 @@ import type { AppSymbolViewProps } from "./AppSymbol"; | ... | @@ -12,7 +13,8 @@ import type { AppSymbolViewProps } from "./AppSymbol"; |
| 12 | export type { SFSymbol } from "expo-symbols"; | 13 | export type { SFSymbol } from "expo-symbols"; |
| 13 | export type { AppSymbolName } from "./AppSymbol"; | 14 | export type { AppSymbolName } from "./AppSymbol"; |
| 14 | 15 | ||
| 15 | const GIT_ICONS = { | 16 | const CUSTOM_ICONS = { |
| 17 | "file.diff": IconFileDiff, | ||
| 16 | "arrow.triangle.pull": IconGitPullRequest, | 18 | "arrow.triangle.pull": IconGitPullRequest, |
| 17 | "arrow.triangle.merge": IconGitMerge, | 19 | "arrow.triangle.merge": IconGitMerge, |
| 18 | "arrow.triangle.branch": IconGitBranch, | 20 | "arrow.triangle.branch": IconGitBranch, |
| ... | @@ -24,10 +26,10 @@ const GIT_ICONS = { | ... | @@ -24,10 +26,10 @@ const GIT_ICONS = { |
| 24 | 26 | ||
| 25 | function AppSymbolView(props: AppSymbolViewProps) { | 27 | function AppSymbolView(props: AppSymbolViewProps) { |
| 26 | const name = typeof props.name === "string" ? props.name : props.name.ios; | 28 | const name = typeof props.name === "string" ? props.name : props.name.ios; |
| 27 | const GitIcon = GIT_ICONS[name as keyof typeof GIT_ICONS]; | 29 | const CustomIcon = CUSTOM_ICONS[name as keyof typeof CUSTOM_ICONS]; |
| 28 | if (GitIcon) { | 30 | if (CustomIcon) { |
| 29 | return ( | 31 | return ( |
| 30 | <GitIcon | 32 | <CustomIcon |
| 31 | accessibilityLabel={props.accessibilityLabel} | 33 | accessibilityLabel={props.accessibilityLabel} |
| 32 | color={props.tintColor} | 34 | color={props.tintColor} |
| 33 | size={props.size} | 35 | size={props.size} |
apps/mobile/src/components/AppSymbol.tsx+10-2| ... | @@ -58,6 +58,7 @@ import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; | ... | @@ -58,6 +58,7 @@ import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; |
| 58 | import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; | 58 | import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; |
| 59 | import IconEye from "@tabler/icons-react-native/IconEye"; | 59 | import IconEye from "@tabler/icons-react-native/IconEye"; |
| 60 | import IconFileText from "@tabler/icons-react-native/IconFileText"; | 60 | import IconFileText from "@tabler/icons-react-native/IconFileText"; |
| 61 | import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; | ||
| 61 | import IconFilter from "@tabler/icons-react-native/IconFilter"; | 62 | import IconFilter from "@tabler/icons-react-native/IconFilter"; |
| 62 | import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; | 63 | import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; |
| 63 | import IconFolder from "@tabler/icons-react-native/IconFolder"; | 64 | import IconFolder from "@tabler/icons-react-native/IconFolder"; |
| ... | @@ -188,6 +189,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { | ... | @@ -188,6 +189,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { |
| 188 | doc: IconFileText, | 189 | doc: IconFileText, |
| 189 | "doc.on.doc": IconCopy, | 190 | "doc.on.doc": IconCopy, |
| 190 | "doc.text": IconFileText, | 191 | "doc.text": IconFileText, |
| 192 | "file.diff": IconFileDiff, | ||
| 191 | ellipsis: IconDots, | 193 | ellipsis: IconDots, |
| 192 | moon: IconMoon, | 194 | moon: IconMoon, |
| 193 | "ellipsis.circle": IconDotsCircleHorizontal, | 195 | "ellipsis.circle": IconDotsCircleHorizontal, |
| ... | @@ -271,10 +273,16 @@ const ANDROID_ICON_BY_SF_SYMBOL = { | ... | @@ -271,10 +273,16 @@ const ANDROID_ICON_BY_SF_SYMBOL = { |
| 271 | xmark: IconX, | 273 | xmark: IconX, |
| 272 | "xmark.circle.fill": IconCircleXFilled, | 274 | "xmark.circle.fill": IconCircleXFilled, |
| 273 | } satisfies Partial< | 275 | } satisfies Partial< |
| 274 | Record<SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit", Icon> | 276 | Record< |
| 277 | SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", | ||
| 278 | Icon | ||
| 279 | > | ||
| 275 | >; | 280 | >; |
| 276 | const SF_ICON_LOOKUP: Partial< | 281 | const SF_ICON_LOOKUP: Partial< |
| 277 | Record<SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit", Icon> | 282 | Record< |
| 283 | SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", | ||
| 284 | Icon | ||
| 285 | > | ||
| 278 | > = ANDROID_ICON_BY_SF_SYMBOL; | 286 | > = ANDROID_ICON_BY_SF_SYMBOL; |
| 279 | 287 | ||
| 280 | // Callers can pass `{ ios, android }` names where `android` is a Material | 288 | // Callers can pass `{ ios, android }` names where `android` is a Material |
apps/mobile/src/features/home/HomeHeader.android.tsx-2| ... | @@ -92,8 +92,6 @@ export function HomeHeader(props: HomeHeaderProps) { | ... | @@ -92,8 +92,6 @@ export function HomeHeader(props: HomeHeaderProps) { |
| 92 | [props], | 92 | [props], |
| 93 | ); | 93 | ); |
| 94 | 94 | ||
| 95 | if (props.usesWorkspaceToolbar) return null; | ||
| 96 | |||
| 97 | return ( | 95 | return ( |
| 98 | <> | 96 | <> |
| 99 | <NativeStackScreenOptions options={{ headerShown: false }} /> | 97 | <NativeStackScreenOptions options={{ headerShown: false }} /> |
apps/mobile/src/features/home/HomeRouteScreen.tsx+2-2| ... | @@ -209,7 +209,7 @@ export function HomeRouteScreen() { | ... | @@ -209,7 +209,7 @@ export function HomeRouteScreen() { |
| 209 | 209 | ||
| 210 | return ( | 210 | return ( |
| 211 | <AndroidHomeFabLayout | 211 | <AndroidHomeFabLayout |
| 212 | disabled={workspace !== null} | 212 | disabled={workspace !== null && Platform.OS !== "android"} |
| 213 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} | 213 | onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} |
| 214 | > | 214 | > |
| 215 | <> | 215 | <> |
| ... | @@ -230,7 +230,7 @@ export function HomeRouteScreen() { | ... | @@ -230,7 +230,7 @@ export function HomeRouteScreen() { |
| 230 | params: { screen: "SettingsEnvironments" }, | 230 | params: { screen: "SettingsEnvironments" }, |
| 231 | }), | 231 | }), |
| 232 | }), | 232 | }), |
| 233 | headerShown: true, | 233 | headerShown: Platform.OS !== "android", |
| 234 | }} | 234 | }} |
| 235 | /> | 235 | /> |
| 236 | )} | 236 | )} |
apps/mobile/src/features/home/HomeScreen.tsx+1-1| ... | @@ -983,7 +983,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -983,7 +983,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 983 | const v2ListHeader = ( | 983 | const v2ListHeader = ( |
| 984 | <> | 984 | <> |
| 985 | {listHeader} | 985 | {listHeader} |
| 986 | {workspace ? ( | 986 | {Platform.OS === "ios" && workspace ? ( |
| 987 | <ThreadListFilters | 987 | <ThreadListFilters |
| 988 | environments={props.environments} | 988 | environments={props.environments} |
| 989 | projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))} | 989 | projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))} |
apps/mobile/src/features/home/MaterialThreadListToolbar.tsx+20-3| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; | 1 | import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; |
| 2 | import { useNavigation, useNavigationState } from "@react-navigation/native"; | ||
| 3 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; | ||
| 2 | import { | 4 | import { |
| 3 | BackHandler, | 5 | BackHandler, |
| 4 | Keyboard, | 6 | Keyboard, |
| ... | @@ -35,6 +37,12 @@ export function MaterialThreadListToolbar(props: { | ... | @@ -35,6 +37,12 @@ export function MaterialThreadListToolbar(props: { |
| 35 | readonly onRequestVisibility?: () => void; | 37 | readonly onRequestVisibility?: () => void; |
| 36 | }) { | 38 | }) { |
| 37 | const insets = useSafeAreaInsets(); | 39 | const insets = useSafeAreaInsets(); |
| 40 | const navigation = useNavigation(); | ||
| 41 | const workspace = useWorkspaceTabs(); | ||
| 42 | const isTabActive = workspace?.isTabActive; | ||
| 43 | const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home"); | ||
| 44 | const listFocused = | ||
| 45 | homeFocused && (workspace === null || workspace.state.selectedTab === "threads"); | ||
| 38 | const { fabSize } = useAndroidControlSizing(); | 46 | const { fabSize } = useAndroidControlSizing(); |
| 39 | const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); | 47 | const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); |
| 40 | const { state } = useWorkspaceState(); | 48 | const { state } = useWorkspaceState(); |
| ... | @@ -57,13 +65,22 @@ export function MaterialThreadListToolbar(props: { | ... | @@ -57,13 +65,22 @@ export function MaterialThreadListToolbar(props: { |
| 57 | }, [onSearchQueryChange]); | 65 | }, [onSearchQueryChange]); |
| 58 | 66 | ||
| 59 | useEffect(() => { | 67 | useEffect(() => { |
| 60 | if (!searching) return; | 68 | if (!searching || !listFocused) return; |
| 61 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { | 69 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { |
| 62 | closeSearch(); | 70 | // Frozen Home screens can keep effects alive after a detail route opens. |
| 71 | const state = navigation.getState(); | ||
| 72 | if ( | ||
| 73 | !state || | ||
| 74 | state.routes[state.index]?.name !== "Home" || | ||
| 75 | isTabActive?.("threads") === false | ||
| 76 | ) | ||
| 77 | return false; | ||
| 78 | if (Keyboard.isVisible()) Keyboard.dismiss(); | ||
| 79 | else closeSearch(); | ||
| 63 | return true; | 80 | return true; |
| 64 | }); | 81 | }); |
| 65 | return () => subscription.remove(); | 82 | return () => subscription.remove(); |
| 66 | }, [closeSearch, searching]); | 83 | }, [closeSearch, searching, listFocused, navigation, isTabActive]); |
| 67 | 84 | ||
| 68 | const filterIcon = props.filterCustomized | 85 | const filterIcon = props.filterCustomized |
| 69 | ? "line.3.horizontal.decrease.circle.fill" | 86 | ? "line.3.horizontal.decrease.circle.fill" |
apps/mobile/src/features/home/home-thread-navigation.test.ts+2| ... | @@ -19,6 +19,8 @@ function loadRouters() { | ... | @@ -19,6 +19,8 @@ function loadRouters() { |
| 19 | }; | 19 | }; |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } })); | ||
| 23 | |||
| 22 | vi.mock("@react-navigation/native", () => { | 24 | vi.mock("@react-navigation/native", () => { |
| 23 | const { CommonActions, StackActions } = loadRouters(); | 25 | const { CommonActions, StackActions } = loadRouters(); |
| 24 | return { CommonActions, StackActions }; | 26 | return { CommonActions, StackActions }; |
apps/mobile/src/features/home/home-thread-navigation.ts+11-1| ... | @@ -7,6 +7,8 @@ import { | ... | @@ -7,6 +7,8 @@ import { |
| 7 | } from "@react-navigation/native"; | 7 | } from "@react-navigation/native"; |
| 8 | import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; | 8 | import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; |
| 9 | import { useCallback, useEffect, useRef } from "react"; | 9 | import { useCallback, useEffect, useRef } from "react"; |
| 10 | import { Platform } from "react-native"; | ||
| 11 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | ||
| 10 | 12 | ||
| 11 | type ThreadSelection = Pick<EnvironmentThreadShell, "environmentId" | "id">; | 13 | type ThreadSelection = Pick<EnvironmentThreadShell, "environmentId" | "id">; |
| 12 | 14 | ||
| ... | @@ -34,6 +36,7 @@ export function useHomeThreadSelection() { | ... | @@ -34,6 +36,7 @@ export function useHomeThreadSelection() { |
| 34 | const navigation = | 36 | const navigation = |
| 35 | useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>(); | 37 | useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>(); |
| 36 | const dismissingRouteKey = useRef<string | null>(null); | 38 | const dismissingRouteKey = useRef<string | null>(null); |
| 39 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | ||
| 37 | 40 | ||
| 38 | useEffect(() => { | 41 | useEffect(() => { |
| 39 | const clear = () => { | 42 | const clear = () => { |
| ... | @@ -58,6 +61,13 @@ export function useHomeThreadSelection() { | ... | @@ -58,6 +61,13 @@ export function useHomeThreadSelection() { |
| 58 | 61 | ||
| 59 | return useCallback( | 62 | return useCallback( |
| 60 | (thread: ThreadSelection) => { | 63 | (thread: ThreadSelection) => { |
| 64 | if (Platform.OS === "android") { | ||
| 65 | selectAndroidWorkspace({ | ||
| 66 | name: "Thread", | ||
| 67 | params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, | ||
| 68 | }); | ||
| 69 | return; | ||
| 70 | } | ||
| 61 | navigation.dispatch((state) => | 71 | navigation.dispatch((state) => |
| 62 | createHomeThreadNavigationAction({ | 72 | createHomeThreadNavigationAction({ |
| 63 | state, | 73 | state, |
| ... | @@ -66,6 +76,6 @@ export function useHomeThreadSelection() { | ... | @@ -66,6 +76,6 @@ export function useHomeThreadSelection() { |
| 66 | }), | 76 | }), |
| 67 | ); | 77 | ); |
| 68 | }, | 78 | }, |
| 69 | [navigation], | 79 | [navigation, selectAndroidWorkspace], |
| 70 | ); | 80 | ); |
| 71 | } | 81 | } |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+133-29| ... | @@ -24,6 +24,7 @@ import { | ... | @@ -24,6 +24,7 @@ import { |
| 24 | } from "react"; | 24 | } from "react"; |
| 25 | import { Platform, useWindowDimensions, View } from "react-native"; | 25 | import { Platform, useWindowDimensions, View } from "react-native"; |
| 26 | import Animated, { | 26 | import Animated, { |
| 27 | runOnJS, | ||
| 27 | useAnimatedStyle, | 28 | useAnimatedStyle, |
| 28 | useDerivedValue, | 29 | useDerivedValue, |
| 29 | useSharedValue, | 30 | useSharedValue, |
| ... | @@ -55,6 +56,13 @@ import { | ... | @@ -55,6 +56,13 @@ import { |
| 55 | useHardwareKeyboardCommand, | 56 | useHardwareKeyboardCommand, |
| 56 | } from "../keyboard/hardwareKeyboardCommands"; | 57 | } from "../keyboard/hardwareKeyboardCommands"; |
| 57 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; | 58 | import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; |
| 59 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | ||
| 60 | import { | ||
| 61 | AndroidWorkspaceNavigation, | ||
| 62 | ANDROID_WORKSPACE_RAIL_WIDTH, | ||
| 63 | } from "../workspace/AndroidWorkspaceNavigation"; | ||
| 64 | import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; | ||
| 65 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | ||
| 58 | import { HomeListOptionsProvider } from "../home/home-list-options"; | 66 | import { HomeListOptionsProvider } from "../home/home-list-options"; |
| 59 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; | 67 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 60 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; | 68 | import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; |
| ... | @@ -243,7 +251,8 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -243,7 +251,8 @@ function AdaptiveWorkspaceLayoutContent( |
| 243 | const nativeWorkspace = use(NativeWorkspaceModeContext); | 251 | const nativeWorkspace = use(NativeWorkspaceModeContext); |
| 244 | const windowDimensions = useWindowDimensions(); | 252 | const windowDimensions = useWindowDimensions(); |
| 245 | const nativeMetrics = useNativeLayoutMetrics(); | 253 | const nativeMetrics = useNativeLayoutMetrics(); |
| 246 | const { width, height } = nativeMetrics ?? windowDimensions; | 254 | const { width: viewportWidth, height } = nativeMetrics ?? windowDimensions; |
| 255 | const { scale: androidControlScale } = useAndroidControlSizing(); | ||
| 247 | const pathname = props.pathname; | 256 | const pathname = props.pathname; |
| 248 | const navigation = useNavigation(); | 257 | const navigation = useNavigation(); |
| 249 | const activeRoleOwner = useRef<symbol | null>(null); | 258 | const activeRoleOwner = useRef<symbol | null>(null); |
| ... | @@ -261,10 +270,18 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -261,10 +270,18 @@ function AdaptiveWorkspaceLayoutContent( |
| 261 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = | 270 | const [focusedAuxiliaryPaneRole, setFocusedAuxiliaryPaneRole] = |
| 262 | useState<WorkspaceAuxiliaryPaneRole | null>(null); | 271 | useState<WorkspaceAuxiliaryPaneRole | null>(null); |
| 263 | const baseLayout = useMemo( | 272 | const baseLayout = useMemo( |
| 264 | () => deriveLayout({ width, height, nativeMetrics }), | 273 | () => deriveLayout({ width: viewportWidth, height, nativeMetrics }), |
| 265 | [height, width, nativeMetrics], | 274 | [height, viewportWidth, nativeMetrics], |
| 266 | ); | 275 | ); |
| 267 | const layout = baseLayout; | 276 | const layout = baseLayout; |
| 277 | // The rail consumes real space before either pane; inspectors and the detail | ||
| 278 | // navigator must use the remaining viewport rather than clipping behind it. | ||
| 279 | const shouldRenderNavigationRail = Platform.OS === "android" && layout.usesSplitView; | ||
| 280 | const navigationRailWidth = | ||
| 281 | shouldRenderNavigationRail && showPrimarySidebar | ||
| 282 | ? ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, androidControlScale) | ||
| 283 | : 0; | ||
| 284 | const width = viewportWidth - navigationRailWidth; | ||
| 268 | // In split layouts the sidebar IS the thread list — it renders on every | 285 | // In split layouts the sidebar IS the thread list — it renders on every |
| 269 | // route, including Home (which shows an empty-detail pane instead of the | 286 | // route, including Home (which shows an empty-detail pane instead of the |
| 270 | // compact list). | 287 | // compact list). |
| ... | @@ -478,22 +495,80 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -478,22 +495,80 @@ function AdaptiveWorkspaceLayoutContent( |
| 478 | [navigation], | 495 | [navigation], |
| 479 | ); | 496 | ); |
| 480 | 497 | ||
| 498 | const inspectorColumnTargetWidth = | ||
| 499 | workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible | ||
| 500 | ? (panes.auxiliaryPaneWidth ?? 0) | ||
| 501 | : 0; | ||
| 502 | const contentTargetWidth = layout.usesSplitView | ||
| 503 | ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) | ||
| 504 | : null; | ||
| 505 | const [contentSizing, setContentSizing] = useState({ | ||
| 506 | viewportWidth, | ||
| 507 | target: contentTargetWidth, | ||
| 508 | sidebarVisible: panes.primarySidebarVisible, | ||
| 509 | width: contentTargetWidth, | ||
| 510 | revision: 0, | ||
| 511 | }); | ||
| 512 | if ( | ||
| 513 | Platform.OS === "android" && | ||
| 514 | (contentSizing.viewportWidth !== viewportWidth || | ||
| 515 | contentSizing.target !== contentTargetWidth || | ||
| 516 | contentSizing.sidebarVisible !== panes.primarySidebarVisible) | ||
| 517 | ) { | ||
| 518 | const openingSidebar = | ||
| 519 | Platform.OS === "android" && | ||
| 520 | contentSizing.viewportWidth === viewportWidth && | ||
| 521 | !contentSizing.sidebarVisible && | ||
| 522 | panes.primarySidebarVisible && | ||
| 523 | contentSizing.width !== null && | ||
| 524 | contentTargetWidth !== null; | ||
| 525 | setContentSizing({ | ||
| 526 | viewportWidth, | ||
| 527 | target: contentTargetWidth, | ||
| 528 | sidebarVisible: panes.primarySidebarVisible, | ||
| 529 | // Clip the wide navigator during expansion, then reflow once at the end. | ||
| 530 | // Narrowing it first exposes an empty strip before the sidebar arrives. | ||
| 531 | width: openingSidebar | ||
| 532 | ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0) | ||
| 533 | : contentTargetWidth, | ||
| 534 | revision: contentSizing.revision + 1, | ||
| 535 | }); | ||
| 536 | } | ||
| 537 | const settleContentWidth = useCallback((revision: number) => { | ||
| 538 | setContentSizing((current) => | ||
| 539 | current.revision !== revision || current.width === current.target | ||
| 540 | ? current | ||
| 541 | : { ...current, width: current.target }, | ||
| 542 | ); | ||
| 543 | }, []); | ||
| 544 | const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth; | ||
| 545 | const contentRevision = contentSizing.revision; | ||
| 546 | |||
| 481 | const renderedSidebarWidth = useSharedValue( | 547 | const renderedSidebarWidth = useSharedValue( |
| 482 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, | 548 | panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, |
| 483 | ); | 549 | ); |
| 550 | const renderedRailWidth = useSharedValue(navigationRailWidth); | ||
| 484 | useEffect(() => { | 551 | useEffect(() => { |
| 485 | if (nativeWorkspace) return; | 552 | if (nativeWorkspace) return; |
| 486 | const targetWidth = panes.primarySidebarVisible | 553 | const targetWidth = panes.primarySidebarVisible |
| 487 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) | 554 | ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) |
| 488 | : 0; | 555 | : 0; |
| 489 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING); | 556 | renderedSidebarWidth.value = withTiming(targetWidth, WORKSPACE_PANE_TIMING, (finished) => { |
| 557 | if (finished && Platform.OS === "android") runOnJS(settleContentWidth)(contentRevision); | ||
| 558 | }); | ||
| 559 | renderedRailWidth.set(withTiming(navigationRailWidth, WORKSPACE_PANE_TIMING)); | ||
| 490 | }, [ | 560 | }, [ |
| 491 | nativeWorkspace, | 561 | nativeWorkspace, |
| 562 | navigationRailWidth, | ||
| 492 | layout.listPaneWidth, | 563 | layout.listPaneWidth, |
| 493 | layout.listPaneGap, | 564 | layout.listPaneGap, |
| 494 | panes.primarySidebarVisible, | 565 | panes.primarySidebarVisible, |
| 495 | renderedSidebarWidth, | 566 | renderedSidebarWidth, |
| 567 | renderedRailWidth, | ||
| 568 | settleContentWidth, | ||
| 569 | contentRevision, | ||
| 496 | ]); | 570 | ]); |
| 571 | const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); | ||
| 497 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ | 572 | const sidebarAnimatedStyle = useAnimatedStyle(() => ({ |
| 498 | // Fading a UIKit glass ancestor can leave remounted header effects missing. | 573 | // Fading a UIKit glass ancestor can leave remounted header effects missing. |
| 499 | // The clipped width already reveals and hides the iOS sidebar. | 574 | // The clipped width already reveals and hides the iOS sidebar. |
| ... | @@ -507,24 +582,30 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -507,24 +582,30 @@ function AdaptiveWorkspaceLayoutContent( |
| 507 | // animating columns merely clip/reveal it over a matching background. | 582 | // animating columns merely clip/reveal it over a matching background. |
| 508 | // Continuously re-wrapping the chat feed was the main source of dropped | 583 | // Continuously re-wrapping the chat feed was the main source of dropped |
| 509 | // frames during sidebar/inspector transitions. | 584 | // frames during sidebar/inspector transitions. |
| 510 | const inspectorColumnTargetWidth = | ||
| 511 | workspaceInspector !== null && workspaceInspector.active && panes.auxiliaryPaneVisible | ||
| 512 | ? (panes.auxiliaryPaneWidth ?? 0) | ||
| 513 | : 0; | ||
| 514 | const contentSettledWidth = layout.usesSplitView | ||
| 515 | ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) | ||
| 516 | : null; | ||
| 517 | const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); | 585 | const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); |
| 518 | const renderedContentWidth = useDerivedValue(() => | 586 | const renderedContentWidth = useDerivedValue(() => |
| 519 | Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value), | 587 | Math.max( |
| 588 | 0, | ||
| 589 | viewportWidth - | ||
| 590 | renderedRailWidth.value - | ||
| 591 | renderedSidebarWidth.value - | ||
| 592 | renderedInspectorWidth.value, | ||
| 593 | ), | ||
| 520 | ); | 594 | ); |
| 521 | 595 | ||
| 596 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | ||
| 522 | const handleSelectThread = useCallback( | 597 | const handleSelectThread = useCallback( |
| 523 | (thread: EnvironmentThreadShell) => { | 598 | (thread: EnvironmentThreadShell) => { |
| 524 | const params = { | 599 | const params = { |
| 525 | environmentId: String(thread.environmentId), | 600 | environmentId: String(thread.environmentId), |
| 526 | threadId: String(thread.id), | 601 | threadId: String(thread.id), |
| 527 | }; | 602 | }; |
| 603 | if (Platform.OS === "android") { | ||
| 604 | // Selecting a sidebar row enters its base chat, including from that chat's nested screens. | ||
| 605 | setFileInspectorPreferredVisible(false); | ||
| 606 | selectAndroidWorkspace({ name: "Thread", params }); | ||
| 607 | return; | ||
| 608 | } | ||
| 528 | const navigationAction = resolveThreadSelectionNavigationAction({ | 609 | const navigationAction = resolveThreadSelectionNavigationAction({ |
| 529 | usesSplitView: layout.usesSplitView, | 610 | usesSplitView: layout.usesSplitView, |
| 530 | pathname, | 611 | pathname, |
| ... | @@ -556,7 +637,14 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -556,7 +637,14 @@ function AdaptiveWorkspaceLayoutContent( |
| 556 | } | 637 | } |
| 557 | navigation.navigate("Thread", params); | 638 | navigation.navigate("Thread", params); |
| 558 | }, | 639 | }, |
| 559 | [layout.usesSplitView, pathname, navigation, selectedThreadKey, props.workspaceRouteKey], | 640 | [ |
| 641 | layout.usesSplitView, | ||
| 642 | pathname, | ||
| 643 | navigation, | ||
| 644 | selectedThreadKey, | ||
| 645 | props.workspaceRouteKey, | ||
| 646 | selectAndroidWorkspace, | ||
| 647 | ], | ||
| 560 | ); | 648 | ); |
| 561 | 649 | ||
| 562 | const contextValue = useMemo( | 650 | const contextValue = useMemo( |
| ... | @@ -606,7 +694,21 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -606,7 +694,21 @@ function AdaptiveWorkspaceLayoutContent( |
| 606 | return ( | 694 | return ( |
| 607 | <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}> | 695 | <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}> |
| 608 | <AdaptiveWorkspaceContext.Provider value={contextValue}> | 696 | <AdaptiveWorkspaceContext.Provider value={contextValue}> |
| 609 | <View testID="adaptive-workspace-layout" className="flex-1 flex-row"> | 697 | <View |
| 698 | testID="adaptive-workspace-layout" | ||
| 699 | className={Platform.OS === "android" ? "flex-1 flex-row bg-header" : "flex-1 flex-row"} | ||
| 700 | > | ||
| 701 | {shouldRenderNavigationRail ? ( | ||
| 702 | <Animated.View | ||
| 703 | className="self-stretch overflow-hidden" | ||
| 704 | accessibilityElementsHidden={navigationRailWidth === 0} | ||
| 705 | importantForAccessibility={navigationRailWidth > 0 ? "auto" : "no-hide-descendants"} | ||
| 706 | pointerEvents={navigationRailWidth > 0 ? "auto" : "none"} | ||
| 707 | style={railAnimatedStyle} | ||
| 708 | > | ||
| 709 | <AndroidWorkspaceNavigation rail onSelect={revealPrimarySidebar} /> | ||
| 710 | </Animated.View> | ||
| 711 | ) : null} | ||
| 610 | {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( | 712 | {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( |
| 611 | <Animated.View | 713 | <Animated.View |
| 612 | className="self-stretch overflow-hidden" | 714 | className="self-stretch overflow-hidden" |
| ... | @@ -628,21 +730,23 @@ function AdaptiveWorkspaceLayoutContent( | ... | @@ -628,21 +730,23 @@ function AdaptiveWorkspaceLayoutContent( |
| 628 | /> | 730 | /> |
| 629 | )} | 731 | )} |
| 630 | > | 732 | > |
| 631 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> | 733 | <AndroidWorkspaceSidebar onRequestVisibility={revealPrimarySidebar}> |
| 632 | <ThreadNavigationSidebar | 734 | <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}> |
| 633 | width={layout.listPaneWidth} | 735 | <ThreadNavigationSidebar |
| 634 | visible={panes.primarySidebarVisible} | 736 | width={layout.listPaneWidth} |
| 635 | onRequestVisibility={revealPrimarySidebar} | 737 | visible={panes.primarySidebarVisible} |
| 636 | selectedThreadKey={selectedThreadKey} | 738 | onRequestVisibility={revealPrimarySidebar} |
| 637 | onOpenSettings={handleOpenSettings} | 739 | selectedThreadKey={selectedThreadKey} |
| 638 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} | 740 | onOpenSettings={handleOpenSettings} |
| 639 | onNewThreadInProject={handleNewThreadInProject} | 741 | onOpenEnvironmentSettings={handleOpenEnvironmentSettings} |
| 640 | onNewThreadOnBranch={handleNewThreadOnBranch} | 742 | onNewThreadInProject={handleNewThreadInProject} |
| 641 | onSelectThread={handleSelectThread} | 743 | onNewThreadOnBranch={handleNewThreadOnBranch} |
| 642 | onSearchQueryChange={setPrimarySidebarSearchQuery} | 744 | onSelectThread={handleSelectThread} |
| 643 | searchQuery={primarySidebarSearchQuery} | 745 | onSearchQueryChange={setPrimarySidebarSearchQuery} |
| 644 | /> | 746 | searchQuery={primarySidebarSearchQuery} |
| 645 | </AndroidHomeFabLayout> | 747 | /> |
| 748 | </AndroidHomeFabLayout> | ||
| 749 | </AndroidWorkspaceSidebar> | ||
| 646 | </RenderErrorBoundary> | 750 | </RenderErrorBoundary> |
| 647 | </View> | 751 | </View> |
| 648 | </Animated.View> | 752 | </Animated.View> |
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+1-1| ... | @@ -10,7 +10,7 @@ export function AndroidWorkspaceSidebarButton() { | ... | @@ -10,7 +10,7 @@ export function AndroidWorkspaceSidebarButton() { |
| 10 | 10 | ||
| 11 | return ( | 11 | return ( |
| 12 | <AndroidHeaderIconButton | 12 | <AndroidHeaderIconButton |
| 13 | accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"} | 13 | accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show sidebar"} |
| 14 | icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"} | 14 | icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"} |
| 15 | onPress={togglePrimarySidebar} | 15 | onPress={togglePrimarySidebar} |
| 16 | /> | 16 | /> |
apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { HorizontalFloatingToolbar, Host, RNHostView } from "@expo/ui/jetpack-compose"; | ||
| 2 | import type { ReactElement } from "react"; | ||
| 3 | |||
| 4 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 5 | |||
| 6 | /** Native toolbar surface; hosted RN controls retain their existing menus and accessibility. */ | ||
| 7 | export function MaterialFloatingToolbar(props: { readonly children: ReactElement }) { | ||
| 8 | const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); | ||
| 9 | return ( | ||
| 10 | <Host | ||
| 11 | matchContents | ||
| 12 | colorScheme={themeAppearance} | ||
| 13 | ignoreSafeAreaKeyboardInsets | ||
| 14 | style={{ alignSelf: "center", maxWidth: "100%" }} | ||
| 15 | > | ||
| 16 | <HorizontalFloatingToolbar | ||
| 17 | colors={{ | ||
| 18 | toolbarContainerColor: colors["--color-card-alt"], | ||
| 19 | toolbarContentColor: colors["--color-foreground"], | ||
| 20 | }} | ||
| 21 | > | ||
| 22 | <RNHostView matchContents>{props.children}</RNHostView> | ||
| 23 | </HorizontalFloatingToolbar> | ||
| 24 | </Host> | ||
| 25 | ); | ||
| 26 | } | ||
apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import type { ReactElement } from "react"; | ||
| 2 | |||
| 3 | export function MaterialFloatingToolbar(props: { readonly children: ReactElement }) { | ||
| 4 | return props.children; | ||
| 5 | } | ||
apps/mobile/src/features/pull-requests/MaterialPullRequestToolbar.android.tsx created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | import { useNavigation, useNavigationState } from "@react-navigation/native"; | ||
| 2 | import { useCallback, useEffect, useRef } from "react"; | ||
| 3 | import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native"; | ||
| 4 | import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 5 | import { CompactBrandTitle } from "../../components/CompactBrandTitle"; | ||
| 6 | import { MaterialSearchField } from "../../components/MaterialSearchField"; | ||
| 7 | import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle"; | ||
| 8 | import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; | ||
| 9 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; | ||
| 10 | |||
| 11 | /** PR search shares the thread list's native Android header placement. */ | ||
| 12 | export function MaterialPullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) { | ||
| 13 | const navigation = useNavigation(); | ||
| 14 | const workspace = useWorkspaceTabs()!; | ||
| 15 | const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace; | ||
| 16 | const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home"); | ||
| 17 | const listFocused = homeFocused && workspace.state.selectedTab === "pullRequests"; | ||
| 18 | const { onRequestVisibility } = props; | ||
| 19 | const tab = workspace.state.tabs.pullRequests; | ||
| 20 | const input = useRef<TextInputInstance>(null); | ||
| 21 | const searching = tab.searchMode !== "resting"; | ||
| 22 | const openSearch = useCallback(() => { | ||
| 23 | onRequestVisibility?.(); | ||
| 24 | dispatch({ | ||
| 25 | type: "scroll", | ||
| 26 | tab: "pullRequests", | ||
| 27 | offset: getScrollOffset("pullRequests"), | ||
| 28 | }); | ||
| 29 | dispatch({ type: "search", tab: "pullRequests" }); | ||
| 30 | input.current?.focus(); | ||
| 31 | return true; | ||
| 32 | }, [dispatch, getScrollOffset, onRequestVisibility]); | ||
| 33 | useHardwareKeyboardCommand("focusSearch", openSearch); | ||
| 34 | const closeSearch = useCallback(() => { | ||
| 35 | Keyboard.dismiss(); | ||
| 36 | recordScrollOffset("pullRequests", tab.searchEntryOffset); | ||
| 37 | dispatch({ type: "cancel", tab: "pullRequests" }); | ||
| 38 | }, [dispatch, recordScrollOffset, tab.searchEntryOffset]); | ||
| 39 | useEffect(() => { | ||
| 40 | if (!searching || !listFocused) return; | ||
| 41 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { | ||
| 42 | // Frozen Home screens can keep effects alive after a detail route opens. | ||
| 43 | const state = navigation.getState(); | ||
| 44 | if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests")) | ||
| 45 | return false; | ||
| 46 | if (Keyboard.isVisible()) Keyboard.dismiss(); | ||
| 47 | else closeSearch(); | ||
| 48 | return true; | ||
| 49 | }); | ||
| 50 | return () => subscription.remove(); | ||
| 51 | }, [searching, closeSearch, listFocused, navigation, isTabActive]); | ||
| 52 | return ( | ||
| 53 | <AndroidScreenHeader | ||
| 54 | title="Pull requests" | ||
| 55 | titleContent={ | ||
| 56 | searching ? ( | ||
| 57 | <MaterialSearchField | ||
| 58 | inputRef={input} | ||
| 59 | accessibilityLabel="Search pull requests" | ||
| 60 | clearAccessibilityLabel="Clear search" | ||
| 61 | placeholder="Search pull requests" | ||
| 62 | value={tab.query} | ||
| 63 | onChangeText={(query) => dispatch({ type: "query", tab: "pullRequests", query })} | ||
| 64 | /> | ||
| 65 | ) : ( | ||
| 66 | <View className="min-w-0 flex-1 pl-3"> | ||
| 67 | <WorkspaceConnectionTitle | ||
| 68 | grow | ||
| 69 | brand={ | ||
| 70 | <CompactBrandTitle | ||
| 71 | allowFontScaling={false} | ||
| 72 | accessibilityLabel="T3 Code, Pull requests" | ||
| 73 | /> | ||
| 74 | } | ||
| 75 | onPress={() => | ||
| 76 | navigation.navigate("SettingsSheet", { | ||
| 77 | screen: "SettingsContent", | ||
| 78 | params: { screen: "SettingsEnvironments" }, | ||
| 79 | }) | ||
| 80 | } | ||
| 81 | /> | ||
| 82 | </View> | ||
| 83 | ) | ||
| 84 | } | ||
| 85 | leading={ | ||
| 86 | searching ? ( | ||
| 87 | <AndroidHeaderIconButton | ||
| 88 | accessibilityLabel="Close search" | ||
| 89 | icon="arrow.left" | ||
| 90 | onPress={closeSearch} | ||
| 91 | /> | ||
| 92 | ) : undefined | ||
| 93 | } | ||
| 94 | actions={ | ||
| 95 | searching | ||
| 96 | ? [] | ||
| 97 | : [ | ||
| 98 | { | ||
| 99 | accessibilityLabel: "Search pull requests", | ||
| 100 | icon: "magnifyingglass", | ||
| 101 | onPress: openSearch, | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | accessibilityLabel: "Open settings", | ||
| 105 | icon: "gearshape", | ||
| 106 | onPress: () => | ||
| 107 | navigation.navigate("SettingsSheet", { | ||
| 108 | screen: "SettingsContent", | ||
| 109 | params: { screen: "Settings" }, | ||
| 110 | }), | ||
| 111 | }, | ||
| 112 | ] | ||
| 113 | } | ||
| 114 | /> | ||
| 115 | ); | ||
| 116 | } | ||
apps/mobile/src/features/pull-requests/PullRequestControls.tsx+43-35| ... | @@ -9,6 +9,7 @@ import { AppText as Text } from "../../components/AppText"; | ... | @@ -9,6 +9,7 @@ import { AppText as Text } from "../../components/AppText"; |
| 9 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; | 9 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 10 | import { MaterialButton } from "../../components/MaterialButton"; | 10 | import { MaterialButton } from "../../components/MaterialButton"; |
| 11 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | 11 | import { MaterialIconButton } from "../../components/MaterialIconButton"; |
| 12 | import { MaterialFloatingToolbar } from "./MaterialFloatingToolbar"; | ||
| 12 | import { ControlPillMenu } from "../../components/ControlPillMenu"; | 13 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 13 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | 14 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 14 | import { | 15 | import { |
| ... | @@ -110,6 +111,7 @@ export function PullRequestActionToolbar(props: { | ... | @@ -110,6 +111,7 @@ export function PullRequestActionToolbar(props: { |
| 110 | readonly params: PullRequestParams; | 111 | readonly params: PullRequestParams; |
| 111 | readonly filePath?: string; | 112 | readonly filePath?: string; |
| 112 | readonly onOpenFiles?: () => void; | 113 | readonly onOpenFiles?: () => void; |
| 114 | readonly onHeightChange?: (height: number) => void; | ||
| 113 | }) { | 115 | }) { |
| 114 | const navigation = useNavigation(); | 116 | const navigation = useNavigation(); |
| 115 | const { draft } = usePullRequestReviewDraft(props.params); | 117 | const { draft } = usePullRequestReviewDraft(props.params); |
| ... | @@ -156,52 +158,57 @@ export function PullRequestActionToolbar(props: { | ... | @@ -156,52 +158,57 @@ export function PullRequestActionToolbar(props: { |
| 156 | const primary = ( | 158 | const primary = ( |
| 157 | <MaterialButton | 159 | <MaterialButton |
| 158 | label={primaryLabel} | 160 | label={primaryLabel} |
| 159 | tone="contrast" | 161 | tone="primary" |
| 160 | fullWidth | ||
| 161 | disabled={primaryDisabled} | 162 | disabled={primaryDisabled} |
| 162 | onPress={primaryPress} | 163 | onPress={primaryPress} |
| 163 | /> | 164 | /> |
| 164 | ); | 165 | ); |
| 165 | return ( | 166 | return ( |
| 166 | <View | 167 | <View |
| 167 | className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2" | 168 | pointerEvents="box-none" |
| 168 | style={{ paddingBottom: Math.max(insets.bottom, 8) }} | 169 | className="absolute inset-x-0 bottom-0 z-10 items-center px-4 pt-2" |
| 170 | style={{ paddingBottom: insets.bottom + 16 }} | ||
| 171 | onLayout={(event) => props.onHeightChange?.(event.nativeEvent.layout.height)} | ||
| 169 | > | 172 | > |
| 170 | {showAgentControls ? ( | 173 | <MaterialFloatingToolbar> |
| 171 | <ControlPillMenu | 174 | <View className="flex-row items-center gap-2"> |
| 172 | title="AI helpers" | 175 | {showAgentControls ? ( |
| 173 | actions={agentActions.map((action) => agent.menuAction(action.task))} | ||
| 174 | onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)} | ||
| 175 | > | ||
| 176 | <View pointerEvents="none"> | ||
| 177 | <MaterialIconButton | ||
| 178 | icon="sparkles" | ||
| 179 | accessibilityLabel="PR agent actions" | ||
| 180 | disabled={agent.disabled} | ||
| 181 | /> | ||
| 182 | </View> | ||
| 183 | </ControlPillMenu> | ||
| 184 | ) : null} | ||
| 185 | {showPrimary ? ( | ||
| 186 | <View className="min-w-0 flex-1"> | ||
| 187 | {!pendingReview && chooseThread ? ( | ||
| 188 | <ControlPillMenu | 176 | <ControlPillMenu |
| 189 | title="Open thread" | 177 | title="AI helpers" |
| 190 | actions={threadMenuItems} | 178 | actions={agentActions.map((action) => agent.menuAction(action.task))} |
| 191 | onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)} | 179 | onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)} |
| 192 | > | 180 | > |
| 193 | <View pointerEvents="none">{primary}</View> | 181 | <View pointerEvents="none"> |
| 182 | <MaterialIconButton | ||
| 183 | icon="sparkles" | ||
| 184 | accessibilityLabel="PR agent actions" | ||
| 185 | disabled={agent.disabled} | ||
| 186 | /> | ||
| 187 | </View> | ||
| 194 | </ControlPillMenu> | 188 | </ControlPillMenu> |
| 195 | ) : ( | 189 | ) : null} |
| 196 | primary | 190 | {showPrimary ? ( |
| 197 | )} | 191 | <View className="min-w-0 shrink"> |
| 192 | {!pendingReview && chooseThread ? ( | ||
| 193 | <ControlPillMenu | ||
| 194 | title="Open thread" | ||
| 195 | actions={threadMenuItems} | ||
| 196 | onPressAction={({ nativeEvent }) => agent.select(nativeEvent.event)} | ||
| 197 | > | ||
| 198 | <View pointerEvents="none">{primary}</View> | ||
| 199 | </ControlPillMenu> | ||
| 200 | ) : ( | ||
| 201 | primary | ||
| 202 | )} | ||
| 203 | </View> | ||
| 204 | ) : null} | ||
| 205 | <MaterialIconButton | ||
| 206 | icon="file.diff" | ||
| 207 | accessibilityLabel="Changed files" | ||
| 208 | onPress={openFiles} | ||
| 209 | /> | ||
| 198 | </View> | 210 | </View> |
| 199 | ) : null} | 211 | </MaterialFloatingToolbar> |
| 200 | <MaterialIconButton | ||
| 201 | icon="doc.on.doc" | ||
| 202 | accessibilityLabel="Changed files" | ||
| 203 | onPress={openFiles} | ||
| 204 | /> | ||
| 205 | </View> | 212 | </View> |
| 206 | ); | 213 | ); |
| 207 | } | 214 | } |
| ... | @@ -299,12 +306,13 @@ export function PullRequestActionToolbar(props: { | ... | @@ -299,12 +306,13 @@ export function PullRequestActionToolbar(props: { |
| 299 | )} | 306 | )} |
| 300 | </NativeHeaderToolbar.Menu> | 307 | </NativeHeaderToolbar.Menu> |
| 301 | ) : null; | 308 | ) : null; |
| 309 | // Tabler file-diff geometry, rasterized at 1x/2x/3x for UIKit template images. | ||
| 302 | const filesControl = ( | 310 | const filesControl = ( |
| 303 | <NativeHeaderToolbar.Button | 311 | <NativeHeaderToolbar.Button |
| 304 | identifier="workspace-trailing-action" | 312 | identifier="workspace-trailing-action" |
| 305 | width={controlSize} | 313 | width={controlSize} |
| 306 | height={controlSize} | 314 | height={controlSize} |
| 307 | icon="document.on.document" | 315 | icon={{ type: "image", source: require("../../../assets/icons/file-diff.png") }} |
| 308 | accessibilityLabel="Changed files" | 316 | accessibilityLabel="Changed files" |
| 309 | onPress={openFiles} | 317 | onPress={openFiles} |
| 310 | separateBackground | 318 | separateBackground |
apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx+65-36| ... | @@ -3,10 +3,12 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" | ... | @@ -3,10 +3,12 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" |
| 3 | import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; | 3 | import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; |
| 4 | import { AsyncResult } from "effect/reactivity"; | 4 | import { AsyncResult } from "effect/reactivity"; |
| 5 | import { useRef, useState } from "react"; | 5 | import { useRef, useState } from "react"; |
| 6 | import { ActivityIndicator, Alert, Pressable, View } from "react-native"; | 6 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; |
| 7 | import { AppText as Text } from "../../components/AppText"; | 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | import { ControlPillMenu } from "../../components/ControlPillMenu"; | 9 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 10 | import { MaterialButton } from "../../components/MaterialButton"; | ||
| 11 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | ||
| 10 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 12 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 11 | import { composerPullRequests } from "../../state/pull-requests"; | 13 | import { composerPullRequests } from "../../state/pull-requests"; |
| 12 | import { useAtomCommand } from "../../state/use-atom-command"; | 14 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | @@ -164,33 +166,50 @@ export function PullRequestMergeControls(props: { | ... | @@ -164,33 +166,50 @@ export function PullRequestMergeControls(props: { |
| 164 | return ( | 166 | return ( |
| 165 | <View className="gap-2 border-t border-border-subtle p-3"> | 167 | <View className="gap-2 border-t border-border-subtle p-3"> |
| 166 | <View | 168 | <View |
| 167 | className={`flex-row overflow-hidden rounded-xl border-continuous ${canMerge ? "bg-foreground" : "bg-subtle-strong"}`} | 169 | className={ |
| 170 | Platform.OS === "android" | ||
| 171 | ? "flex-row items-center gap-2" | ||
| 172 | : `flex-row overflow-hidden rounded-xl border-continuous ${canMerge ? "bg-foreground" : "bg-subtle-strong"}` | ||
| 173 | } | ||
| 168 | > | 174 | > |
| 169 | <Pressable | 175 | {Platform.OS === "android" ? ( |
| 170 | accessibilityRole="button" | 176 | <View className="min-w-0 flex-1"> |
| 171 | accessibilityLabel={ | 177 | <MaterialButton |
| 172 | scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label | 178 | label={busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} |
| 173 | } | 179 | tone="primary" |
| 174 | accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }} | 180 | fullWidth |
| 175 | disabled={!canMerge} | 181 | disabled={!canMerge} |
| 176 | onPress={() => confirm("merge")} | 182 | loading={busy || checkingStack} |
| 177 | className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70" | 183 | onPress={() => confirm("merge")} |
| 178 | > | ||
| 179 | {busy || checkingStack ? ( | ||
| 180 | <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> | ||
| 181 | ) : ( | ||
| 182 | <SymbolView | ||
| 183 | name="arrow.triangle.merge" | ||
| 184 | size={16} | ||
| 185 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | ||
| 186 | /> | 184 | /> |
| 187 | )} | 185 | </View> |
| 188 | <Text | 186 | ) : ( |
| 189 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`} | 187 | <Pressable |
| 188 | accessibilityRole="button" | ||
| 189 | accessibilityLabel={ | ||
| 190 | scope ? `${label}, ${scope.layers.length} stack layers through #${pr.number}` : label | ||
| 191 | } | ||
| 192 | accessibilityState={{ disabled: !canMerge, busy: busy || checkingStack }} | ||
| 193 | disabled={!canMerge} | ||
| 194 | onPress={() => confirm("merge")} | ||
| 195 | className="min-h-11 min-w-0 flex-1 flex-row items-center justify-center gap-2 px-3 py-2 active:opacity-70" | ||
| 190 | > | 196 | > |
| 191 | {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} | 197 | {busy || checkingStack ? ( |
| 192 | </Text> | 198 | <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> |
| 193 | </Pressable> | 199 | ) : ( |
| 200 | <SymbolView | ||
| 201 | name="arrow.triangle.merge" | ||
| 202 | size={16} | ||
| 203 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | ||
| 204 | /> | ||
| 205 | )} | ||
| 206 | <Text | ||
| 207 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`} | ||
| 208 | > | ||
| 209 | {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} | ||
| 210 | </Text> | ||
| 211 | </Pressable> | ||
| 212 | )} | ||
| 194 | {methods.length > 0 || canAutoMerge ? ( | 213 | {methods.length > 0 || canAutoMerge ? ( |
| 195 | <ControlPillMenu | 214 | <ControlPillMenu |
| 196 | title="Merge options" | 215 | title="Merge options" |
| ... | @@ -219,17 +238,27 @@ export function PullRequestMergeControls(props: { | ... | @@ -219,17 +238,27 @@ export function PullRequestMergeControls(props: { |
| 219 | } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction); | 238 | } else if (nativeEvent.event === autoAction && canAutoMerge) confirm(autoAction); |
| 220 | }} | 239 | }} |
| 221 | > | 240 | > |
| 222 | <Pressable | 241 | {Platform.OS === "android" ? ( |
| 223 | accessibilityRole="button" | 242 | <View pointerEvents="none"> |
| 224 | accessibilityLabel="Merge options" | 243 | <MaterialIconButton |
| 225 | className="min-h-11 w-11 items-center justify-center border-l border-border-subtle" | 244 | accessibilityLabel="Merge options" |
| 226 | > | 245 | icon="chevron.down" |
| 227 | <SymbolView | 246 | variant="tonal" |
| 228 | name="chevron.down" | 247 | /> |
| 229 | size={13} | 248 | </View> |
| 230 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | 249 | ) : ( |
| 231 | /> | 250 | <Pressable |
| 232 | </Pressable> | 251 | accessibilityRole="button" |
| 252 | accessibilityLabel="Merge options" | ||
| 253 | className="min-h-11 w-11 items-center justify-center border-l border-border-subtle" | ||
| 254 | > | ||
| 255 | <SymbolView | ||
| 256 | name="chevron.down" | ||
| 257 | size={13} | ||
| 258 | tintColorClassName={canMerge ? "accent-screen" : "accent-icon-muted"} | ||
| 259 | /> | ||
| 260 | </Pressable> | ||
| 261 | )} | ||
| 233 | </ControlPillMenu> | 262 | </ControlPillMenu> |
| 234 | ) : null} | 263 | ) : null} |
| 235 | </View> | 264 | </View> |
apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx+2-2| ... | @@ -4,7 +4,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" | ... | @@ -4,7 +4,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" |
| 4 | import type { PullRequestReviewVerdict } from "@t3tools/contracts"; | 4 | import type { PullRequestReviewVerdict } from "@t3tools/contracts"; |
| 5 | import { useCallback, useRef, useState } from "react"; | 5 | import { useCallback, useRef, useState } from "react"; |
| 6 | import { useFocusEffect } from "@react-navigation/native"; | 6 | import { useFocusEffect } from "@react-navigation/native"; |
| 7 | import { Pressable, View } from "react-native"; | 7 | import { Platform, Pressable, View } from "react-native"; |
| 8 | import { clearSubmittedReview } from "./pullRequestReviewDrafts"; | 8 | import { clearSubmittedReview } from "./pullRequestReviewDrafts"; |
| 9 | import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; | 9 | import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; |
| 10 | import { reviewPositionLabel } from "./pullRequestDiffComments"; | 10 | import { reviewPositionLabel } from "./pullRequestDiffComments"; |
| ... | @@ -137,7 +137,7 @@ export function PullRequestReviewSheet({ | ... | @@ -137,7 +137,7 @@ export function PullRequestReviewSheet({ |
| 137 | : "Post comment" | 137 | : "Post comment" |
| 138 | } | 138 | } |
| 139 | fullWidth | 139 | fullWidth |
| 140 | tone="contrast" | 140 | tone={Platform.OS === "android" ? "primary" : "contrast"} |
| 141 | loading={submission.busy} | 141 | loading={submission.busy} |
| 142 | disabled={!canSubmit || result.isPending || result.error !== null} | 142 | disabled={!canSubmit || result.isPending || result.error !== null} |
| 143 | onPress={() => void perform()} | 143 | onPress={() => void perform()} |
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+48-9| ... | @@ -57,6 +57,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -57,6 +57,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 57 | 57 | ||
| 58 | import { AppText as Text } from "../../components/AppText"; | 58 | import { AppText as Text } from "../../components/AppText"; |
| 59 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | 59 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 60 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | ||
| 61 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 62 | import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; | ||
| 63 | import { useAndroidWorkspaceBack } from "../workspace/useAndroidWorkspaceNavigation"; | ||
| 60 | import { EmptyState } from "../../components/EmptyState"; | 64 | import { EmptyState } from "../../components/EmptyState"; |
| 61 | import { SegmentedControl } from "../../components/SegmentedControl"; | 65 | import { SegmentedControl } from "../../components/SegmentedControl"; |
| 62 | import { SymbolView } from "../../components/AppSymbol"; | 66 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | @@ -269,9 +273,18 @@ function PullRequestOverview(props: { | ... | @@ -269,9 +273,18 @@ function PullRequestOverview(props: { |
| 269 | ); | 273 | ); |
| 270 | } | 274 | } |
| 271 | 275 | ||
| 276 | function usePullRequestToolbarHeight(bottomInset: number) { | ||
| 277 | const { buttonSize } = useAndroidControlSizing(); | ||
| 278 | // Reserve the controls, native toolbar padding, and outer gaps before onLayout measures them. | ||
| 279 | return useState(() => (Platform.OS === "android" ? bottomInset + buttonSize + 40 : 0)); | ||
| 280 | } | ||
| 281 | |||
| 272 | export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) { | 282 | export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestParams>) { |
| 283 | const returnToList = useAndroidWorkspaceBack("pullRequests"); | ||
| 284 | const { layout } = useAdaptiveWorkspaceLayout(); | ||
| 273 | const navigation = useNavigation(); | 285 | const navigation = useNavigation(); |
| 274 | const insets = useSafeAreaInsets(); | 286 | const insets = useSafeAreaInsets(); |
| 287 | const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom); | ||
| 275 | const params = route.params; | 288 | const params = route.params; |
| 276 | const connected = usePullRequestConnected(params.environmentId); | 289 | const connected = usePullRequestConnected(params.environmentId); |
| 277 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); | 290 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); |
| ... | @@ -356,7 +369,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -356,7 +369,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 356 | params={params} | 369 | params={params} |
| 357 | value={pr?.provider === "github" ? pr.url : null} | 370 | value={pr?.provider === "github" ? pr.url : null} |
| 358 | > | 371 | > |
| 359 | <View className="flex-1 bg-screen"> | 372 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> |
| 360 | <NativeStackScreenOptions | 373 | <NativeStackScreenOptions |
| 361 | options={{ | 374 | options={{ |
| 362 | title: `#${params.number}`, | 375 | title: `#${params.number}`, |
| ... | @@ -369,7 +382,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -369,7 +382,9 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 369 | {Platform.OS === "android" ? ( | 382 | {Platform.OS === "android" ? ( |
| 370 | <AndroidScreenHeader | 383 | <AndroidScreenHeader |
| 371 | title={`#${params.number}${connected ? "" : " · Offline"}`} | 384 | title={`#${params.number}${connected ? "" : " · Offline"}`} |
| 372 | onBack={() => navigation.goBack()} | 385 | subtitle={params.repository} |
| 386 | onBack={layout.usesSplitView ? undefined : returnToList} | ||
| 387 | leading={layout.usesSplitView ? <AndroidWorkspaceSidebarButton /> : undefined} | ||
| 373 | actions={[ | 388 | actions={[ |
| 374 | ...(permissions.verdicts.length | 389 | ...(permissions.verdicts.length |
| 375 | ? [ | 390 | ? [ |
| ... | @@ -442,7 +457,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -442,7 +457,15 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 442 | </NativeHeaderToolbar.Menu> | 457 | </NativeHeaderToolbar.Menu> |
| 443 | </NativeHeaderToolbar> | 458 | </NativeHeaderToolbar> |
| 444 | <View | 459 | <View |
| 445 | style={{ flex: 1 }} | 460 | style={{ |
| 461 | flex: 1, | ||
| 462 | marginHorizontal: Platform.OS === "android" && layout.usesSplitView ? 4 : 0, | ||
| 463 | }} | ||
| 464 | className={ | ||
| 465 | Platform.OS === "android" | ||
| 466 | ? "overflow-hidden rounded-t-[28px] bg-sheet-solid" | ||
| 467 | : undefined | ||
| 468 | } | ||
| 446 | onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)} | 469 | onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)} |
| 447 | > | 470 | > |
| 448 | {(["overview", "guide", "activity"] as const) | 471 | {(["overview", "guide", "activity"] as const) |
| ... | @@ -499,7 +522,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -499,7 +522,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 499 | contentContainerStyle={{ | 522 | contentContainerStyle={{ |
| 500 | paddingHorizontal: 16, | 523 | paddingHorizontal: 16, |
| 501 | paddingTop: 8, | 524 | paddingTop: 8, |
| 502 | paddingBottom: 24, | 525 | paddingBottom: 24 + (Platform.OS === "android" ? toolbarHeight : 0), |
| 503 | // Short tabs must still be tall enough to preserve the collapsed header. | 526 | // Short tabs must still be tall enough to preserve the collapsed header. |
| 504 | minHeight: Math.max( | 527 | minHeight: Math.max( |
| 505 | 0, | 528 | 0, |
| ... | @@ -621,7 +644,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -621,7 +644,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 621 | > | 644 | > |
| 622 | <PullRequestIdentity pr={identity} params={params} /> | 645 | <PullRequestIdentity pr={identity} params={params} /> |
| 623 | <SegmentedControl | 646 | <SegmentedControl |
| 624 | className="bg-grouped-card" | 647 | className={Platform.OS === "android" ? undefined : "bg-grouped-card"} |
| 625 | options={[ | 648 | options={[ |
| 626 | { value: "overview", label: "Overview" }, | 649 | { value: "overview", label: "Overview" }, |
| 627 | ...(guidesEnabled | 650 | ...(guidesEnabled |
| ... | @@ -643,7 +666,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP | ... | @@ -643,7 +666,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 643 | </View> | 666 | </View> |
| 644 | ))} | 667 | ))} |
| 645 | </View> | 668 | </View> |
| 646 | <PullRequestActionToolbar pr={pr} params={params} onOpenFiles={openFiles} /> | 669 | <PullRequestActionToolbar |
| 670 | pr={pr} | ||
| 671 | params={params} | ||
| 672 | onOpenFiles={openFiles} | ||
| 673 | onHeightChange={setToolbarHeight} | ||
| 674 | /> | ||
| 647 | </View> | 675 | </View> |
| 648 | </PullRequestMarkdownRepository> | 676 | </PullRequestMarkdownRepository> |
| 649 | ); | 677 | ); |
| ... | @@ -685,8 +713,10 @@ export function PullRequestFileRouteScreen({ | ... | @@ -685,8 +713,10 @@ export function PullRequestFileRouteScreen({ |
| 685 | readonly focus?: GuideDiffFocus; | 713 | readonly focus?: GuideDiffFocus; |
| 686 | } | 714 | } |
| 687 | >) { | 715 | >) { |
| 716 | const { layout } = useAdaptiveWorkspaceLayout(); | ||
| 688 | const navigation = useNavigation(); | 717 | const navigation = useNavigation(); |
| 689 | const insets = useSafeAreaInsets(); | 718 | const insets = useSafeAreaInsets(); |
| 719 | const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom); | ||
| 690 | const commit = route.params.commit; | 720 | const commit = route.params.commit; |
| 691 | const result = usePullRequestDiff(route.params); | 721 | const result = usePullRequestDiff(route.params); |
| 692 | const filter = usePullRequestFileFilter( | 722 | const filter = usePullRequestFileFilter( |
| ... | @@ -875,7 +905,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -875,7 +905,7 @@ export function PullRequestFileRouteScreen({ |
| 875 | const seekingFile = !targetFile && result.nextCursor !== null; | 905 | const seekingFile = !targetFile && result.nextCursor !== null; |
| 876 | const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error); | 906 | const loadingDiff = (result.isPending && !data.rows.length) || (seekingFile && !result.error); |
| 877 | return ( | 907 | return ( |
| 878 | <View className="flex-1 bg-screen"> | 908 | <View className={Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 bg-screen"}> |
| 879 | <NativeStackScreenOptions | 909 | <NativeStackScreenOptions |
| 880 | options={{ | 910 | options={{ |
| 881 | title, | 911 | title, |
| ... | @@ -1022,7 +1052,15 @@ export function PullRequestFileRouteScreen({ | ... | @@ -1022,7 +1052,15 @@ export function PullRequestFileRouteScreen({ |
| 1022 | /> | 1052 | /> |
| 1023 | </View> | 1053 | </View> |
| 1024 | ) : null} | 1054 | ) : null} |
| 1025 | <View className="flex-1" collapsable={false}> | 1055 | <View |
| 1056 | className={ | ||
| 1057 | Platform.OS === "android" | ||
| 1058 | ? "flex-1 overflow-hidden rounded-t-[28px] bg-sheet-solid" | ||
| 1059 | : "flex-1" | ||
| 1060 | } | ||
| 1061 | collapsable={false} | ||
| 1062 | style={{ marginHorizontal: Platform.OS === "android" && layout.usesSplitView ? 4 : 0 }} | ||
| 1063 | > | ||
| 1026 | {loadingDiff || result.error ? ( | 1064 | {loadingDiff || result.error ? ( |
| 1027 | <View style={{ paddingTop: Platform.OS === "ios" && !staleDiff ? insets.top : 0 }}> | 1065 | <View style={{ paddingTop: Platform.OS === "ios" && !staleDiff ? insets.top : 0 }}> |
| 1028 | <PullRequestReadStatus pending={loadingDiff} error={result.error} /> | 1066 | <PullRequestReadStatus pending={loadingDiff} error={result.error} /> |
| ... | @@ -1097,7 +1135,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -1097,7 +1135,7 @@ export function PullRequestFileRouteScreen({ |
| 1097 | rowHeight={nativeReviewDiffStyle.rowHeight} | 1135 | rowHeight={nativeReviewDiffStyle.rowHeight} |
| 1098 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | 1136 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} |
| 1099 | contentInsetTop={Platform.OS === "ios" ? (staleDiff ? 0 : insets.top) : undefined} | 1137 | contentInsetTop={Platform.OS === "ios" ? (staleDiff ? 0 : insets.top) : undefined} |
| 1100 | contentInsetBottom={Platform.OS === "ios" ? insets.bottom : undefined} | 1138 | contentInsetBottom={Platform.OS === "android" ? toolbarHeight : insets.bottom} |
| 1101 | contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`} | 1139 | contentResetKey={`pull-request:${route.params.environmentId}:${route.params.repository}:${route.params.number}:${commit ?? "all"}`} |
| 1102 | /> | 1140 | /> |
| 1103 | </View> | 1141 | </View> |
| ... | @@ -1147,6 +1185,7 @@ export function PullRequestFileRouteScreen({ | ... | @@ -1147,6 +1185,7 @@ export function PullRequestFileRouteScreen({ |
| 1147 | params={route.params} | 1185 | params={route.params} |
| 1148 | filePath={visibleFile?.path} | 1186 | filePath={visibleFile?.path} |
| 1149 | onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)} | 1187 | onOpenFiles={() => navigation.navigate("PullRequestFiles", route.params)} |
| 1188 | onHeightChange={setToolbarHeight} | ||
| 1150 | /> | 1189 | /> |
| 1151 | </View> | 1190 | </View> |
| 1152 | ); | 1191 | ); |
apps/mobile/src/features/pull-requests/PullRequestSheetContent.tsx+91-35| ... | @@ -1,9 +1,12 @@ | ... | @@ -1,9 +1,12 @@ |
| 1 | import { useNavigation } from "@react-navigation/native"; | 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { ReactNode } from "react"; | 2 | import type { ReactNode } from "react"; |
| 3 | import { ScrollView, View } from "react-native"; | 3 | import { Platform, ScrollView, useWindowDimensions, View } from "react-native"; |
| 4 | import { KeyboardAvoidingView } from "react-native-keyboard-controller"; | 4 | import { KeyboardAvoidingView } from "react-native-keyboard-controller"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | import { AppText as Text } from "../../components/AppText"; | 6 | import { AppText as Text } from "../../components/AppText"; |
| 7 | import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; | ||
| 8 | import { MaterialIconButton } from "../../components/MaterialIconButton"; | ||
| 9 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | ||
| 7 | import { ControlPill } from "../../components/ControlPill"; | 10 | import { ControlPill } from "../../components/ControlPill"; |
| 8 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 11 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 9 | 12 | ||
| ... | @@ -17,44 +20,97 @@ export function PullRequestSheetContent(props: { | ... | @@ -17,44 +20,97 @@ export function PullRequestSheetContent(props: { |
| 17 | }) { | 20 | }) { |
| 18 | const navigation = useNavigation(); | 21 | const navigation = useNavigation(); |
| 19 | const insets = useSafeAreaInsets(); | 22 | const insets = useSafeAreaInsets(); |
| 20 | return ( | 23 | const { height: windowHeight } = useWindowDimensions(); |
| 21 | <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1 bg-sheet"> | 24 | const android = Platform.OS === "android"; |
| 22 | <NativeStackScreenOptions options={{ gestureEnabled: !props.busy }} /> | 25 | const fitToContents = android && props.scrollable !== false; |
| 23 | <View className="flex-row items-center gap-2 px-4 pt-5 pb-5"> | 26 | const footer = props.footer ? ( |
| 24 | <ControlPill | 27 | <View |
| 25 | icon="xmark" | 28 | className={ |
| 26 | accessibilityLabel="Close" | 29 | android && props.scrollable !== false ? "bg-sheet-solid pt-3" : "bg-sheet px-4 pt-3" |
| 27 | disabled={props.busy} | 30 | } |
| 28 | onPress={() => navigation.goBack()} | 31 | style={{ paddingBottom: Math.max(insets.bottom, 16) }} |
| 32 | > | ||
| 33 | {android ? <View style={{ minHeight: 48 }}>{props.footer}</View> : props.footer} | ||
| 34 | </View> | ||
| 35 | ) : null; | ||
| 36 | const content = ( | ||
| 37 | <> | ||
| 38 | {android ? ( | ||
| 39 | <AndroidSheetHeader | ||
| 40 | title={props.title} | ||
| 41 | subtitle={props.subtitle} | ||
| 42 | hideBottomBorder | ||
| 43 | leading={ | ||
| 44 | <MaterialIconButton | ||
| 45 | icon="arrow.left" | ||
| 46 | accessibilityLabel="Close" | ||
| 47 | disabled={props.busy} | ||
| 48 | tintColorClassName="accent-header-foreground" | ||
| 49 | onPress={() => navigation.goBack()} | ||
| 50 | /> | ||
| 51 | } | ||
| 29 | /> | 52 | /> |
| 30 | <View className="min-w-0 flex-1 items-center gap-1"> | ||
| 31 | <Text className="text-center text-base font-t3-bold">{props.title}</Text> | ||
| 32 | <Text numberOfLines={1} className="text-center text-xs text-foreground-muted"> | ||
| 33 | {props.subtitle} | ||
| 34 | </Text> | ||
| 35 | </View> | ||
| 36 | <View className="w-11" /> | ||
| 37 | </View> | ||
| 38 | {props.scrollable === false ? ( | ||
| 39 | <View className="flex-1" pointerEvents={props.busy ? "none" : "auto"}> | ||
| 40 | {props.children} | ||
| 41 | </View> | ||
| 42 | ) : ( | 53 | ) : ( |
| 43 | <ScrollView | 54 | <View className="flex-row items-center gap-2 px-4 pt-5 pb-5"> |
| 44 | className="flex-1" | 55 | <ControlPill |
| 45 | keyboardShouldPersistTaps="handled" | 56 | icon="xmark" |
| 46 | contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }} | 57 | accessibilityLabel="Close" |
| 47 | > | 58 | disabled={props.busy} |
| 48 | <View className="gap-4" pointerEvents={props.busy ? "none" : "auto"}> | 59 | onPress={() => navigation.goBack()} |
| 49 | {props.children} | 60 | /> |
| 61 | <View className="min-w-0 flex-1 items-center gap-1"> | ||
| 62 | <Text className="text-center text-base font-t3-bold">{props.title}</Text> | ||
| 63 | <Text numberOfLines={1} className="text-center text-xs text-foreground-muted"> | ||
| 64 | {props.subtitle} | ||
| 65 | </Text> | ||
| 50 | </View> | 66 | </View> |
| 51 | </ScrollView> | 67 | <View className="w-11" /> |
| 52 | )} | ||
| 53 | {props.footer ? ( | ||
| 54 | <View className="bg-sheet px-4 pt-3" style={{ paddingBottom: Math.max(insets.bottom, 16) }}> | ||
| 55 | {props.footer} | ||
| 56 | </View> | 68 | </View> |
| 57 | ) : null} | 69 | )} |
| 70 | <MaterialScreenContent fitToContents={fitToContents}> | ||
| 71 | {props.scrollable === false ? ( | ||
| 72 | <View className="flex-1" pointerEvents={props.busy ? "none" : "auto"}> | ||
| 73 | {props.children} | ||
| 74 | </View> | ||
| 75 | ) : ( | ||
| 76 | <ScrollView | ||
| 77 | nestedScrollEnabled | ||
| 78 | className="android:shrink android:grow-0 ios:flex-1" | ||
| 79 | keyboardShouldPersistTaps="handled" | ||
| 80 | contentContainerStyle={{ | ||
| 81 | paddingHorizontal: 16, | ||
| 82 | paddingTop: android ? 16 : 0, | ||
| 83 | paddingBottom: 20, | ||
| 84 | }} | ||
| 85 | > | ||
| 86 | <View className="gap-4" pointerEvents={props.busy ? "none" : "auto"}> | ||
| 87 | {props.children} | ||
| 88 | {/* Android partial-height sheets keep submit actions within the scrollable form. */} | ||
| 89 | {android ? footer : null} | ||
| 90 | </View> | ||
| 91 | </ScrollView> | ||
| 92 | )} | ||
| 93 | |||
| 94 | {!android || props.scrollable === false ? footer : null} | ||
| 95 | </MaterialScreenContent> | ||
| 96 | </> | ||
| 97 | ); | ||
| 98 | return ( | ||
| 99 | <KeyboardAvoidingView | ||
| 100 | automaticOffset | ||
| 101 | behavior="padding" | ||
| 102 | collapsable={false} | ||
| 103 | className={props.scrollable === false ? "flex-1 bg-sheet" : "bg-sheet ios:flex-1"} | ||
| 104 | style={fitToContents ? { maxHeight: windowHeight * 0.92 } : undefined} | ||
| 105 | > | ||
| 106 | <NativeStackScreenOptions | ||
| 107 | options={{ | ||
| 108 | gestureEnabled: !props.busy, | ||
| 109 | ...(android ? { sheetCornerRadius: 28 } : {}), | ||
| 110 | ...(fitToContents ? { sheetAllowedDetents: "fitToContents" } : {}), | ||
| 111 | }} | ||
| 112 | /> | ||
| 113 | {content} | ||
| 58 | </KeyboardAvoidingView> | 114 | </KeyboardAvoidingView> |
| 59 | ); | 115 | ); |
| 60 | } | 116 | } |
apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx+9-8| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; | 1 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 2 | import { useState } from "react"; | 2 | import { useState } from "react"; |
| 3 | import { View } from "react-native"; | 3 | import { Platform, View } from "react-native"; |
| 4 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | ||
| 4 | import { AppText as Text } from "../../components/AppText"; | 5 | import { AppText as Text } from "../../components/AppText"; |
| 5 | import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; | 6 | import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; |
| 6 | import { pullRequestStatus } from "./pullRequestStatus"; | 7 | import { pullRequestStatus } from "./pullRequestStatus"; |
| ... | @@ -12,6 +13,7 @@ export function PullRequestStackCard(props: { | ... | @@ -12,6 +13,7 @@ export function PullRequestStackCard(props: { |
| 12 | readonly state: PullRequestStackState; | 13 | readonly state: PullRequestStackState; |
| 13 | }) { | 14 | }) { |
| 14 | const navigation = useNavigation(); | 15 | const navigation = useNavigation(); |
| 16 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | ||
| 15 | const [expanded, setExpanded] = useState(false); | 17 | const [expanded, setExpanded] = useState(false); |
| 16 | const stack = props.state.stack; | 18 | const stack = props.state.stack; |
| 17 | if (!stack) { | 19 | if (!stack) { |
| ... | @@ -62,13 +64,12 @@ export function PullRequestStackCard(props: { | ... | @@ -62,13 +64,12 @@ export function PullRequestStackCard(props: { |
| 62 | onPress={ | 64 | onPress={ |
| 63 | selected | 65 | selected |
| 64 | ? undefined | 66 | ? undefined |
| 65 | : () => | 67 | : () => { |
| 66 | navigation.dispatch( | 68 | const params = { ...props.params, number: layer.number }; |
| 67 | StackActions.push("PullRequest", { | 69 | if (Platform.OS === "android") |
| 68 | ...props.params, | 70 | selectAndroidWorkspace({ name: "PullRequest", params }); |
| 69 | number: layer.number, | 71 | else navigation.dispatch(StackActions.push("PullRequest", params)); |
| 70 | }), | 72 | } |
| 71 | ) | ||
| 72 | } | 73 | } |
| 73 | last={index === stack.layers.length - 1} | 74 | last={index === stack.layers.length - 1} |
| 74 | /> | 75 | /> |
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+7-3| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | } from "./pullRequestList"; | 36 | } from "./pullRequestList"; |
| 37 | import { useDebouncedValue } from "../../state/queries"; | 37 | import { useDebouncedValue } from "../../state/queries"; |
| 38 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; | 38 | import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; |
| 39 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | ||
| 39 | 40 | ||
| 40 | function PullRequestScrollView(props: ScrollViewProps) { | 41 | function PullRequestScrollView(props: ScrollViewProps) { |
| 41 | const insets = useSafeAreaInsets(); | 42 | const insets = useSafeAreaInsets(); |
| ... | @@ -77,19 +78,22 @@ export function PullRequestsScreen() { | ... | @@ -77,19 +78,22 @@ export function PullRequestsScreen() { |
| 77 | const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId) | 78 | const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId) |
| 78 | ? selectedEnvironmentId | 79 | ? selectedEnvironmentId |
| 79 | : null; | 80 | : null; |
| 81 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | ||
| 80 | const selectPullRequest = useCallback( | 82 | const selectPullRequest = useCallback( |
| 81 | (item: EnvironmentPullRequestListEntry) => { | 83 | (item: EnvironmentPullRequestListEntry) => { |
| 82 | Keyboard.dismiss(); | 84 | Keyboard.dismiss(); |
| 83 | navigation.navigate("PullRequest", { | 85 | const params = { |
| 84 | environmentId: String(item.environmentId), | 86 | environmentId: String(item.environmentId), |
| 85 | projectId: String(item.projectId), | 87 | projectId: String(item.projectId), |
| 86 | repository: item.repository, | 88 | repository: item.repository, |
| 87 | number: item.number, | 89 | number: item.number, |
| 88 | host: item.host, | 90 | host: item.host, |
| 89 | initialSummary: item, | 91 | initialSummary: item, |
| 90 | }); | 92 | }; |
| 93 | if (Platform.OS === "android") selectAndroidWorkspace({ name: "PullRequest", params }); | ||
| 94 | else navigation.navigate("PullRequest", params); | ||
| 91 | }, | 95 | }, |
| 92 | [navigation], | 96 | [navigation, selectAndroidWorkspace], |
| 93 | ); | 97 | ); |
| 94 | const controlsAtom = pullRequestListControlsAtom(environmentId ?? ""); | 98 | const controlsAtom = pullRequestListControlsAtom(environmentId ?? ""); |
| 95 | const controls = useAtomValue(controlsAtom); | 99 | const controls = useAtomValue(controlsAtom); |
apps/mobile/src/features/pull-requests/usePullRequestListActions.ts+10-4| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | } from "./pullRequestActions"; | 16 | } from "./pullRequestActions"; |
| 17 | import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList"; | 17 | import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList"; |
| 18 | import { pullRequestTarget } from "./usePullRequestDiff"; | 18 | import { pullRequestTarget } from "./usePullRequestDiff"; |
| 19 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | ||
| 19 | 20 | ||
| 20 | export function usePullRequestListActions( | 21 | export function usePullRequestListActions( |
| 21 | environmentIds: readonly EnvironmentId[], | 22 | environmentIds: readonly EnvironmentId[], |
| ... | @@ -26,6 +27,7 @@ export function usePullRequestListActions( | ... | @@ -26,6 +27,7 @@ export function usePullRequestListActions( |
| 26 | ) { | 27 | ) { |
| 27 | const registry = use(RegistryContext); | 28 | const registry = use(RegistryContext); |
| 28 | const navigation = useNavigation(); | 29 | const navigation = useNavigation(); |
| 30 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | ||
| 29 | const permissionKey = JSON.stringify(environmentIds); | 31 | const permissionKey = JSON.stringify(environmentIds); |
| 30 | const permissionAtom = useMemo(() => { | 32 | const permissionAtom = useMemo(() => { |
| 31 | const ids: readonly EnvironmentId[] = JSON.parse(permissionKey); | 33 | const ids: readonly EnvironmentId[] = JSON.parse(permissionKey); |
| ... | @@ -74,7 +76,9 @@ export function usePullRequestListActions( | ... | @@ -74,7 +76,9 @@ export function usePullRequestListActions( |
| 74 | !pr.viewerPermissions.requestReviewers | 76 | !pr.viewerPermissions.requestReviewers |
| 75 | ) | 77 | ) |
| 76 | throw new Error("Review requests aren't available for this pull request."); | 78 | throw new Error("Review requests aren't available for this pull request."); |
| 77 | navigation.navigate("PullRequestReviewers", params(entry)); | 79 | if (Platform.OS === "android") { |
| 80 | selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }, params(entry)); | ||
| 81 | } else navigation.navigate("PullRequestReviewers", params(entry)); | ||
| 78 | return; | 82 | return; |
| 79 | } | 83 | } |
| 80 | if ( | 84 | if ( |
| ... | @@ -102,12 +106,14 @@ export function usePullRequestListActions( | ... | @@ -102,12 +106,14 @@ export function usePullRequestListActions( |
| 102 | setBusyKey(null); | 106 | setBusyKey(null); |
| 103 | } | 107 | } |
| 104 | }, | 108 | }, |
| 105 | [registry, navigation, params, run, onChanged], | 109 | [registry, navigation, params, run, onChanged, selectAndroidWorkspace], |
| 106 | ); | 110 | ); |
| 107 | const select = useCallback( | 111 | const select = useCallback( |
| 108 | (entry: EnvironmentPullRequestListEntry, action: string) => { | 112 | (entry: EnvironmentPullRequestListEntry, action: string) => { |
| 109 | if (action === "open") { | 113 | if (action === "open") { |
| 110 | navigation.navigate("PullRequest", params(entry)); | 114 | if (Platform.OS === "android") |
| 115 | selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }); | ||
| 116 | else navigation.navigate("PullRequest", params(entry)); | ||
| 111 | return; | 117 | return; |
| 112 | } | 118 | } |
| 113 | if (action === "request-review") { | 119 | if (action === "request-review") { |
| ... | @@ -143,7 +149,7 @@ export function usePullRequestListActions( | ... | @@ -143,7 +149,7 @@ export function usePullRequestListActions( |
| 143 | }, | 149 | }, |
| 144 | ]); | 150 | ]); |
| 145 | }, | 151 | }, |
| 146 | [navigation, params, perform], | 152 | [navigation, params, perform, selectAndroidWorkspace], |
| 147 | ); | 153 | ); |
| 148 | const actions = useCallback( | 154 | const actions = useCallback( |
| 149 | (entry: EnvironmentPullRequestListEntry): ControlPillMenuProps["actions"] => [ | 155 | (entry: EnvironmentPullRequestListEntry): ControlPillMenuProps["actions"] => [ |
apps/mobile/src/features/pull-requests/usePullRequestThreads.ts+8-3| ... | @@ -11,7 +11,8 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR | ... | @@ -11,7 +11,8 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR |
| 11 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | 11 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 12 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | 12 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; |
| 13 | import { useRef, useState } from "react"; | 13 | import { useRef, useState } from "react"; |
| 14 | import { Alert } from "react-native"; | 14 | import { Alert, Platform } from "react-native"; |
| 15 | import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; | ||
| 15 | import { pullRequestComposerContext } from "../../lib/composerContext"; | 16 | import { pullRequestComposerContext } from "../../lib/composerContext"; |
| 16 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 17 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 17 | import { uuidv4 } from "../../lib/uuid"; | 18 | import { uuidv4 } from "../../lib/uuid"; |
| ... | @@ -70,10 +71,14 @@ export function usePullRequestThreads( | ... | @@ -70,10 +71,14 @@ export function usePullRequestThreads( |
| 70 | const canAsk = !connected || canOperate; | 71 | const canAsk = !connected || canOperate; |
| 71 | const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); | 72 | const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); |
| 72 | const navigation = useNavigation(); | 73 | const navigation = useNavigation(); |
| 74 | const selectAndroidWorkspace = useAndroidWorkspaceSelection(); | ||
| 73 | const pending = useRef(false); | 75 | const pending = useRef(false); |
| 74 | const previousPrompts = useRef(new Map<string, string>()); | 76 | const previousPrompts = useRef(new Map<string, string>()); |
| 75 | const openThread = (thread: LinkedThread) => | 77 | const openThread = (thread: LinkedThread) => { |
| 76 | navigation.navigate("Thread", { environmentId: params.environmentId, threadId: thread.id }); | 78 | const destination = { environmentId: params.environmentId, threadId: thread.id }; |
| 79 | if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination }); | ||
| 80 | else navigation.navigate("Thread", destination); | ||
| 81 | }; | ||
| 77 | const [busy, setBusy] = useState(false); | 82 | const [busy, setBusy] = useState(false); |
| 78 | const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => { | 83 | const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => { |
| 79 | if (!pr || !canAsk || pending.current) return; | 84 | if (!pr || !canAsk || pending.current) return; |
apps/mobile/src/features/threads/ThreadHeader.tsx+3-12| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; | ||
| 2 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 3 | import { ScreenHeader } from "../../components/ScreenHeader"; | 2 | import { ScreenHeader } from "../../components/ScreenHeader"; |
| 4 | import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; | 3 | import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; |
| ... | @@ -6,6 +5,7 @@ import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; | ... | @@ -6,6 +5,7 @@ import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; |
| 6 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 5 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 7 | import type { ThreadInspectorMode } from "./thread-inspector-content-stack"; | 6 | import type { ThreadInspectorMode } from "./thread-inspector-content-stack"; |
| 8 | import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; | 7 | import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; |
| 8 | import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation"; | ||
| 9 | 9 | ||
| 10 | export function ThreadHeader( | 10 | export function ThreadHeader( |
| 11 | props: Parameters<typeof useThreadHeaderOptions>[0] & { | 11 | props: Parameters<typeof useThreadHeaderOptions>[0] & { |
| ... | @@ -18,7 +18,7 @@ export function ThreadHeader( | ... | @@ -18,7 +18,7 @@ export function ThreadHeader( |
| 18 | readonly onOpenFilesInspector: () => void; | 18 | readonly onOpenFilesInspector: () => void; |
| 19 | }, | 19 | }, |
| 20 | ) { | 20 | ) { |
| 21 | const navigation = useNavigation(); | 21 | const returnToList = useWorkspaceListNavigation("threads"); |
| 22 | const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); | 22 | const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); |
| 23 | const { onOpenTerminal, onMergeBack } = props.gitControls; | 23 | const { onOpenTerminal, onMergeBack } = props.gitControls; |
| 24 | const native = useThreadHeaderOptions(props); | 24 | const native = useThreadHeaderOptions(props); |
| ... | @@ -96,16 +96,7 @@ export function ThreadHeader( | ... | @@ -96,16 +96,7 @@ export function ThreadHeader( |
| 96 | /> | 96 | /> |
| 97 | ) : null | 97 | ) : null |
| 98 | } | 98 | } |
| 99 | onBack={ | 99 | onBack={layout.usesSplitView ? undefined : returnToList} |
| 100 | layout.usesSplitView | ||
| 101 | ? undefined | ||
| 102 | : () => { | ||
| 103 | // A deep link or cold start has no previous route; Home is the way out. | ||
| 104 | // Read the history at press time: it changes without re-rendering this screen. | ||
| 105 | if (navigation.canGoBack()) navigation.goBack(); | ||
| 106 | else navigation.dispatch(StackActions.replace("Home")); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | actions={androidHeaderActions} | 100 | actions={androidHeaderActions} |
| 110 | hideBottomBorder | 101 | hideBottomBorder |
| 111 | /> | 102 | /> |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+7-9| ... | @@ -87,6 +87,10 @@ import { | ... | @@ -87,6 +87,10 @@ import { |
| 87 | } from "./thread-inspector-content-stack"; | 87 | } from "./thread-inspector-content-stack"; |
| 88 | import { threadRouteIsHydrating } from "./thread-route-hydration"; | 88 | import { threadRouteIsHydrating } from "./thread-route-hydration"; |
| 89 | import { ThreadHeader } from "./ThreadHeader"; | 89 | import { ThreadHeader } from "./ThreadHeader"; |
| 90 | import { | ||
| 91 | useAndroidWorkspaceBack, | ||
| 92 | useWorkspaceListNavigation, | ||
| 93 | } from "../workspace/useAndroidWorkspaceNavigation"; | ||
| 90 | 94 | ||
| 91 | interface ThreadInspectorSelection { | 95 | interface ThreadInspectorSelection { |
| 92 | readonly routeThreadIdentity: string | null; | 96 | readonly routeThreadIdentity: string | null; |
| ... | @@ -114,7 +118,7 @@ function ThreadPane(props: { readonly children: ReactNode }) { | ... | @@ -114,7 +118,7 @@ function ThreadPane(props: { readonly children: ReactNode }) { |
| 114 | } | 118 | } |
| 115 | 119 | ||
| 116 | function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { | 120 | function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { |
| 117 | const navigation = useNavigation(); | 121 | const returnToList = useWorkspaceListNavigation("threads"); |
| 118 | const { layout } = useAdaptiveWorkspaceLayout(); | 122 | const { layout } = useAdaptiveWorkspaceLayout(); |
| 119 | const { themeVariables } = useAppearancePreferences(); | 123 | const { themeVariables } = useAppearancePreferences(); |
| 120 | if (Platform.OS !== "android") return props.children; | 124 | if (Platform.OS !== "android") return props.children; |
| ... | @@ -124,14 +128,7 @@ function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { | ... | @@ -124,14 +128,7 @@ function ThreadRouteFallbackLayout(props: { readonly children: ReactNode }) { |
| 124 | <ScreenHeader | 128 | <ScreenHeader |
| 125 | title="Thread" | 129 | title="Thread" |
| 126 | options={{ contentStyle: { backgroundColor: themeVariables["--color-header"] } }} | 130 | options={{ contentStyle: { backgroundColor: themeVariables["--color-header"] } }} |
| 127 | onBack={ | 131 | onBack={layout.usesSplitView ? undefined : returnToList} |
| 128 | layout.usesSplitView | ||
| 129 | ? undefined | ||
| 130 | : () => { | ||
| 131 | if (navigation.canGoBack()) navigation.goBack(); | ||
| 132 | else navigation.dispatch(StackActions.replace("Home")); | ||
| 133 | } | ||
| 134 | } | ||
| 135 | hideBottomBorder | 132 | hideBottomBorder |
| 136 | /> | 133 | /> |
| 137 | <ThreadPane>{props.children}</ThreadPane> | 134 | <ThreadPane>{props.children}</ThreadPane> |
| ... | @@ -190,6 +187,7 @@ function ThreadUnavailableScreen(props: { | ... | @@ -190,6 +187,7 @@ function ThreadUnavailableScreen(props: { |
| 190 | } | 187 | } |
| 191 | 188 | ||
| 192 | export function ThreadRouteScreen(props: ThreadRouteScreenProps) { | 189 | export function ThreadRouteScreen(props: ThreadRouteScreenProps) { |
| 190 | useAndroidWorkspaceBack("threads"); | ||
| 193 | const connectionsReady = useConnectionsReady(); | 191 | const connectionsReady = useConnectionsReady(); |
| 194 | const { connectionState } = useRemoteConnectionStatus(); | 192 | const { connectionState } = useRemoteConnectionStatus(); |
| 195 | const { selectedThread } = useThreadSelection(); | 193 | const { selectedThread } = useThreadSelection(); |
apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx created+98| ... | @@ -0,0 +1,98 @@ | ||
| 1 | import { requireNativeView } from "expo"; | ||
| 2 | import { CommonActions, useNavigation } from "@react-navigation/native"; | ||
| 3 | import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; | ||
| 4 | import { | ||
| 5 | Keyboard, | ||
| 6 | processColor, | ||
| 7 | View, | ||
| 8 | type NativeSyntheticEvent, | ||
| 9 | type ViewProps, | ||
| 10 | } from "react-native"; | ||
| 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 12 | import { useKeyboardState } from "react-native-keyboard-controller"; | ||
| 13 | |||
| 14 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | ||
| 15 | import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme"; | ||
| 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 17 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | ||
| 18 | import type { WorkspaceTab } from "./workspace-tabs-state"; | ||
| 19 | |||
| 20 | export const ANDROID_WORKSPACE_RAIL_WIDTH = 80; | ||
| 21 | |||
| 22 | interface NativeNavigationProps extends ViewProps { | ||
| 23 | readonly rail: boolean; | ||
| 24 | readonly selectedTab: WorkspaceTab; | ||
| 25 | readonly dark: boolean; | ||
| 26 | readonly textScale: number; | ||
| 27 | readonly containerColor: number; | ||
| 28 | readonly selectedColor: number; | ||
| 29 | readonly indicatorColor: number; | ||
| 30 | readonly unselectedColor: number; | ||
| 31 | readonly onTabSelected: (event: NativeSyntheticEvent<{ readonly tab: WorkspaceTab }>) => void; | ||
| 32 | } | ||
| 33 | |||
| 34 | const NativeNavigation = requireNativeView<NativeNavigationProps>("T3WorkspaceNavigation"); | ||
| 35 | |||
| 36 | function nativeColor(value: string) { | ||
| 37 | const color = processColor(value); | ||
| 38 | return typeof color === "number" ? color : 0; | ||
| 39 | } | ||
| 40 | |||
| 41 | export function AndroidWorkspaceNavigation(props: { | ||
| 42 | readonly rail?: boolean; | ||
| 43 | readonly onSelect?: () => void; | ||
| 44 | }) { | ||
| 45 | const workspace = useWorkspaceTabs()!; | ||
| 46 | const navigation = useNavigation(); | ||
| 47 | const insets = useSafeAreaInsets(); | ||
| 48 | const keyboardVisible = useKeyboardState((state) => state.isVisible); | ||
| 49 | const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); | ||
| 50 | const { scale } = useAndroidControlSizing(); | ||
| 51 | const rail = Boolean(props.rail); | ||
| 52 | const containerColor = rail | ||
| 53 | ? colors["--color-header"] | ||
| 54 | : flattenThemeColor( | ||
| 55 | themeColorWithAlpha(colors["--color-header-foreground"], 0.05), | ||
| 56 | flattenThemeColor(colors["--color-header"], colors["--color-screen"]), | ||
| 57 | ); | ||
| 58 | if (!rail && keyboardVisible) return null; | ||
| 59 | |||
| 60 | return ( | ||
| 61 | <View | ||
| 62 | style={{ | ||
| 63 | backgroundColor: containerColor, | ||
| 64 | ...(rail | ||
| 65 | ? { | ||
| 66 | flex: 1, | ||
| 67 | width: ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, scale), | ||
| 68 | paddingTop: insets.top, | ||
| 69 | paddingBottom: insets.bottom, | ||
| 70 | } | ||
| 71 | : { paddingBottom: insets.bottom }), | ||
| 72 | }} | ||
| 73 | > | ||
| 74 | <NativeNavigation | ||
| 75 | rail={rail} | ||
| 76 | selectedTab={workspace.state.selectedTab} | ||
| 77 | dark={themeAppearance === "dark"} | ||
| 78 | textScale={scale} | ||
| 79 | containerColor={nativeColor(containerColor)} | ||
| 80 | selectedColor={nativeColor(colors["--color-secondary-foreground"])} | ||
| 81 | indicatorColor={nativeColor(colors["--color-secondary"])} | ||
| 82 | unselectedColor={nativeColor(colors["--color-header-foreground"])} | ||
| 83 | onTabSelected={(event) => { | ||
| 84 | Keyboard.dismiss(); | ||
| 85 | const tab = event.nativeEvent.tab; | ||
| 86 | if (tab !== workspace.state.selectedTab) { | ||
| 87 | workspace.dispatch({ type: "select", tab }); | ||
| 88 | navigation.dispatch((state) => | ||
| 89 | CommonActions.reset(androidWorkspaceNavigationState(state)), | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | props.onSelect?.(); | ||
| 93 | }} | ||
| 94 | style={rail ? { flex: 1 } : { height: 80 * Math.max(1, scale) }} | ||
| 95 | /> | ||
| 96 | </View> | ||
| 97 | ); | ||
| 98 | } | ||
apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | /** Other clients own their workspace navigation chrome. */ | ||
| 2 | export function AndroidWorkspaceNavigation(_props: { | ||
| 3 | readonly rail?: boolean; | ||
| 4 | readonly onSelect?: () => void; | ||
| 5 | }) { | ||
| 6 | return null; | ||
| 7 | } | ||
| 8 | |||
| 9 | export const ANDROID_WORKSPACE_RAIL_WIDTH = 80; | ||
apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { useMemo, type ReactNode } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | ||
| 4 | import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; | ||
| 5 | import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android"; | ||
| 6 | import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; | ||
| 7 | |||
| 8 | export function AndroidWorkspaceSidebar(props: { | ||
| 9 | readonly children: ReactNode; | ||
| 10 | readonly onRequestVisibility: () => void; | ||
| 11 | }) { | ||
| 12 | const workspace = useWorkspaceTabs()!; | ||
| 13 | const context = useMemo(() => ({ ...workspace, bottomClearance: 16 }), [workspace]); | ||
| 14 | return workspace.state.selectedTab === "threads" ? ( | ||
| 15 | props.children | ||
| 16 | ) : ( | ||
| 17 | <WorkspaceTabsContext value={context}> | ||
| 18 | <View className="flex-1 bg-header"> | ||
| 19 | <MaterialPullRequestToolbar onRequestVisibility={props.onRequestVisibility} /> | ||
| 20 | <MaterialScreenContent insetHorizontal> | ||
| 21 | <PullRequestsScreen /> | ||
| 22 | </MaterialScreenContent> | ||
| 23 | </View> | ||
| 24 | </WorkspaceTabsContext> | ||
| 25 | ); | ||
| 26 | } | ||
apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.tsx created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | |||
| 3 | export function AndroidWorkspaceSidebar(props: { | ||
| 4 | readonly children: ReactNode; | ||
| 5 | readonly onRequestVisibility: () => void; | ||
| 6 | }) { | ||
| 7 | return props.children; | ||
| 8 | } | ||
apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { Platform } from "react-native"; | ||
| 3 | import { WorkspaceTabsProvider } from "./WorkspaceTabsContext"; | ||
| 4 | |||
| 5 | /** Android's persistent sidebar and compact root share one tab controller. */ | ||
| 6 | export function AndroidWorkspaceTabsProvider(props: { readonly children: ReactNode }) { | ||
| 7 | return Platform.OS === "android" ? ( | ||
| 8 | <WorkspaceTabsProvider>{props.children}</WorkspaceTabsProvider> | ||
| 9 | ) : ( | ||
| 10 | props.children | ||
| 11 | ); | ||
| 12 | } | ||
apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 4 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 5 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 6 | import { EmptyState } from "../../components/EmptyState"; | ||
| 7 | import { MaterialScreenContent } from "../../components/MaterialScreenContent"; | ||
| 8 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | ||
| 9 | import { HomeRouteScreen } from "../home/HomeRouteScreen"; | ||
| 10 | import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; | ||
| 11 | import { MaterialPullRequestToolbar } from "../pull-requests/MaterialPullRequestToolbar.android"; | ||
| 12 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | ||
| 13 | import { AndroidWorkspaceNavigation } from "./AndroidWorkspaceNavigation"; | ||
| 14 | import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; | ||
| 15 | |||
| 16 | export function WorkspaceRootScreen() { | ||
| 17 | const { layout } = useAdaptiveWorkspaceLayout(); | ||
| 18 | const workspace = useWorkspaceTabs()!; | ||
| 19 | const insets = useSafeAreaInsets(); | ||
| 20 | const { fabClearance } = useAndroidControlSizing(); | ||
| 21 | const context = useMemo( | ||
| 22 | () => ({ | ||
| 23 | ...workspace, | ||
| 24 | bottomClearance: workspace.state.selectedTab === "threads" ? fabClearance + 16 : 16, | ||
| 25 | }), | ||
| 26 | [workspace, fabClearance], | ||
| 27 | ); | ||
| 28 | |||
| 29 | // Split lists live beside the navigator; Home remains an empty detail pane. | ||
| 30 | if (layout.usesSplitView) { | ||
| 31 | return workspace.state.selectedTab === "threads" ? ( | ||
| 32 | <HomeRouteScreen /> | ||
| 33 | ) : ( | ||
| 34 | <> | ||
| 35 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 36 | <AndroidScreenHeader title="Pull requests" /> | ||
| 37 | <View className="flex-1 items-center justify-center px-10"> | ||
| 38 | <EmptyState | ||
| 39 | title="Select a pull request" | ||
| 40 | detail="Choose a pull request from the sidebar to review it." | ||
| 41 | variant="plain" | ||
| 42 | /> | ||
| 43 | </View> | ||
| 44 | </> | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 48 | return ( | ||
| 49 | <WorkspaceTabsContext value={context}> | ||
| 50 | <View className="flex-1 bg-header"> | ||
| 51 | <NativeStackScreenOptions options={{ headerShown: false }} /> | ||
| 52 | <SafeAreaInsetsContext value={{ ...insets, bottom: 0 }}> | ||
| 53 | <View className="flex-1"> | ||
| 54 | {workspace.state.selectedTab === "threads" ? ( | ||
| 55 | <HomeRouteScreen /> | ||
| 56 | ) : ( | ||
| 57 | <> | ||
| 58 | <MaterialPullRequestToolbar /> | ||
| 59 | <MaterialScreenContent> | ||
| 60 | <PullRequestsScreen /> | ||
| 61 | </MaterialScreenContent> | ||
| 62 | </> | ||
| 63 | )} | ||
| 64 | </View> | ||
| 65 | </SafeAreaInsetsContext> | ||
| 66 | <AndroidWorkspaceNavigation /> | ||
| 67 | </View> | ||
| 68 | </WorkspaceTabsContext> | ||
| 69 | ); | ||
| 70 | } | ||
apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | import { | ||
| 2 | CommonActions, | ||
| 3 | StackActions, | ||
| 4 | useFocusEffect, | ||
| 5 | useNavigation, | ||
| 6 | } from "@react-navigation/native"; | ||
| 7 | import { useCallback } from "react"; | ||
| 8 | import { BackHandler, Keyboard, Platform } from "react-native"; | ||
| 9 | import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; | ||
| 10 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | ||
| 11 | import type { WorkspaceTab } from "./workspace-tabs-state"; | ||
| 12 | |||
| 13 | export function useWorkspaceListNavigation(tab: WorkspaceTab) { | ||
| 14 | const navigation = useNavigation(); | ||
| 15 | const dispatch = useWorkspaceTabs()?.dispatch; | ||
| 16 | return useCallback(() => { | ||
| 17 | if (Platform.OS === "android") { | ||
| 18 | dispatch?.({ type: "select", tab }); | ||
| 19 | navigation.dispatch((state) => CommonActions.reset(androidWorkspaceNavigationState(state))); | ||
| 20 | } else if (navigation.canGoBack()) navigation.goBack(); | ||
| 21 | else navigation.dispatch(StackActions.replace("Home")); | ||
| 22 | }, [navigation, dispatch, tab]); | ||
| 23 | } | ||
| 24 | |||
| 25 | /** Only base details own this Back handler; focused sheets and nested screens take priority. */ | ||
| 26 | export function useAndroidWorkspaceBack(tab: WorkspaceTab) { | ||
| 27 | const dispatch = useWorkspaceTabs()?.dispatch; | ||
| 28 | const returnToList = useWorkspaceListNavigation(tab); | ||
| 29 | useFocusEffect( | ||
| 30 | useCallback(() => { | ||
| 31 | if (Platform.OS !== "android") return; | ||
| 32 | dispatch?.({ type: "select", tab }); | ||
| 33 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { | ||
| 34 | if (Keyboard.isVisible()) Keyboard.dismiss(); | ||
| 35 | else returnToList(); | ||
| 36 | return true; | ||
| 37 | }); | ||
| 38 | return () => subscription.remove(); | ||
| 39 | }, [returnToList, dispatch, tab]), | ||
| 40 | ); | ||
| 41 | return returnToList; | ||
| 42 | } | ||
apps/mobile/src/features/workspace/useAndroidWorkspaceSelection.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { CommonActions, useNavigation } from "@react-navigation/native"; | ||
| 2 | import { useCallback } from "react"; | ||
| 3 | import { Keyboard } from "react-native"; | ||
| 4 | import { | ||
| 5 | androidWorkspaceNavigationState, | ||
| 6 | type AndroidWorkspaceDestination, | ||
| 7 | } from "../../lib/android-workspace-navigation"; | ||
| 8 | import { useWorkspaceTabs } from "./WorkspaceTabsContext"; | ||
| 9 | |||
| 10 | export function useAndroidWorkspaceSelection() { | ||
| 11 | const navigation = useNavigation(); | ||
| 12 | const dispatch = useWorkspaceTabs()?.dispatch; | ||
| 13 | return useCallback( | ||
| 14 | ( | ||
| 15 | destination: AndroidWorkspaceDestination, | ||
| 16 | reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], | ||
| 17 | ) => { | ||
| 18 | Keyboard.dismiss(); | ||
| 19 | dispatch?.({ | ||
| 20 | type: "select", | ||
| 21 | tab: destination.name === "Thread" ? "threads" : "pullRequests", | ||
| 22 | }); | ||
| 23 | navigation.dispatch((state) => | ||
| 24 | CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), | ||
| 25 | ); | ||
| 26 | }, | ||
| 27 | [navigation, dispatch], | ||
| 28 | ); | ||
| 29 | } | ||
apps/mobile/src/lib/android-workspace-navigation.test.ts created+186| ... | @@ -0,0 +1,186 @@ | ||
| 1 | import * as NodeModule from "node:module"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | import { | ||
| 4 | androidWorkspaceNavigationState, | ||
| 5 | type AndroidWorkspaceDestination, | ||
| 6 | } from "./android-workspace-navigation"; | ||
| 7 | |||
| 8 | const require = NodeModule.createRequire(import.meta.url); | ||
| 9 | const nativePackage = require.resolve("@react-navigation/native/package.json"); | ||
| 10 | const requireFromNative = NodeModule.createRequire(nativePackage); | ||
| 11 | const corePackage = requireFromNative.resolve("@react-navigation/core/package.json"); | ||
| 12 | const requireFromCore = NodeModule.createRequire(corePackage); | ||
| 13 | const { CommonActions, StackActions, StackRouter } = requireFromCore( | ||
| 14 | "@react-navigation/routers", | ||
| 15 | ) as { | ||
| 16 | readonly CommonActions: typeof import("@react-navigation/native").CommonActions; | ||
| 17 | readonly StackActions: typeof import("@react-navigation/native").StackActions; | ||
| 18 | readonly StackRouter: typeof import("@react-navigation/native").StackRouter; | ||
| 19 | }; | ||
| 20 | const router = StackRouter({}); | ||
| 21 | const options = { | ||
| 22 | routeNames: [ | ||
| 23 | "Home", | ||
| 24 | "Thread", | ||
| 25 | "ThreadFiles", | ||
| 26 | "ThreadTerminal", | ||
| 27 | "GitOverview", | ||
| 28 | "PullRequest", | ||
| 29 | "PullRequestFile", | ||
| 30 | "PullRequestReviewers", | ||
| 31 | "SettingsSheet", | ||
| 32 | ], | ||
| 33 | routeParamList: {}, | ||
| 34 | routeGetIdList: {}, | ||
| 35 | }; | ||
| 36 | const thread = { | ||
| 37 | name: "Thread", | ||
| 38 | params: { environmentId: "environment-a", threadId: "thread-a" }, | ||
| 39 | } as const; | ||
| 40 | const pullRequest = { | ||
| 41 | name: "PullRequest", | ||
| 42 | params: { | ||
| 43 | environmentId: "environment-a", | ||
| 44 | projectId: "project-a", | ||
| 45 | repository: "owner/repository", | ||
| 46 | number: 42, | ||
| 47 | host: "github.com", | ||
| 48 | }, | ||
| 49 | } as const; | ||
| 50 | |||
| 51 | function apply( | ||
| 52 | state: ReturnType<typeof router.getInitialState>, | ||
| 53 | action: Parameters<typeof router.getStateForAction>[1], | ||
| 54 | ) { | ||
| 55 | const result = router.getStateForAction(state, action, options); | ||
| 56 | if (!result) throw new Error(`Router rejected ${action.type}`); | ||
| 57 | return router.getRehydratedState(result, options); | ||
| 58 | } | ||
| 59 | |||
| 60 | function select( | ||
| 61 | state: ReturnType<typeof router.getInitialState>, | ||
| 62 | destination?: AndroidWorkspaceDestination, | ||
| 63 | reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], | ||
| 64 | ) { | ||
| 65 | return apply( | ||
| 66 | state, | ||
| 67 | CommonActions.reset(androidWorkspaceNavigationState(state, destination, reviewers)), | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | function legacyHistory() { | ||
| 72 | let state = router.getInitialState(options); | ||
| 73 | state = apply(state, StackActions.push(thread.name, thread.params)); | ||
| 74 | state = apply(state, StackActions.push(pullRequest.name, pullRequest.params)); | ||
| 75 | return state; | ||
| 76 | } | ||
| 77 | |||
| 78 | function names(state: ReturnType<typeof router.getInitialState>) { | ||
| 79 | return state.routes.map((route) => route.name); | ||
| 80 | } | ||
| 81 | |||
| 82 | describe("Android workspace navigation", () => { | ||
| 83 | it("replaces cross-tab history with one detail and returns directly to its list", () => { | ||
| 84 | const previous = legacyHistory(); | ||
| 85 | const state = select(previous, pullRequest); | ||
| 86 | expect(names(state)).toEqual(["Home", "PullRequest"]); | ||
| 87 | expect(state.routes[0]?.key).toBe(previous.routes[0]?.key); | ||
| 88 | expect(state.routes[1]?.key).toBe(previous.routes[2]?.key); | ||
| 89 | expect(names(apply(state, StackActions.pop()))).toEqual(["Home"]); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("replaces peer thread selections without leaving the previous thread underneath", () => { | ||
| 93 | const first = select(router.getInitialState(options), thread); | ||
| 94 | const second = select(first, { | ||
| 95 | name: "Thread", | ||
| 96 | params: { ...thread.params, threadId: "thread-b" }, | ||
| 97 | }); | ||
| 98 | expect(names(second)).toEqual(["Home", "Thread"]); | ||
| 99 | expect(second.routes[1]?.params).toEqual({ ...thread.params, threadId: "thread-b" }); | ||
| 100 | expect(second.routes[1]?.key).not.toBe(first.routes[1]?.key); | ||
| 101 | expect(names(apply(second, StackActions.pop()))).toEqual(["Home"]); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it("dismisses nested content and overlays when the sidebar selects a peer", () => { | ||
| 105 | let state = legacyHistory(); | ||
| 106 | state = apply( | ||
| 107 | state, | ||
| 108 | StackActions.push("PullRequestFile", { ...pullRequest.params, path: "src/main.ts" }), | ||
| 109 | ); | ||
| 110 | state = apply(state, StackActions.push("SettingsSheet")); | ||
| 111 | const selected = select(state, thread); | ||
| 112 | expect(names(selected)).toEqual(["Home", "Thread"]); | ||
| 113 | expect(selected.routes[1]?.params).toEqual(thread.params); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("creates Home beneath cold-linked details and gives direct list escape", () => { | ||
| 117 | const initial = router.getInitialState(options); | ||
| 118 | const cold = apply(initial, CommonActions.reset({ index: 0, routes: [pullRequest] })); | ||
| 119 | expect(names(select(cold, pullRequest))).toEqual(["Home", "PullRequest"]); | ||
| 120 | expect(names(select(cold))).toEqual(["Home"]); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("keeps a request-review flow above the chosen PR instead of an old chat", () => { | ||
| 124 | const state = select(legacyHistory(), pullRequest, pullRequest.params); | ||
| 125 | expect(names(state)).toEqual(["Home", "PullRequest", "PullRequestReviewers"]); | ||
| 126 | const back = apply(state, StackActions.pop()); | ||
| 127 | expect(names(back)).toEqual(["Home", "PullRequest"]); | ||
| 128 | expect(back.routes[back.index]?.params).toEqual(pullRequest.params); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("preserves normal nested PR Back behavior", () => { | ||
| 132 | const detail = select(legacyHistory(), pullRequest); | ||
| 133 | const nested = apply( | ||
| 134 | detail, | ||
| 135 | StackActions.push("PullRequestFile", { ...pullRequest.params, path: "file.ts" }), | ||
| 136 | ); | ||
| 137 | const back = apply(nested, StackActions.pop()); | ||
| 138 | expect(back.routes[back.index]?.key).toBe(detail.routes[detail.index]?.key); | ||
| 139 | expect(names(back)).toEqual(["Home", "PullRequest"]); | ||
| 140 | }); | ||
| 141 | |||
| 142 | it.each(["ThreadFiles", "ThreadTerminal", "GitOverview"])( | ||
| 143 | "returns to chat when its sidebar row is selected from %s", | ||
| 144 | (route) => { | ||
| 145 | const detail = select(router.getInitialState(options), thread); | ||
| 146 | const nested = apply(detail, StackActions.push(route, thread.params)); | ||
| 147 | const selected = select(nested, thread); | ||
| 148 | expect(names(selected)).toEqual(["Home", "Thread"]); | ||
| 149 | expect(selected.routes[1]?.key).toBe(detail.routes[1]?.key); | ||
| 150 | expect(names(apply(selected, StackActions.pop()))).toEqual(["Home"]); | ||
| 151 | }, | ||
| 152 | ); | ||
| 153 | |||
| 154 | it.each([ | ||
| 155 | { environmentId: "environment-b" }, | ||
| 156 | { projectId: "project-b" }, | ||
| 157 | { repository: "owner/other" }, | ||
| 158 | { host: "git.example.test" }, | ||
| 159 | { number: 43 }, | ||
| 160 | ])("does not reuse another PR's key when its scope differs: %o", (change) => { | ||
| 161 | const previous = select(router.getInitialState(options), pullRequest); | ||
| 162 | const state = select(previous, { | ||
| 163 | name: "PullRequest", | ||
| 164 | params: { ...pullRequest.params, ...change }, | ||
| 165 | }); | ||
| 166 | expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key); | ||
| 167 | expect(state.routes[1]?.params).toEqual({ ...pullRequest.params, ...change }); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("does not reuse a thread key from another environment", () => { | ||
| 171 | const previous = select(router.getInitialState(options), thread); | ||
| 172 | const state = select(previous, { | ||
| 173 | name: "Thread", | ||
| 174 | params: { ...thread.params, environmentId: "environment-b" }, | ||
| 175 | }); | ||
| 176 | expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key); | ||
| 177 | }); | ||
| 178 | |||
| 179 | it("drops stale detail history on a tab switch while retaining Home", () => { | ||
| 180 | const previous = legacyHistory(); | ||
| 181 | const state = select(previous); | ||
| 182 | expect(names(state)).toEqual(["Home"]); | ||
| 183 | expect(state.routes[0]?.key).toBe(previous.routes[0]?.key); | ||
| 184 | expect(state.index).toBe(0); | ||
| 185 | }); | ||
| 186 | }); | ||
apps/mobile/src/lib/android-workspace-navigation.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import type { NavigationState } from "@react-navigation/native"; | ||
| 2 | |||
| 3 | export type AndroidWorkspaceDestination = | ||
| 4 | | { readonly name: "Thread"; readonly params: ReactNavigation.RootParamList["Thread"] } | ||
| 5 | | { readonly name: "PullRequest"; readonly params: ReactNavigation.RootParamList["PullRequest"] }; | ||
| 6 | |||
| 7 | /** Peer details share one slot above Home; nested routes retain ordinary back navigation. */ | ||
| 8 | export function androidWorkspaceNavigationState( | ||
| 9 | state: NavigationState, | ||
| 10 | destination?: AndroidWorkspaceDestination, | ||
| 11 | reviewers?: ReactNavigation.RootParamList["PullRequestReviewers"], | ||
| 12 | ) { | ||
| 13 | const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" }; | ||
| 14 | if (!destination) return { ...state, index: 0, routes: [home] }; | ||
| 15 | const identity = | ||
| 16 | destination.name === "Thread" | ||
| 17 | ? ["environmentId", "threadId"] | ||
| 18 | : ["environmentId", "projectId", "repository", "number", "host"]; | ||
| 19 | const params = destination.params as Readonly<Record<string, unknown>>; | ||
| 20 | const existing = state.routes.findLast((route) => { | ||
| 21 | if (route.name !== destination.name) return false; | ||
| 22 | const current = route.params as Readonly<Record<string, unknown>> | undefined; | ||
| 23 | return identity.every((key) => current?.[key] === params[key]); | ||
| 24 | }); | ||
| 25 | return { | ||
| 26 | ...state, | ||
| 27 | index: reviewers ? 2 : 1, | ||
| 28 | routes: [ | ||
| 29 | home, | ||
| 30 | { ...existing, ...destination }, | ||
| 31 | ...(reviewers ? [{ name: "PullRequestReviewers", params: reviewers }] : []), | ||
| 32 | ], | ||
| 33 | }; | ||
| 34 | } | ||
apps/mobile/src/native/NativeHeaderToolbar.tsx+11-4| ... | @@ -49,10 +49,17 @@ type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { | ... | @@ -49,10 +49,17 @@ type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { |
| 49 | }; | 49 | }; |
| 50 | 50 | ||
| 51 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { | 51 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { |
| 52 | if (typeof icon !== "string") { | 52 | if (typeof icon === "string") return { type: "sfSymbol", name: icon as never }; |
| 53 | return undefined; | 53 | if ( |
| 54 | typeof icon === "object" && | ||
| 55 | icon !== null && | ||
| 56 | "type" in icon && | ||
| 57 | icon.type === "image" && | ||
| 58 | "source" in icon | ||
| 59 | ) { | ||
| 60 | return icon as NativeStackHeaderIcon; | ||
| 54 | } | 61 | } |
| 55 | return { type: "sfSymbol", name: icon as never }; | 62 | return undefined; |
| 56 | } | 63 | } |
| 57 | 64 | ||
| 58 | type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode }; | 65 | type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode }; |
| ... | @@ -275,7 +282,7 @@ function NativeHeaderToolbarButton(_props: { | ... | @@ -275,7 +282,7 @@ function NativeHeaderToolbarButton(_props: { |
| 275 | readonly accessibilityLabel?: string; | 282 | readonly accessibilityLabel?: string; |
| 276 | readonly axisBehavior?: NativeStackHeaderAxisBehavior; | 283 | readonly axisBehavior?: NativeStackHeaderAxisBehavior; |
| 277 | readonly disabled?: boolean; | 284 | readonly disabled?: boolean; |
| 278 | readonly icon?: string; | 285 | readonly icon?: string | Extract<NativeStackHeaderIcon, { type: "image" }>; |
| 279 | readonly label?: string; | 286 | readonly label?: string; |
| 280 | readonly onPress?: () => void; | 287 | readonly onPress?: () => void; |
| 281 | readonly separateBackground?: boolean; | 288 | readonly separateBackground?: boolean; |
apps/mobile/src/native/useOpenPullRequest.ts+10-1| ... | @@ -1,6 +1,9 @@ | ... | @@ -1,6 +1,9 @@ |
| 1 | import { useNavigation, useRoute, StackActions } from "@react-navigation/native"; | 1 | import { useNavigation, useRoute, StackActions } from "@react-navigation/native"; |
| 2 | import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; | 2 | import { nativePullRequestDestination } from "../lib/pullRequestNavigation"; |
| 3 | import { useCallback } from "react"; | 3 | import { useCallback } from "react"; |
| 4 | import { Platform } from "react-native"; | ||
| 5 | import { CommonActions } from "@react-navigation/native"; | ||
| 6 | import { androidWorkspaceNavigationState } from "../lib/android-workspace-navigation"; | ||
| 4 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | import { useThreadShell } from "../state/entities"; | 8 | import { useThreadShell } from "../state/entities"; |
| 6 | import { tryOpenExternalUrl } from "../lib/openExternalUrl"; | 9 | import { tryOpenExternalUrl } from "../lib/openExternalUrl"; |
| ... | @@ -42,7 +45,13 @@ export function useOpenPullRequest(options?: { | ... | @@ -42,7 +45,13 @@ export function useOpenPullRequest(options?: { |
| 42 | : null, | 45 | : null, |
| 43 | ); | 46 | ); |
| 44 | if (!params) return tryOpenExternalUrl(url, "pull-request"); | 47 | if (!params) return tryOpenExternalUrl(url, "pull-request"); |
| 45 | if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); | 48 | if (Platform.OS === "android") |
| 49 | navigation.dispatch((state) => | ||
| 50 | CommonActions.reset( | ||
| 51 | androidWorkspaceNavigationState(state, { name: "PullRequest", params }), | ||
| 52 | ), | ||
| 53 | ); | ||
| 54 | else if (replace) navigation.dispatch(StackActions.replace("PullRequest", params)); | ||
| 46 | else navigation.navigate("PullRequest", params); | 55 | else navigation.navigate("PullRequest", params); |
| 47 | return true; | 56 | return true; |
| 48 | }, | 57 | }, |