diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e7a2d99a814..606dbac2eb3e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9887,6 +9887,13 @@ private final class SidebarTabItemSettingsStore: ObservableObject { } } +private struct SidebarTabItemPresentationSnapshot: Equatable { + let tabId: UUID + let unreadCount: Int + let latestNotificationText: String? + let showsModifierShortcutHints: Bool +} + struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void @@ -9902,6 +9909,7 @@ struct VerticalTabsSidebar: View { @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? + @State private var frozenTabItemPresentation: SidebarTabItemPresentationSnapshot? @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @@ -9967,6 +9975,26 @@ struct VerticalTabsSidebar: View { let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets ? allSelectedRemoteContextMenuTargetsDisconnected : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) + let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) + let liveLatestNotificationText: String? = { + guard showsSidebarNotificationMessage, + let notification = notificationStore.latestNotification(forTabId: tab.id) else { + return nil + } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() + let liveShowsModifierShortcutHints = modifierKeyMonitor.isModifierPressed + let livePresentation = SidebarTabItemPresentationSnapshot( + tabId: tab.id, + unreadCount: liveUnreadCount, + latestNotificationText: liveLatestNotificationText, + showsModifierShortcutHints: liveShowsModifierShortcutHints + ) + let frozenPresentation = frozenTabItemPresentation?.tabId == tab.id + ? frozenTabItemPresentation + : nil TabItemView( tabManager: tabManager, notificationStore: notificationStore, @@ -9980,21 +10008,13 @@ struct VerticalTabsSidebar: View { workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, canCloseWorkspace: canCloseWorkspace, accessibilityWorkspaceCount: workspaceCount, - unreadCount: notificationStore.unreadCount(forTabId: tab.id), - latestNotificationText: { - guard showsSidebarNotificationMessage, - let notification = notificationStore.latestNotification(forTabId: tab.id) else { - return nil - } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }(), + unreadCount: frozenPresentation?.unreadCount ?? liveUnreadCount, + latestNotificationText: frozenPresentation?.latestNotificationText ?? liveLatestNotificationText, rowSpacing: tabRowSpacing, setSelectionToTabs: { selection = .tabs }, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, + showsModifierShortcutHints: frozenPresentation?.showsModifierShortcutHints ?? liveShowsModifierShortcutHints, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, @@ -10002,7 +10022,9 @@ struct VerticalTabsSidebar: View { remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, - settings: tabItemSettings + settings: tabItemSettings, + livePresentation: livePresentation, + frozenPresentation: $frozenTabItemPresentation ) .equatable() } @@ -10103,6 +10125,11 @@ struct VerticalTabsSidebar: View { dragAutoScrollController.stop() dropIndicator = nil } + .onChange(of: tabs.map(\.id)) { tabIds in + guard let frozenTabItemPresentation, + !tabIds.contains(frozenTabItemPresentation.tabId) else { return } + self.frozenTabItemPresentation = nil + } .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in guard draggedTabId != nil else { return } let reason = SidebarDragLifecycleNotification.reason(from: notification) @@ -12377,6 +12404,11 @@ enum SidebarTrailingAccessoryWidthPolicy { // and bridge only sidebar-visible workspace changes into local state. // Do NOT add @EnvironmentObject or new @Binding without updating ==. // Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +private final class SidebarTabItemContextMenuState: ObservableObject { + var isVisible = false + var hasDeferredWorkspaceObservationInvalidation = false +} + private struct TabItemView: View, Equatable { private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) @@ -12429,7 +12461,10 @@ private struct TabItemView: View, Equatable { let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool let settings: SidebarTabItemSettingsSnapshot + let livePresentation: SidebarTabItemPresentationSnapshot + @Binding var frozenPresentation: SidebarTabItemPresentationSnapshot? @State private var workspaceObservationGeneration: UInt64 = 0 + @StateObject private var contextMenuState = SidebarTabItemContextMenuState() @State private var isHovering = false @State private var rowHeight: CGFloat = 1 @@ -13027,7 +13062,7 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif - workspaceObservationGeneration &+= 1 + scheduleWorkspaceObservationInvalidation() } .onReceive( tab.sidebarObservationPublisher @@ -13047,7 +13082,7 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif - workspaceObservationGeneration &+= 1 + scheduleWorkspaceObservationInvalidation() } .onDrag { #if DEBUG @@ -13078,6 +13113,7 @@ private struct TabItemView: View, Equatable { updateSelection() } .onHover { hovering in + guard !contextMenuState.isVisible else { return } isHovering = hovering } .accessibilityElement(children: .combine) @@ -13089,7 +13125,37 @@ private struct TabItemView: View, Equatable { .accessibilityAction(named: Text(moveDownActionText)) { moveBy(1) } - .contextMenu { workspaceContextMenu } + .contextMenu { + workspaceContextMenu + .onAppear { + contextMenuState.isVisible = true + contextMenuState.hasDeferredWorkspaceObservationInvalidation = false + frozenPresentation = livePresentation + } + .onDisappear { + contextMenuState.isVisible = false + frozenPresentation = nil + if isHovering { + isHovering = false + } + flushDeferredWorkspaceObservationInvalidation() + } + } + } + + private func scheduleWorkspaceObservationInvalidation() { + // Keep the context menu stable while background workspace telemetry keeps arriving. + if contextMenuState.isVisible { + contextMenuState.hasDeferredWorkspaceObservationInvalidation = true + return + } + workspaceObservationGeneration &+= 1 + } + + private func flushDeferredWorkspaceObservationInvalidation() { + guard contextMenuState.hasDeferredWorkspaceObservationInvalidation else { return } + contextMenuState.hasDeferredWorkspaceObservationInvalidation = false + workspaceObservationGeneration &+= 1 } private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String {