Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SwiftUI
#if os(iOS)
/// Extends the workspace table under the vertical bars on iOS 26, where the
/// scroll edge effect and `WorkspaceListScrollEdgeCoordinator`'s bar
/// registration render the App Store-style soft blur over the underlap.
///
/// SwiftUI fits a `UIViewRepresentable` inside the safe area, so without this
/// the table's frame starts below the search drawer and ends above the tab
/// bar: rows hard-clip at the chrome and no scroll edge effect can render.
/// The real UIKit bars still contribute safe area, so the table's automatic
/// content-inset adjustment keeps rows and indicators clear of the chrome,
/// and the keyboard region stays respected. Earlier releases keep the fitted
/// frame: the coordinator's registration is iOS 26-gated, and underlapping
/// without it would scroll full-opacity rows beneath legacy bars.
struct WorkspaceListBarUnderlap: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.ignoresSafeArea(.container, edges: .vertical)
} else {
content
}
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ public struct WorkspaceListLayoutPreviewView: View {
ProcessInfo.processInfo.environment["CMUX_UITEST_NOTIFICATION_BANNER"] == "1"
}

/// `CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS=1` wraps the list in a tab
/// scaffold mirroring the shell's TabView, so scroll-edge behavior against
/// the real floating tab bar can be exercised without Mac pairing. Off by
/// default: the App Store screenshot rig expects the bare list chrome.
private var showsTabScaffold: Bool {
ProcessInfo.processInfo.environment["CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS"] == "1"
}

public var body: some View {
let workspacesBinding = $model.workspaces
let refreshGenerationBinding = $refreshGeneration
Expand All @@ -220,7 +228,7 @@ public struct WorkspaceListLayoutPreviewView: View {
} else if UITestConfig.workspaceDetailDelayedTerminalPreviewEnabled {
WorkspaceDetailDelayedTerminalPreviewView()
} else {
NavigationStack {
let workspaceListStack = NavigationStack {
WorkspaceListSearchHost { searchText in
WorkspaceListView(
workspaces: model.workspaces,
Expand Down Expand Up @@ -302,6 +310,19 @@ public struct WorkspaceListLayoutPreviewView: View {
.accessibilityHidden(true)
}
}
if showsTabScaffold {
TabView {
Tab("Workspaces", systemImage: "rectangle.stack") {
workspaceListStack
}
Tab("Notifications", systemImage: "bell") {
Text("Notification feed fixture")
.foregroundStyle(.secondary)
}
}
} else {
workspaceListStack
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
.overlay(alignment: .topLeading) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#if os(iOS)
import UIKit

/// Registers the workspace table as the content scroll view of its enclosing
/// navigation and tab bar controllers so UIKit renders the scroll edge effect
/// under the top chrome (navigation bar + search drawer) and behind the tab
/// bar, App Store-style.
///
/// SwiftUI only drives bar scroll edge effects for its own scroll views. The
/// workspace list is a `UIViewRepresentable` `UITableView`, invisible to that
/// machinery, so without this registration the table's `.soft` top edge style
/// never renders and rows hard-clip at the search bar's bottom edge.
@MainActor
final class WorkspaceListScrollEdgeCoordinator {
private weak var registeredScrollView: UIScrollView?
private weak var navigationContentController: UIViewController?
private weak var tabContentController: UIViewController?

/// Re-resolves the bar-owning controllers for `scrollView` and claims any
/// edge whose registration is vacant. Called on every layout pass: the
/// controller chain can assemble incrementally (navigation controller
/// before tab controller) or reparent without the window changing, so a
/// one-shot registration would strand a partially registered edge.
///
/// Ownership arbitration: an edge is claimed only when its current
/// registration is nil or held by a detached scroll view. A different
/// LIVE table keeps its registration, so coexisting tables (SwiftUI
/// transition overlap) never steal from each other; when the owner
/// departs it clears the edge and the survivor reclaims on its next
/// layout pass. When nothing changed this is a no-op costing a short
/// parent walk and two getter comparisons.
func registerIfNeeded(for scrollView: UIScrollView) {
guard #available(iOS 26.0, *) else { return }
guard scrollView.window != nil else { return }
let navigationContent = contentController(
hosting: scrollView, inParentOfKind: UINavigationController.self
)
let tabContent = contentController(
hosting: scrollView, inParentOfKind: UITabBarController.self
)
guard navigationContent != nil || tabContent != nil else { return }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if navigationContent !== navigationContentController
|| scrollView !== registeredScrollView {
clearEdgeIfOwned(on: navigationContentController, edge: .top)
}
if tabContent !== tabContentController || scrollView !== registeredScrollView {
clearEdgeIfOwned(on: tabContentController, edge: .bottom)
}
registeredScrollView = scrollView
navigationContentController = navigationContent
tabContentController = tabContent

if let navigationContent,
canClaim(current: navigationContent.contentScrollView(for: .top),
claimant: scrollView) {
navigationContent.setContentScrollView(scrollView, for: .top)
}
if let tabContent,
canClaim(current: tabContent.contentScrollView(for: .bottom),
claimant: scrollView) {
tabContent.setContentScrollView(scrollView, for: .bottom)
}
}

/// Clears this coordinator's registrations. A registration held by
/// another table (same controller, different scroll view) is left intact.
func unregister() {
guard #available(iOS 26.0, *) else { return }
clearEdgeIfOwned(on: navigationContentController, edge: .top)
clearEdgeIfOwned(on: tabContentController, edge: .bottom)
navigationContentController = nil
tabContentController = nil
registeredScrollView = nil
}

private func clearEdgeIfOwned(on controller: UIViewController?, edge: NSDirectionalRectEdge) {
guard let controller,
let scrollView = registeredScrollView,
controller.contentScrollView(for: edge) === scrollView else { return }
controller.setContentScrollView(nil, for: edge)
// Deterministic handoff: a waiting table stood down while this one
// owned the edge, and UIKit does not guarantee it another layout pass
// after this teardown. Nudge it so its next pass claims the vacancy;
// the woken table still runs its own claim arbitration.
if let waiting = firstWorkspaceTable(
under: controller.viewIfLoaded, excluding: scrollView
) {
waiting.setNeedsLayout()
}
}

private func firstWorkspaceTable(
under view: UIView?, excluding departing: UIScrollView
) -> WorkspaceListUITableView? {
guard let view else { return nil }
if let table = view as? WorkspaceListUITableView, table !== departing {
return table
}
for subview in view.subviews {
if let found = firstWorkspaceTable(under: subview, excluding: departing) {
return found
}
}
return nil
}

/// Vacant (nil) and stale (holder departed its window without clearing,
/// e.g. deallocated mid-transition) registrations are claimable. An edge
/// already held by the claimant needs no re-set, and a different live
/// scroll view keeps ownership.
private func canClaim(current: UIScrollView?, claimant: UIScrollView) -> Bool {
guard let current else { return true }
if current === claimant { return false }
return current.window == nil
}

/// The last view controller on `view`'s parent chain before the first
/// container of `Kind`: the content controller whose bars that container
/// derives from `setContentScrollView(_:for:)` registrations.
private func contentController<Kind: UIViewController>(
hosting view: UIView, inParentOfKind kind: Kind.Type
) -> UIViewController? {
var responder: UIResponder? = view.next
while let current = responder, !(current is UIViewController) {
responder = current.next
}
guard var content = responder as? UIViewController else { return nil }
while let parent = content.parent {
if parent is Kind { return content }
content = parent
}
return nil
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ final class WorkspaceListUITableView: UITableView {
var layoutMetricsDidChange: (() -> Void)?

private var measuredWidth: CGFloat = 0
private let scrollEdgeCoordinator = WorkspaceListScrollEdgeCoordinator()

override init(frame: CGRect, style: UITableView.Style) {
super.init(frame: frame, style: style)
Expand All @@ -18,13 +19,25 @@ final class WorkspaceListUITableView: UITableView {
configureTopScrollEdgeEffect()
}

override func didMoveToWindow() {
super.didMoveToWindow()
if window == nil {
scrollEdgeCoordinator.unregister()
} else {
scrollEdgeCoordinator.registerIfNeeded(for: self)
}
}

override func layoutSubviews() {
let previousWidth = measuredWidth
super.layoutSubviews()
measuredWidth = bounds.width
if previousWidth > 0, abs(previousWidth - measuredWidth) > 0.5 {
layoutMetricsDidChange?()
}
if window != nil {
scrollEdgeCoordinator.registerIfNeeded(for: self)
}
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ struct WorkspaceListView: View {
)
#if os(iOS)
let baseList = workspaceTable
.modifier(WorkspaceListBarUnderlap())
#else
let baseList = List {
switch connectionChrome {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#if os(iOS)
import Testing
import UIKit
@testable import CmuxMobileShellUI

/// The workspace table is a `UIViewRepresentable`, so SwiftUI never registers
/// it with the enclosing bars. These tests pin the UIKit contract that makes
/// the soft scroll edge effect render: once hosted under a navigation and tab
/// bar controller, the table must be each container's content scroll view.
@MainActor
@Suite struct WorkspaceListScrollEdgeEffectTests {
@Test func hostedTableDrivesNavigationAndTabBarScrollEdgeEffects() throws {
guard #available(iOS 26.0, *) else { return }
let fixture = Fixture()

#expect(fixture.content.contentScrollView(for: .top) === fixture.tableView)
#expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView)
}

@Test func tableLeavingTheWindowReleasesBarRegistrations() throws {
guard #available(iOS 26.0, *) else { return }
let fixture = Fixture()

fixture.tableView.removeFromSuperview()

#expect(fixture.content.contentScrollView(for: .top) == nil)
#expect(fixture.navigation.contentScrollView(for: .bottom) == nil)
}

/// The controller chain can assemble incrementally: the navigation
/// controller can host the table before it joins a tab bar controller.
/// Whichever lifecycle path fires for the move (UIKit container
/// attachment always relocates the child's view, so `didMoveToWindow`
/// and layout passes both run), the bottom edge must end registered.
/// UIKit's hierarchy-consistency check forbids re-parenting a controller
/// whose view stays in a foreign hierarchy, so a window-stable variant of
/// this scenario is not constructible; the window-stable layout-retry
/// path is covered by
/// ``survivingTableReclaimsRegistrationAfterOwnerDeparts()``.
@Test func lateTabControllerAttachmentStillRegistersBottomEdge() throws {
guard #available(iOS 26.0, *) else { return }
let tableView = WorkspaceListUITableView(frame: .zero, style: .plain)
let content = UIViewController()
content.view.addSubview(tableView)
let navigation = UINavigationController(rootViewController: content)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = navigation
window.isHidden = false
window.layoutIfNeeded()
content.view.layoutIfNeeded()
#expect(content.contentScrollView(for: .top) === tableView)
#expect(navigation.contentScrollView(for: .bottom) == nil)

let tabs = UITabBarController()
window.rootViewController = tabs
tabs.viewControllers = [navigation]
window.layoutIfNeeded()
tableView.setNeedsLayout()
tableView.layoutIfNeeded()

#expect(content.contentScrollView(for: .top) === tableView)
#expect(navigation.contentScrollView(for: .bottom) === tableView)
}

/// While two live tables coexist (SwiftUI transition overlap), the later
/// one must not steal the registration: ownership would otherwise become
/// layout-order dependent and thrash on every pass.
@Test func coexistingTablesDoNotStealRegistrationFromEachOther() throws {
guard #available(iOS 26.0, *) else { return }
let fixture = Fixture()
let second = WorkspaceListUITableView(frame: .zero, style: .plain)

fixture.content.view.addSubview(second)
second.layoutIfNeeded()
#expect(fixture.content.contentScrollView(for: .top) === fixture.tableView)

second.setNeedsLayout()
second.layoutIfNeeded()
fixture.tableView.setNeedsLayout()
fixture.tableView.layoutIfNeeded()

#expect(fixture.content.contentScrollView(for: .top) === fixture.tableView)
#expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView)
}

/// When the owning table departs it clears the edges and nudges the
/// waiting table, which claims them on the next layout flush. The test
/// only flushes pending window layout; the survivor is never marked
/// dirty by hand, so a missing wake fails the assertions.
@Test func survivingTableReclaimsRegistrationAfterOwnerDeparts() throws {
guard #available(iOS 26.0, *) else { return }
let fixture = Fixture()
let second = WorkspaceListUITableView(frame: .zero, style: .plain)
fixture.content.view.addSubview(second)
second.layoutIfNeeded()
#expect(fixture.content.contentScrollView(for: .top) === fixture.tableView)

fixture.tableView.removeFromSuperview()
#expect(fixture.content.contentScrollView(for: .top) == nil)

fixture.window.layoutIfNeeded()

#expect(fixture.content.contentScrollView(for: .top) === second)
#expect(fixture.navigation.contentScrollView(for: .bottom) === second)
}

/// Table hosted under `UITabBarController > UINavigationController >
/// content`, mirroring the shell's TabView + NavigationStack chrome.
@MainActor
private struct Fixture {
let tableView: WorkspaceListUITableView
let content: UIViewController
let navigation: UINavigationController
let tabs: UITabBarController
let window: UIWindow

init() {
tableView = WorkspaceListUITableView(frame: .zero, style: .plain)
content = UIViewController()
content.view.addSubview(tableView)
navigation = UINavigationController(rootViewController: content)
tabs = UITabBarController()
tabs.viewControllers = [navigation]
window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = tabs
window.isHidden = false
window.layoutIfNeeded()
content.view.layoutIfNeeded()
}
}
}
#endif