diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index d2d9cf5939aa..6f66eecd7ac6 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -1,5 +1,10 @@ name: Setup CI dependencies description: Install Vite+ and the workspace dependencies from cache. +inputs: + task-cache-read-token: + description: Read-only credential for the private maintainer task cache + required: false + default: "" runs: using: composite steps: @@ -65,3 +70,20 @@ runs: - name: Install dependencies shell: bash run: vp install + + - name: Enable shared task cache + if: >- + github.repository == 'pingdotgg/t3code' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + hashFiles('.task-cache.json') != '' && + inputs.task-cache-read-token != '' + shell: bash + env: + T3_TASK_CACHE_READ_TOKEN: ${{ inputs.task-cache-read-token }} + run: | + node infra/task-cache/scripts/install-ci.ts + if [[ ( "$GITHUB_EVENT_NAME" == pull_request && "$GITHUB_BASE_REF" == main ) || ( "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ) ]]; then + echo 'VP_REMOTE_CACHE=read-write' >> "$GITHUB_ENV" + else + echo 'VP_REMOTE_CACHE=read' >> "$GITHUB_ENV" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b229e7146510..5f6c75a0b77e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: # file, docs included. The `Check` job at the end of this file passes only # when every other job passes or was skipped by `changes`. lint: + permissions: + contents: read + id-token: write name: Lint runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -82,6 +85,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -108,6 +113,9 @@ jobs: run: vp check build: + permissions: + contents: read + id-token: write name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} @@ -139,6 +147,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -197,6 +207,9 @@ jobs: # dependency layers. The concurrency limit stays at the default 4 so peak load # per runner is unchanged. test: + permissions: + contents: read + id-token: write name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} @@ -237,6 +250,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -278,6 +293,9 @@ jobs: # started last and ran alone for about 40s after everything else finished. # release.yml mirrors this test, test_web, and test_server split. test_web: + permissions: + contents: read + id-token: write name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} @@ -295,6 +313,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # The browser project runs React tests in headless Chromium. Same cache key as # test_server, so both jobs share one downloaded browser. @@ -333,6 +353,9 @@ jobs: # The shards are the longest jobs, so they gate themselves instead of waiting # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: + permissions: + contents: read + id-token: write name: Test Server ${{ matrix.shard }} runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -359,6 +382,8 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.test_server != 'false' uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -495,6 +520,9 @@ jobs: run: vp run lint:mobile release_smoke: + permissions: + contents: read + id-token: write name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} @@ -512,6 +540,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2d8da3208f4..90dfdb56b062 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -245,6 +247,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -275,6 +279,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -309,6 +315,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Resolve Playwright version id: playwright_version @@ -359,6 +367,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` diff --git a/.task-cache.json b/.task-cache.json new file mode 100644 index 000000000000..0e62827f9d64 --- /dev/null +++ b/.task-cache.json @@ -0,0 +1,3 @@ +{ + "endpoint": "https://t3-task-cache.pinglabs.workers.dev/projects/t3code" +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 3f9b0a6f0da6..d78fa318051e 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -46,6 +46,10 @@ public final class T3NativeControlsModule: Module { Events("onMetricsChange") } + View(T3ScrollEdgeBleedView.self) { + ViewName("ScrollEdgeBleed") + } + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift new file mode 100644 index 000000000000..642a87f14e85 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -0,0 +1,147 @@ +import ExpoModulesCore +import UIKit + +/// Wraps a screen's header scroll view. Where a column pads its content away from the iPhone Duo's +/// vertical bar, this widens the scroll view under the bar. UIKit draws +/// the header's scroll-edge effect inside the scroll view, so the blur reaches the bar like in the +/// system apps while the content stays in the safe area. Everything happens in UIKit layout, so +/// folds move the blur and the content together. +final class T3ScrollEdgeBleedView: ExpoView { + // Fabric recycles scroll views, so everything changed here is put back before this view leaves + // the window, which unmounting does before any recycling. + private var scrollView: UIScrollView? + private var container: UIView? + private var bleed: CGFloat = 0 + /// Whether the scroll view currently carries this view's changes. + private var applied = false + /// React Native's clipping, saved while bleeding. React Native only rewrites it when the + /// `overflow` style changes, so it stays off while bleeding. + private var containerClipped: Bool? + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + #if canImport(UIKit, _version: 9127.0.85) + if #available(iOS 27.1, *) { + registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) { (view: T3ScrollEdgeBleedView, _: UITraitCollection) in + view.setNeedsLayout() + } + } + #endif + } + + deinit { + MainActor.assumeIsolated { detach() } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { detach() } else { setNeedsLayout() } + } + + override func didAddSubview(_ subview: UIView) { + super.didAddSubview(subview) + setNeedsLayout() + } + + override func willRemoveSubview(_ subview: UIView) { + if let scrollView, scrollView.isDescendant(of: subview) { detach() } + super.willRemoveSubview(subview) + } + + override func layoutSubviews() { + super.layoutSubviews() + guard window != nil, let found = findScrollView(), let parent = found.superview else { return } + if found !== scrollView { + detach() + scrollView = found + container = parent + } + bleed = verticalBarBleed() + apply() + } + + /// How far the vertical bar's safe area reaches past this view's right edge, when this view sits + /// flush against the padding its column added for it. Nothing else is bled into: not a sidebar + /// column overlapping this one, not a notch, and not a bar on the left, where widening would + /// move the content and need a horizontal offset React Native keeps rewriting. + private func verticalBarBleed() -> CGFloat { + guard verticalBarIsOnTheRight() else { return 0 } + var ancestor = superview + while let view = ancestor { + let inset = view.safeAreaInsets.right + if inset > 0 { + let frame = convert(bounds, to: view) + return abs(view.bounds.maxX - inset - frame.maxX) < 0.5 ? inset : 0 + } + ancestor = view.superview + } + return 0 + } + + private func verticalBarIsOnTheRight() -> Bool { + // The vertical-bar API ships in the iOS 27.1 SDK (UIKit 9127.0.85); see T3LayoutMetricsView. + #if canImport(UIKit, _version: 9127.0.85) + if #available(iOS 27.1, *) { + let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft + switch traitCollection.verticalBarEdge { + case .leading: return rtl + case .trailing: return !rtl + default: return false + } + } + #endif + return false + } + + /// Widening to the right leaves the content where React Native laid it out, so only the frame, + /// the container's clipping and the scroll indicator change; contentInset and contentOffset are + /// never touched. Nothing at all is written unless this view is bleeding or undoing its bleed. + private func apply() { + guard let scrollView, let container else { return } + guard bleed > 0 || applied else { return } + // React Native sizes the scroll view's component view; the UIScrollView inside follows it by + // autoresizing, so the widened frame survives React Native's own layout updates. + let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: -bleed)) + if scrollView.frame != frame { scrollView.frame = frame } + if bleed == 0 { + if let clipped = containerClipped { + containerClipped = nil + container.layer.masksToBounds = clipped + } + } else if container.layer.masksToBounds { + // The component view clips to its own bounds, which would cut the widened scroll view back. + // The scroll view itself still clips its content. + containerClipped = containerClipped ?? true + container.layer.masksToBounds = false + } + var indicators = scrollView.verticalScrollIndicatorInsets + if indicators.right != bleed { + indicators.right = bleed + scrollView.verticalScrollIndicatorInsets = indicators + } + applied = bleed > 0 + } + + private func detach() { + guard scrollView != nil else { return } + bleed = 0 + apply() + scrollView = nil + container = nil + } + + /// The scroll view sits a fixed few levels down (marker, React Native's scroll view component); + /// never walk into a scroll view's content. + private func findScrollView() -> UIScrollView? { + var level: [UIView] = subviews + for _ in 0..<4 { + var next: [UIView] = [] + for view in level { + if let scrollView = view as? UIScrollView { return scrollView } + next.append(contentsOf: view.subviews) + } + level = next + } + return nil + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index b7afac0b4b98..f96c00962682 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -92,8 +92,7 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { } configuration.cornerStyle = .capsule configuration.baseForegroundColor = .label - configuration.image = UIImage(systemName: "square.and.pencil") - configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) + configuration.image = tabIcon(UIImage(systemName: "square.and.pencil")) composeButton.configuration = configuration } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index a15c3ada2932..8426ddb565a4 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -33,6 +33,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private val onToggleComment by EventDispatcher() private var rows: List = emptyList() private var visibleRows: List = emptyList() + private var visibleRowIndices = IntArray(0) private var collapsedFileIds: Set = emptySet() private var viewedFileIds: Set = emptySet() private var selectedRowIds: Set = emptySet() @@ -75,8 +76,8 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont onDebug( mapOf( "message" to "visible-range", - "firstRowIndex" to first, - "lastRowIndex" to last, + "firstRowIndex" to visibleRowIndices[first], + "lastRowIndex" to visibleRowIndices[last], "totalRows" to rows.size, ), ) @@ -105,6 +106,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont horizontalScroller.forceFinished(true) rows = emptyList() visibleRows = emptyList() + visibleRowIndices = IntArray(0) canvasView.rows = emptyList() canvasView.tokensByRowId = emptyMap() lastVisibleFileId = null @@ -393,16 +395,20 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private fun rebuildVisibleRows() { canvasView.setViewportEventsPaused(true) val filtered = ArrayList(rows.size) + val indices = IntArray(rows.size) var currentFileCollapsed = false - rows.forEach { row -> + rows.forEachIndexed { index, row -> if (row.kind == "file") { currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId) - filtered.add(row) - } else if (!currentFileCollapsed) { + } + if (row.kind == "file" || !currentFileCollapsed) { + indices[filtered.size] = index filtered.add(row) } } visibleRows = filtered + // JS selects highlights from the full payload, including collapsed rows. + visibleRowIndices = indices.copyOf(filtered.size) canvasView.rows = filtered canvasView.viewedFileIds = viewedFileIds canvasView.selectedRowIds = selectedRowIds diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt index 28935a11db8d..10d9a45107c7 100644 --- a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffViewportTest.kt @@ -117,6 +117,55 @@ class ReviewDiffViewportTest { } } + @Test + fun collapsedFilesKeepViewportIndicesInTheFullPayload() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val appContext = AppContext() + val view = T3ReviewDiffView(activity, appContext) + val decoder = ManualDecoder() + view.javaClass.getDeclaredField("payloadDecodeExecutor").apply { isAccessible = true } + .set(view, decoder) + activity.setContentView(view) + val canvas = view.getChildAt(0) + val measure = View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY) + canvas.measure(measure, measure) + canvas.layout(0, 0, 320, 320) + try { + view.setRowsJson(rows("expanded-file", 100001, 100226)) + decoder.completeNext() + assertEquals(0, ranges(appContext).last()["firstRowIndex"]) + assertTrue((ranges(appContext).last()["lastRowIndex"] as Int) < 100001) + appContext.events.clear() + + view.setCollapsedFileIdsJson("[\"first-file\"]") + val collapsedRange = ranges(appContext).single() + assertEquals("the collapsed file header stays visible", 0, collapsedRange["firstRowIndex"]) + assertTrue( + "the following expanded file must be addressed in the full payload", + (collapsedRange["lastRowIndex"] as Int) > 100001 + ) + assertEquals(100226, collapsedRange["totalRows"]) + appContext.events.clear() + + view.setInitialFileId("expanded-file") + assertEquals(100001, ranges(appContext).last()["firstRowIndex"]) + assertEquals( + "expanded-file", + appContext.events.last { it.first == "onVisibleFileChange" }.second["fileId"] + ) + appContext.events.clear() + + view.setCollapsedFileIdsJson("[]") + view.setInitialFileId("first-file") + assertEquals(0, ranges(appContext).last()["firstRowIndex"]) + assertTrue((ranges(appContext).last()["lastRowIndex"] as Int) < 100001) + } finally { + view.cleanup() + activity.finish() + appContext.events.clear() + } + } + private fun rowTop(canvas: View, index: Int): Int = canvas.javaClass .getDeclaredMethod("rowTop", Int::class.javaPrimitiveType).apply { isAccessible = true } .invoke(canvas, index) as Int @@ -125,13 +174,17 @@ class ReviewDiffViewportTest { .filter { (name, payload) -> name == "onDebug" && payload["message"] == "visible-range" } .map { it.second } - private fun rows(selectedFileId: String): String = JSONArray().apply { - for (index in 0 until 1000) { + private fun rows( + selectedFileId: String, + selectedStart: Int = 900, + count: Int = 1000 + ): String = JSONArray().apply { + for (index in 0 until count) { put( JSONObject().apply { put("id", "$selectedFileId:$index") - put("kind", if (index == 0 || index == 900) "file" else "line") - put("fileId", if (index < 900) "first-file" else selectedFileId) + put("kind", if (index == 0 || index == selectedStart) "file" else "line") + put("fileId", if (index < selectedStart) "first-file" else selectedFileId) put("content", "const value = $index") } ) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 1d5b2cdd570e..8b444aa08f4f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -6,7 +6,12 @@ import { StatusBar } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; -import { createStaticNavigation, getStateFromPath } from "@react-navigation/native"; +import { + createNavigationContainerRef, + createStaticNavigation, + getActionFromState, + getStateFromPath, +} from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -38,6 +43,7 @@ import { recordWorkspaceSelections, settingsLinkPage, } from "./features/workspace/workspace-selection-store"; +import { workspaceDetailLinkAction } from "./features/workspace/workspace-detail-selection"; import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; import "../global.css"; @@ -54,6 +60,13 @@ const appLinking = { config: { initialRouteName: "Home" }, filter: shouldHandleAppLink, getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()), + getActionFromState: ( + state: Parameters[0], + options: Parameters[1], + ): ReturnType => + // Our router handles the selection; React Navigation's type only lists its own actions. + (workspaceDetailLinkAction(state) as ReturnType) ?? + getActionFromState(state, options), getStateFromPath: (path: string, options: Parameters[1]) => restoreSettingsLinkState( restoreDraftLinkState(getStateFromPath(path, options), path), @@ -62,6 +75,7 @@ const appLinking = { }; const Navigation = createStaticNavigation(RootStack); +const navigationRef = createNavigationContainerRef(); function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); @@ -116,7 +130,9 @@ function AppContent() { recordWorkspaceSelections(navigationRef.getRootState())} theme={navigationTheme} onStateChange={recordWorkspaceSelections} /> diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index fad15278907a..8a07f8b7a086 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -149,6 +149,10 @@ import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; +import { + selectWorkspaceDetail, + workspaceDetailRouter, +} from "./features/workspace/workspace-detail-selection"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -525,7 +529,12 @@ function RootStackLayout(props: { if (!transition.shareIdToPresent) { return; } - navigation.navigate("Thread", { draft: { incomingShareId: transition.shareIdToPresent } }); + navigation.dispatch( + selectWorkspaceDetail({ + name: "Thread", + params: { draft: { incomingShareId: transition.shareIdToPresent } }, + }), + ); }, [navigation, pendingShare, props.state]); return ( @@ -599,6 +608,7 @@ function NotFoundScreen() { const RootStackConfig = createWorkspaceStackNavigator({ initialRouteName: "Home", + UNSTABLE_router: workspaceDetailRouter, // React Navigation calls layouts inline; compiled components would share the navigator's memo cache. layout: (props) => , screenOptions: { diff --git a/apps/mobile/src/components/ui/ScreenScrollView.tsx b/apps/mobile/src/components/ui/ScreenScrollView.tsx index 684d2e3bdf51..5e48866c1633 100644 --- a/apps/mobile/src/components/ui/ScreenScrollView.tsx +++ b/apps/mobile/src/components/ui/ScreenScrollView.tsx @@ -1,14 +1,22 @@ import { createContext, use, type ComponentProps } from "react"; import { Platform } from "react-native"; +import { cn } from "../../lib/cn"; import { NativeScrollView as ScrollView } from "../../native/NativeScrollViewMarker"; export const ScreenScrollViewBottomInset = createContext(0); +/** + * The screen's surface class. UIKit's scroll-edge material under the header takes the scroll + * view's own color; a clear scroll view gets the system's near-white bar over the screen's surface. + */ +export const ScreenScrollViewSurface = createContext(undefined); export function ScreenScrollView(props: ComponentProps) { const bottomInset = use(ScreenScrollViewBottomInset); + const surface = use(ScreenScrollViewSurface); return ( 0 ? { ...props.contentInset, bottom: (props.contentInset?.bottom ?? 0) + bottomInset } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 64443efaa838..90f722e53669 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { Button } from "../../components/ui/Button"; import type { EnvironmentProject, @@ -428,6 +429,7 @@ export function ArchivedThreadsScreen(props: { { - if (pulse) { - pulseProgress.value = withRepeat( - withTiming(1, { - duration: 1100, - easing: Easing.out(Easing.cubic), - }), - -1, - false, - ); - return; - } - - cancelAnimation(pulseProgress); - pulseProgress.value = withTiming(0, { - duration: 180, - easing: Easing.out(Easing.quad), - }); - }, [pulse, pulseProgress]); - - return pulseProgress; -} - export function ConnectionStatusDot(props: { readonly state: ConnectionStatusDotState; readonly pulse: boolean; readonly size?: number; }) { - const pulseProgress = usePulseAnimation(props.pulse); const { themeAppearance, themeVariables } = useAppearancePreferences(); const tone = statusDotTone(props.state, themeVariables, themeAppearance === "dark"); const dotSize = props.size ?? 10; const haloSize = dotSize + 4; const containerSize = haloSize + 4; - const haloStyle = useAnimatedStyle(() => ({ - opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0, - transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }], - })); - return ( - + opacity: 0.3, + }} + /> + ) : null} { expect([...new Set(loadedFiles)].sort()).toEqual(["file-7", "file-8"]); }); + it("skips collapsed code before the row cap and restores it on expansion", async () => { + const collapsedSource = Array.from( + { length: 100_000 }, + (_, index) => `int hidden${index} = 1;`, + ); + const expandedSource = [ + "/* open", + ...Array.from({ length: 222 }, () => "still a comment"), + "*/ int visible = 1;", + ]; + const collapsedLoad = vi.fn(async () => ({ + oldContents: "", + newContents: collapsedSource.join("\n"), + })); + const expandedLoad = vi.fn(async () => ({ + oldContents: "", + newContents: expandedSource.join("\n"), + })); + const files = [ + { + ...TYPESCRIPT_FILE, + id: "collapsed", + language: "c", + path: "highlights.c", + loadContents: collapsedLoad, + }, + { + ...TYPESCRIPT_FILE, + id: "expanded", + language: "c", + path: "highlights.h", + loadContents: expandedLoad, + }, + ]; + const rows = [collapsedSource, expandedSource].flatMap((source, fileIndex) => { + const fileId = files[fileIndex]!.id; + return [ + { kind: "file", id: `${fileId}-header`, fileId }, + ...source.map((content, index) => ({ + ...makeLine({ + id: `${fileId}-${index}`, + content, + change: "add", + newLineNumber: index + 1, + }), + fileId, + })), + ] satisfies NativeReviewDiffRow[]; + }); + const input = { + rows, + files, + scheme: "dark" as const, + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + }; + // A collapsed header and the next expanded header can share an iOS viewport, + // with 100,000 zero-height code rows between their canonical indices. + const viewport = await highlightNativeReviewDiffVisibleRows({ + ...input, + collapsedFileIds: new Set(["collapsed"]), + overscanRows: 0, + }); + expect(Object.keys(viewport.tokensByRowId)).toEqual( + expandedSource.map((_, index) => `expanded-${index}`), + ); + const overscan = await highlightNativeReviewDiffVisibleRows({ + ...input, + collapsedFileIds: new Set(["collapsed"]), + lastRowIndex: 100_002, + alreadyHighlightedRowIds: new Set(["expanded-0"]), + }); + expect(Object.keys(overscan.tokensByRowId)).toEqual( + Array.from({ length: 160 }, (_, index) => `expanded-${index + 1}`), + ); + expect(overscan.tokensByRowId["expanded-1"]).toEqual( + highlightCode(expandedSource.join("\n"), "c", "dark")[1], + ); + expect(collapsedLoad).not.toHaveBeenCalled(); + expect(expandedLoad).toHaveBeenCalled(); + + const expanded = await highlightNativeReviewDiffVisibleRows({ + ...input, + lastRowIndex: 400, + collapsedFileIds: new Set(), + overscanRows: 0, + }); + expect(expanded.rowCount).toBe(360); + expect(expanded.tokensByRowId["collapsed-0"]).toBeDefined(); + expect(collapsedLoad).toHaveBeenCalledTimes(1); + }); + it("delivers the dominant viewport file before fetching a smaller blocked neighbor", async () => { const neighborStarted = Promise.withResolvers(); const neighborResponse = Promise.withResolvers<{ oldContents: string; newContents: string }>(); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index a4084d2ccaa1..f25d058995a4 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -18,6 +18,7 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly overscanRows?: number; readonly maxRows?: number; readonly alreadyHighlightedRowIds?: ReadonlySet; + readonly collapsedFileIds?: ReadonlySet; readonly signal?: AbortSignal; readonly onChunk?: (chunk: NativeReviewDiffHighlightChunk) => void; } @@ -290,6 +291,7 @@ export async function highlightNativeReviewDiffVisibleRows( row && isHighlightableLineRow(row) && visibleFileIds.has(row.fileId) && + !input.collapsedFileIds?.has(row.fileId) && !input.alreadyHighlightedRowIds?.has(row.id) && fileMap.has(row.fileId) ) { diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts index 3dcc3c7597f0..6c2cb8f0d3ad 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts @@ -1,6 +1,7 @@ import { expect, it, vi } from "vite-plus/test"; import { createNativeReviewDiffSourceLoader } from "./nativeReviewDiffSource"; +import * as DiffHighlightTrace from "./nativeReviewDiffTrace"; import type { NativeReviewDiffSource } from "./nativeReviewDiffTypes"; const SOURCE = { oldContents: "const before = true;", newContents: "const after = false;" }; @@ -57,17 +58,24 @@ const failureKeys = [ ["function", () => () => SOURCE], ] as const; -it.each(failureKeys)("bounds transient retries for a %s key", async (_name, createKey) => { - const key = createKey(); - const fetch = vi.fn(async () => { - throw new Error("Offline"); - }); - const load = createNativeReviewDiffSourceLoader(fetch); - await expect(load(key)).rejects.toThrow("Offline"); - expect(await load(key)).toBeNull(); - expect(await load(key)).toBeNull(); - expect(fetch).toHaveBeenCalledTimes(2); -}); +it.each(failureKeys)( + "recovers after two transient failures on a later load for a %s key", + async (_name, createKey) => { + const key = createKey(); + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error("Offline")) + .mockRejectedValueOnce(new Error("Still offline")) + .mockResolvedValue(SOURCE); + const load = createNativeReviewDiffSourceLoader(fetch); + await expect(load(key)).rejects.toThrow("Offline"); + await expect(load(key)).rejects.toThrow("Still offline"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await load(key)).toBe(SOURCE); + expect(await load(key)).toBe(SOURCE); + expect(fetch).toHaveBeenCalledTimes(3); + }, +); it.each(failureKeys)( "remembers permanent unavailability for a %s key", @@ -149,3 +157,40 @@ it("serializes RPCs and drops cancelled queued files", async () => { await load("next.ts"); expect(fetch.mock.calls).toEqual([["active.ts"], ["next.ts"]]); }); + +it("starts a shared fresh request for a file whose cancelled request was dropped", async () => { + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + const dropped = Promise.withResolvers(); + const replacementStarted = Promise.withResolvers(); + const replacementResponse = Promise.withResolvers(); + using _trace = vi.spyOn(DiffHighlightTrace, "traceDiffHighlight").mockImplementation((event) => { + if (event === "source-dropped") dropped.resolve(); + }); + const fetch = vi.fn((file: string) => { + if (file === "active.ts") { + started.resolve(); + return response.promise; + } + replacementStarted.resolve(); + return replacementResponse.promise; + }); + const load = createNativeReviewDiffSourceLoader(fetch); + const active = load("active.ts"); + await started.promise; + const controller = new AbortController(); + const queued = load("cancelled.ts", controller.signal); + controller.abort(); + expect(await queued).toBeNull(); + response.resolve(SOURCE); + await dropped.promise; + const fresh = load("cancelled.ts"); + // Settling first means the fresh caller joined the dropped request instead of fetching. + await Promise.race([replacementStarted.promise, fresh]); + const shared = load("cancelled.ts"); + replacementResponse.resolve(SOURCE); + expect(await fresh).toBe(SOURCE); + expect(await shared).toBe(SOURCE); + expect(await active).toBe(SOURCE); + expect(fetch.mock.calls).toEqual([["active.ts"], ["cancelled.ts"]]); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts index 52fae25d35b9..7f503239c21d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.ts @@ -5,7 +5,6 @@ const MAX_SOURCE_FILES = 4; // Bound retained UTF-16 text across files without discarding an already-fetched // authoritative source just because it is large. Provider response limits still apply. const MAX_CACHED_SOURCE_CHARACTERS = 8_000_000; -const MAX_SOURCE_ATTEMPTS = 2; function isWeakKey(value: unknown): value is object { return (typeof value === "object" && value !== null) || typeof value === "function"; @@ -114,6 +113,8 @@ export function createNativeReviewDiffSourceLoader( const promise = tail .then(async () => { if ([...subscribers].every((subscriber) => subscriber?.aborted)) { + // A cancelled request must stop being shareable before its promise settles. + pending.delete(fileId); traceDiffHighlight("source-dropped", { loader, file }); return null; } @@ -156,12 +157,12 @@ export function createNativeReviewDiffSourceLoader( permanent, reason: reason ?? "transient", }); - if (permanent || attempt >= MAX_SOURCE_ATTEMPTS) { + if (permanent) { failure.unavailable = true; traceDiffHighlight("source-unavailable", { loader, file, - reason: permanent ? "permanent-error" : "attempts", + reason: "permanent-error", }); return null; } @@ -169,7 +170,9 @@ export function createNativeReviewDiffSourceLoader( throw error; } }) - .finally(() => pending.delete(fileId)); + .finally(() => { + if (pending.get(fileId) === request) pending.delete(fileId); + }); tail = promise.then( () => {}, () => {}, diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx index 52cc18053427..81d43353d4c1 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.tsx @@ -192,13 +192,14 @@ function setup(integrated = false) { rowIdByCommentLineId: new Map(), }; const ids: string[] = []; + let collapsedFileIds: ReadonlyArray = ids; function PullRequestDiff({ targetFileId }: { targetFileId?: string }) { const bridge = useNativeReviewDiffBridge({ threadKey: null, sectionId: "pull-request:test:1:all", diff: "test diff", data, - collapsedFileIds: ids, + collapsedFileIds, viewedFileIds: ids, selectedRowIds: ids, canHighlight: true, @@ -247,6 +248,10 @@ function setup(integrated = false) { async mountPullRequest(targetFileId?: string) { await act(async () => root.render()); }, + async collapseFiles(fileIds: ReadonlyArray) { + collapsedFileIds = fileIds; + await act(async () => root.render()); + }, async updateComment() { data = { ...data, @@ -403,3 +408,35 @@ it("recognizes registration races for the installed native view name", async () ), ).toBe(true); }); + +it("cancels stale collapse work and highlights expansion without resetting accepted tokens", async () => { + using screen = setup(true); + await screen.mountPullRequest(); + await screen.jump(0); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0"); + + const response = Promise.withResolvers(); + const highlight = mocks.highlight.getMockImplementation()!; + mocks.highlight.mockImplementation(async (input: HighlightNativeReviewDiffVisibleRowsInput) => { + if (input.firstRowIndex === 400) { + await response.promise; + if (input.collapsedFileIds?.has("last-file")) { + return { tokensByRowId: {}, rowCount: 0, retryableRowIds: [], durationMs: 0 }; + } + } + return highlight(input); + }); + await screen.jump(400); + await screen.collapseFiles(["last-file"]); + await act(async () => response.resolve()); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0"); + + await screen.collapseFiles([]); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0,r400"); + await screen.jump(0); + await screen.deliverPayloads(); + expect(screen.tokenRows).toBe("r0,r400"); +}); diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index fc2620dbed68..bc38fde79466 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { Button } from "../../components/ui/Button"; import { LegendList } from "@legendapp/list/react-native"; import type { ProjectEntry } from "@t3tools/contracts"; @@ -221,6 +222,7 @@ export function FileTreeBrowser(props: { // Keep the list as direct screen content so UIKit can apply header insets. return ( + selectDetail({ + name: "Thread", + params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, + }), + [selectDetail], + ); + const selectThreadRoute = useCallback( + (params: ReactNavigation.RootParamList["Thread"]) => selectDetail({ name: "Thread", params }), + [selectDetail], + ); const handleNewThreadOnBranch = useCallback( (thread: EnvironmentThreadShell) => { selectThreadRoute({ @@ -104,7 +115,8 @@ export function HomeRouteScreen() { unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); - const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); + const { openPendingTask, confirmDeletePendingTask } = + usePendingTaskListActions(selectThreadRoute); const environments = useMemo(() => { const connectionStateByEnvironmentId = new Map( workspaceEnvironments.map( diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e5d62df7c9e8..7adf6f0e8a5e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -27,7 +27,7 @@ import { type NativeScrollEvent, type NativeSyntheticEvent, } from "react-native"; -import { ScrollViewMarker } from "react-native-screens"; +import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { GestureDetector, useNativeGesture } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; diff --git a/apps/mobile/src/features/home/home-thread-navigation.test.ts b/apps/mobile/src/features/home/home-thread-navigation.test.ts deleted file mode 100644 index d02d602767af..000000000000 --- a/apps/mobile/src/features/home/home-thread-navigation.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import * as NodeModule from "node:module"; -import type { - StackNavigationState, - StackRouter as StackRouterType, - StackActions as StackActionsType, -} from "@react-navigation/native"; -import { describe, expect, it, vi } from "vite-plus/test"; - -function loadRouters() { - const require = NodeModule.createRequire(import.meta.url); - const nativePackage = require.resolve("@react-navigation/native/package.json"); - const requireFromNative = NodeModule.createRequire(nativePackage); - const corePackage = requireFromNative.resolve("@react-navigation/core/package.json"); - const requireFromCore = NodeModule.createRequire(corePackage); - return requireFromCore("@react-navigation/routers") as { - readonly CommonActions: typeof import("@react-navigation/native").CommonActions; - readonly StackActions: typeof StackActionsType; - readonly StackRouter: typeof StackRouterType; - }; -} - -vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Keyboard: { dismiss: vi.fn() } })); - -vi.mock("../workspace/useAndroidWorkspaceSelection", () => ({ - useAndroidWorkspaceSelection: vi.fn(), -})); - -vi.mock("@react-navigation/native", () => { - const { CommonActions, StackActions } = loadRouters(); - return { CommonActions, StackActions }; -}); - -import { - createHomeThreadRouteAction, - createHomeThreadNavigationAction, -} from "./home-thread-navigation"; - -const { StackActions, StackRouter } = loadRouters(); -const routeNames = ["Home", "Thread"]; -const routeParamList = { - Home: undefined, - Thread: undefined, -}; -const router = StackRouter({}); -const routerOptions = { - routeNames, - routeParamList, - routeGetIdList: {}, -}; - -type ThreadSelection = Parameters[0]["thread"]; - -function thread(id: string): ThreadSelection { - return { - environmentId: "environment-1", - id, - } as ThreadSelection; -} - -function initialState() { - return router.getInitialState(routerOptions); -} - -function apply( - state: StackNavigationState>, - action: Parameters[1], -) { - const nextState = router.getStateForAction(state, action, routerOptions); - expect(nextState).not.toBeNull(); - return nextState as StackNavigationState>; -} - -function selectThread( - state: ReturnType, - selectedThread: ThreadSelection, - dismissingRouteKey: string | null = null, -) { - return apply( - state, - createHomeThreadNavigationAction({ - state, - dismissingRouteKey, - thread: selectedThread, - }), - ); -} - -function dismissRoute(state: ReturnType, routeKey: string) { - return apply(state, { - ...StackActions.pop(), - source: routeKey, - target: state.key, - }); -} - -describe("createHomeThreadNavigationAction", () => { - it("coalesces ordinary repeat selections onto the current thread route", () => { - const firstState = selectThread(initialState(), thread("thread-a")); - const threadRouteKey = firstState.routes[firstState.index]?.key; - const secondAction = createHomeThreadNavigationAction({ - state: firstState, - dismissingRouteKey: null, - thread: thread("thread-b"), - }); - - const secondState = apply(firstState, secondAction); - expect(secondState.routes).toHaveLength(2); - expect(secondState.routes[secondState.index]).toMatchObject({ - key: threadRouteKey, - name: "Thread", - params: { environmentId: "environment-1", threadId: "thread-b" }, - }); - }); - - it("keeps an overlap selection after native dismisses the outgoing route", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const overlapAction = createHomeThreadNavigationAction({ - state: outgoingState, - dismissingRouteKey: outgoingRouteKey ?? null, - thread: thread("thread-b"), - }); - const overlapState = apply(outgoingState, overlapAction); - const incomingRoute = overlapState.routes[overlapState.index]; - expect(incomingRoute?.key).not.toBe(outgoingRouteKey); - - const dismissedState = dismissRoute(overlapState, outgoingRouteKey!); - expect(dismissedState.routes).toHaveLength(2); - expect(dismissedState.routes[dismissedState.index]).toMatchObject({ - key: incomingRoute?.key, - name: "Thread", - params: { environmentId: "environment-1", threadId: "thread-b" }, - }); - }); - - it("uses a fresh key for the same thread selected during dismissal", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const overlapState = selectThread(outgoingState, thread("thread-a"), outgoingRouteKey ?? null); - const incomingRouteKey = overlapState.routes[overlapState.index]?.key; - expect(incomingRouteKey).not.toBe(outgoingRouteKey); - - const dismissedState = dismissRoute(overlapState, outgoingRouteKey!); - expect(dismissedState.routes[dismissedState.index]).toMatchObject({ - key: incomingRouteKey, - params: { environmentId: "environment-1", threadId: "thread-a" }, - }); - }); - - it("coalesces a second overlap selection onto the fresh incoming route", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const firstOverlapState = selectThread( - outgoingState, - thread("thread-b"), - outgoingRouteKey ?? null, - ); - const incomingRouteKey = firstOverlapState.routes[firstOverlapState.index]?.key; - const secondOverlapAction = createHomeThreadNavigationAction({ - state: firstOverlapState, - dismissingRouteKey: outgoingRouteKey ?? null, - thread: thread("thread-c"), - }); - - const secondOverlapState = apply(firstOverlapState, secondOverlapAction); - expect(secondOverlapState.routes).toHaveLength(3); - expect(secondOverlapState.routes[secondOverlapState.index]).toMatchObject({ - key: incomingRouteKey, - params: { environmentId: "environment-1", threadId: "thread-c" }, - }); - - const dismissedState = dismissRoute(secondOverlapState, outgoingRouteKey!); - expect(dismissedState.routes).toHaveLength(2); - expect(dismissedState.routes[dismissedState.index]).toMatchObject({ - key: incomingRouteKey, - params: { environmentId: "environment-1", threadId: "thread-c" }, - }); - }); - - it("uses ordinary navigation when native pops before the selection", () => { - const outgoingState = selectThread(initialState(), thread("thread-a")); - const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; - expect(outgoingRouteKey).toBeDefined(); - - const poppedState = dismissRoute(outgoingState, outgoingRouteKey!); - const action = createHomeThreadNavigationAction({ - state: poppedState, - dismissingRouteKey: outgoingRouteKey ?? null, - thread: thread("thread-b"), - }); - - const selectedState = apply(poppedState, action); - expect(selectedState.routes).toHaveLength(2); - expect(selectedState.routes[selectedState.index]).toMatchObject({ - name: "Thread", - params: { environmentId: "environment-1", threadId: "thread-b" }, - }); - }); -}); - -describe("draft and live thread selection", () => { - it("keeps the screen key and clears old identity when switching both ways", () => { - const live = selectThread(initialState(), thread("thread-a")); - const key = live.routes[live.index]!.key; - const draft = apply( - live, - createHomeThreadRouteAction({ - state: live, - dismissingRouteKey: null, - params: { draft: { draftId: "new-task:draft-a" } }, - }), - ); - expect(draft.routes[draft.index]).toMatchObject({ - key, - params: { - environmentId: undefined, - threadId: undefined, - draft: { draftId: "new-task:draft-a" }, - }, - }); - const restored = selectThread(draft, thread("thread-b")); - expect(restored.routes[restored.index]).toMatchObject({ - key, - params: { - environmentId: "environment-1", - threadId: "thread-b", - draft: undefined, - }, - }); - expect(restored.routes).toHaveLength(live.routes.length); - }); -}); diff --git a/apps/mobile/src/features/home/home-thread-navigation.ts b/apps/mobile/src/features/home/home-thread-navigation.ts deleted file mode 100644 index 3c1b18bf6101..000000000000 --- a/apps/mobile/src/features/home/home-thread-navigation.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { - CommonActions, - StackActions, - useNavigation, - type NavigationState, -} from "@react-navigation/native"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useCallback, useEffect, useRef } from "react"; -import { Platform } from "react-native"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; - -type ThreadSelection = Pick; - -export function createHomeThreadRouteAction(input: { - readonly state: Pick; - readonly dismissingRouteKey: string | null; - readonly params: ReactNavigation.RootParamList["Thread"]; -}) { - const currentRoute = input.state.routes[input.state.index]; - if (input.dismissingRouteKey !== null && currentRoute?.key === input.dismissingRouteKey) { - return StackActions.push("Thread", input.params); - } - if (currentRoute?.name === "Thread") { - return { - ...CommonActions.setParams({ - environmentId: undefined, - threadId: undefined, - draft: undefined, - ...input.params, - }), - source: currentRoute.key, - }; - } - return CommonActions.navigate("Thread", input.params); -} - -export function createHomeThreadNavigationAction(input: { - readonly state: Pick; - readonly dismissingRouteKey: string | null; - readonly thread: ThreadSelection; -}) { - return createHomeThreadRouteAction({ - ...input, - params: { - environmentId: input.thread.environmentId, - threadId: input.thread.id, - }, - }); -} - -export function useHomeThreadRouteSelection() { - const navigation = - useNavigation>(); - const dismissingRouteKey = useRef(null); - const { layout, setPrimarySidebarVisible } = useAdaptiveWorkspaceLayout(); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); - - useEffect(() => { - const clear = () => { - dismissingRouteKey.current = null; - }; - // For Home, swipe-back is opening; Thread's closing event targets the outgoing route. - const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { - const state = navigation.getState(); - const currentRoute = state.routes[state.index]; - dismissingRouteKey.current = - !data.closing && currentRoute?.name === "Thread" ? currentRoute.key : null; - }); - const removeFocus = navigation.addListener("focus", clear); - - return () => { - clear(); - removeTransitionStart(); - removeFocus(); - }; - }, [navigation]); - - return useCallback( - (params: ReactNavigation.RootParamList["Thread"]) => { - if (Platform.OS === "android") { - selectAndroidWorkspace({ - name: "Thread", - params, - }); - return; - } - if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); - navigation.dispatch((state) => - createHomeThreadRouteAction({ - state, - dismissingRouteKey: dismissingRouteKey.current, - params, - }), - ); - }, - [ - layout.primarySidebarOverlaysContent, - navigation, - selectAndroidWorkspace, - setPrimarySidebarVisible, - ], - ); -} - -export function useHomeThreadSelection() { - const selectRoute = useHomeThreadRouteSelection(); - return useCallback( - (thread: ThreadSelection) => - selectRoute({ environmentId: thread.environmentId, threadId: thread.id }), - [selectRoute], - ); -} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 493a5848fe59..e75b29a8805a 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -121,6 +121,9 @@ function resolveSecondaryAction(input: { /** Keep the scroll gate in context so scroll start cannot re-render every row and its subscriptions. */ const SwipeableScrollGateContext = createContext(true); +// Swipeable rebuilds its native pan gesture whenever its config changes identity, so the props +// that feed it stay stable across row re-renders such as selection. +const FAIL_OFFSET_Y: [number, number] = [-10, 10]; export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; @@ -402,11 +405,77 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { }, [content], ); - const handleWillOpen = () => { - if (!props.dormant && !dismissalRef.current && swipeableRef.current) { - props.onSwipeableWillOpen?.(swipeableRef.current); + const { dormant, onSwipeableWillOpen, backgroundColor, containerStyle } = props; + const handleWillOpen = useCallback(() => { + if (!dormant && !dismissalRef.current && swipeableRef.current) { + onSwipeableWillOpen?.(swipeableRef.current); } + }, [dormant, onSwipeableWillOpen]); + const handleSwipeableClose = useCallback(() => { + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + }, [onSwipeableClose]); + const compactActions = props.compactActions === true; + const { secondaryAction, threadTitle } = props; + // Swipeable calls this on every render; returning the previous element while nothing it shows + // changed keeps a selection change from re-rendering the hidden actions. + const rightActions = useRef<{ deps: readonly unknown[]; element: ReactNode } | null>(null); + const renderRightActions = ( + _progress: SharedValue, + translation: SharedValue, + methods: SwipeableMethods, + ) => { + const deps = [ + dormant, + translation, + methods, + actionStyle, + backgroundColor, + compactActions, + fullSwipeAction, + fullSwipeThreshold, + handleFullSwipeArmedChange, + primaryAction, + commitPrimaryAction, + onDelete, + secondaryAction, + threadTitle, + ]; + const cached = rightActions.current; + if (cached && deps.every((dep, index) => Object.is(dep, cached.deps[index]))) { + return cached.element; + } + const element = dormant ? null : ( + { + activeTranslationRef.current = translation; + }} + style={actionStyle} + > + methods.close(), + onDelete, + secondaryAction, + threadTitle, + })} + translation={translation} + /> + + ); + rightActions.current = { deps, element }; + return element; }; + const childrenContainerStyle = useMemo(() => ({ backgroundColor }), [backgroundColor]); + const swipeableContainerStyle = useMemo( + () => [{ backgroundColor }, containerStyle], + [backgroundColor, containerStyle], + ); return ( @@ -419,53 +488,21 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { { - if (swipeableRef.current) { - props.onSwipeableClose?.(swipeableRef.current); - } - }} + onSwipeableClose={handleSwipeableClose} onSwipeableRelease={handleRelease} onSwipeableOpenStartDrag={handleWillOpen} onSwipeableWillOpen={handleWillOpen} overshootFriction={1} overshootRight - renderRightActions={(_progress, translation, methods) => - props.dormant ? null : ( - { - activeTranslationRef.current = translation; - }} - style={actionStyle} - > - methods.close(), - onDelete: props.onDelete, - secondaryAction: props.secondaryAction, - threadTitle: props.threadTitle, - })} - translation={translation} - /> - - ) - } + renderRightActions={renderRightActions} rightThreshold={actionsWidth * 0.42} simultaneousWith={props.simultaneousWith} > diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index f9c9d47f5264..698df0bfce70 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -1,5 +1,4 @@ import * as Dialog from "../../components/ui/Dialog"; -import { useHomeThreadRouteSelection } from "./home-thread-navigation"; import { useCallback } from "react"; import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; @@ -14,14 +13,11 @@ import { import { withThreadDismissal } from "./thread-dismissal"; export function usePendingTaskListActions( - selectRoute?: (params: ReactNavigation.RootParamList["Thread"]) => void, + selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void, ): { readonly openPendingTask: (pendingTask: PendingNewTask) => void; readonly confirmDeletePendingTask: (pendingTask: PendingNewTask) => void; } { - const selectHomeRoute = useHomeThreadRouteSelection(); - const selectThreadRoute = selectRoute ?? selectHomeRoute; - const openPendingTask = useCallback( (pendingTask: PendingNewTask) => { selectThreadRoute( diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 425fab9c56c5..c5c3608f6c75 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -48,7 +48,6 @@ import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; import { @@ -62,6 +61,7 @@ import { threadJumpIndex } from "./threadKeyboardShortcuts"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; const PALETTE_COMMANDS: ReadonlyArray = [ "commandPalette", @@ -212,7 +212,7 @@ export function CommandPalette(props: { const navigation = useNavigation(); const selectTab = useSelectWorkspaceTab(); const { themeVariables } = useAppearancePreferences(); - const { selectThread, selectThreadRoute } = useAdaptiveWorkspaceLayout(); + const selectDetail = useSelectWorkspaceDetail(); const runCommand = props.onCommand; const projects = useProjects(); const threads = useThreadShells(); @@ -323,12 +323,15 @@ export function CommandPalette(props: { title: `New thread in ${activeProject.title}`, searchTerms: ["new task", "chat", "create"], run: () => - selectThreadRoute({ - draft: { - requestId: uuidv4(), - environmentId: activeProject.environmentId, - projectId: activeProject.id, - title: activeProject.title, + selectDetail({ + name: "Thread", + params: { + draft: { + requestId: uuidv4(), + environmentId: activeProject.environmentId, + projectId: activeProject.id, + title: activeProject.title, + }, }, }), }); @@ -358,12 +361,15 @@ export function CommandPalette(props: { detail: `New thread · ${savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId}`, searchTerms: [project.workspaceRoot, "new thread", "project"], run: () => - selectThreadRoute({ - draft: { - requestId: uuidv4(), - environmentId: project.environmentId, - projectId: project.id, - title: project.title, + selectDetail({ + name: "Thread", + params: { + draft: { + requestId: uuidv4(), + environmentId: project.environmentId, + projectId: project.id, + title: project.title, + }, }, }), })); @@ -391,7 +397,11 @@ export function CommandPalette(props: { ...(thread.environmentId === activeThread?.environmentId && thread.id === activeThread.id ? { secondary: true } : {}), - run: () => selectThread(thread), + run: () => + selectDetail({ + name: "Thread", + params: { environmentId: String(thread.environmentId), threadId: String(thread.id) }, + }), }; }); // Recent means used recently: written in or opened, whichever is later. @@ -410,8 +420,7 @@ export function CommandPalette(props: { projects, runCommand, savedConnectionsById, - selectThread, - selectThreadRoute, + selectDetail, threads, ]); const rankedMessageKeys = useMemo(() => { diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index df104a711884..eeb06c4e2e30 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -32,6 +32,7 @@ import { subscribeToHardwareKeyboardCommandRegistrations, type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; +import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; const EMPTY_COPY_FEEDBACK: GitActionProgress = { phase: "idle", @@ -153,7 +154,7 @@ export function HardwareKeyboardCommandProvider({ } if (command === "newTask") { - navigation.navigate("Thread", { draft: {} }); + navigation.dispatch(selectWorkspaceDetail({ name: "Thread", params: { draft: {} } })); return; } if (command === "back") { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 70463d795353..872f1f521f79 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -9,13 +9,7 @@ import type { import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { - CommonActions, - NavigationContext, - NavigationRouteContext, - StackActions, - useNavigation, -} from "@react-navigation/native"; +import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { use, useDeferredValue, @@ -26,7 +20,7 @@ import { useState, type ReactNode, } from "react"; -import { Platform, useWindowDimensions, View } from "react-native"; +import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { runOnJS, @@ -47,10 +41,6 @@ import { isWorkspaceListPath, type WorkspaceAuxiliaryPaneRole, } from "../../lib/layout"; -import { - resolveThreadSelectionNavigationAction, - resolveThreadSelectionOverlayState, -} from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -71,7 +61,12 @@ import { ANDROID_WORKSPACE_RAIL_WIDTH, } from "../workspace/AndroidWorkspaceNavigation"; import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { + selectWorkspaceDetail, + type WorkspaceDetail, + type WorkspaceDetailSheet, +} from "../workspace/workspace-detail-selection"; +import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; @@ -493,78 +488,47 @@ function AdaptiveWorkspaceLayoutContent( }; const handleOpenSettings = () => openWorkspaceTab("settings"); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); const deferredThreadSelection = useMemo(() => createDeferredNavigation(), []); - function handleSelectThreadRoute(params: ReactNavigation.RootParamList["Thread"]) { - deferredThreadSelection.run(() => applyThreadRouteSelection(params)); - } - function applyThreadRouteSelection(params: ReactNavigation.RootParamList["Thread"]) { - if (Platform.OS === "android") { + /** The one way to show a page in the detail column; see `selectWorkspaceDetail`. */ + function handleSelectDetail(detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) { + deferredThreadSelection.run(() => { + if (Platform.OS === "android") Keyboard.dismiss(); setFileInspectorPreferredVisible(false); - selectAndroidWorkspace({ name: "Thread", params }); - return; - } - const navigationAction = resolveThreadSelectionNavigationAction({ - usesSplitView: layout.usesSplitView, - pathname, - workspaceRouteName: navigation - .getState() - ?.routes.find((route) => route.key === props.workspaceRouteKey)?.name, - }); - const overlayState = resolveThreadSelectionOverlayState({ - state: navigation.getState(), - workspaceRouteKey: props.workspaceRouteKey, - action: navigationAction, - params, + if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false); + workspace?.dispatch({ + type: "select", + tab: detail.name === "Thread" ? "threads" : "pullRequests", + }); + startOpeningDetail(detail); + navigation.dispatch(selectWorkspaceDetail(detail, sheet)); }); - if (overlayState !== null) { - setFileInspectorPreferredVisible(false); - navigation.dispatch(CommonActions.reset(overlayState)); - return; - } - if (navigationAction === "set-params") { - setFileInspectorPreferredVisible(false); - const state = navigation.getState(); - const route = state?.routes.find((candidate) => candidate.key === props.workspaceRouteKey); - if (route?.name === "Thread") { - navigation.dispatch({ - ...CommonActions.setParams({ - environmentId: undefined, - threadId: undefined, - draft: undefined, - ...params, - }), - source: route.key, - }); - } else { - navigation.navigate("Thread", params); - } - return; - } - if (navigationAction === "replace") { - setFileInspectorPreferredVisible(false); - navigation.dispatch(StackActions.replace("Thread", params)); - return; - } - navigation.navigate("Thread", params); } + const handleSelectThreadRoute = (params: ReactNavigation.RootParamList["Thread"]) => + handleSelectDetail({ name: "Thread", params }); const projects = useProjects(); const compactAndroidSettings = Platform.OS === "android" && androidSettingsOpen && !layout.usesSplitView; + // Reads the open page when pressed, so the layout context doesn't change on every navigation. const handleStartNewTask = () => { - const thread = appAtomRegistry - .get(environmentThreadShells.navigationThreadShellsAtom) - .find((candidate) => candidate.environmentId === environmentId && candidate.id === threadId); - const currentRoute = navigation - .getState() - ?.routes.find((route) => route.key === props.workspaceRouteKey); - const draft = + const state = rootStackState(navigation); + const currentRoute = workspaceRoutesOf( + state?.routes.slice(0, state.index + 1) ?? [], + ).workspace.at(-1); + const params = currentRoute?.name === "Thread" - ? (currentRoute.params as ReactNavigation.RootParamList["Thread"])?.draft + ? (currentRoute.params as ReactNavigation.RootParamList["Thread"]) : undefined; const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; + const thread = appAtomRegistry + .get(environmentThreadShells.navigationThreadShellsAtom) + .find( + (candidate) => + candidate.environmentId === first(params?.environmentId) && + candidate.id === first(params?.threadId), + ); + const draft = params?.draft; const draftEnvironmentId = first(draft?.environmentId); const draftProjectId = first(draft?.projectId); const currentProject = thread @@ -786,8 +750,7 @@ function AdaptiveWorkspaceLayoutContent( primarySidebarSearchQuery, startNewTask: handleStartNewTask, deferThreadSelectionUntil: deferredThreadSelection.waitFor, - selectThread: handleSelectThread, - selectThreadRoute: handleSelectThreadRoute, + selectDetail: handleSelectDetail, activateAuxiliaryPaneRole, registerWorkspaceInspector, setPrimarySidebarSearchQuery, diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index 04d82f548f37..4f5f2033e029 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -1,6 +1,9 @@ import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; import { createContext, use, type ReactNode } from "react"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { + WorkspaceDetail, + WorkspaceDetailSheet, +} from "../workspace/workspace-detail-selection"; import { deriveFileInspectorPaneLayout, deriveLayout, @@ -18,8 +21,8 @@ interface AdaptiveWorkspaceContextValue { readonly primarySidebarSearchQuery: string; readonly startNewTask: () => void; readonly deferThreadSelectionUntil: (operation: Promise) => void; - readonly selectThread: (thread: EnvironmentThreadShell) => void; - readonly selectThreadRoute: (params: ReactNavigation.RootParamList["Thread"]) => void; + /** Use `useSelectWorkspaceDetail`, which also works outside the layout. */ + readonly selectDetail?: (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => void; readonly activateAuxiliaryPaneRole: (role: WorkspaceAuxiliaryPaneRole) => () => void; readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void; readonly setPrimarySidebarSearchQuery: (query: string) => void; @@ -60,8 +63,6 @@ export const AdaptiveWorkspaceContext = createContext undefined, deferThreadSelectionUntil: () => undefined, - selectThread: () => undefined, - selectThreadRoute: () => undefined, activateAuxiliaryPaneRole: () => () => undefined, registerWorkspaceInspector: () => () => undefined, setPrimarySidebarSearchQuery: () => undefined, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index 136a1d50e141..5a42b61d2594 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -19,7 +19,16 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { use, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; +import { + Fragment, + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; import { View } from "react-native"; import { Split, type SplitHostCommands } from "react-native-screens"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -29,7 +38,7 @@ import { WorkspaceNativeTabs } from "../workspace/WorkspaceNativeTabs.ios"; import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; -import { useWorkspaceSidebarHeaderItem } from "./useWorkspaceSidebarHeaderItem"; +import { setNativelyPoppingWorkspaceRoute } from "../workspace/workspace-detail-selection"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -71,18 +80,12 @@ const isPullRequestRoute = (route: Route) => route.name === "PullRequestFile" || route.name === "PullRequestFiles"; -/** Only these screens follow the sidebar's state; a thread's header draws its own toggle. */ -function SidebarToggleHeader(props: ComponentProps) { - const sidebarItem = useWorkspaceSidebarHeaderItem(); - const options = { - ...props.options, - unstable_headerLeftItems: () => [sidebarItem], - } as AppNativeStackNavigationOptions as typeof props.options; - return ; -} +/** Beside the list, the first Settings page shows on one native screen that swaps its content. */ +const SETTINGS_DETAIL_SCREEN = "settings-detail"; function ColumnScreen(props: { readonly descriptor: Descriptor; + readonly screenKey?: string; readonly primary?: boolean; readonly attached?: boolean; readonly selectedThreadKey?: string | null; @@ -90,8 +93,6 @@ function ColumnScreen(props: { readonly onDismiss: (routeKey: string, native: boolean) => void; readonly onAppearance: (type: "willAppear" | "didAppear" | "willDisappear", key: string) => void; readonly onCompose?: () => void; - /** Settings pages don't draw their own sidebar toggle, unlike threads and pull requests. */ - readonly sidebarToggle?: boolean; readonly navigation: ViewProps["navigation"]; }) { const { descriptor } = props; @@ -101,8 +102,6 @@ function ColumnScreen(props: { descriptor.route.key, descriptor.options, ); - const Header = - props.sidebarToggle && !options.unstable_headerLeftItems ? SidebarToggleHeader : V5StackHeader; const [presented, setPresented] = useState(false); const primaryColumn = useMemo( () => @@ -118,7 +117,7 @@ function ColumnScreen(props: { const key = descriptor.route.key; return ( { props.onDismiss(key, event.nativeEvent.isNativeDismiss); @@ -143,7 +142,7 @@ function ColumnScreen(props: { > -
- {descriptor.render()} + {/* The Settings slot swaps pages here; its column keeps its measured insets. */} + {descriptor.render()} @@ -184,6 +184,8 @@ function WorkspaceColumns( openWorkspaceTab, } = useAdaptiveWorkspaceLayout(); const deviceType = useDeviceType(); + const settingsDetailIn = (detail: readonly Route[]) => + layout.usesSplitView && detail[0] && isSettingsRoute(detail[0].name) ? detail[0] : undefined; // Split screens must remain mounted while UIKit animates a detached route away. const [screens, setScreens] = useState({ routes: props.detail, @@ -196,9 +198,13 @@ function WorkspaceColumns( screens.observedRoutes !== props.detail || screens.observedDescriptors !== props.descriptors ) { - const routes = reconcileStackScreens(screens.routes, props.detail, screens.completed); - const retained = new Set(routes.map((route) => route.key)); const active = new Set(props.detail.map((route) => route.key)); + const settingsSlot = settingsDetailIn(props.detail); + const routes = reconcileStackScreens(screens.routes, props.detail, screens.completed).filter( + // The slot's native screen stays; the pages it showed, and any above them, just go. + (route) => !settingsSlot || active.has(route.key) || !isSettingsRoute(route.name), + ); + const retained = new Set(routes.map((route) => route.key)); setScreens({ routes, descriptors: Object.fromEntries( @@ -280,6 +286,7 @@ function WorkspaceColumns( props.primary.key, ); collapsedColumn.current = transition.column; + setNativelyPoppingWorkspaceRoute(transition.column.leaving); if (transition.pop) props.navigation.dispatch(transition.pop); }; @@ -301,9 +308,11 @@ function WorkspaceColumns( onCollapse={() => { collapsed.current = true; collapsedColumn.current = { visible: compactColumn, leaving: null }; + setNativelyPoppingWorkspaceRoute(null); }} onExpand={() => { collapsed.current = false; + setNativelyPoppingWorkspaceRoute(null); // Native callbacks can outlive the render that created them, so decide from the live // router state. An expanded split has columns, so its tab gets a page beside the list. const live = props.navigation.getState(); @@ -392,15 +401,13 @@ function WorkspaceColumns( {screens.routes.map((route) => { const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key]; + const inSettingsSlot = settingsDetailIn(props.detail)?.key === route.key; + const screenKey = inSettingsSlot ? SETTINGS_DETAIL_SCREEN : route.key; return descriptor ? ( current.key === route.key)} onDismiss={handleNativeDismiss} onAppearance={followCollapsedSplit} @@ -469,6 +476,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean item.key} diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx index 71cbf7f9b761..1fa5cd83b874 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx @@ -17,7 +17,7 @@ import { type ScrollViewProps, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ScrollViewMarker } from "react-native-screens"; +import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { Button } from "../../components/ui/Button"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index e988da1a0af1..190d707cc8cf 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -43,6 +43,7 @@ import { import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles"; import { isPullRequestTestFile } from "./pullRequestFileFilters"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; import { type PullRequestDetail, type PullRequestListEntry } from "@t3tools/contracts"; @@ -88,7 +89,7 @@ import { type NativeReviewDiffViewProps, } from "../diffs/nativeReviewDiffSurface"; import { - buildNativeReviewDiffData, + getCachedNativeReviewDiffData, NATIVE_REVIEW_DIFF_CONTENT_WIDTH, } from "../review/nativeReviewDiffAdapter"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -99,6 +100,7 @@ import { PullRequestActionToolbar, PullRequestCardGroup } from "./PullRequestCon import { pullRequestTarget as requestTarget, usePullRequestDiff, + usePullRequestDiffEntry, type PullRequestDiffParams, type PullRequestParams, } from "./usePullRequestDiff"; @@ -847,16 +849,35 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps) { +>; + +export function PullRequestFileRouteScreen(props: PullRequestFileRouteProps) { + const navigation = + useNavigation>(); + const ready = usePullRequestDiffEntry(navigation); + if (ready) return ; + const title = props.route.params.commit ? "Commit changes" : "Changes"; + return ( + + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + + ); +} + +function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -894,7 +915,7 @@ export function PullRequestFileRouteScreen({ !result.isPending && !result.error; const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(route.params))); - const baseData = useMemo(() => buildNativeReviewDiffData(parsed), [parsed]); + const baseData = useMemo(() => getCachedNativeReviewDiffData({ parsedDiff: parsed }), [parsed]); const highlightFiles = usePullRequestDiffHighlightFiles(route.params, parsed, baseData.files); const data = useMemo( () => diff --git a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx index 734be2162a67..b2f51a9bd0ab 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStackCard.tsx @@ -1,7 +1,7 @@ import { StackActions, useNavigation } from "@react-navigation/native"; import { useState } from "react"; import { Platform, View } from "react-native"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import { AppText as Text } from "../../components/ui/AppText"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; import { pullRequestStatus } from "./pullRequestStatus"; @@ -14,7 +14,7 @@ export function PullRequestStackCard(props: { readonly state: PullRequestStackState; }) { const navigation = useNavigation(); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const selectDetail = useSelectWorkspaceDetail(); const [expanded, setExpanded] = useState(false); const stack = props.state.stack; if (!stack) { @@ -68,7 +68,7 @@ export function PullRequestStackCard(props: { : () => { const params = { ...props.params, number: layer.number }; if (Platform.OS === "android") - selectAndroidWorkspace({ name: "PullRequest", params }); + selectDetail({ name: "PullRequest", params }); else navigation.dispatch(StackActions.push("PullRequest", params)); } } diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index e722f052b261..1554c184d390 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -16,7 +16,7 @@ import { View, type ScrollViewProps, } from "react-native"; -import { ScrollViewMarker } from "react-native-screens"; +import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; @@ -42,7 +42,7 @@ import { } from "./pullRequestList"; import { useDebouncedValue } from "../../state/queries"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; function PullRequestScrollView(props: ScrollViewProps) { return ( @@ -106,7 +106,7 @@ export function PullRequestsScreen( const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId) ? selectedEnvironmentId : null; - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const selectDetail = useSelectWorkspaceDetail(); const selectPullRequest = useCallback( (item: EnvironmentPullRequestListEntry) => { Keyboard.dismiss(); @@ -118,10 +118,9 @@ export function PullRequestsScreen( host: item.host, initialSummary: item, }; - if (Platform.OS === "android") selectAndroidWorkspace({ name: "PullRequest", params }); - else navigation.navigate("PullRequest", params); + selectDetail({ name: "PullRequest", params }); }, - [navigation, selectAndroidWorkspace], + [selectDetail], ); const allProjects = useProjects(); const savedControls = listControls.controlsFor(environmentId); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts index 3d419f262da3..b48933573f36 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffHighlightFiles.test.ts @@ -265,8 +265,12 @@ it.each(["permanent", "transient"])( fetch.mockRejectedValueOnce(error); if (failure === "transient") fetch.mockRejectedValueOnce(error); const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache); - if (failure === "transient") await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); - expect(await first[1]!.loadContents!()).toBeNull(); + if (failure === "transient") { + await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); + await expect(first[1]!.loadContents!()).rejects.toThrow("Offline"); + } else { + expect(await first[1]!.loadContents!()).toBeNull(); + } const second = createPullRequestDiffHighlightFiles( { target }, { ...parsed, files: [parsed.files[0]!, { ...parsed.files[1]! }] }, @@ -275,8 +279,8 @@ it.each(["permanent", "transient"])( cache, ); expect(await second[1]!.loadContents!()).toBe(contents); - expect(await first[1]!.loadContents!()).toBeNull(); - expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 3); + expect(await first[1]!.loadContents!()).toBe(failure === "permanent" ? null : contents); + expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 4); }, ); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts index a2d06d06ca0c..ee5f02443074 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PullRequestDiffResult } from "@t3tools/contracts"; import type { ReviewParsedDiff } from "../review/reviewModel"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { combinePullRequestDiffPages, retainPullRequestDiffPage } from "./pullRequestDiffPages"; +import * as reviewModel from "../review/reviewModel"; +import { + combinePullRequestDiffPages, + getCachedPullRequestDiffPages, + retainPullRequestDiffPage, +} from "./pullRequestDiffPages"; const GITHUB = sourceControlClients.get("github").changeRequest; @@ -24,6 +29,65 @@ function withheldDiff(): PullRequestDiffResult { } describe("paged mobile pull request diffs", () => { + it("isolates scopes, cursors, and provider terminology for the same result", () => { + const result = withheldDiff(); + const pages = [{ cursor: null, result }]; + const first = getCachedPullRequestDiffPages(pages, "first", GITHUB); + const otherScope = getCachedPullRequestDiffPages(pages, "second", GITHUB); + const otherCursor = getCachedPullRequestDiffPages( + [{ cursor: "next", result }], + "first", + GITHUB, + ); + const otherProvider = getCachedPullRequestDiffPages( + pages, + "first", + sourceControlClients.get("gitlab").changeRequest, + ); + expect(otherScope).not.toBe(first); + expect(otherCursor).not.toBe(first); + expect(otherProvider).not.toBe(first); + if (first.kind !== "files" || otherScope.kind !== "files" || otherProvider.kind !== "files") + throw new Error("Expected structured files"); + expect(otherScope.files[0]?.id).not.toBe(first.files[0]?.id); + expect(first.notice).toContain("pull request"); + expect(otherProvider.notice).toContain("merge request"); + expect(getCachedPullRequestDiffPages(pages, "first", GITHUB)).toBe(first); + }); + + it("reuses earlier pages on append and invalidates changed continuation results", () => { + using parse = vi.spyOn(reviewModel, "buildReviewParsedDiff"); + const firstPage = { cursor: null, result: diff("first.ts") }; + const first = getCachedPullRequestDiffPages([firstPage], "append", GITHUB); + const nextPage = { cursor: "next", result: withheldDiff() }; + const combined = getCachedPullRequestDiffPages([firstPage, nextPage], "append", GITHUB); + expect(parse).toHaveBeenCalledTimes(2); + if (first.kind !== "files" || combined.kind !== "files") throw new Error("Expected files"); + expect(combined.files[0]).toBe(first.files[0]); + expect(combined.files.map((file) => file.path)).toEqual(["first.ts", "large.ts"]); + expect(getCachedPullRequestDiffPages([firstPage, nextPage], "append", GITHUB)).toBe(combined); + const refreshed = getCachedPullRequestDiffPages( + [ + firstPage, + { + ...nextPage, + result: { + ...nextPage.result, + omittedFileStats: [{ path: "large.ts", additions: 4000, deletions: 2000 }], + }, + }, + ], + "append", + GITHUB, + ); + expect(parse).toHaveBeenCalledTimes(3); + expect(refreshed).not.toBe(combined); + if (refreshed.kind !== "files") throw new Error("Expected refreshed files"); + expect(refreshed.files[0]).toBe(first.files[0]); + expect(refreshed.files[1]).toMatchObject({ additions: 4000, deletions: 2000 }); + expect(combined.files[1]?.additions).toBe(3000); + }); + it("withheld PR hunks must retain provider line counts", () => { const result = withheldDiff(); const cache = new WeakMap(); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts index c30f3a3198ad..9abf7cfc0c07 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts @@ -7,6 +7,46 @@ export interface PullRequestDiffPage { readonly result: PullRequestDiffResult; } +interface CachedPullRequestDiffPages { + readonly pages: readonly PullRequestDiffPage[]; + readonly cache: WeakMap; + readonly parsed: ReviewParsedDiff; +} + +// Keep only the latest combination per scoped first page; query results own its lifetime. +const parsedSnapshots = new WeakMap< + PullRequestDiffResult, + Map +>(); + +export function getCachedPullRequestDiffPages( + pages: readonly PullRequestDiffPage[], + scope: string, + terminology: ChangeRequestTerminology, +): ReviewParsedDiff { + const first = pages[0]; + if (!first) return { kind: "empty" }; + const key = JSON.stringify([scope, terminology.singular, first.cursor]); + let snapshots = parsedSnapshots.get(first.result); + if (!snapshots) { + snapshots = new Map(); + parsedSnapshots.set(first.result, snapshots); + } + const previous = snapshots.get(key); + if ( + previous?.pages.length === pages.length && + pages.every((page, index) => { + const cached = previous.pages[index]; + return cached?.cursor === page.cursor && cached.result === page.result; + }) + ) + return previous.parsed; + const cache = previous?.cache ?? new WeakMap(); + const parsed = combinePullRequestDiffPages(pages, cache, scope, terminology); + snapshots.set(key, { pages, cache, parsed }); + return parsed; +} + /** A changed page invalidates later cursors, which belong to the previous patch. */ export function retainPullRequestDiffPage( pages: readonly PullRequestDiffPage[], diff --git a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts index faed80ecc134..ab07c99b6f0d 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestNavigation.test.ts @@ -1,6 +1,6 @@ import { EnvironmentId, ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { nativePullRequestDestination } from "../../lib/pullRequestNavigation"; +import { nativePullRequestDestination as destination } from "../../lib/pullRequestNavigation"; const context = { environmentId: "remote", projectId: "project" }; const project = (id: string, canonicalKey: string, provider: string, remoteUrl: string) => ({ @@ -19,6 +19,14 @@ const projects = [ project("forgejo", "forgejo.test/org/repo", "forgejo", "http://forgejo.test:3000/org/repo.git"), ]; +const hostReads = () => ({ pullRequests: true, threadPullRequests: true }); +const nativePullRequestDestination = ( + url: string, + linkContext: typeof context | null, + linkProjects: typeof projects, + capabilities: Parameters[3] = hostReads, +) => destination(url, linkContext, linkProjects, capabilities); + describe("native PR entry points", () => { it("keeps a thread link in its own environment and handles renamed host path shapes", () => { expect( @@ -41,9 +49,25 @@ describe("native PR entry points", () => { ).toBe("forgejo.test:3000"); }); - it("leaves pull requests from repositories without a project to the external opener", () => { + it("opens a repository nobody has checked out under a project on its host, as web does", () => { expect( nativePullRequestDestination("https://github.com/someone/else/pull/12", context, projects), + ).toEqual({ ...context, host: "github.com", repository: "someone/else", number: 12 }); + }); + + it("leaves links to the external opener when the environment cannot show them", () => { + const url = "https://github.com/org/repo/pull/12"; + expect(nativePullRequestDestination(url, context, projects, () => undefined)).toBeNull(); + expect( + nativePullRequestDestination( + "https://github.com/someone/else/pull/12", + context, + projects, + () => ({ + pullRequests: true, + threadPullRequests: false, + }), + ), ).toBeNull(); }); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx index 0548cbb24192..3000efb9633b 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.test.tsx @@ -3,14 +3,16 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDiffResult } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { act, useLayoutEffect } from "react"; +import { act, useLayoutEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ diff: vi.fn() })); vi.mock("../../state/pull-requests", () => ({ composerPullRequests: { diff: mocks.diff } })); -import { usePullRequestDiff } from "./usePullRequestDiff"; +import { usePullRequestDiff, usePullRequestDiffEntry } from "./usePullRequestDiff"; +import * as reviewModel from "../review/reviewModel"; +import { getCachedNativeReviewDiffData } from "../review/nativeReviewDiffAdapter"; function patch(path: string, nextCursor: string | null = null): PullRequestDiffResult { return { @@ -67,6 +69,15 @@ function setup() { ); return { requests, + remount() { + act(() => + root.render( + + + , + ), + ); + }, get current() { return current; }, @@ -87,6 +98,123 @@ function setup() { }; } +it("reuses the parsed snapshot when a new route reads the same query result", async () => { + using parse = vi.spyOn(reviewModel, "buildReviewParsedDiff"); + using screen = setup(); + await screen.succeed(0, patch("first.ts")); + const parsed = screen.current.parsed; + const parses = parse.mock.calls.length; + screen.remount(); + expect(screen.current.parsed).toBe(parsed); + expect(parse).toHaveBeenCalledTimes(parses); + expect(screen.requests).toHaveLength(1); +}); + +function setupEntry() { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const frames = new Map(); + let frameId = 0; + const requestFrame = vi + .spyOn(globalThis, "requestAnimationFrame") + .mockImplementation((callback) => { + frames.set(++frameId, callback); + return frameId; + }); + const cancelFrame = vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation((id) => { + if (id != null) frames.delete(id); + }); + const listeners = new Map void>(); + const navigation = { + addListener( + type: "transitionStart" | "transitionEnd", + listener: (event: { data: { closing: boolean } }) => void, + ) { + listeners.set(type, listener); + return () => { + listeners.delete(type); + }; + }, + }; + const events: string[] = []; + const prepare = vi.fn(() => { + events.push("prepare"); + return getCachedNativeReviewDiffData({ + parsedDiff: reviewModel.buildReviewParsedDiff( + patch("first.ts").patch + patch("neighbor.ts").patch, + "entry", + ), + }); + }); + function Body() { + const [data] = useState(prepare); + return
{data.files.map((file) => file.path).join(",")}
; + } + function Entry() { + const ready = usePullRequestDiffEntry(navigation); + useLayoutEffect(() => { + events.push(ready ? "body-commit" : "shell-commit"); + }, [ready]); + return ready ? :
Opening changes
; + } + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render()); + return { + prepare, + events, + container, + frame() { + const pending = [...frames.values()]; + frames.clear(); + act(() => { + for (const callback of pending) callback(0); + }); + }, + event(type: "transitionStart" | "transitionEnd", closing = false) { + act(() => listeners.get(type)?.({ data: { closing } })); + }, + fallback() { + act(() => vi.runAllTimers()); + }, + [Symbol.dispose]() { + act(() => root.unmount()); + requestFrame.mockRestore(); + cancelFrame.mockRestore(); + vi.useRealTimers(); + }, + }; +} + +it("commits the loading shell and waits for the opening transition before building the diff", () => { + using screen = setupEntry(); + expect(screen.events).toEqual(["shell-commit"]); + screen.event("transitionStart"); + screen.frame(); + screen.frame(); + screen.event("transitionEnd", true); + expect(screen.prepare).not.toHaveBeenCalled(); + screen.event("transitionEnd"); + expect(screen.events).toEqual(["shell-commit", "prepare", "body-commit"]); + expect(screen.container.textContent).toBe("first.ts,neighbor.ts"); +}); + +it.each(["unanimated", "missing-completion"] as const)( + "opens the diff after a %s presentation without stranding the shell", + (presentation) => { + using screen = setupEntry(); + if (presentation === "missing-completion") screen.event("transitionStart"); + screen.frame(); + expect(screen.prepare).not.toHaveBeenCalled(); + screen.frame(); + if (presentation === "missing-completion") { + expect(screen.prepare).not.toHaveBeenCalled(); + screen.fallback(); + } + expect(screen.prepare).toHaveBeenCalledOnce(); + }, +); + it("retries a failed continuation without discarding loaded files or restarting page one", async () => { using screen = setup(); await screen.succeed(0, patch("first.ts", "second")); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 2b33c86871ac..c3c25b859193 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -1,19 +1,13 @@ import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; -import { - EnvironmentId, - ProjectId, - type PullRequestDiffResult, - type PullRequestListEntry, -} from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type PullRequestListEntry } from "@t3tools/contracts"; import { useAtomRefresh } from "@effect/atom-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; import { usePullToRefresh } from "../../state/use-pull-to-refresh"; -import type { ReviewParsedDiff } from "../review/reviewModel"; import { - combinePullRequestDiffPages, + getCachedPullRequestDiffPages, retainPullRequestDiffPage, type PullRequestDiffPage, } from "./pullRequestDiffPages"; @@ -43,6 +37,46 @@ export function pullRequestTarget(params: PullRequestParams) { const NO_PAGES: readonly PullRequestDiffPage[] = []; +export function usePullRequestDiffEntry(navigation: { + readonly addListener: ( + type: "transitionStart" | "transitionEnd", + listener: (event: { readonly data: { readonly closing: boolean } }) => void, + ) => () => void; +}) { + const [ready, setReady] = useState(false); + useLayoutEffect(() => { + if (ready) return; + let opening = false; + let closing = false; + const finish = () => { + if (!closing) setReady(true); + }; + const removeStart = navigation.addListener("transitionStart", ({ data }) => { + closing = data.closing; + if (!closing) opening = true; + }); + const removeEnd = navigation.addListener("transitionEnd", ({ data }) => { + closing = data.closing; + if (!closing) finish(); + }); + // Initial and unanimated presentations may have no transition events; let the shell paint first. + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (!opening) finish(); + }); + }); + // A missing native completion must not strand the route on its loading state. + const fallback = setTimeout(finish, 1_000); + return () => { + removeStart(); + removeEnd(); + cancelAnimationFrame(frame); + clearTimeout(fallback); + }; + }, [navigation, ready]); + return ready; +} + export function usePullRequestDiff( params: PullRequestDiffParams, terminology: ChangeRequestTerminology, @@ -74,10 +108,9 @@ export function usePullRequestDiff( }, [cursor, key, query.data, state.key, state.pages]); if (state.key !== key || state.pages !== pages) setState({ key, cursor, pages }); - const [parseCache] = useState(() => new WeakMap()); const parsed = useMemo( - () => combinePullRequestDiffPages(pages, parseCache, key, terminology), - [key, pages, parseCache, terminology], + () => getCachedPullRequestDiffPages(pages, key, terminology), + [key, pages, terminology], ); const patch = useMemo(() => pages.map((page) => page.result.patch).join("\n"), [pages]); const nextCursor = pages.at(-1)?.result.nextCursor ?? null; diff --git a/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts b/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts index 188ecdd439a9..c7cfe62f43b5 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestListActions.ts @@ -22,7 +22,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { scopedPullRequestListKey, type EnvironmentPullRequestListEntry } from "./pullRequestList"; import { copyPullRequestLink, sharePullRequestLink } from "./pullRequestLink"; import { pullRequestTarget } from "./usePullRequestDiff"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; export function usePullRequestListActions( environmentIds: readonly EnvironmentId[], @@ -33,7 +33,7 @@ export function usePullRequestListActions( ) { const registry = use(RegistryContext); const navigation = useNavigation(); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const selectDetail = useSelectWorkspaceDetail(); const permissionKey = JSON.stringify(environmentIds); const permissionAtom = (() => { const ids: readonly EnvironmentId[] = JSON.parse(permissionKey); @@ -80,7 +80,10 @@ export function usePullRequestListActions( ) throw new Error(`Review requests aren't available for this ${terminology.singular}.`); if (Platform.OS === "android") { - selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }, params(entry)); + selectDetail( + { name: "PullRequest", params: params(entry) }, + { name: "PullRequestReviewers", params: params(entry) }, + ); } else navigation.navigate("PullRequestReviewers", params(entry)); return; } @@ -112,9 +115,7 @@ export function usePullRequestListActions( }; const select = (entry: EnvironmentPullRequestListEntry, action: string) => { if (action === "open") { - if (Platform.OS === "android") - selectAndroidWorkspace({ name: "PullRequest", params: params(entry) }); - else navigation.navigate("PullRequest", params(entry)); + selectDetail({ name: "PullRequest", params: params(entry) }); return; } if (action === "request-review") { diff --git a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts index 16e04b593d4b..e1d9d8715106 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestThreads.ts @@ -1,7 +1,12 @@ import { AsyncResult } from "effect/reactivity"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Dialog from "../../components/ui/Dialog"; -import { useNavigation, useNavigationState, useRoute } from "@react-navigation/native"; +import { + StackActions, + useNavigation, + useNavigationState, + useRoute, +} from "@react-navigation/native"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { AuthOrchestrationOperateScope, @@ -15,7 +20,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { normalizeThreadPullRequestKey } from "@t3tools/client-runtime/thread-pull-requests"; import { useRef, useState } from "react"; import { Platform } from "react-native"; -import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; import { pullRequestComposerContext } from "../../lib/composerContext"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { uuidv4 } from "../../lib/uuid"; @@ -71,13 +76,16 @@ export function usePullRequestThreads( const canAsk = !connected || canOperate; const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); const navigation = useNavigation(); - const selectAndroidWorkspace = useAndroidWorkspaceSelection(); + const selectDetail = useSelectWorkspaceDetail(); const pending = useRef(false); const previousPrompts = useRef(new Map()); const openThread = (thread: LinkedThread) => { - const destination = { environmentId: params.environmentId, threadId: thread.id }; - if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination }); - else navigation.navigate("Thread", destination); + openThreadRoute({ environmentId: params.environmentId, threadId: thread.id }); + }; + // Android shows one page at a time; iOS opens the thread over the pull request, so Back returns. + const openThreadRoute = (destination: ReactNavigation.RootParamList["Thread"]) => { + if (Platform.OS === "android") selectDetail({ name: "Thread", params: destination }); + else navigation.dispatch(StackActions.push("Thread", destination)); }; const [busy, setBusy] = useState(false); const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => { @@ -152,10 +160,7 @@ export function usePullRequestThreads( ); previousPrompts.current.set(draftKey, pullRequestAgentPrompt(task, terminology)); if (thread) openThread(thread); - else - navigation.navigate("Thread", { - draft: { draftId: draftKey, title: `Ask about #${pr.number}` }, - }); + else openThreadRoute({ draft: { draftId: draftKey, title: `Ask about #${pr.number}` } }); }); pending.current = false; setBusy(false); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 03cb70c8a492..6e864e2f3f5d 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -68,6 +68,7 @@ import { resolveReviewAvailability } from "./reviewAvailability"; import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; import { buildReviewSectionMenu } from "./review-section-menu"; import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal"; +import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; function ReviewHeader( props: Parameters[0] & { @@ -433,6 +434,7 @@ export function ReviewSheet(props: ReviewSheetProps) { useAdaptiveWorkspacePaneRole("inspector"); const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); const insets = useSafeAreaInsets(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); const headerIcon = String(useUniwindTheme()["--color-icon"]); @@ -610,9 +612,9 @@ export function ReviewSheet(props: ReviewSheetProps) { navigation.goBack(); return; } - navigation.navigate("Thread", { - environmentId: String(environmentId), - threadId: String(threadId), + selectDetail({ + name: "Thread", + params: { environmentId: String(environmentId), threadId: String(threadId) }, }); }; const androidHeaderSubtitle = [ diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index d51000bb1f7d..2ae7c77e14d9 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -43,6 +43,7 @@ export function useNativeReviewDiffBridge(input: { () => createNativeReviewDiffTheme(scheme, themeId, appTheme), [appTheme, scheme, themeId], ); + const collapsedFileIdsSet = useMemo(() => new Set(collapsedFileIds), [collapsedFileIds]); const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); @@ -69,6 +70,7 @@ export function useNativeReviewDiffBridge(input: { useNativeReviewDiffHighlighting({ files: data.files, rows: data.rows, + collapsedFileIds: collapsedFileIdsSet, scheme, resetKey: tokensResetKey, enabled: canHighlight, diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 1658f8e76951..6c454d94a6b8 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -25,10 +25,11 @@ export function useNativeReviewDiffHighlighting(input: { readonly files: ReadonlyArray; readonly rows: ReadonlyArray; readonly scheme: HighlightScheme; + readonly collapsedFileIds: ReadonlySet; readonly resetKey: string; readonly enabled: boolean; }) { - const { enabled, files, resetKey, rows, scheme } = input; + const { collapsedFileIds, enabled, files, resetKey, rows, scheme } = input; const highlightedRowIdsRef = useRef>(new Set()); const viewportScope = `${enabled}:${resetKey}:${rows.length}`; const [viewport, setViewport] = useState<{ @@ -120,6 +121,7 @@ export function useNativeReviewDiffHighlighting(input: { files, rows, scheme, + collapsedFileIds, firstRowIndex: requestRange.firstRowIndex, lastRowIndex: requestRange.lastRowIndex, overscanRows, @@ -140,6 +142,7 @@ export function useNativeReviewDiffHighlighting(input: { error: squashAtomCommandFailure(settled), resetKey, scheme, + collapsedFileIds, firstRowIndex: requestRange.firstRowIndex, lastRowIndex: requestRange.lastRowIndex, }); @@ -171,7 +174,7 @@ export function useNativeReviewDiffHighlighting(input: { traceDiffHighlight("viewport-abort", { generation, reset }); abortController.abort(); }; - }, [enabled, files, resetKey, rows, scheme, viewport, viewportScope]); + }, [collapsedFileIds, enabled, files, resetKey, rows, scheme, viewport, viewportScope]); const acknowledgeTokenPatch = useCallback((payload: Readonly>) => { const pending = pendingPatchesRef.current; diff --git a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx index eba94cdf3bd6..e9774d37d4d1 100644 --- a/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsOpenSourceLicensesRouteScreen.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { SearchField } from "../../components/ui/SearchField"; import { Toolbar } from "../../components/ui/Toolbar"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; @@ -127,6 +128,7 @@ export function SettingsOpenSourceLicensesRouteScreen() { ) : null} { const list = state.routes.findIndex((route) => route.name === "Settings"); - return list < 0 ? null : (state.routes[list + 1]?.name ?? null); + return list < 0 + ? rememberedSettingsPage(currentWorkspaceSelections()) + : (state.routes[list + 1]?.name ?? null); }); const sidebar = useMemo(() => (inSidebar ? { selected } : null), [inSidebar, selected]); const content = hasCloudPublicConfig() ? ( @@ -56,11 +67,24 @@ export function SettingsRouteScreen() { return ( - + {Platform.OS === "android" ? ( - }> + } + search={ + workspace + ? { + value: workspace.state.tabs.settings.query, + onChangeText: (query) => + workspace.dispatch({ type: "query", tab: "settings", query }), + placeholder: "Search settings", + } + : undefined + } + > {content} ) : ( @@ -72,11 +96,14 @@ export function SettingsRouteScreen() { ); } +type SettingsListRow = ComponentProps & { readonly id: string }; + +interface SettingsListSection { + readonly title?: string; + readonly rows: readonly SettingsListRow[]; +} + function ConfiguredSettingsRouteScreen() { - // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match - // the sidebar's surface so the bar doesn't darken over content at rest. - const listSurface = use(NativePrimaryColumnContext) ? "bg-drawer" : "bg-screen"; - const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); @@ -88,76 +115,96 @@ function ConfiguredSettingsRouteScreen() { : (user?.primaryEmailAddress?.emailAddress ?? "Signed in"); return ( - - - - - navigation.navigate("SettingsAuth")} - /> - - - - - - - + navigation.navigate("SettingsAuth"), + }, + environmentsRow(Object.keys(savedConnectionsById).length), + { + id: "SettingsNotifications", + icon: "bell.badge", + label: "Notifications", + target: "SettingsNotifications", + }, + ]} + /> ); } function LocalSettingsRouteScreen() { + const { savedConnectionsById } = useSavedRemoteConnections(); + return ; +} + +function environmentsRow(count: number): SettingsListRow { + return { + id: "SettingsEnvironments", + icon: "desktopcomputer", + label: "Environments", + value: `${count}`, + valuePosition: "trailing", + target: "SettingsEnvironments", + }; +} + +function SettingsList(props: { readonly connections: readonly SettingsListRow[] }) { // UIKit's scroll-edge material takes the scroll view's own color; like the chat list, match // the sidebar's surface so the bar doesn't darken over content at rest. - const listSurface = use(NativePrimaryColumnContext) ? "bg-drawer" : "bg-screen"; + // Grouped cards only lift off the screen surface; the drawer is as dark as they are. + const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; const insets = useSafeAreaInsets(); - const { savedConnectionsById } = useSavedRemoteConnections(); - const environmentCount = Object.keys(savedConnectionsById).length; + const workspace = useWorkspaceTabs(); + const query = workspace?.state.tabs.settings.query ?? ""; + const overflowRows = useOverflowRows(); + const indexSections = useSettingsIndexSections(); + const sections = filterSettingsSections( + [{ rows: overflowRows }, { title: "Connections", rows: props.connections }, ...indexSections], + query, + ); return ( - - - ( + + {section.rows.map(({ id, ...row }) => ( + + ))} + + )) + ) : ( + - - - + )} ); } -function SettingsIndexSections() { +function useSettingsIndexSections(): SettingsListSection[] { const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const noServerTargets = selectedTargets.length === 0; const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); @@ -174,101 +221,74 @@ function SettingsIndexSections() { members: scopedProjectMembers, }) : (selectedProject?.label ?? "Unavailable project"); - return ( - <> - - - {navigationCustomizationAvailable() ? ( - - ) : null} - {Platform.OS === "ios" ? ( - <> - - - - ) : null} - - - - - - - - {selectedProjectKey !== null ? ( - - ) : null} - - - - - - - - - - - - - - - - - - - ); + const page = ( + target: SettingsPageTarget, + icon: SettingsListRow["icon"], + label: string, + extra?: Pick, + ): SettingsListRow => ({ id: target, icon, label, target, ...extra }); + const serverPage = (target: SettingsPageTarget, icon: SettingsListRow["icon"], label: string) => + page(target, icon, label, { disabled: noServerTargets }); + return [ + { + title: "Interface", + rows: [ + page("SettingsAppearance", "paintbrush", "Appearance"), + ...(navigationCustomizationAvailable() + ? [page("SettingsNavigation", "line.3.horizontal", "Navigation tabs")] + : []), + ...(Platform.OS === "ios" + ? [ + page("SettingsKeyboard", "keyboard", "Keyboard"), + page("SettingsMicrophone", "mic", "Microphone"), + ] + : []), + ], + }, + { + title: "Automations", + rows: [page("SettingsScheduledTasks", "clock", "Scheduled tasks")], + }, + { + title: "Projects & threads", + rows: [ + ...(selectedProjectKey !== null + ? [page("SettingsProjectOverview", "folder", "Overview", { value: projectLabel })] + : []), + page("SettingsOrganization", "folder", "Organization"), + page("SettingsThreads", "text.bubble", "Thread behavior"), + page("SettingsFollowUp", "arrow.turn.left.up", "Follow-ups"), + page("SettingsArchive", "archivebox", "Archived Threads"), + ], + }, + { + title: "Server settings", + rows: [ + serverPage("SettingsProviderAccounts", "person.crop.circle", "Provider accounts"), + serverPage("SettingsEnvironmentNewThreads", "text.bubble", "New threads"), + serverPage("SettingsEnvironmentSourceControl", "arrow.triangle.branch", "Source control"), + serverPage("SettingsEnvironmentAgentBehavior", "text.alignleft", "Agent behavior"), + serverPage("SettingsEnvironmentMaintenance", "arrow.clockwise", "Maintenance"), + ], + }, + { + title: "App", + rows: [page("SettingsAbout", "info.circle", "About T3 Code")], + }, + ]; } -function OverflowSection() { +function useOverflowRows(): SettingsListRow[] { const order = useNavigationTabOrder(); const hidden = useHiddenNavigationTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const tabs = Platform.OS === "android" && layout.usesSplitView ? [] : overflowNavigationTabs(order, hidden); const selectTab = useSelectWorkspaceTab(); - return tabs.length ? ( - - {tabs.map((tab) => ( - selectTab(tab)} - /> - ))} - - ) : null; + return tabs.map((tab) => ({ + id: tab, + icon: NAVIGATION_TAB_ICONS[tab], + label: WORKSPACE_TAB_LABELS[tab], + onPress: () => selectTab(tab), + })); } diff --git a/apps/mobile/src/features/settings/components/SettingsScreen.tsx b/apps/mobile/src/features/settings/components/SettingsScreen.tsx index e05d71239ac6..74a749ed24d4 100644 --- a/apps/mobile/src/features/settings/components/SettingsScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsScreen.tsx @@ -9,12 +9,15 @@ import { useWorkspaceTabs } from "../../workspace/WorkspaceTabsContext"; import { useWorkspaceSurface } from "../../workspace/useWorkspaceSurface"; import { useDeviceType } from "../../../native/native-layout-metrics"; import { useCompactWorkspaceTabs } from "../../workspace/useSystemWorkspaceTabBar"; -import { ScreenScrollViewBottomInset } from "../../../components/ui/ScreenScrollView"; +import { + ScreenScrollViewBottomInset, + ScreenScrollViewSurface, +} from "../../../components/ui/ScreenScrollView"; export { SettingsScreenContent }; export function SettingsScreen( - props: Pick & { + props: Pick & { readonly children: ReactNode; readonly formSheet?: boolean; readonly workspaceRoot?: boolean; @@ -32,25 +35,29 @@ export function SettingsScreen( props.workspaceRoot || (Platform.OS === "android" && workspace !== null && route.name === "Settings"); + // Settings' grouped cards only lift off the screen surface, so the folded Duo doesn't take + // the workspace's drawer surface here. + const surfaceClassName = cover ? "bg-screen" : workspaceRoot ? surface.className : "bg-sheet"; + return ( - + navigation.goBack()} hideBottomBorder={!props.formSheet} /> - {props.formSheet ? ( - props.children - ) : ( - {props.children} - )} + + {props.formSheet ? ( + props.children + ) : ( + {props.children} + )} + ); diff --git a/apps/mobile/src/features/settings/settings-search.test.ts b/apps/mobile/src/features/settings/settings-search.test.ts new file mode 100644 index 000000000000..6c11a3dae7f7 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-search.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { filterSettingsSections } from "./settings-search"; + +const SECTIONS = [ + { rows: [{ id: "usage", label: "Usage" }] }, + { + title: "Connections", + rows: [ + { id: "account", label: "T3 Account" }, + { id: "SettingsEnvironments", label: "Environments" }, + ], + }, + { + title: "Interface", + rows: [ + { id: "SettingsAppearance", label: "Appearance" }, + { id: "SettingsKeyboard", label: "Keyboard" }, + ], + }, + { + title: "Server settings", + rows: [ + { id: "SettingsProviderAccounts", label: "Provider accounts" }, + { id: "SettingsEnvironmentSourceControl", label: "Source control" }, + ], + }, +]; + +function visible(query: string) { + return filterSettingsSections(SECTIONS, query).map((section) => [ + section.title ?? null, + section.rows.map((row) => row.label), + ]); +} + +describe("filterSettingsSections", () => { + it.each([ + { + query: "", + expected: SECTIONS.map((section) => [ + section.title ?? null, + section.rows.map((row) => row.label), + ]), + }, + { query: " ", expected: visible("") }, + { query: "appear", expected: [["Interface", ["Appearance"]]] }, + { query: "KEYBOARD", expected: [["Interface", ["Keyboard"]]] }, + { query: "dark mode", expected: [["Interface", ["Appearance"]]] }, + { query: "branch naming", expected: [["Server settings", ["Source control"]]] }, + { + query: "server settings", + expected: [["Server settings", ["Provider accounts", "Source control"]]], + }, + { + query: "accounts", + expected: [["Server settings", ["Provider accounts"]]], + }, + { query: "usage", expected: [[null, ["Usage"]]] }, + { query: "zzzz", expected: [] }, + ])("$query", ({ query, expected }) => { + expect(visible(query)).toEqual(expected); + }); + + it("drops sections that start empty", () => { + expect(filterSettingsSections([{ rows: [] }, ...SECTIONS], "").length).toBe(SECTIONS.length); + }); +}); diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts new file mode 100644 index 000000000000..f82f90dfe833 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -0,0 +1,120 @@ +import { foldSearchQuery, rankSearchItems, scoreSearchTerms } from "@t3tools/shared/paletteSearch"; + +import type { SettingsPageTarget } from "./components/settings-page-targets"; + +/** What each page holds besides its row's label, so search finds a setting by its own name. */ +const SETTINGS_SEARCH_TERMS = { + account: ["sign in", "sign out", "login", "clerk", "connect"], + SettingsEnvironments: [ + "servers", + "connections", + "pair", + "add environment", + "remote", + "tailscale", + ], + SettingsNotifications: ["push", "alerts", "live activity", "agent activity"], + SettingsAppearance: [ + "theme", + "dark mode", + "light mode", + "colors", + "font size", + "text size", + "code", + "diffs", + "terminal", + "word break", + "math", + ], + SettingsNavigation: ["tabs", "tab order", "reorder", "hide tabs"], + SettingsKeyboard: ["return key", "send message", "new line", "hardware keyboard"], + SettingsMicrophone: ["voice", "dictation", "audio input", "bluetooth"], + SettingsScheduledTasks: ["automations", "schedule", "cron", "recurring", "webhook"], + SettingsProjectOverview: ["project", "checkouts", "worktrees", "delete project"], + SettingsOrganization: ["project grouping", "group projects", "sidebar"], + SettingsThreads: [ + "auto-settle", + "settle", + "snooze", + "usage limits", + "auto-resume", + "plan mode", + "working section", + ], + SettingsFollowUp: ["queue", "steer", "while the agent is running"], + SettingsArchive: ["archive", "archived", "restore", "unarchive"], + SettingsProviderAccounts: ["codex", "claude", "cursor", "opencode", "sign in", "login", "auth"], + SettingsEnvironmentNewThreads: [ + "default workspace", + "worktree", + "submodules", + "default permissions", + "access", + ], + SettingsEnvironmentSourceControl: [ + "git", + "branch naming", + "pull requests", + "co-authors", + "agent credits", + "default branch", + "automatically pull", + "worktrees", + "start from remote", + ], + SettingsEnvironmentAgentBehavior: ["response streaming", "preview browser", "browser access"], + SettingsEnvironmentMaintenance: ["updates", "provider updates", "restart", "manage environments"], + SettingsAbout: [ + "version", + "diagnostics", + "client storage", + "cache", + "legal", + "privacy", + "terms", + "open source licenses", + ], +} satisfies Partial>; +const termsById: ReadonlyMap = new Map( + Object.entries(SETTINGS_SEARCH_TERMS), +); + +export interface SettingsSearchRow { + readonly id: string; + readonly label: string; +} + +export interface SettingsSearchSection { + readonly title?: string; + readonly rows: readonly Row[]; +} + +/** + * Keeps the rows whose label, section, or page contents match `query`, in list order, and drops + * sections left empty. Matching is the command palette's, so both searches agree. + */ +export function filterSettingsSections< + Row extends SettingsSearchRow, + Section extends SettingsSearchSection, +>(sections: readonly Section[], query: string): Section[] { + const normalizedQuery = foldSearchQuery(query); + const rows = sections.flatMap((section) => + section.rows.map((row) => ({ row, section: section.title })), + ); + const matched = normalizedQuery + ? new Set( + rankSearchItems(rows, ({ row, section }, options) => + scoreSearchTerms( + normalizedQuery, + [row.label, ...(section ? [section] : []), ...(termsById.get(row.id) ?? [])], + options, + ), + ).map(({ item }) => item.row), + ) + : null; + return sections.flatMap((section) => { + const kept = matched ? section.rows.filter((row) => matched.has(row)) : section.rows; + return kept.length ? [{ ...section, rows: kept }] : []; + }); +} diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index bd6e79adc19b..10df7a298a4c 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -34,6 +34,7 @@ import { } from "./showcaseRenderSignal"; import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; import { openSettings } from "../settings/settings-navigation"; +import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -250,7 +251,7 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string }) } else if (requestedScene === "review") { navigation.dispatch(CommonActions.navigate("ThreadReview", params)); } else { - navigation.dispatch(CommonActions.navigate("Thread", params)); + navigation.dispatch(selectWorkspaceDetail({ name: "Thread", params })); } }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 3238c5a501a3..8a56f3c16066 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -1,3 +1,4 @@ +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import { SearchField } from "../../components/ui/SearchField"; import { Button } from "../../components/ui/Button"; import { Toolbar } from "../../components/ui/Toolbar"; @@ -449,6 +450,7 @@ export function BranchPickerScreen(props: { ) : ( diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index 02d773c13f12..0112257fec71 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -1,9 +1,9 @@ import { SECRET_REQUEST_DEFAULT_PLACEHOLDER, - SECRET_REQUEST_PRIVACY_NOTE, secretRequestAnswerInput, secretRequestDisplay, secretRequestFailureMessage, + secretRequestPrivacyNote, type SecretRequestItem, } from "@t3tools/client-runtime/secret-request"; import { @@ -144,7 +144,7 @@ function PendingSecretRequestForm(props: { type="monochrome" /> - {SECRET_REQUEST_PRIVACY_NOTE} + {secretRequestPrivacyNote(item)}