Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10612,6 +10612,13 @@ struct VerticalTabsSidebar: View, Equatable {
/// notifications) would otherwise stay unrendered until the next
/// unrelated sidebar change. The bump forces one fresh rebuild.
@State private var appKitPostResizeRefreshToken: UInt64 = 0
// Bumped when a completed row click parks in the table controller
// awaiting live actions. The park mutates no other tracked state and
// this view is Equatable-gated, so without this token nothing would
// re-evaluate the body, no authoritative apply would re-arm the rows,
// and the parked click would wait on unrelated invalidation
// (issue #9690: taps only landed after an app focus cycle).
@State private var appKitTableApplyRequestToken: UInt64 = 0
@State private var workspaceScrollContentMinHeight: CGFloat = 0
@State private var checklistPopoverWorkspaceId: UUID?
// Pending keyed refresh ids are intentionally non-observed. Workspace
Expand Down Expand Up @@ -11444,6 +11451,7 @@ struct VerticalTabsSidebar: View, Equatable {
private func appKitWorkspaceScrollArea(renderContext: WorkspaceListRenderContext) -> some View {
let _ = anchorCwdRevision
let _ = appKitPostResizeRefreshToken
let _ = appKitTableApplyRequestToken
let tableRows: [SidebarWorkspaceTableRowConfiguration]
let isDividerDragActive = isPresented
&& TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive(in: observedWindow)
Expand Down Expand Up @@ -11476,7 +11484,8 @@ struct VerticalTabsSidebar: View, Equatable {
selectedWorkspaceId: selectedWorkspaceId,
selectedScrollTargetWorkspaceId: selectedScrollTargetWorkspaceId,
isPresented: isPresented,
unreadSource: sidebarUnread
unreadSource: sidebarUnread,
onDeferredClickAwaitingApply: { appKitTableApplyRequestToken &+= 1 }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.mask(
Expand Down
15 changes: 15 additions & 0 deletions Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS
private var rows: [SidebarWorkspaceTableRowConfiguration] = []
private var actions: SidebarWorkspaceTableActions?
private var deferredRowClick: DeferredRowClick?
/// SwiftUI-side wake-up for a parked click. A deferred click only lands
/// through the next authoritative apply, and applies only happen when
/// the deliberately Equatable-gated sidebar body re-evaluates. The park
/// itself mutates no SwiftUI-tracked state, so without requesting an
/// apply an idle app never re-arms the rows and the click waits on
/// unrelated invalidation — historically an app deactivate/reactivate
/// (issue #9690).
var onDeferredRowClickAwaitingApply: (() -> Void)?
private var hoveredRowId: SidebarWorkspaceRenderItemID?
private var contextMenuRowId: SidebarWorkspaceRenderItemID?
private var workspaceIds: [UUID] = []
Expand Down Expand Up @@ -565,6 +573,13 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS
// so preserve the completed click by stable row identity.
previewSelection(row: row, modifiers: modifiers, hitView: nil)
deferredRowClick = click
// Request the apply the replay depends on. Fired only from a
// physical click (never from a replay re-park), so a request per
// click is the ceiling and a pathological apply cannot loop.
#if DEBUG
cmuxDebugLog("sidebar.table.applyRequest row=\(row)")
#endif
onDeferredRowClickAwaitingApply?()
case .invalid:
deferredRowClick = nil
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ struct SidebarWorkspaceTableView: NSViewRepresentable {
let selectedScrollTargetWorkspaceId: UUID?
let isPresented: Bool
let unreadSource: SidebarUnreadModel
/// Invoked when a completed row click parks awaiting live actions; the
/// owner must invalidate itself so this view re-applies (issue #9690).
let onDeferredClickAwaitingApply: () -> Void

#if DEBUG
@Environment(\.sidebarLazyContractProbe) private var sidebarLazyContractProbe
Expand All @@ -28,6 +31,7 @@ struct SidebarWorkspaceTableView: NSViewRepresentable {
context.coordinator.reconfigurationProbe = sidebarLazyContractProbe.tableRootViewReconfigure
#endif
context.coordinator.setUnreadSource(unreadSource)
context.coordinator.onDeferredRowClickAwaitingApply = onDeferredClickAwaitingApply
context.coordinator.setPresentationActive(isPresented, workspaceIds: workspaceIds)
guard isPresented else { return }
context.coordinator.apply(
Expand Down
88 changes: 88 additions & 0 deletions cmuxTests/SidebarWorkspaceTableSuspensionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,94 @@ struct SidebarWorkspaceTableSuspensionTests {
)
}

@Test
func visibleRowClickWhileRevealApplyIsPendingRequestsAuthoritativeApply() async throws {
let tabManager = TabManager(autoWelcomeIfNeeded: false)
let initiallySelectedWorkspace = try #require(tabManager.selectedWorkspace)
let clickedWorkspace = tabManager.addWorkspace(
select: false,
autoWelcomeIfNeeded: false,
autoRefreshMetadata: false
)
let model = SidebarWorkspaceRowSuspensionTests.makeModel(
workspaceId: clickedWorkspace.id
)
let row = SidebarWorkspaceTableRowConfiguration(
workspaceRowModel: model,
actions: SidebarWorkspaceRowSuspensionTests.makeActions(
model: model,
workspace: clickedWorkspace,
tabManager: tabManager
),
groupId: nil,
isPinned: false,
environment: SidebarWorkspaceTableEnvironmentSnapshot(
colorScheme: .light,
globalFontMagnificationPercent: 100,
lazyContractProbe: SidebarLazyContractProbe()
)
)
let controller = SidebarWorkspaceTableController()
let container = controller.makeContainerView()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.contentView = container
defer {
window.contentView = nil
window.close()
}
var applyRequests = 0
controller.onDeferredRowClickAwaitingApply = { applyRequests += 1 }

controller.apply(
rows: [row],
actions: makeTableActions(),
workspaceIds: [clickedWorkspace.id],
selectedWorkspaceId: initiallySelectedWorkspace.id,
selectedScrollTargetWorkspaceId: nil
)
await flushStagedTableMutations()
container.layoutSubtreeIfNeeded()
container.tableView.layoutSubtreeIfNeeded()

controller.setPresentationActive(false, workspaceIds: [clickedWorkspace.id])
controller.setPresentationActive(true, workspaceIds: [clickedWorkspace.id])

let table = container.tableView
let action = try #require(table.action)
let target = try #require(table.target)
table.setValue(0, forKey: "clickedRow")
defer { table.setValue(-1, forKey: "clickedRow") }
#expect(table.sendAction(action, to: target))

#expect(
applyRequests == 1,
"""
A click parked on a reveal-time row must request an authoritative \
apply: the park mutates no SwiftUI-tracked state, so nothing else \
re-evaluates the Equatable-gated sidebar and the click stays \
parked until unrelated invalidation (issue #9690: taps only \
landed after an app focus cycle).
"""
)

// Respond to the request the way production SwiftUI does — with a
// fresh authoritative apply — and confirm the parked click lands.
controller.apply(
rows: [row],
actions: makeTableActions(),
workspaceIds: [clickedWorkspace.id],
selectedWorkspaceId: initiallySelectedWorkspace.id,
selectedScrollTargetWorkspaceId: nil
)
await flushStagedTableMutations()
#expect(tabManager.selectedTabId == clickedWorkspace.id)
}

@Test
func hidingRetiresNativeReorderSession() async {
let controller = SidebarWorkspaceTableController()
Expand Down