From 46106c864689e2544c4979c2d384fc520fcf2928 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:37:43 -0700 Subject: [PATCH 1/2] Test workspace search state restoration --- ios/cmuxUITests/cmuxUITests.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 126f92693bd0..40c529c4e47f 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -1146,7 +1146,7 @@ final class cmuxUITests: XCTestCase { } @MainActor - func testWorkspaceSearchIsMinimizedAndPreservesQueryAcrossRefresh() throws { + func testWorkspaceSearchRestoresPresentationQueryAndResultsAfterWorkspaceDetailAndRefresh() throws { guard #available(iOS 26.0, *) else { throw XCTSkip("The detached workspace search control requires iOS 26.") } @@ -1220,7 +1220,19 @@ final class cmuxUITests: XCTestCase { tap(backButton, in: app) XCTAssertNotNil(waitForVisibleElement(in: workspaceListTables, app: app, timeout: 3)) XCTAssertTrue(minimizedSearch.waitForExistence(timeout: 3)) - XCTAssertTrue(waitForKeyboardDismissal(in: app)) + XCTAssertTrue( + searchField.waitForExistence(timeout: 3), + "Returning from workspace detail must restore the expanded Search presentation." + ) + XCTAssertEqual( + searchField.value as? String, + "Docs", + "Returning from workspace detail must restore the exact search query." + ) + XCTAssertTrue( + app.keyboards.firstMatch.waitForExistence(timeout: 3), + "Returning from workspace detail must restore Search focus and its keyboard." + ) XCTAssertTrue(waitForHittable(docsRow, timeout: 3)) XCTAssertTrue(waitForNotHittable(mainRow, timeout: 3)) @@ -1228,7 +1240,7 @@ final class cmuxUITests: XCTestCase { NSPredicate(format: "identifier == %@", "MobileWorkspaceListPreviewRefresh") ) guard let previewRefresh = waitForVisibleElement(in: previewRefreshButtons, app: app, timeout: 3) else { - XCTFail("Visible preview refresh trigger disappeared after leaving Search") + XCTFail("Visible preview refresh trigger disappeared after returning to Search") return } tap(previewRefresh, in: app) From c4704c01f9d22f3f11defd956d25d1812424bbee Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:39:46 -0700 Subject: [PATCH 2/2] Preserve workspace search across detail navigation --- .../WorkspaceListLayoutPreviewView.swift | 79 ++++++++++--------- .../WorkspaceShellView.swift | 45 ++++++++++- 2 files changed, 83 insertions(+), 41 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift index baf4393d75fb..931b2d2fd7c8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListLayoutPreviewView.swift @@ -165,7 +165,8 @@ public struct WorkspaceListLayoutPreviewView: View { } @State private var fixtureRoute: FixtureWorkspaceRoute? - @State private var pendingSearchFixtureRoute: FixtureWorkspaceRoute? + @State private var searchFixtureRoute: FixtureWorkspaceRoute? + @State private var restoresSearchPresentationOnReturn = false private var scrollMetricsEnabled: Bool { ProcessInfo.processInfo.environment["CMUX_UITEST_SCROLL_METRICS"] == "1" @@ -496,33 +497,11 @@ public struct WorkspaceListLayoutPreviewView: View { workspaceListFixture(searchText: searchText) } .navigationDestination(item: $fixtureRoute) { route in - VStack(spacing: 12) { - Text( - model.workspaces.first(where: { $0.id == route.id })?.name - ?? route.id.rawValue - ) - .font(.title2) - Text("Fixture workspace detail") - .foregroundStyle(.secondary) - } - .accessibilityIdentifier("FixtureWorkspaceDetail") - .toolbarVisibility(.hidden, for: .tabBar, .bottomBar) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - WorkspaceBackButton(unreadCount: 0) { - fixtureRoute = nil - } - } + fixtureWorkspaceDetail(route: route) { + fixtureRoute = nil } } } - .onAppear { - consumePendingSearchFixtureNavigation() - } - .onChange(of: pendingSearchFixtureRoute) { _, _ in - consumePendingSearchFixtureNavigation() - } .overlay(alignment: .bottomTrailing) { if scrollMetricsEnabled { WorkspaceListScrollMetricsProbe(runsSweep: scrollSweepEnabled) @@ -549,6 +528,15 @@ public struct WorkspaceListLayoutPreviewView: View { ) { searchText in workspaceListFixture(searchText: searchText) } + .navigationDestination(item: $searchFixtureRoute) { route in + fixtureWorkspaceDetail(route: route) { + searchFixtureRoute = nil + } + } + } + .onChange(of: searchFixtureRoute) { oldRoute, newRoute in + guard oldRoute != nil, newRoute == nil else { return } + restoreSearchPresentationIfNeeded() } } notificationSearch: { Text("Notification feed fixture") @@ -559,10 +547,6 @@ public struct WorkspaceListLayoutPreviewView: View { } } } - .onChange(of: primarySearchCoordinator.isPresented) { _, isPresented in - guard !isPresented else { return } - consumePendingSearchFixtureNavigation() - } .overlay(alignment: .topLeading) { ZStack(alignment: .topLeading) { Color.clear @@ -601,19 +585,40 @@ public struct WorkspaceListLayoutPreviewView: View { let route = FixtureWorkspaceRoute(id: id) if showsTabScaffold, selectedPrimaryTab == .search || primarySearchCoordinator.isPresented { - pendingSearchFixtureRoute = route - transitionPrimaryTab(to: .workspaces) + restoresSearchPresentationOnReturn = primarySearchCoordinator.isPresented + searchFixtureRoute = route } else { fixtureRoute = route } } - private func consumePendingSearchFixtureNavigation() { - guard !primarySearchCoordinator.isPresented, - selectedPrimaryTab == .workspaces, - let route = pendingSearchFixtureRoute else { return } - pendingSearchFixtureRoute = nil - fixtureRoute = route + private func fixtureWorkspaceDetail( + route: FixtureWorkspaceRoute, + onBack: @escaping () -> Void + ) -> some View { + VStack(spacing: 12) { + Text( + model.workspaces.first(where: { $0.id == route.id })?.name + ?? route.id.rawValue + ) + .font(.title2) + Text("Fixture workspace detail") + .foregroundStyle(.secondary) + } + .accessibilityIdentifier("FixtureWorkspaceDetail") + .toolbarVisibility(.hidden, for: .tabBar, .bottomBar) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + WorkspaceBackButton(unreadCount: 0, action: onBack) + } + } + } + + private func restoreSearchPresentationIfNeeded() { + guard restoresSearchPresentationOnReturn else { return } + restoresSearchPresentationOnReturn = false + primarySearchCoordinator.setPresentation(true) } @discardableResult diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 655545535b83..174aa3dc6088 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -167,7 +167,9 @@ struct WorkspaceShellView: View { #if os(iOS) @State private var selectedPrimaryTab: MobilePrimaryTab = .workspaces @State private var notificationNavigationPath: [MobileWorkspacePreview.ID] = [] + @State private var workspaceSearchNavigationPath: [MobileWorkspacePreview.ID] = [] @State private var notificationSearchNavigationPath: [MobileWorkspacePreview.ID] = [] + @State private var restoreSearchOnDetailReturn = false @State private var pendingPrimarySearchWorkspaceNavigationID: MobileWorkspacePreview.ID? @State private var pendingPrimarySearchNotificationNavigationID: MobileWorkspacePreview.ID? @State private var showingRootSettings = false @@ -344,7 +346,7 @@ struct WorkspaceShellView: View { private func workspaceSearchTabContent(canCreateWorkspaceForSelection: Bool) -> some View { workspaceActionToastOverlay { - NavigationStack { + NavigationStack(path: $workspaceSearchNavigationPath) { MobilePrimaryWorkspaceSearchContentHost( searchCoordinator: primarySearchCoordinator ) { searchText in @@ -360,9 +362,30 @@ struct WorkspaceShellView: View { ) } .toolbar { - rootToolbarContent + if workspaceSearchNavigationPath.isEmpty { + rootToolbarContent + } + } + .navigationDestination(for: MobileWorkspacePreview.ID.self) { workspaceID in + workspaceDestination( + for: workspaceID, + createWorkspace: createWorkspaceInCompactStack, + canCreateWorkspaceForSelection: canCreateWorkspaceForSelection, + backButtonConfiguration: WorkspaceBackButtonConfiguration( + unreadCount: unreadWorkspaceCount(excluding: workspaceID), + badgeContrast: .darkBackground, + action: popWorkspaceSearchStack + ) + ) + .toolbarVisibility(.hidden, for: .tabBar, .bottomBar) + .navigationBarBackButtonHidden(true) + .background(InteractiveSwipeBackEnabler()) } } + .onChange(of: workspaceSearchNavigationPath) { oldPath, newPath in + guard !oldPath.isEmpty, newPath.isEmpty else { return } + restoreWorkspaceSearchPresentationIfNeeded() + } } } @@ -482,6 +505,7 @@ struct WorkspaceShellView: View { } } .onChange(of: store.selectedWorkspaceID) { _, selectedWorkspaceID in + guard workspaceSearchNavigationPath.isEmpty else { return } if let createdPath = compactNavigationPolicy.pathForCreatedWorkspaceSelection( currentPath: compactNavigationPath, selectedWorkspaceID: selectedWorkspaceID, @@ -900,8 +924,21 @@ struct WorkspaceShellView: View { } private func selectWorkspaceFromSearch(_ id: MobileWorkspacePreview.ID) { - pendingPrimarySearchWorkspaceNavigationID = id - transitionPrimaryTab(to: .workspaces) + restoreSearchOnDetailReturn = primarySearchCoordinator.isPresented + workspaceSearchNavigationPath = [id] + pendingCompactCreateNavigationWorkspaceIDs = nil + store.selectedWorkspaceID = id + } + + private func popWorkspaceSearchStack() { + guard !workspaceSearchNavigationPath.isEmpty else { return } + workspaceSearchNavigationPath.removeLast() + } + + private func restoreWorkspaceSearchPresentationIfNeeded() { + guard restoreSearchOnDetailReturn else { return } + restoreSearchOnDetailReturn = false + primarySearchCoordinator.setPresentation(true) } private func createWorkspaceFromSearch() {