diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b77b017b63b0..22ce1ccf1994 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9892,7 +9892,7 @@ struct VerticalTabsSidebar: View { @State var modifierKeyMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State var pointerInteractionMonitor = SidebarPointerInteractionMonitor() @StateObject var dragAutoScrollController = SidebarDragAutoScrollController() - @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() + @State private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore( initialSidebarFontSize: GhosttyConfig.load().sidebarFontSize ) @@ -9911,7 +9911,6 @@ struct VerticalTabsSidebar: View { @State private var frozenShortcutHintsTabId: UUID? @State private var frozenShortcutHintsValue: Bool = false @State private var pendingSelectedWorkspaceScrollId: UUID? - @State private var workspaceScrollContentMinHeight: CGFloat = 0 @State private var collapsedExtensionSidebarSectionIds: Set = [] @State private var extensionSidebarWorktreeCreationInFlightSectionIds: Set = [] // Per-workspace transient checklist UI state (never persisted): which @@ -9925,6 +9924,15 @@ struct VerticalTabsSidebar: View { // the sidebar). Held at the container so rows stay behind the snapshot // boundary. @State private var checklistPopoverWorkspaceId: UUID? + // Pending keyed refresh ids are intentionally non-observed. Workspace + // publisher bursts cross into SwiftUI once per run-loop batch instead of + // invalidating the full parent projection once per emitting workspace. + @State private var workspaceSnapshotRefreshCoalescer = SidebarWorkspaceSnapshotRefreshCoalescer() + // Parent-owned immutable workspace projections. Workspace publishers and + // async observation streams terminate here, above the LazyVStack; rows + // receive only values and action closures. This is the ownership boundary + // that prevents layout/realization from publishing row state (#6707). + @State private var workspaceSnapshotsById: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot] = [:] @State private var extensionSidebarUpdateToken: UInt64 = 0 // Stable, memoized merged observation publishers for the extension // sidebar's `.onReceive` handlers. Rebuilding them inline each body pass @@ -9954,6 +9962,7 @@ struct VerticalTabsSidebar: View { @LiveSetting(\.sidebar.showAgentActivity) private var showAgentActivity #if DEBUG @Environment(\.minimalModeInvalidationProbe) private var minimalModeInvalidationProbe + @Environment(\.sidebarLazyContractProbe) private var sidebarLazyContractProbe #endif // The provider to actually render. Built-in views are always honored; only @@ -10252,6 +10261,7 @@ struct VerticalTabsSidebar: View { let allSelectedRemoteContextMenuTargetsDisconnected: Bool let workspaceGroups: [WorkspaceGroup] let workspaceGroupById: [UUID: WorkspaceGroup] + let memberWorkspaceIdsByGroupId: [UUID: [UUID]] let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot let workspaceRenderItems: [SidebarWorkspaceRenderItem] let visibleWorkspaceRowIds: [UUID] @@ -10293,6 +10303,7 @@ struct VerticalTabsSidebar: View { selectedRemoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected } let workspaceGroups = tabManager.workspaceGroups let workspaceGroupById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) + let memberWorkspaceIdsByGroupId = SidebarWorkspaceRenderItem.memberWorkspaceIdsByGroupId(tabs: tabs) let workspaceGroupMenuSnapshot = WorkspaceGroupMenuSnapshot( items: workspaceGroups.map { WorkspaceGroupMenuSnapshot.Item(id: $0.id, name: $0.name) } ) @@ -10331,6 +10342,7 @@ struct VerticalTabsSidebar: View { allSelectedRemoteContextMenuTargetsDisconnected: allSelectedRemoteContextMenuTargetsDisconnected, workspaceGroups: workspaceGroups, workspaceGroupById: workspaceGroupById, + memberWorkspaceIdsByGroupId: memberWorkspaceIdsByGroupId, workspaceGroupMenuSnapshot: workspaceGroupMenuSnapshot, workspaceRenderItems: workspaceRenderItems, visibleWorkspaceRowIds: visibleWorkspaceRowIds @@ -10477,10 +10489,19 @@ struct VerticalTabsSidebar: View { private func workspaceScrollArea(renderContext: WorkspaceListRenderContext) -> some View { let scrollInsets = SidebarWorkspaceScrollInsets.workspaceList - return ScrollViewReader { scrollProxy in - ScrollView(.vertical) { - workspaceScrollContent(renderContext: renderContext, minHeight: workspaceScrollContentMinHeight) - } + return GeometryReader { viewport in + // Keep viewport geometry as a downward-only layout input. Writing + // this value into @State from onGeometryChange feeds an + // NSHostingView layout pass back into the same LazyVStack graph; + // scrolling plus row-height churn can then prevent convergence. + let contentMinHeight = SidebarWorkspaceScrollLayout.contentMinHeight( + viewportHeight: viewport.size.height, + insets: scrollInsets + ) + ScrollViewReader { scrollProxy in + ScrollView(.vertical) { + workspaceScrollContent(renderContext: renderContext, minHeight: contentMinHeight) + } .coordinateSpace(name: SidebarPointerInteractionMonitor.coordinateSpaceName) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .sidebarPointerEventHost(pointerInteractionMonitor) @@ -10523,9 +10544,6 @@ struct VerticalTabsSidebar: View { } .background(Color.clear) .modifier(ClearScrollBackground()) - .onGeometryChange(for: CGFloat.self) { - SidebarWorkspaceScrollLayout.contentMinHeight(viewportHeight: $0.size.height, insets: scrollInsets) - } action: { updateWorkspaceScrollContentMinHeight($0) } .onAppear { requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) } @@ -10595,11 +10613,41 @@ struct VerticalTabsSidebar: View { } } } - } - private func updateWorkspaceScrollContentMinHeight(_ contentMinHeight: CGFloat) { - guard workspaceScrollContentMinHeight != contentMinHeight else { return } - var transaction = Transaction(animation: nil); transaction.disablesAnimations = true - withTransaction(transaction) { workspaceScrollContentMinHeight = contentMinHeight } + } + .sidebarProcessTitleObservations( + ids: renderContext.workspaceIds, + models: renderContext.tabs.map(\.sidebarProcessTitleObservation) + ) { workspaceId in + scheduleWorkspaceSnapshotRefresh(workspaceId: workspaceId) + } + .sidebarAgentRuntimeObservations( + ids: renderContext.workspaceIds, + models: renderContext.tabs.map(\.sidebarAgentRuntimeObservation) + ) { workspaceId in + scheduleWorkspaceSnapshotRefresh(workspaceId: workspaceId) + } + .sidebarWorkspaceObservations( + ids: renderContext.workspaceIds, + workspaces: renderContext.tabs, + debouncedInterval: Self.extensionSidebarObservationCoalesceInterval + ) { workspaceId in + scheduleWorkspaceSnapshotRefresh(workspaceId: workspaceId) + } + .onAppear { + refreshWorkspaceSnapshots() + } + .onChange(of: renderContext.workspaceIds) { _, _ in + refreshWorkspaceSnapshots() + } + .onChange(of: renderContext.tabItemSettings) { _, _ in + refreshWorkspaceSnapshots() + } + .onChange(of: renderContext.showsAgentActivity) { _, _ in + refreshWorkspaceSnapshots() + } + .onDisappear { + workspaceSnapshotRefreshCoalescer.cancel() + } } // Applies one stable overlay/autohide scroller config and never toggles it. @@ -10789,6 +10837,71 @@ struct VerticalTabsSidebar: View { extensionSidebarUpdateToken &+= 1 } + private func scheduleWorkspaceSnapshotRefresh(workspaceId: UUID) { + workspaceSnapshotRefreshCoalescer.schedule(workspaceId: workspaceId) { workspaceIds in + refreshWorkspaceSnapshots(workspaceIds: workspaceIds) + } + } + + private func refreshWorkspaceSnapshots(workspaceIds: Set) { + guard !workspaceIds.isEmpty else { return } + let workspaceById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) + let settings = tabItemSettingsStore.snapshot + let showsAgentActivity = showAgentActivity && CmuxFeatureFlags.shared.isSidebarWorkspaceAgentSpinnerEnabled + var next = workspaceSnapshotsById + var changed = false + for workspaceId in workspaceIds { + guard let workspace = workspaceById[workspaceId] else { + changed = next.removeValue(forKey: workspaceId) != nil || changed + continue + } + let snapshot = makeWorkspaceSnapshot( + workspace: workspace, + settings: settings, + showsAgentActivity: showsAgentActivity + ) + guard next[workspaceId] != snapshot else { continue } + next[workspaceId] = snapshot + changed = true + } + guard changed else { return } + workspaceSnapshotsById = next + } + + private func refreshWorkspaceSnapshots() { + workspaceSnapshotRefreshCoalescer.cancel() + let tabs = tabManager.tabs + let liveIds = Set(tabs.map(\.id)) + let settings = tabItemSettingsStore.snapshot + let showsAgentActivity = showAgentActivity && CmuxFeatureFlags.shared.isSidebarWorkspaceAgentSpinnerEnabled + var next: [UUID: SidebarWorkspaceSnapshotBuilder.Snapshot] = [:] + next.reserveCapacity(tabs.count) + for workspace in tabs { + next[workspace.id] = makeWorkspaceSnapshot( + workspace: workspace, + settings: settings, + showsAgentActivity: showsAgentActivity + ) + } + guard next != workspaceSnapshotsById || Set(workspaceSnapshotsById.keys) != liveIds else { return } + workspaceSnapshotsById = next + } + + private func makeWorkspaceSnapshot( + workspace: Workspace, + settings: SidebarTabItemSettingsSnapshot, + showsAgentActivity: Bool + ) -> SidebarWorkspaceSnapshotBuilder.Snapshot { +#if DEBUG + sidebarLazyContractProbe.workspaceSnapshotBuild?() +#endif + return SidebarWorkspaceSnapshotFactory( + workspace: workspace, + settings: settings, + showsAgentActivity: showsAgentActivity + ).makeSnapshot() + } + private func clearExtensionSidebarObservationPublishers() { extensionSidebarObservationWorkspaceIds = [] extensionSidebarObservationPublishersBuilt = false @@ -11778,26 +11891,64 @@ struct VerticalTabsSidebar: View { ) -> some View { let signpost = SidebarProfilingSignposts.begin("sidebar-workspace-rows", "renderItems=\(renderContext.workspaceRenderItems.count) collectDropTargets=\(shouldCollectWorkspaceDropTargets)") let renderItems = renderContext.workspaceRenderItems - // LazyVStack is safe here because `dragState` is @Observable: - // drag mutations at 60fps invalidate only the rows/overlays that - // read them, never this sidebar body. See SidebarDragState and - // https://github.com/manaflow-ai/cmux/issues/2586. + // Reduce live models to cheap immutable values above the LazyVStack. + // Shared notification/selection projections are built once here; full + // row trees and row-specific closure binding remain lazy. + let unreadSummariesByWorkspaceId = sidebarUnread.summaryByWorkspaceId + let notificationIndex = SidebarWorkspaceNotificationIndex( + notifications: notificationStore.notifications + ) + let workspaceRowInputsById = Dictionary(uniqueKeysWithValues: renderContext.tabs.map { workspace in + ( + workspace.id, + workspaceRowInput( + workspace, + renderContext: renderContext, + unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId + ) + ) + }) + let _ = anchorCwdRevision + let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in + ( + group.id, + sidebarWorkspaceGroupRowSnapshot( + group: group, + memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[group.id] ?? [], + renderContext: renderContext, + unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId, + notificationIndex: notificationIndex, + shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets, + showModifierHoldHints: showModifierHoldHints + ) + ) + }) + let listSnapshot = SidebarWorkspaceRowsSnapshot( + workspaceRowsById: workspaceRowInputsById, + groupRowsById: groupRowSnapshotsById, + selectedContextTargetIds: renderContext.selectedContextTargetIds, + anchorWorkspaceIds: Set(renderContext.workspaceGroups.map(\.anchorWorkspaceId)), + workspaceGroupMenuSnapshot: renderContext.workspaceGroupMenuSnapshot, + canCreateEmptyGroup: tabManager.selectedTab?.isRemoteTmuxMirror != true, + notificationIndex: notificationIndex + ) + let actionFactory = makeWorkspaceRowActionFactory() let rows = LazyVStack(spacing: tabRowSpacing) { ForEach(renderItems, id: \.id) { item in switch item { - case .groupHeader(let group, let memberWorkspaceIds): - sidebarWorkspaceGroupHeader( - group: group, - memberWorkspaceIds: memberWorkspaceIds, - renderContext: renderContext, - shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets, showModifierHoldHints: showModifierHoldHints - ) - case .workspace(let tab): - workspaceRow( - tab, - renderContext: renderContext, - shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets - ) + case .groupHeader(let groupId, _): + if let snapshot = listSnapshot.groupRowsById[groupId] { + sidebarWorkspaceGroupRow(snapshot: snapshot) + } + case .workspace(let workspaceId): + if let input = listSnapshot.workspaceRowsById[workspaceId] { + workspaceRow( + input: input, + listSnapshot: listSnapshot, + actionFactory: actionFactory, + shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets + ) + } } } } @@ -12170,31 +12321,210 @@ struct VerticalTabsSidebar: View { ) } - @ViewBuilder - private func workspaceRow( + private func selectWorkspaceRow( + _ workspace: Workspace, + index: Int, + modifiers: NSEvent.ModifierFlags + ) { + let isCommand = modifiers.contains(.command) + let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == workspace.id +#if DEBUG + var modifierDescription = "" + if isCommand { modifierDescription += "cmd " } + if isShift { modifierDescription += "shift " } + if modifiers.contains(.option) { modifierDescription += "opt " } + if modifiers.contains(.control) { modifierDescription += "ctrl " } + cmuxDebugLog( + "sidebar.select workspace=\(workspace.id.uuidString.prefix(5)) modifiers=" + + (modifierDescription.isEmpty + ? "none" + : modifierDescription.trimmingCharacters(in: .whitespaces)) + ) +#endif + + let workspaceIds = tabManager.tabs.map(\.id) + let shiftAnchorIndex = isShift + ? SidebarWorkspaceSelectionSyncPolicy().shiftClickAnchorIndex( + existingAnchorIndex: lastSidebarSelectionIndex, + selectedWorkspaceIds: selectedTabIds, + focusedWorkspaceId: tabManager.selectedTabId, + liveWorkspaceIds: workspaceIds + ) + : nil + + if isShift, let anchorIndex = shiftAnchorIndex { + let lower = min(anchorIndex, index) + let upper = max(anchorIndex, index) + let collapsedGroupIds = Set( + tabManager.workspaceGroups.filter(\.isCollapsed).map(\.id) + ) + let anchorIdsByGroup = Dictionary( + uniqueKeysWithValues: tabManager.workspaceGroups.map { ($0.id, $0.anchorWorkspaceId) } + ) + let rangeIds = tabManager.tabs[lower...upper].compactMap { candidate -> UUID? in + if let groupId = candidate.groupId, + collapsedGroupIds.contains(groupId), + anchorIdsByGroup[groupId] != candidate.id { + return nil + } + return candidate.id + } + if isCommand { + selectedTabIds.formUnion(rangeIds) + } else { + selectedTabIds = Set(rangeIds) + } + } else if isCommand { + if selectedTabIds.contains(workspace.id) { + selectedTabIds.remove(workspace.id) + } else { + selectedTabIds.insert(workspace.id) + } + } else { + selectedTabIds = [workspace.id] + } + + lastSidebarSelectionIndex = SidebarWorkspaceSelectionSyncPolicy().anchorIndexAfterWorkspaceClick( + isShiftClick: isShift, + resolvedShiftAnchorIndex: shiftAnchorIndex, + clickedIndex: index + ) + tabManager.selectTab(workspace) + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: workspace.id, + surfaceId: tabManager.focusedSurfaceId(for: workspace.id) + ) + } + selection = .tabs + } + + private func syncWorkspaceRowSelectionAfterMutation() { + let existingIds = Set(tabManager.tabs.map(\.id)) + selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + } + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + } + + private func moveWorkspaceRow(_ workspace: Workspace, by delta: Int) { + guard tabManager.reorderWorkspace(tabId: workspace.id, by: delta) else { return } + selectedTabIds = [workspace.id] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == workspace.id } + tabManager.selectTab(workspace) + selection = .tabs + } + + private func closeWorkspaceRows(_ workspaceIds: [UUID], allowPinned: Bool) { + tabManager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned) + syncWorkspaceRowSelectionAfterMutation() + } + + private func moveWorkspaceRows(_ workspaceIds: [UUID], toWindow windowId: UUID) { + guard let app = AppDelegate.shared else { return } + let orderedIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard !orderedIds.isEmpty else { return } + var movedIds: [UUID] = [] + movedIds.reserveCapacity(orderedIds.count) + for workspaceId in orderedIds { + if app.moveWorkspaceToWindow( + workspaceId: workspaceId, + windowId: windowId, + focus: false + ) { + movedIds.append(workspaceId) + } + } + guard let focusId = movedIds.last else { return } + // The workspace is already attached to the destination, so this takes + // AppDelegate's same-manager focus path rather than moving it twice. + _ = app.moveWorkspaceToWindow(workspaceId: focusId, windowId: windowId, focus: true) + selectedTabIds.subtract(movedIds) + syncWorkspaceRowSelectionAfterMutation() + } + + private func moveWorkspaceRowsToNewWindow(_ workspaceIds: [UUID]) { + guard let app = AppDelegate.shared else { return } + let orderedIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard let firstId = orderedIds.first else { return } + guard let newWindowId = app.moveWorkspaceToNewWindow( + workspaceId: firstId, + focus: false + ) else { return } + var movedIds = [firstId] + movedIds.reserveCapacity(orderedIds.count) + if orderedIds.count > 1 { + for workspaceId in orderedIds.dropFirst() { + if app.moveWorkspaceToWindow( + workspaceId: workspaceId, + windowId: newWindowId, + focus: false + ) { + movedIds.append(workspaceId) + } + } + } + // Focus the final successful attachment without detaching or attaching + // it again; AppDelegate recognizes it is already in this window. + if let focusId = movedIds.last { + _ = app.moveWorkspaceToWindow(workspaceId: focusId, windowId: newWindowId, focus: true) + } + selectedTabIds.subtract(movedIds) + syncWorkspaceRowSelectionAfterMutation() + } + + private func openWorkspaceRowPullRequest( + _ url: URL, + workspace: Workspace, + index: Int, + opensInCmuxBrowser: Bool + ) { + selectWorkspaceRow(workspace, index: index, modifiers: NSEvent.modifierFlags) + if opensInCmuxBrowser, + tabManager.openBrowser( + inWorkspace: workspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) != nil { + return + } + NSWorkspace.shared.open(url) + } + + private func openWorkspaceRowPort( + _ port: Int, + workspace: Workspace, + index: Int, + opensInCmuxBrowser: Bool + ) { + guard let url = URL(string: "http://localhost:\(port)") else { return } + openWorkspaceRowPullRequest( + url, + workspace: workspace, + index: index, + opensInCmuxBrowser: opensInCmuxBrowser + ) + } + + private func workspaceRowInput( _ tab: Workspace, renderContext: WorkspaceListRenderContext, - shouldCollectWorkspaceDropTargets: Bool - ) -> some View { + unreadSummariesByWorkspaceId: [UUID: SidebarWorkspaceUnreadSummary] + ) -> SidebarWorkspaceRowInput { +#if DEBUG + sidebarLazyContractProbe.workspaceRowInputProjection?() +#endif let signpost = SidebarProfilingSignposts.begin("sidebar-workspace-row", "index=\(renderContext.tabIndexById[tab.id] ?? -1) workspace=\(sidebarShortTabId(tab.id)) selected=\(tabManager.selectedTabId == tab.id)") let index = renderContext.tabIndexById[tab.id] ?? 0 let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) let contextMenuWorkspaceIds = usesSelectedContextMenuTargets ? renderContext.selectedContextTargetIds : [tab.id] - let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? renderContext.selectedRemoteContextMenuWorkspaceIds - : (tab.isRemoteWorkspace && !tab.isManagedCloudVMWorkspace ? [tab.id] : []) - let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets - ? renderContext.allSelectedRemoteContextMenuTargetsConnecting - : ( - tab.isRemoteWorkspace && - !tab.isManagedCloudVMWorkspace && - (tab.remoteConnectionState == .connecting || tab.remoteConnectionState == .reconnecting) - ) - let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets - ? renderContext.allSelectedRemoteContextMenuTargetsDisconnected - : (tab.isRemoteWorkspace && !tab.isManagedCloudVMWorkspace && tab.remoteConnectionState == .disconnected) let contextMenuPinTarget = WorkspaceActionDispatcher.Target( workspaceIds: contextMenuWorkspaceIds, anchorWorkspaceId: tab.id @@ -12203,9 +12533,10 @@ struct VerticalTabsSidebar: View { in: renderContext.pinResolutionContext, target: contextMenuPinTarget ) - let liveUnreadCount = sidebarUnread.unreadCount(forWorkspaceId: tab.id) - let liveLatestNotificationText: String? = showsSidebarNotificationMessage - ? sidebarUnread.latestNotificationText(forWorkspaceId: tab.id) + let unreadSummary = unreadSummariesByWorkspaceId[tab.id] + ?? SidebarWorkspaceUnreadSummary(unreadCount: 0, latestNotificationText: nil) + let liveLatestNotificationText: String? = renderContext.tabItemSettings.showsNotificationMessage + ? unreadSummary.latestNotificationText : nil let liveShowsModifierShortcutHints = showModifierHoldHints && modifierKeyMonitor.isModifierPressed let resolvedShowsModifierShortcutHints = SidebarShortcutHintFreezePolicy().resolved( @@ -12214,15 +12545,6 @@ struct VerticalTabsSidebar: View { frozenTabId: frozenShortcutHintsTabId, frozenValue: frozenShortcutHintsValue ) - let onContextMenuAppear: () -> Void = { [tabId = tab.id, snapshot = resolvedShowsModifierShortcutHints] in - frozenShortcutHintsTabId = tabId - frozenShortcutHintsValue = snapshot - } - let onContextMenuDisappear: () -> Void = { [tabId = tab.id] in - if frozenShortcutHintsTabId == tabId { - frozenShortcutHintsTabId = nil - } - } let isPointerHovering = pointerInteractionMonitor.hoveredRowId == .workspace(tab.id) // Per-row drag/drop snapshots. Reading `dragState` here in the parent @@ -12245,182 +12567,454 @@ struct VerticalTabsSidebar: View { tabIds: sidebarReorderIds, indicatorScope: dragState.dropIndicatorScope ) - let onDragStart: () -> NSItemProvider = { [tabId = tab.id] in - #if DEBUG - cmuxDebugLog("sidebar.onDrag tab=\(tabId.uuidString.prefix(5))") - #endif - dragState.beginDragging(tabId: tabId) - return SidebarTabDragPayload(tabId: tabId).provider() - } - let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? = { tabId in - guard let app = AppDelegate.shared else { return nil } - return app.locateBonsplitSurface(tabId: tabId)?.workspaceId - } - let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool = { transfer, workspaceId in - guard let app = AppDelegate.shared else { return false } - return app.moveBonsplitTab( - tabId: transfer.tab.id, - toWorkspace: workspaceId, - focus: true, - focusWindow: true + let settings = renderContext.tabItemSettings + let cachedWorkspaceSnapshot = workspaceSnapshotsById[tab.id] + let expectedPresentationKey = SidebarWorkspaceSnapshotFactory.presentationKey( + settings: settings, + showsAgentActivity: renderContext.showsAgentActivity + ) + let workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + if let cachedWorkspaceSnapshot, + cachedWorkspaceSnapshot.presentationKey == expectedPresentationKey { + workspaceSnapshot = cachedWorkspaceSnapshot + } else { + workspaceSnapshot = makeWorkspaceSnapshot( + workspace: tab, + settings: settings, + showsAgentActivity: renderContext.showsAgentActivity ) } - let syncSidebarSelectionAfterBonsplitDrop: @MainActor () -> Void = { - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } - let onToggleChecklistExpansion: () -> Void = { [tabId = tab.id] in - if expandedChecklistWorkspaceIds.contains(tabId) { - expandedChecklistWorkspaceIds.remove(tabId) - } else { - expandedChecklistWorkspaceIds.insert(tabId) - } - } - let onConsumeChecklistAddFieldActivation: () -> Void = { [tabId = tab.id] in - checklistAddFieldActivationTokens[tabId] = nil - } - let onChecklistPopoverPresentedChange: @MainActor (Bool) -> Void = { [tabId = tab.id] presented in - if presented { - checklistPopoverWorkspaceId = tabId - } else if checklistPopoverWorkspaceId == tabId { - checklistPopoverWorkspaceId = nil + + let todoStatusResolution = WorkspaceTaskStatusOverride.effectiveStatus( + override: tab.todoState.statusOverride, + inferred: tab.inferredTaskStatus + ) + let activeTodoOverride: WorkspaceTaskStatus? = { + guard let override = tab.todoState.statusOverride, + !todoStatusResolution.shouldClearOverride else { + return nil } - } - let row = TabItemView( - tabManager: tabManager, - notificationStore: notificationStore, - tab: tab, + return override.status + }() + let result = SidebarWorkspaceRowInput( + workspaceId: tab.id, + groupId: tab.groupId, index: index, + workspaceCount: renderContext.workspaceCount, + workspace: workspaceSnapshot, + isActive: tabManager.selectedTabId == tab.id, + isMultiSelected: selectedTabIds.contains(tab.id), + hasUserCustomTitle: tab.effectiveCustomTitleSource == .user, + hasCustomTitle: tab.hasCustomTitle, + hasCustomDescription: tab.hasCustomDescription, + customTitle: tab.customTitle, workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( at: index, workspaceCount: renderContext.workspaceCount ), workspaceShortcutModifierSymbol: renderContext.workspaceNumberShortcut.numberedDigitHintPrefix, canCloseWorkspace: renderContext.canCloseWorkspace, - accessibilityWorkspaceCount: renderContext.workspaceCount, - unreadCount: liveUnreadCount, + unreadCount: unreadSummary.unreadCount, latestNotificationText: liveLatestNotificationText, showsAgentActivity: renderContext.showsAgentActivity, rowSpacing: tabRowSpacing, - setSelectionToTabs: { selection = .tabs }, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, showsModifierShortcutHints: resolvedShowsModifierShortcutHints, isPointerHovering: isPointerHovering, - dragAutoScrollController: dragAutoScrollController, isBeingDragged: isBeingDragged, topDropIndicatorVisible: topDropIndicatorVisible, bottomDropIndicatorVisible: bottomDropIndicatorVisible, isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive, - bonsplitSourceWorkspaceId: bonsplitSourceWorkspaceId, - moveBonsplitTabToWorkspace: moveBonsplitTabToWorkspace, - syncSidebarSelectionAfterBonsplitDrop: syncSidebarSelectionAfterBonsplitDrop, - onDragStart: onDragStart, - contextMenuWorkspaceIds: contextMenuWorkspaceIds, - remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, - allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, - allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, - contextMenuPinState: contextMenuPinState, - workspaceGroupMenuSnapshot: renderContext.workspaceGroupMenuSnapshot, - settings: renderContext.tabItemSettings, + settings: settings, isChecklistExpanded: expandedChecklistWorkspaceIds.contains(tab.id), checklistAddFieldActivationToken: checklistAddFieldActivationTokens[tab.id] ?? 0, - onToggleChecklistExpansion: onToggleChecklistExpansion, - onConsumeChecklistAddFieldActivation: onConsumeChecklistAddFieldActivation, isChecklistPopoverPresented: checklistPopoverWorkspaceId == tab.id, - onChecklistPopoverPresentedChange: onChecklistPopoverPresentedChange, - onContextMenuAppear: onContextMenuAppear, - onContextMenuDisappear: onContextMenuDisappear + isRemoteContextMenuEligible: tab.isRemoteWorkspace && !tab.isManagedCloudVMWorkspace, + remoteConnectionState: tab.remoteConnectionState, + contextMenuPinState: contextMenuPinState, + inferredTaskStatus: tab.inferredTaskStatus, + activeTodoOverride: activeTodoOverride, + isTodoStatusHidden: tab.todoState.statusHidden ) - .equatable() - .id(tab.id) - .accessibilityIdentifier("sidebarWorkspace.\(tab.id.uuidString)") - - let _ = SidebarProfilingSignposts.end(signpost) - let rowId = SidebarWorkspaceRenderItemID.workspace(tab.id) - let workspaceId = tab.id - row - .sidebarWorkspaceFrameAnchor(id: tab.id, isEnabled: shouldCollectWorkspaceDropTargets) - .sidebarPointerFrameReporting( - onFrameChange: { [pointerInteractionMonitor] frame in - pointerInteractionMonitor.updateFrame(frame, for: rowId, workspaceId: workspaceId) - }, - onDisappear: { [pointerInteractionMonitor] in pointerInteractionMonitor.removeFrame(for: rowId) } - ) - .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - -} - -struct SidebarWorkspaceFrameAnchorModifier: ViewModifier { - let id: UUID - let isEnabled: Bool - - func body(content: Content) -> some View { - // Branchless: always apply anchorPreference, emit [:] when disabled. An - // if/else gives `content` distinct identity per state, so flipping - // isEnabled at drag start/end recreated every visible row's subtree - // (lost @State, fresh snapshot builds + relayout mid-drag). The frame - // *reader* stays gated on the drag (#5325), so an empty emit costs nothing. - content.anchorPreference(key: SidebarWorkspaceRowFramePreferenceKey.self, value: .bounds) { anchor in - isEnabled ? [id: anchor] : [:] - } - } -} - -extension View { - func sidebarWorkspaceFrameAnchor(id: UUID, isEnabled: Bool) -> some View { - modifier(SidebarWorkspaceFrameAnchorModifier(id: id, isEnabled: isEnabled)) - } -} - -struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { - static let defaultValue: [UUID: Anchor] = [:] - - static func reduce(value: inout [UUID: Anchor], nextValue: () -> [UUID: Anchor]) { - value.merge(nextValue()) { _, next in next } + SidebarProfilingSignposts.end(signpost) + return result } -} - -@MainActor -private final class SidebarDragFailsafeMonitor: ObservableObject { - private static let escapeKeyCode: UInt16 = 53 - // One-shot timer bridges synchronous AppKit event monitors to a cancellable drag-teardown deadline. - private var pendingClearTimer: DispatchSourceTimer? - private var pendingClearGeneration: UInt64 = 0 - private var appResignObserver: NSObjectProtocol? - private var keyDownMonitor: Any? - private var localMouseMonitor: Any? - private var globalMouseMonitor: Any? - private var onRequestClear: ((String) -> Void)? - func start(onRequestClear: @escaping (String) -> Void) { - self.onRequestClear = onRequestClear - if SidebarDragFailsafePolicy().shouldRequestClearWhenMonitoringStarts( - isLeftMouseButtonDown: CGEventSource.buttonState( - .combinedSessionState, - button: .left - ) - ) { - requestClearSoon(reason: "mouse_up_failsafe") - } - if appResignObserver == nil { - appResignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "app_resign_active") - } + /// Captures the parent action surface once per list evaluation. Invoking + /// this factory below `LazyVStack` only binds immutable ids/values into + /// closures; live models are resolved later when the user performs an + /// action, never while SwiftUI realizes or lays out a row. + private func makeWorkspaceRowActionFactory() -> SidebarWorkspaceRowActionFactory { + let pointerInteractionMonitor = pointerInteractionMonitor + return { input in + let tabId = input.workspaceId + let index = input.index + let settings = input.settings + let rowId = SidebarWorkspaceRenderItemID.workspace(tabId) + let workspace: @MainActor () -> Workspace? = { + tabManager.tabs.first { $0.id == tabId } + } + let checklistActions = SidebarWorkspaceChecklistActions( + setItemState: { itemId, state in + guard let tab = workspace() else { return } + WorkspaceTodoActions.setChecklistItemState(id: itemId, state: state, in: tab) + }, + removeItem: { itemId in + guard let tab = workspace() else { return } + WorkspaceTodoActions.removeChecklistItem(id: itemId, from: tab) + }, + addItem: { text in + guard let tab = workspace() else { return } + WorkspaceTodoActions.addChecklistItem(text: text, to: tab) + }, + editItem: { itemId, text in + guard let tab = workspace() else { return } + WorkspaceTodoActions.editChecklistItem(id: itemId, text: text, in: tab) + }, + moveItem: { itemId, toIndex in + guard let tab = workspace() else { return } + WorkspaceTodoActions.moveChecklistItem(id: itemId, toIndex: toIndex, in: tab) + }, + openPane: { + guard let tab = workspace() else { return } + WorkspaceTodoActions.openTodoPane(for: tab) } - } - if keyDownMonitor == nil { + ) + return SidebarWorkspaceRowActions( + select: { modifiers in + guard let tab = workspace() else { return } + selectWorkspaceRow(tab, index: index, modifiers: modifiers) + }, + setCustomTitle: { title in + tabManager.setCustomTitle(tabId: tabId, title: title) + }, + clearCustomTitle: { + tabManager.clearCustomTitle(tabId: tabId) + }, + clearCustomDescription: { + tabManager.clearCustomDescription(tabId: tabId) + }, + editDescription: { + guard let tab = workspace() else { return } + selectedTabIds = [tabId] + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + selection = .tabs + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + }, + closeWorkspace: { + guard let tab = workspace() else { return } + tabManager.closeWorkspaceFromTabCloseButton(tab) + }, + moveBy: { delta in + guard let tab = workspace() else { return } + moveWorkspaceRow(tab, by: delta) + }, + moveTargetsToTop: { targetIds in + tabManager.moveTabsToTop(Set(targetIds)) + syncWorkspaceRowSelectionAfterMutation() + }, + currentWindowMoveTargets: { + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + return AppDelegate.shared? + .windowMoveTargets(referenceWindowId: referenceWindowId) + .map { + SidebarWorkspaceWindowMoveTarget( + windowId: $0.windowId, + label: $0.label, + isCurrentWindow: $0.isCurrentWindow + ) + } ?? [] + }, + moveTargetsToWindow: { targetIds, windowId in + moveWorkspaceRows(targetIds, toWindow: windowId) + }, + moveTargetsToNewWindow: { targetIds in + moveWorkspaceRowsToNewWindow(targetIds) + }, + closeTargets: { targetIds, allowPinned in + closeWorkspaceRows(targetIds, allowPinned: allowPinned) + }, + closeOtherTargets: { targetIds in + let keepIds = Set(targetIds) + let idsToClose = tabManager.tabs.compactMap { + keepIds.contains($0.id) ? nil : $0.id + } + closeWorkspaceRows(idsToClose, allowPinned: true) + }, + closeTargetsBelow: { + guard let anchorIndex = tabManager.tabs.firstIndex( + where: { $0.id == tabId } + ) else { return } + closeWorkspaceRows( + Array(tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id)), + allowPinned: true + ) + }, + closeTargetsAbove: { + guard let anchorIndex = tabManager.tabs.firstIndex( + where: { $0.id == tabId } + ) else { return } + closeWorkspaceRows( + Array(tabManager.tabs.prefix(upTo: anchorIndex).map(\.id)), + allowPinned: true + ) + }, + performPin: { + guard let contextMenuPinState = input.contextMenuPinState else { + NSSound.beep() + return + } + _ = WorkspaceActionDispatcher.performPinAction( + contextMenuPinState, + in: tabManager + ) + syncWorkspaceRowSelectionAfterMutation() + }, + createEmptyGroup: { + _ = AppDelegate.shared?.createEmptyWorkspaceGroup(tabManager: tabManager) + }, + createGroup: { workspaceIds in + guard !workspaceIds.isEmpty else { return } + tabManager.createWorkspaceGroup(name: "", childWorkspaceIds: workspaceIds) + }, + addTargetsToGroup: { workspaceIds, groupId in + for workspaceId in workspaceIds { + tabManager.addWorkspaceToGroup( + workspaceId: workspaceId, + groupId: groupId + ) + } + }, + removeTargetsFromGroup: { workspaceIds in + for workspaceId in workspaceIds { + tabManager.removeWorkspaceFromGroup(workspaceId: workspaceId) + } + }, + reconnectTargets: { workspaceIds in + for workspaceId in workspaceIds { + tabManager.tabs.first { $0.id == workspaceId }? + .reconnectRemoteConnection() + } + }, + disconnectTargets: { workspaceIds in + for workspaceId in workspaceIds { + tabManager.tabs.first { $0.id == workspaceId }? + .disconnectRemoteConnection(clearConfiguration: false) + } + }, + applyColor: { hex, workspaceIds in + tabManager.applyWorkspaceColor(hex, toWorkspaceIds: workspaceIds) + }, + applyTodoStatus: { status, workspaceIds in + let workspaces = workspaceIds.compactMap { workspaceId in + tabManager.tabs.first { $0.id == workspaceId } + } + WorkspaceTodoActions.applyStatusOverride(status, to: workspaces) + }, + hideTodoStatus: { workspaceIds in + let workspaces = workspaceIds.compactMap { workspaceId in + tabManager.tabs.first { $0.id == workspaceId } + } + WorkspaceTodoActions.hideStatus(for: workspaces) + }, + requestChecklistAdd: { + WorkspaceTodoActions.requestChecklistAddField(workspaceId: tabId) + }, + markRead: { workspaceIds in + for workspaceId in workspaceIds where + notificationStore.canMarkWorkspaceRead(forTabIds: [workspaceId]) { + notificationStore.markRead(forTabId: workspaceId) + } + }, + markUnread: { workspaceIds in + for workspaceId in workspaceIds where + notificationStore.canMarkWorkspaceUnread(forTabIds: [workspaceId]) { + notificationStore.markUnread(forTabId: workspaceId) + } + }, + clearLatestNotifications: { workspaceIds in + for workspaceId in workspaceIds { + notificationStore.clearLatestNotification(forTabId: workspaceId) + } + }, + openNotification: { notification in + if AppDelegate.shared?.openTerminalNotification(notification) != true { + NSSound.beep() + } + }, + copyWorkspaceLinks: { workspaceIds in + WorkspaceSurfaceIdentifierClipboardText.copyWorkspaceLinks( + workspaceIds, + resolvingStableIdsFrom: tabManager.tabs + ) + }, + openPullRequest: { url in + guard let tab = workspace() else { return } + openWorkspaceRowPullRequest( + url, + workspace: tab, + index: index, + opensInCmuxBrowser: settings.openPullRequestLinksInCmuxBrowser + ) + }, + openPort: { port in + guard let tab = workspace() else { return } + openWorkspaceRowPort( + port, + workspace: tab, + index: index, + opensInCmuxBrowser: settings.openPortLinksInCmuxBrowser + ) + }, + checklist: checklistActions, + onDragStart: { +#if DEBUG + cmuxDebugLog("sidebar.onDrag tab=\(tabId.uuidString.prefix(5))") +#endif + dragState.beginDragging(tabId: tabId) + return SidebarTabDragPayload(tabId: tabId).provider() + }, + bonsplitSourceWorkspaceId: { bonsplitTabId in + AppDelegate.shared?.locateBonsplitSurface(tabId: bonsplitTabId)?.workspaceId + }, + moveBonsplitTabToWorkspace: { transfer, workspaceId in + guard let app = AppDelegate.shared else { return false } + return app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) + }, + syncAfterBonsplitDrop: { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + }, + selectAfterBonsplitDrop: { + selectedTabIds = [tabId] + }, + onToggleChecklistExpansion: { + if expandedChecklistWorkspaceIds.contains(tabId) { + expandedChecklistWorkspaceIds.remove(tabId) + } else { + expandedChecklistWorkspaceIds.insert(tabId) + } + }, + onConsumeChecklistAddFieldActivation: { + checklistAddFieldActivationTokens[tabId] = nil + }, + onChecklistPopoverPresentedChange: { presented in + if presented { + checklistPopoverWorkspaceId = tabId + } else if checklistPopoverWorkspaceId == tabId { + checklistPopoverWorkspaceId = nil + } + }, + onContextMenuAppear: { + frozenShortcutHintsTabId = tabId + frozenShortcutHintsValue = input.showsModifierShortcutHints + }, + onContextMenuDisappear: { + if frozenShortcutHintsTabId == tabId { + frozenShortcutHintsTabId = nil + } + }, + onPointerFrameChange: { [pointerInteractionMonitor] frame in + pointerInteractionMonitor.updateFrame( + frame, + for: rowId, + workspaceId: tabId + ) + }, + onPointerFrameDisappear: { [pointerInteractionMonitor] in + pointerInteractionMonitor.removeFrame(for: rowId) + } + ) + } + } + + private func workspaceRow( + input: SidebarWorkspaceRowInput, + listSnapshot: SidebarWorkspaceRowsSnapshot, + actionFactory: SidebarWorkspaceRowActionFactory, + shouldCollectWorkspaceDropTargets: Bool + ) -> SidebarWorkspaceRowView { + SidebarWorkspaceRowView( + snapshot: input.rowSnapshot(list: listSnapshot), + actions: actionFactory(input), + shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets + ) + } + +} + +struct SidebarWorkspaceFrameAnchorModifier: ViewModifier { + let id: UUID + let isEnabled: Bool + + func body(content: Content) -> some View { + // Branchless: always apply anchorPreference, emit [:] when disabled. An + // if/else gives `content` distinct identity per state, so flipping + // isEnabled at drag start/end recreated every visible row's subtree + // (lost @State, fresh snapshot builds + relayout mid-drag). The frame + // *reader* stays gated on the drag (#5325), so an empty emit costs nothing. + content.anchorPreference(key: SidebarWorkspaceRowFramePreferenceKey.self, value: .bounds) { anchor in + isEnabled ? [id: anchor] : [:] + } + } +} + +extension View { + func sidebarWorkspaceFrameAnchor(id: UUID, isEnabled: Bool) -> some View { + modifier(SidebarWorkspaceFrameAnchorModifier(id: id, isEnabled: isEnabled)) + } +} + +struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { + static let defaultValue: [UUID: Anchor] = [:] + + static func reduce(value: inout [UUID: Anchor], nextValue: () -> [UUID: Anchor]) { + value.merge(nextValue()) { _, next in next } + } +} + +@MainActor +private final class SidebarDragFailsafeMonitor { + private static let escapeKeyCode: UInt16 = 53 + // One-shot timer bridges synchronous AppKit event monitors to a cancellable drag-teardown deadline. + private var pendingClearTimer: DispatchSourceTimer? + private var pendingClearGeneration: UInt64 = 0 + private var appResignObserver: NSObjectProtocol? + private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? + private var onRequestClear: ((String) -> Void)? + + func start(onRequestClear: @escaping (String) -> Void) { + self.onRequestClear = onRequestClear + if SidebarDragFailsafePolicy().shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") + } + if appResignObserver == nil { + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "app_resign_active") + } + } + } + if keyDownMonitor == nil { keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in if event.keyCode == Self.escapeKeyCode { self?.requestClearSoon(reason: "escape_cancel") @@ -12882,56 +13476,15 @@ private struct SidebarHelpMenuButton: View { } -// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when -// the parent rebuilds with unchanged values. Without this, every TabManager -// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of -// main thread during typing). If you add new properties, update == below. -// Reactive workspace state inside the row must not rely on parent diffs alone: -// `.equatable()` can otherwise leave sidebar badges/details stale until an -// unrelated parent change sneaks through. Keep the workspace reference plain -// 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. +// PERF: TabItemView is an Equatable value projection. The parent owns every +// workspace/store observation and passes one immutable render snapshot plus a +// closure capability bundle. No live model, binding, or observable store may +// cross this LazyVStack boundary (#6707 / #2586). struct TabItemView: View, Equatable { - private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) - private static let legacyVMWebSocketDescription = "VM WebSocket PTY" - - // Closures, Bindings, and object references are excluded from == - // because they're recreated every parent eval but don't affect rendering. nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { - lhs.tab === rhs.tab && - lhs.index == rhs.index && - lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && - lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && - lhs.canCloseWorkspace == rhs.canCloseWorkspace && - lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && - lhs.unreadCount == rhs.unreadCount && - lhs.latestNotificationText == rhs.latestNotificationText && - lhs.showsAgentActivity == rhs.showsAgentActivity && - lhs.rowSpacing == rhs.rowSpacing && - lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && - lhs.isPointerHovering == rhs.isPointerHovering && - lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && - lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && - lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && - lhs.contextMenuPinState == rhs.contextMenuPinState && - lhs.workspaceGroupMenuSnapshot == rhs.workspaceGroupMenuSnapshot && - lhs.isBeingDragged == rhs.isBeingDragged && - lhs.topDropIndicatorVisible == rhs.topDropIndicatorVisible && - lhs.bottomDropIndicatorVisible == rhs.bottomDropIndicatorVisible && - lhs.isBonsplitWorkspaceDropActive == rhs.isBonsplitWorkspaceDropActive && - lhs.isChecklistExpanded == rhs.isChecklistExpanded && - lhs.checklistAddFieldActivationToken == rhs.checklistAddFieldActivationToken && - lhs.isChecklistPopoverPresented == rhs.isChecklistPopoverPresented && - lhs.settings == rhs.settings - } - - // Use plain references instead of @EnvironmentObject to avoid subscribing - // to ALL changes on these objects. Body reads use precomputed parameters; - // action handlers use the plain references without triggering re-evaluation. - let tabManager: TabManager - let notificationStore: TerminalNotificationStore + lhs.snapshot == rhs.snapshot + } + @Environment(\.colorScheme) private var colorScheme // Global font magnification percent, read once per row instead of through a // per-label `CmuxFontModifier`. Each `.cmuxFont(...)` is a custom @@ -12948,75 +13501,9 @@ struct TabItemView: View, Equatable { // like all closures. See SidebarLazyContractProbe. @Environment(\.sidebarLazyContractProbe) private var sidebarLazyContractProbe #endif - let tab: Tab - let index: Int - let workspaceShortcutDigit: Int? - let workspaceShortcutModifierSymbol: String - let canCloseWorkspace: Bool - let accessibilityWorkspaceCount: Int - let unreadCount: Int - let latestNotificationText: String? - let showsAgentActivity: Bool - let rowSpacing: CGFloat - let setSelectionToTabs: () -> Void - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let showsModifierShortcutHints: Bool - let isPointerHovering: Bool - let dragAutoScrollController: SidebarDragAutoScrollController - // Row receives precomputed drag/drop snapshot values + action closures - // instead of an `@Observable` store reference. This keeps TabItemView in - // compliance with the snapshot-boundary rule for views under a LazyVStack - // (see CLAUDE.md). When drag state changes, the parent recomputes these - // per-row snapshots and `==` skips re-render for rows whose snapshot is - // unchanged. - let isBeingDragged: Bool - let topDropIndicatorVisible: Bool - let bottomDropIndicatorVisible: Bool - let isBonsplitWorkspaceDropActive: Bool - let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? - let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool - let syncSidebarSelectionAfterBonsplitDrop: @MainActor () -> Void - let onDragStart: () -> NSItemProvider - let contextMenuWorkspaceIds: [UUID] - let remoteContextMenuWorkspaceIds: [UUID] - let allRemoteContextMenuTargetsConnecting: Bool - let allRemoteContextMenuTargetsDisconnected: Bool - let contextMenuPinState: WorkspaceActionDispatcher.PinState? - let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot - let settings: SidebarTabItemSettingsSnapshot - // Checklist expansion is per-workspace transient UI state owned by the - // sidebar container (a Set there), passed in as a Bool + closures to - // respect the snapshot boundary. The activation token arms the checklist - // add-item field when a context-menu/palette "Add Checklist Item…" fires. - let isChecklistExpanded: Bool - let checklistAddFieldActivationToken: Int - let onToggleChecklistExpansion: () -> Void - let onConsumeChecklistAddFieldActivation: () -> Void - // The checklist popover is per-workspace transient UI state owned by the - // sidebar container (UUID? there, at most one open), passed in as a Bool - // + change closure like the checklist expansion. - let isChecklistPopoverPresented: Bool - let onChecklistPopoverPresentedChange: @MainActor (Bool) -> Void - /// Called from this row's contextMenu.onAppear so the parent can freeze - /// `showsModifierShortcutHints` to the value it last passed in. Prevents - /// modifier-key transitions from flipping the badges on the row sitting - /// behind the open context menu. - let onContextMenuAppear: () -> Void - let onContextMenuDisappear: () -> Void - @State private var workspaceSnapshotStorage: SidebarWorkspaceSnapshotBuilder.Snapshot? - // Fallback memo for the `workspaceSnapshot` getter: a plain box, not - // observed state, so body evaluations can fill it without invalidating the - // row. Before onAppear seeds workspaceSnapshotStorage, every access used to - // rebuild the snapshot (a full bonsplit tree walk) — several times per - // mounted row per scroll. - final class WorkspaceSnapshotScratch { - var value: SidebarWorkspaceSnapshotBuilder.Snapshot? - } - @State private var workspaceSnapshotScratch = WorkspaceSnapshotScratch() - // Row-local selection projection: selectedTabId changes update only rows whose boolean flips. - @State private var observedIsActive: Bool? - @State private var contextMenuState = SidebarTabItemContextMenuState() + let snapshot: SidebarWorkspaceRowSnapshot + let actions: SidebarWorkspaceRowActions + @State private var contextMenuVisible = false @State var workspaceFinderDirectoryOpenRequest: WorkspaceFinderDirectoryOpenRequest? @State private var isEditing = false @@ -13026,9 +13513,30 @@ struct TabItemView: View, Equatable { private static let maxWrappedTitleLines = 8 private static let maxDisplayedTitleCharacters = 2048 - var isMultiSelected: Bool { - selectedTabIds.contains(tab.id) - } + var workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot { snapshot.workspace } + var workspaceId: UUID { snapshot.workspaceId } + var index: Int { snapshot.index } + var isActive: Bool { snapshot.isActive } + var isMultiSelected: Bool { snapshot.isMultiSelected } + var workspaceShortcutDigit: Int? { snapshot.workspaceShortcutDigit } + var workspaceShortcutModifierSymbol: String { snapshot.workspaceShortcutModifierSymbol } + var canCloseWorkspace: Bool { snapshot.canCloseWorkspace } + var accessibilityWorkspaceCount: Int { snapshot.workspaceCount } + var unreadCount: Int { snapshot.unreadCount } + var latestNotificationText: String? { snapshot.latestNotificationText } + var showsAgentActivity: Bool { snapshot.showsAgentActivity } + var rowSpacing: CGFloat { snapshot.rowSpacing } + var showsModifierShortcutHints: Bool { snapshot.showsModifierShortcutHints } + var isPointerHovering: Bool { snapshot.isPointerHovering } + var isBeingDragged: Bool { snapshot.isBeingDragged } + var topDropIndicatorVisible: Bool { snapshot.topDropIndicatorVisible } + var bottomDropIndicatorVisible: Bool { snapshot.bottomDropIndicatorVisible } + var isBonsplitWorkspaceDropActive: Bool { snapshot.isBonsplitWorkspaceDropActive } + var contextMenuWorkspaceIds: [UUID] { snapshot.contextMenu.targetWorkspaceIds } + var settings: SidebarTabItemSettingsSnapshot { snapshot.settings } + var isChecklistExpanded: Bool { snapshot.isChecklistExpanded } + var checklistAddFieldActivationToken: Int { snapshot.checklistAddFieldActivationToken } + var isChecklistPopoverPresented: Bool { snapshot.isChecklistPopoverPresented } private var sidebarShortcutHintXOffset: Double { settings.sidebarShortcutHintXOffset @@ -13066,29 +13574,10 @@ struct TabItemView: View, Equatable { settings.showsSSH } - var workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot { - let presentationKey = workspaceSnapshotPresentationKey - if let workspaceSnapshotStorage, - workspaceSnapshotStorage.presentationKey == presentationKey { - return workspaceSnapshotStorage - } - if let scratch = workspaceSnapshotScratch.value, - scratch.presentationKey == presentationKey { - return scratch - } - let snapshot = makeWorkspaceSnapshot() - workspaceSnapshotScratch.value = snapshot - return snapshot - } - private var activeTabIndicatorStyle: WorkspaceIndicatorStyle { settings.activeTabIndicatorStyle } - private var isActive: Bool { - observedIsActive ?? (tabManager.selectedTabId == tab.id) - } - private var sidebarSelectionColorHex: String? { settings.selectionColorHex } @@ -13111,14 +13600,6 @@ struct TabItemView: View, Equatable { ) } - private var openSidebarPullRequestLinksInCmuxBrowser: Bool { - settings.openPullRequestLinksInCmuxBrowser - } - - private var openSidebarPortLinksInCmuxBrowser: Bool { - settings.openPortLinksInCmuxBrowser - } - private var titleFontWeight: Font.Weight { .semibold } @@ -13155,8 +13636,10 @@ struct TabItemView: View, Equatable { return font } - private var showsLeadingRail: Bool { - explicitRailColor != nil + private func showsLeadingRail( + for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + ) -> Bool { + explicitRailColor(for: workspaceSnapshot) != nil } private var activeBorderLineWidth: CGFloat { @@ -13233,63 +13716,10 @@ struct TabItemView: View, Equatable { (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil } - private var remoteWorkspaceSidebarText: String? { - guard tab.isRemoteWorkspace else { return nil } - let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedTarget, !trimmedTarget.isEmpty { - return trimmedTarget - } - return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "Remote workspace") - } - - private var copyableSidebarSSHError: String? { - let fallbackTarget = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - if tab.remoteConnectionState == .error || tab.remoteConnectionState == .suspended, - let trimmedDetail, !trimmedDetail.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: trimmedDetail - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - if let statusValue = tab.statusEntries["remote.error"]?.value - .trimmingCharacters(in: .whitespacesAndNewlines), - !statusValue.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: statusValue - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - return nil - } - - private var remoteConnectionStatusText: String { - switch tab.remoteConnectionState { - case .connected: - return String(localized: "remote.status.connected", defaultValue: "Connected") - case .connecting: - return String(localized: "remote.status.connecting", defaultValue: "Connecting") - case .reconnecting: - return String(localized: "remote.status.reconnecting", defaultValue: "Reconnecting") - case .error: - return String(localized: "remote.status.error", defaultValue: "Error") - case .disconnected: - return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") - case .suspended: - return String(localized: "remote.status.suspended", defaultValue: "Unreachable") - } - } - @ViewBuilder - private var remoteWorkspaceSection: some View { - let workspaceSnapshot = self.workspaceSnapshot + private func remoteWorkspaceSection( + snapshot workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + ) -> some View { if !settings.hidesAllDetails, sidebarShowSSH, let remoteWorkspaceSidebarText = workspaceSnapshot.remoteWorkspaceSidebarText { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { @@ -13308,7 +13738,7 @@ struct TabItemView: View, Equatable { if workspaceSnapshot.showsRemoteReconnectAffordance { Button { - tab.reconnectRemoteConnection() + actions.reconnectTargets([workspaceId]) } label: { Label( String(localized: "sidebar.remote.reconnect.button", defaultValue: "Reconnect"), @@ -13340,30 +13770,22 @@ struct TabItemView: View, Equatable { } func copyWorkspaceLinksToPasteboard(_ ids: [UUID]) { - WorkspaceSurfaceIdentifierClipboardText.copyWorkspaceLinks(ids, resolvingStableIdsFrom: tabManager.tabs) + actions.copyWorkspaceLinks(ids) } private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { settings.visibleAuxiliaryDetails } - private var workspaceSnapshotPresentationKey: SidebarWorkspaceSnapshotBuilder.PresentationKey { - SidebarWorkspaceSnapshotBuilder.PresentationKey( - showsWorkspaceDescription: settings.showsWorkspaceDescription, - usesVerticalBranchLayout: sidebarBranchVerticalLayout, - showsGitBranch: sidebarShowGitBranch, - usesViewportAwarePath: sidebarUsesLastSegmentPath, - showsAgentActivity: showsAgentActivity, - visibleAuxiliaryDetails: visibleAuxiliaryDetails - ) - } - var body: some View { #if DEBUG let _ = { sidebarLazyContractProbe.workspaceRowBody?() }() #endif - let signpost = SidebarProfilingSignposts.begin("sidebar-tab-item-body", "index=\(index) workspace=\(sidebarShortTabId(tab.id)) active=\(isActive) unread=\(unreadCount)") + let signpost = SidebarProfilingSignposts.begin("sidebar-tab-item-body", "index=\(index) workspace=\(sidebarShortTabId(workspaceId)) active=\(isActive) unread=\(unreadCount)") let workspaceSnapshot = self.workspaceSnapshot + let rowBackgroundColor = backgroundColor(for: workspaceSnapshot) + let rowRailColor = railColor(for: workspaceSnapshot) + let accessibilityTitle = accessibilityTitle(for: workspaceSnapshot) let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") let protectedWorkspaceTooltip = String( localized: "sidebar.pinnedWorkspaceProtected.tooltip", @@ -13451,7 +13873,7 @@ struct TabItemView: View, Equatable { baseline: renameDraft, baselineHadUserCustomTitle: renameBaselineHadUserCustomTitle ) { - tabManager.setCustomTitle(tabId: tab.id, title: title) + actions.setCustomTitle(title) } isEditing = false }, @@ -13473,7 +13895,7 @@ struct TabItemView: View, Equatable { } if trailingStatusActive || canCloseWorkspace { - SidebarWorkspaceTrailingStatusSlot(showsSpinner: spinnerOnTrailing, showsBadge: badgeOnTrailing, unreadCount: unreadCount, side: scaledUnreadBadgeSize, width: scaledCloseButtonWidth, height: scaledCloseButtonHitSize, badgeFont: badgeFont, badgeFillColor: activeUnreadBadgeFillColor, badgeTextColor: activeUnreadBadgeTextColor, spinnerColor: spinnerColor, spinnerTooltip: spinnerTooltip, canCloseWorkspace: canCloseWorkspace, showsCloseButton: showCloseButton, closeButtonTooltip: closeButtonTooltip, closeButtonColor: activeSecondaryColor(0.7), closeButtonFontSize: scaledFontSize(9), closeAction: { tabManager.closeWorkspaceWithConfirmation(tab) }) + SidebarWorkspaceTrailingStatusSlot(showsSpinner: spinnerOnTrailing, showsBadge: badgeOnTrailing, unreadCount: unreadCount, side: scaledUnreadBadgeSize, width: scaledCloseButtonWidth, height: scaledCloseButtonHitSize, badgeFont: badgeFont, badgeFillColor: activeUnreadBadgeFillColor, badgeTextColor: activeUnreadBadgeTextColor, spinnerColor: spinnerColor, spinnerTooltip: spinnerTooltip, canCloseWorkspace: canCloseWorkspace, showsCloseButton: showCloseButton, closeButtonTooltip: closeButtonTooltip, closeButtonColor: activeSecondaryColor(0.7), closeButtonFontSize: scaledFontSize(9), closeAction: actions.closeWorkspace) } } @@ -13496,7 +13918,7 @@ struct TabItemView: View, Equatable { .fixedSize(horizontal: false, vertical: true) } - remoteWorkspaceSection + remoteWorkspaceSection(snapshot: workspaceSnapshot) if detailVisibility.showsMetadata { let metadataEntries = workspaceSnapshot.metadataEntries @@ -13727,10 +14149,10 @@ struct TabItemView: View, Equatable { summaryFont: magnifiedFont(scaledFontSize(10), weight: .semibold, monospacedDigit: true), itemFont: magnifiedFont(scaledFontSize(10)), fontScale: fontScale, - onToggleExpansion: onToggleChecklistExpansion, - onPopoverPresentedChange: onChecklistPopoverPresentedChange, - onConsumeAddFieldActivation: onConsumeChecklistAddFieldActivation, - actions: workspaceTodoChecklistActions + onToggleExpansion: actions.onToggleChecklistExpansion, + onPopoverPresentedChange: actions.onChecklistPopoverPresentedChange, + onConsumeAddFieldActivation: actions.onConsumeChecklistAddFieldActivation, + actions: actions.checklist ) } } @@ -13747,15 +14169,15 @@ struct TabItemView: View, Equatable { .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 6) - .fill(backgroundColor) + .fill(rowBackgroundColor) .overlay { RoundedRectangle(cornerRadius: 6) .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) } .overlay(alignment: .leading) { - if showsLeadingRail { + if showsLeadingRail(for: workspaceSnapshot) { Capsule(style: .continuous) - .fill(railColor) + .fill(rowRailColor) .frame(width: 3) .padding(.leading, 4) .padding(.vertical, 5) @@ -13789,73 +14211,21 @@ struct TabItemView: View, Equatable { isBottomEdge: true ) } - .onAppear { - updateObservedActiveState(tabManager.selectedTabId == tab.id) - refreshWorkspaceSnapshot(force: true) - } - .onReceive( - tabManager.selectedTabIdPublisher - .map { $0 == tab.id } - .removeDuplicates() - ) { isSelected in - updateObservedActiveState(isSelected) - } .task(id: workspaceFinderDirectoryOpenRequest) { guard let request = workspaceFinderDirectoryOpenRequest else { return } await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } workspaceFinderDirectoryOpenRequest = nil } - .sidebarAgentRuntimeObservation(id: tab.id, model: tab.sidebarAgentRuntimeObservation) { refreshWorkspaceSnapshot() } - .sidebarProcessTitleObservation(id: tab.id, model: tab.sidebarProcessTitleObservation) { refreshWorkspaceSnapshot() } - .onReceive( - tab.sidebarImmediateObservationPublisher - .receive(on: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=immediate " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - refreshWorkspaceSnapshot() - } - .onReceive( - tab.sidebarObservationPublisher - .receive(on: RunLoop.main) - // Prompt-time sidebar telemetry can arrive as a short burst - // (pwd, branch, PR, shell state). Coalesce that burst so the - // row redraws once with the settled state instead of blinking. - .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=debounced " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - refreshWorkspaceSnapshot() - } - .onChange(of: settings) { _ in - refreshWorkspaceSnapshot(force: true) - } - .sidebarRowDragGate(isEditing: isEditing, onDragStart) + .sidebarRowDragGate(isEditing: isEditing, actions.onDragStart) .internalOnlyTabDrag() .modifier(SidebarBonsplitWorkspaceRowDropModifier( isEnabled: isBonsplitWorkspaceDropActive, - targetWorkspaceId: tab.id, - bonsplitSourceWorkspaceId: bonsplitSourceWorkspaceId, - moveBonsplitTabToWorkspace: moveBonsplitTabToWorkspace, - syncSidebarSelectionAfterDrop: syncSidebarSelectionAfterBonsplitDrop, - selectedTabIds: $selectedTabIds + targetWorkspaceId: workspaceId, + bonsplitSourceWorkspaceId: actions.bonsplitSourceWorkspaceId, + moveBonsplitTabToWorkspace: actions.moveBonsplitTabToWorkspace, + syncSidebarSelectionAfterDrop: actions.syncAfterBonsplitDrop, + selectTargetAfterDrop: actions.selectAfterBonsplitDrop )) .onTapGesture { if !isEditing { updateSelection() } @@ -13880,14 +14250,11 @@ struct TabItemView: View, Equatable { TabItemWorkspaceContextMenuContent(row: self) .onAppear { contextMenuVisible = true - contextMenuState.hasDeferredWorkspaceObservationInvalidation = false - contextMenuState.pendingWorkspaceSnapshot = nil - onContextMenuAppear() + actions.onContextMenuAppear() } .onDisappear { contextMenuVisible = false - onContextMenuDisappear() - flushDeferredWorkspaceObservationInvalidation() + actions.onContextMenuDisappear() } } let _ = SidebarProfilingSignposts.end(signpost) @@ -13899,46 +14266,13 @@ struct TabItemView: View, Equatable { private func beginInlineRename() { updateSelection() renameDraft = workspaceSnapshot.title - renameBaselineHadUserCustomTitle = tab.effectiveCustomTitleSource == .user + renameBaselineHadUserCustomTitle = snapshot.hasUserCustomTitle isEditing = true } - private func updateObservedActiveState(_ isActive: Bool) { - guard observedIsActive != isActive else { return } - observedIsActive = isActive - } - - func refreshWorkspaceSnapshot(force: Bool = false) { - let nextSnapshot = makeWorkspaceSnapshot() - workspaceSnapshotScratch.value = nextSnapshot - let decision = SidebarWorkspaceSnapshotRefreshPolicy().decision( - current: workspaceSnapshotStorage, - next: nextSnapshot, - force: force, - contextMenuVisible: contextMenuVisible - ) - - if workspaceSnapshotStorage != decision.workspaceSnapshotStorage { - workspaceSnapshotStorage = decision.workspaceSnapshotStorage - } - if contextMenuState.pendingWorkspaceSnapshot != decision.pendingWorkspaceSnapshot { - contextMenuState.pendingWorkspaceSnapshot = decision.pendingWorkspaceSnapshot - } - if contextMenuState.hasDeferredWorkspaceObservationInvalidation != decision.hasDeferredWorkspaceObservationInvalidation { - contextMenuState.hasDeferredWorkspaceObservationInvalidation = decision.hasDeferredWorkspaceObservationInvalidation - } - } - - private func flushDeferredWorkspaceObservationInvalidation() { - guard contextMenuState.hasDeferredWorkspaceObservationInvalidation else { return } - contextMenuState.hasDeferredWorkspaceObservationInvalidation = false - if let pendingSnapshot = contextMenuState.pendingWorkspaceSnapshot { - workspaceSnapshotStorage = pendingSnapshot - } - contextMenuState.pendingWorkspaceSnapshot = nil - } - - private var backgroundColor: Color { + private func backgroundColor( + for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + ) -> Color { let style = sidebarWorkspaceRowBackgroundStyle( activeTabIndicatorStyle: activeTabIndicatorStyle, isActive: isActive, @@ -13951,11 +14285,15 @@ struct TabItemView: View, Equatable { return Color(nsColor: color).opacity(style.opacity) } - private var railColor: Color { - explicitRailColor ?? .clear + private func railColor( + for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + ) -> Color { + explicitRailColor(for: workspaceSnapshot) ?? .clear } - private var explicitRailColor: Color? { + private func explicitRailColor( + for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + ) -> Color? { guard let railColor = sidebarWorkspaceRowExplicitRailNSColor( activeTabIndicatorStyle: activeTabIndicatorStyle, customColorHex: workspaceSnapshot.customColorHex, @@ -13974,488 +14312,18 @@ struct TabItemView: View, Equatable { ) ?? NSColor(hex: hex) ?? .gray } - private var accessibilityTitle: String { + private func accessibilityTitle( + for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + ) -> String { String(localized: "accessibility.workspacePosition", defaultValue: "\(workspaceSnapshot.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") } func moveBy(_ delta: Int) { - guard tabManager.reorderWorkspace(tabId: tab.id, by: delta) else { return } - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } - tabManager.selectTab(tab) - setSelectionToTabs() + actions.moveBy(delta) } private func updateSelection() { - let modifiers = NSEvent.modifierFlags - let isCommand = modifiers.contains(.command) - let isShift = modifiers.contains(.shift) - let wasSelected = tabManager.selectedTabId == tab.id - #if DEBUG - var modStr = "" - if modifiers.contains(.command) { modStr += "cmd " } - if modifiers.contains(.shift) { modStr += "shift " } - if modifiers.contains(.option) { modStr += "opt " } - if modifiers.contains(.control) { modStr += "ctrl " } - cmuxDebugLog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") - #endif - - let workspaceIds = tabManager.tabs.map(\.id) - let shiftAnchorIndex = isShift - ? SidebarWorkspaceSelectionSyncPolicy().shiftClickAnchorIndex( - existingAnchorIndex: lastSidebarSelectionIndex, - selectedWorkspaceIds: selectedTabIds, - focusedWorkspaceId: tabManager.selectedTabId, - liveWorkspaceIds: workspaceIds - ) - : nil - - if isShift, let anchorIndex = shiftAnchorIndex { - let lower = min(anchorIndex, index) - let upper = max(anchorIndex, index) - // Filter out workspaces hidden inside collapsed groups so a - // Shift-click range never silently includes rows the user - // can't see (e.g. clicking a collapsed group's anchor and - // then Shift-clicking a row below would otherwise sweep - // every collapsed child between them). - let collapsedGroupIds: Set = Set( - tabManager.workspaceGroups - .filter { $0.isCollapsed } - .map(\.id) - ) - let anchorIdsByGroup: [UUID: UUID] = Dictionary( - uniqueKeysWithValues: tabManager.workspaceGroups.map { ($0.id, $0.anchorWorkspaceId) } - ) - let rangeIds = tabManager.tabs[lower...upper].compactMap { tab -> UUID? in - if let gid = tab.groupId, - collapsedGroupIds.contains(gid), - anchorIdsByGroup[gid] != tab.id { - return nil - } - return tab.id - } - if isCommand { - selectedTabIds.formUnion(rangeIds) - } else { - selectedTabIds = Set(rangeIds) - } - } else if isCommand { - if selectedTabIds.contains(tab.id) { - selectedTabIds.remove(tab.id) - } else { - selectedTabIds.insert(tab.id) - } - } else { - selectedTabIds = [tab.id] - } - - lastSidebarSelectionIndex = SidebarWorkspaceSelectionSyncPolicy().anchorIndexAfterWorkspaceClick( - isShiftClick: isShift, - resolvedShiftAnchorIndex: shiftAnchorIndex, - clickedIndex: index - ) - tabManager.selectTab(tab) - if wasSelected, !isCommand, !isShift { - tabManager.dismissNotificationOnDirectInteraction( - tabId: tab.id, - surfaceId: tabManager.focusedSurfaceId(for: tab.id) - ) - } - setSelectionToTabs() - } - - func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { - tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) - syncSelectionAfterMutation() - } - - func closeOtherTabs(_ targetIds: [UUID]) { - let keepIds = Set(targetIds) - let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - func closeTabsBelow(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - func closeTabsAbove(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - func markTabsRead(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markRead(forTabId: id) - } - } - - func markTabsUnread(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markUnread(forTabId: id) - } - } - - func clearLatestNotifications(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.clearLatestNotification(forTabId: id) - } - } - - func hasLatestNotifications(in targetIds: [UUID]) -> Bool { - targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } - } - - func syncSelectionAfterMutation() { - let existingIds = Set(tabManager.tabs.map { $0.id }) - selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } - if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - } - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - } - - private var remoteStateHelpText: String { - let target = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - switch tab.remoteConnectionState { - case .connected: - return String( - format: String( - localized: "sidebar.remote.help.connected", - defaultValue: "Remote connected to %@" - ), - locale: .current, - target - ) - case .connecting: - return String( - format: String( - localized: "sidebar.remote.help.connecting", - defaultValue: "Remote connecting to %@" - ), - locale: .current, - target - ) - case .reconnecting: - return String( - format: String( - localized: "sidebar.remote.help.reconnecting", - defaultValue: "Remote reconnecting to %@" - ), - locale: .current, - target - ) - case .error: - if let detail, !detail.isEmpty { - return String( - format: String( - localized: "sidebar.remote.help.errorWithDetail", - defaultValue: "Remote error for %@: %@" - ), - locale: .current, - target, - detail - ) - } - return String( - format: String( - localized: "sidebar.remote.help.error", - defaultValue: "Remote error for %@" - ), - locale: .current, - target - ) - case .disconnected: - return String( - format: String( - localized: "sidebar.remote.help.disconnected", - defaultValue: "Remote disconnected from %@" - ), - locale: .current, - target - ) - case .suspended: - return String( - format: String( - localized: "sidebar.remote.help.suspended", - defaultValue: "SSH host %@ is unreachable. Automatic reconnect is paused — use Reconnect to retry." - ), - locale: .current, - target - ) - } - } - - private func makeWorkspaceSnapshot() -> SidebarWorkspaceSnapshotBuilder.Snapshot { -#if DEBUG - sidebarLazyContractProbe.workspaceSnapshotBuild?() -#endif - let signpost = SidebarProfilingSignposts.begin("sidebar-workspace-snapshot", "workspace=\(sidebarShortTabId(tab.id)) details=\(settings.visibleAuxiliaryDetails)"); defer { SidebarProfilingSignposts.end(signpost) } - let detailVisibility = visibleAuxiliaryDetails - let orderedPanelIds: [UUID]? = (detailVisibility.showsBranchDirectory || detailVisibility.showsPullRequests) - ? tab.sidebarOrderedPanelIds() - : nil - let compactGitBranchSummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - sidebarShowGitBranch, - let orderedPanelIds else { - return nil - } - return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactDirectoryCandidates: [String] = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - let orderedPanelIds else { - return [] - } - return compactDirectoryCandidatesList(orderedPanelIds: orderedPanelIds) - }() - let compactBranchDirectoryCandidates = compactBranchDirectoryCandidatesList( - gitSummary: compactGitBranchSummaryText, - directoryCandidates: compactDirectoryCandidates - ) - let branchDirectoryLines: [SidebarWorkspaceSnapshotBuilder.VerticalBranchDirectoryLine] = { - guard detailVisibility.showsBranchDirectory, - sidebarBranchVerticalLayout, - let orderedPanelIds else { - return [] - } - return verticalBranchDirectoryLines(orderedPanelIds: orderedPanelIds) - }() - let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } - let pullRequestRows: [SidebarWorkspaceSnapshotBuilder.PullRequestDisplay] = { - guard detailVisibility.showsPullRequests, let orderedPanelIds else { return [] } - return pullRequestDisplays(orderedPanelIds: orderedPanelIds) - }() - // Pure reads only: effective-status resolution never mutates; the - // expired-override cleanup happens at explicit mutation entry points. - // Sidebar rows draw no status glyph; the resolved status only feeds - // the done-row dim, and a workspace can opt out entirely (None). - let workspaceStatusVisible = !tab.todoState.statusHidden - let inferredTaskStatus = workspaceStatusVisible ? tab.inferredTaskStatus : nil - let taskStatusResolution: WorkspaceTaskStatusOverride.Resolution? = inferredTaskStatus.map { inferred in - WorkspaceTaskStatusOverride.effectiveStatus( - override: tab.todoState.statusOverride, - inferred: inferred - ) - } - let checklistProgress = tab.checklistProgressSummary - - return SidebarWorkspaceSnapshotBuilder.Snapshot( - presentationKey: workspaceSnapshotPresentationKey, - title: tab.title, - customDescription: settings.showsWorkspaceDescription ? sidebarVisibleCustomDescription : nil, - isPinned: tab.isPinned, - customColorHex: tab.customColor, - remoteWorkspaceSidebarText: remoteWorkspaceSidebarText, - remoteConnectionStatusText: remoteConnectionStatusText, - remoteStateHelpText: remoteStateHelpText, - showsRemoteReconnectAffordance: !tab.isManagedCloudVMWorkspace - && (tab.remoteConnectionState == .suspended || tab.remoteConnectionState == .disconnected), - copyableSidebarSSHError: copyableSidebarSSHError, - latestConversationMessage: tab.latestConversationMessage, - metadataEntries: detailVisibility.showsMetadata ? tab.sidebarStatusEntriesInDisplayOrder() : [], - metadataBlocks: detailVisibility.showsMetadata ? tab.sidebarMetadataBlocksInDisplayOrder() : [], - latestLog: detailVisibility.showsLog ? tab.logEntries.last : nil, - progress: detailVisibility.showsProgress ? tab.progress : nil, - activeCodingAgentCount: SidebarAgentActivitySummary.visibleActiveCodingAgentCount( - showsAgentActivity: showsAgentActivity, - statesByPanelId: tab.agentLifecycleStatesByPanelId - ), - compactGitBranchSummaryText: compactGitBranchSummaryText, - compactDirectoryCandidates: compactDirectoryCandidates, - compactBranchDirectoryCandidates: compactBranchDirectoryCandidates, - branchDirectoryLines: branchDirectoryLines, - branchLinesContainBranch: branchLinesContainBranch, - pullRequestRows: pullRequestRows, - listeningPorts: detailVisibility.showsPorts ? tab.listeningPorts : [], - finderDirectoryPath: WorkspaceFinderDirectoryResolver.path(for: tab), - mediaActivity: tab.browserMediaActivity, - taskStatus: taskStatusResolution?.effective, - checklistItems: tab.todoState.checklist, - checklistCompletedCount: checklistProgress.completedCount, - checklistTotalCount: checklistProgress.totalCount, - checklistFirstUncheckedText: checklistProgress.firstUncheckedText - ) - } - - private var sidebarVisibleCustomDescription: String? { - guard let description = tab.customDescription else { return nil } - if tab.title.hasPrefix("vm:"), - description.trimmingCharacters(in: .whitespacesAndNewlines) == Self.legacyVMWebSocketDescription { - return nil - } - return description - } - - func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard !orderedWorkspaceIds.isEmpty else { return } - - for (index, workspaceId) in orderedWorkspaceIds.enumerated() { - let shouldFocus = index == orderedWorkspaceIds.count - 1 - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } - - let shouldFocusImmediately = orderedWorkspaceIds.count == 1 - guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { - return - } - - if orderedWorkspaceIds.count > 1 { - for workspaceId in orderedWorkspaceIds.dropFirst() { - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) - } - if let finalWorkspaceId = orderedWorkspaceIds.last { - _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) - } - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - // latestNotificationText is now passed as a parameter from the parent view - // to avoid subscribing to notificationStore changes in every TabItemView. - - // Builds the joined "branch · directory" candidates list for inline mode. - // Each entry pairs the (fixed) git summary with one entry from the - // directory candidates list, so ViewThatFits can choose how aggressively to - // shorten the directory portion as the row width changes. - private func compactBranchDirectoryCandidatesList( - gitSummary: String?, - directoryCandidates: [String] - ) -> [String] { - if directoryCandidates.isEmpty { - return gitSummary.flatMap { $0.isEmpty ? nil : [$0] } ?? [] - } - guard let gitSummary, !gitSummary.isEmpty else { return directoryCandidates } - return directoryCandidates.map { "\(gitSummary) · \($0)" } - } - - private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { - let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) - guard !lines.isEmpty else { return nil } - return lines.joined(separator: " | ") - } - - private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { - tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in - "\(branch.branch)\(branch.isDirty ? "*" : "")" - } - } - - private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [SidebarWorkspaceSnapshotBuilder.VerticalBranchDirectoryLine] { - let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) - let home = SidebarPathFormatter.homeDirectoryPath - let useViewportAwarePath = sidebarUsesLastSegmentPath - return entries.compactMap { entry in - let branchText: String? = { - guard sidebarShowGitBranch, let branch = entry.branch else { return nil } - return "\(branch)\(entry.isDirty ? "*" : "")" - }() - - let directoryCandidates: [String] = { - guard let directory = entry.directory else { return [] } - // Display labels are reporter-supplied text, not paths; render - // them verbatim instead of path-shortening. - if entry.directoryIsDisplayLabel { return [directory] } - if useViewportAwarePath { - return SidebarPathFormatter.pathCandidates(directory, homeDirectoryPath: home) - } - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? [] : [shortened] - }() - - if branchText == nil && directoryCandidates.isEmpty { - return nil - } - return SidebarWorkspaceSnapshotBuilder.VerticalBranchDirectoryLine( - branch: branchText, - directoryCandidates: directoryCandidates - ) - } - } - - // Candidates for the inline-mode directory line, longest → shortest. When - // viewport-aware truncation is off, returns a single element with each - // panel directory shortened via `~/`. When on, walks per-path candidate - // indices, bumping the rightmost path that can still shrink at each step. - // Each emitted candidate differs from the previous by exactly one path - // collapsing one level, so ViewThatFits sees a strictly monotone gradient - // (`full|full`, `full|mid`, `full|leaf`, `mid|leaf`, `leaf|leaf`) — later - // panels shrink before earlier ones, preserving the leading workspace dir - // as long as the row width allows. - private func compactDirectoryCandidatesList(orderedPanelIds: [UUID]) -> [String] { - let home = SidebarPathFormatter.homeDirectoryPath - let directories = tab.sidebarDisplayedDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds) - guard !directories.isEmpty else { return [] } - - // Display labels are reporter-supplied text, not paths; render them - // verbatim instead of path-shortening. - if !sidebarUsesLastSegmentPath { - let joined = directories - .map { $0.isDisplayLabel ? $0.text : SidebarPathFormatter.shortenedPath($0.text, homeDirectoryPath: home) } - .filter { !$0.isEmpty } - .joined(separator: " | ") - return joined.isEmpty ? [] : [joined] - } - - let perDirectoryCandidates: [[String]] = directories - .map { $0.isDisplayLabel ? [$0.text] : SidebarPathFormatter.pathCandidates($0.text, homeDirectoryPath: home) } - .filter { !$0.isEmpty } - guard !perDirectoryCandidates.isEmpty else { return [] } - - var indices = Array(repeating: 0, count: perDirectoryCandidates.count) - var result: [String] = [] - while true { - let pieces = zip(indices, perDirectoryCandidates).map { idx, candidates in - candidates[idx] - } - let joined = pieces.joined(separator: " | ") - if !joined.isEmpty, result.last != joined { - result.append(joined) - } - guard let bumpIdx = indices.indices.last(where: { indices[$0] < perDirectoryCandidates[$0].count - 1 }) else { - break - } - indices[bumpIdx] += 1 - } - return result - } - - private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [SidebarWorkspaceSnapshotBuilder.PullRequestDisplay] { - tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in - SidebarWorkspaceSnapshotBuilder.PullRequestDisplay( - id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", - number: pullRequest.number, - label: pullRequest.label, - url: pullRequest.url, - status: pullRequest.status, - isStale: pullRequest.isStale - ) - } + actions.select(NSEvent.modifierFlags) } private var pullRequestForegroundColor: Color { @@ -14463,36 +14331,11 @@ struct TabItemView: View, Equatable { } private func openPullRequestLink(_ url: URL) { - updateSelection() - if openSidebarPullRequestLinksInCmuxBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) + actions.openPullRequest(url) } private func openPortLink(_ port: Int) { - guard let url = URL(string: "http://localhost:\(port)") else { return } - updateSelection() - if openSidebarPortLinksInCmuxBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) + actions.openPort(port) } private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { @@ -14658,7 +14501,7 @@ struct TabItemView: View, Equatable { } func applyTabColor(_ hex: String?, targetIds: [UUID]) { - tabManager.applyWorkspaceColor(hex, toWorkspaceIds: targetIds) + actions.applyColor(hex, targetIds) } func promptCustomColor(targetIds: [UUID]) { @@ -14666,7 +14509,7 @@ struct TabItemView: View, Equatable { alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") - let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" + let seed = workspaceSnapshot.customColorHex ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" let input = NSTextField(string: seed) input.placeholderString = "#1565C0" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) @@ -14708,7 +14551,7 @@ struct TabItemView: View, Equatable { let alert = NSAlert() alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) + let input = NSTextField(string: snapshot.customTitle ?? workspaceSnapshot.title) input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input @@ -14722,15 +14565,11 @@ struct TabItemView: View, Equatable { } let response = alert.runModal() guard response == .alertFirstButtonReturn else { return } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + actions.setCustomTitle(input.stringValue) } func beginWorkspaceDescriptionEditFromContextMenu() { - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - setSelectionToTabs() - _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + actions.editDescription() } } diff --git a/Sources/Debug/SidebarLazyContractProbe.swift b/Sources/Debug/SidebarLazyContractProbe.swift index 672210c6044c..d59b000089dc 100644 --- a/Sources/Debug/SidebarLazyContractProbe.swift +++ b/Sources/Debug/SidebarLazyContractProbe.swift @@ -16,5 +16,6 @@ struct SidebarLazyContractProbe { var workspaceRowBodyEnd: (() -> Void)? var groupHeaderRowBody: (() -> Void)? var workspaceSnapshotBuild: (() -> Void)? + var workspaceRowInputProjection: (() -> Void)? } #endif diff --git a/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift b/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift index 1ea320ce442f..9d5b7389ed32 100644 --- a/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift +++ b/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift @@ -8,7 +8,7 @@ struct SidebarBonsplitTabDropDelegate: DropDelegate { let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool let syncSidebarSelectionAfterDrop: @MainActor () -> Void - @Binding var selectedTabIds: Set + let selectTargetAfterDrop: @MainActor () -> Void func validateDrop(info: DropInfo) -> Bool { guard isEnabled else { return false } @@ -36,7 +36,7 @@ struct SidebarBonsplitTabDropDelegate: DropDelegate { return false } - selectedTabIds = [targetWorkspaceId] + selectTargetAfterDrop() syncSidebarSelectionAfterDrop() return true } diff --git a/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift b/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift index 811fd60abf38..7275df3a2ab3 100644 --- a/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift +++ b/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift @@ -7,7 +7,7 @@ struct SidebarBonsplitWorkspaceRowDropModifier: ViewModifier { let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool let syncSidebarSelectionAfterDrop: @MainActor () -> Void - @Binding var selectedTabIds: Set + let selectTargetAfterDrop: @MainActor () -> Void func body(content: Content) -> some View { let delegate = SidebarBonsplitTabDropDelegate( @@ -16,7 +16,7 @@ struct SidebarBonsplitWorkspaceRowDropModifier: ViewModifier { bonsplitSourceWorkspaceId: bonsplitSourceWorkspaceId, moveBonsplitTabToWorkspace: moveBonsplitTabToWorkspace, syncSidebarSelectionAfterDrop: syncSidebarSelectionAfterDrop, - selectedTabIds: $selectedTabIds + selectTargetAfterDrop: selectTargetAfterDrop ) return content.onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: delegate) } diff --git a/Sources/SidebarWorkspaceContextMenuSnapshot.swift b/Sources/SidebarWorkspaceContextMenuSnapshot.swift new file mode 100644 index 000000000000..313ecaf3a95a --- /dev/null +++ b/Sources/SidebarWorkspaceContextMenuSnapshot.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Immutable context-menu inputs for one workspace row. +/// +/// The live workspace graph is resolved above the sidebar's `LazyVStack` and +/// reduced to this value. Menu actions are supplied separately as closures, so +/// opening a menu cannot subscribe a lazy row to `TabManager`, `Workspace`, or +/// the notification store. +struct SidebarWorkspaceContextMenuSnapshot: Equatable { + let targetWorkspaceIds: [UUID] + let remoteTargetWorkspaceIds: [UUID] + let allRemoteTargetsConnecting: Bool + let allRemoteTargetsDisconnected: Bool + let pinState: WorkspaceActionDispatcher.PinState? + let groupMenuSnapshot: WorkspaceGroupMenuSnapshot + let canCreateEmptyGroup: Bool + let eligibleGroupTargetIds: [UUID] + let allEligibleTargetsGroupId: UUID? + let hasGroupedEligibleTarget: Bool + let todoStatusLanes: [WorkspaceTodoStatusLane] + let canMarkRead: Bool + let canMarkUnread: Bool + let hasLatestNotification: Bool + let notifications: [TerminalNotification] +} diff --git a/Sources/SidebarWorkspaceContextMenuTargetAggregate.swift b/Sources/SidebarWorkspaceContextMenuTargetAggregate.swift new file mode 100644 index 000000000000..60c90eb3d2a1 --- /dev/null +++ b/Sources/SidebarWorkspaceContextMenuTargetAggregate.swift @@ -0,0 +1,71 @@ +import CmuxCore +import Foundation + +/// Shared context-menu facts derived from a target workspace selection. +/// +/// The sidebar computes the selected-set aggregate once above `LazyVStack`. +/// Selected rows reuse this immutable value instead of repeating remote, +/// grouping, unread, and notification aggregation during row realization. +struct SidebarWorkspaceContextMenuTargetAggregate: Equatable { + let targetWorkspaceIds: [UUID] + let remoteTargetWorkspaceIds: [UUID] + let allRemoteTargetsConnecting: Bool + let allRemoteTargetsDisconnected: Bool + let eligibleGroupTargetIds: [UUID] + let allEligibleTargetsGroupId: UUID? + let hasGroupedEligibleTarget: Bool + let canMarkRead: Bool + let canMarkUnread: Bool + let hasLatestNotification: Bool + let notifications: [TerminalNotification] + + @MainActor + init( + targetWorkspaceIds: [UUID], + workspaceRowsById: [UUID: SidebarWorkspaceRowInput], + anchorWorkspaceIds: Set, + notificationIndex: SidebarWorkspaceNotificationIndex + ) { + self.targetWorkspaceIds = targetWorkspaceIds + remoteTargetWorkspaceIds = targetWorkspaceIds.filter { + workspaceRowsById[$0]?.isRemoteContextMenuEligible == true + } + allRemoteTargetsConnecting = !remoteTargetWorkspaceIds.isEmpty + && remoteTargetWorkspaceIds.allSatisfy { + guard let state = workspaceRowsById[$0]?.remoteConnectionState else { return false } + return state == .connecting || state == .reconnecting + } + allRemoteTargetsDisconnected = !remoteTargetWorkspaceIds.isEmpty + && remoteTargetWorkspaceIds.allSatisfy { + workspaceRowsById[$0]?.remoteConnectionState == .disconnected + } + eligibleGroupTargetIds = targetWorkspaceIds.filter { + !anchorWorkspaceIds.contains($0) && workspaceRowsById[$0] != nil + } + let eligibleGroupIds = eligibleGroupTargetIds.map { workspaceRowsById[$0]?.groupId } + allEligibleTargetsGroupId = Self.commonGroupId(eligibleGroupIds) + hasGroupedEligibleTarget = eligibleGroupTargetIds.contains { + workspaceRowsById[$0]?.groupId != nil + } + canMarkRead = targetWorkspaceIds.contains { + (workspaceRowsById[$0]?.unreadCount ?? 0) > 0 + } + canMarkUnread = targetWorkspaceIds.contains { + (workspaceRowsById[$0]?.unreadCount ?? 0) == 0 + } + hasLatestNotification = notificationIndex.hasNotification( + workspaceIds: targetWorkspaceIds + ) + notifications = notificationIndex.contextMenuNotifications( + workspaceIds: targetWorkspaceIds + ) + } + + private static func commonGroupId(_ groupIds: [UUID?]) -> UUID? { + guard let first = groupIds.first, + groupIds.allSatisfy({ $0 == first }) else { + return nil + } + return first + } +} diff --git a/Sources/SidebarWorkspaceGroupRowSnapshot.swift b/Sources/SidebarWorkspaceGroupRowSnapshot.swift new file mode 100644 index 000000000000..e9c2a16de69f --- /dev/null +++ b/Sources/SidebarWorkspaceGroupRowSnapshot.swift @@ -0,0 +1,41 @@ +import AppKit +import CmuxSettings +import Foundation + +/// Immutable presentation and menu state for one workspace-group row. +/// +/// Live group, notification, config, drag, and pointer models are reduced to +/// this value before the lazy-list boundary. Only action closures are bound +/// when SwiftUI realizes the row. +struct SidebarWorkspaceGroupRowSnapshot { + let groupId: UUID + let anchorWorkspaceId: UUID + let name: String + let iconSymbol: String + let tintHex: String? + let isCollapsed: Bool + let isPinned: Bool + let isAnchorActive: Bool + let memberCount: Int + let anchorUnreadCount: Int + let canMarkRead: Bool + let canMarkUnread: Bool + let hasLatestNotifications: Bool + let canMarkAllRead: Bool + let canMarkAllUnread: Bool + let shortcutDigit: Int? + let shortcutModifierSymbol: String? + let showsShortcutHint: Bool + let isPointerHovering: Bool + let shortcutHintXOffset: Double + let shortcutHintYOffset: Double + let fontScale: CGFloat + let cwdContextMenuItems: [CmuxResolvedConfigContextMenuItem] + let newWorkspacePlacement: WorkspaceGroupNewPlacement? + let rowSpacing: CGFloat + let isFirstRow: Bool + let isBeingDragged: Bool + let topDropIndicatorVisible: Bool + let bottomDropIndicatorVisible: Bool + let shouldCollectWorkspaceDropTargets: Bool +} diff --git a/Sources/SidebarWorkspaceGroupRowView.swift b/Sources/SidebarWorkspaceGroupRowView.swift new file mode 100644 index 000000000000..c81f33ef18c3 --- /dev/null +++ b/Sources/SidebarWorkspaceGroupRowView.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftUI + +/// Mounts one immutable workspace-group projection below the lazy-list boundary. +struct SidebarWorkspaceGroupRowView: View { + let header: SidebarWorkspaceGroupHeaderView + let groupId: UUID + let anchorWorkspaceId: UUID + let shouldCollectWorkspaceDropTargets: Bool + let onPointerFrameChange: (CGRect) -> Void + let onPointerFrameDisappear: () -> Void + + var body: some View { + header + .equatable() + .id(anchorWorkspaceId) + .accessibilityIdentifier("sidebarWorkspaceGroup.\(groupId.uuidString)") + .sidebarWorkspaceFrameAnchor( + id: anchorWorkspaceId, + isEnabled: shouldCollectWorkspaceDropTargets + ) + .sidebarPointerFrameReporting( + onFrameChange: onPointerFrameChange, + onDisappear: onPointerFrameDisappear + ) + } +} diff --git a/Sources/SidebarWorkspaceNotificationIndex.swift b/Sources/SidebarWorkspaceNotificationIndex.swift new file mode 100644 index 000000000000..2cff33543dbc --- /dev/null +++ b/Sources/SidebarWorkspaceNotificationIndex.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Immutable notification projection shared by all rows in one sidebar pass. +/// +/// The parent groups and sorts the flat store snapshot once. Lazy rows perform +/// dictionary presence checks or a bounded merge of the requested workspace +/// buckets; they never rescan the full notification history. +@MainActor +struct SidebarWorkspaceNotificationIndex { + static let contextMenuNotificationLimit = 50 + + private let notificationsByWorkspaceId: [UUID: [TerminalNotification]] + + init(notifications: [TerminalNotification]) { + var grouped: [UUID: [TerminalNotification]] = [:] + grouped.reserveCapacity(min(notifications.count, 64)) + for notification in notifications { + grouped[notification.tabId, default: []].append(notification) + } + for workspaceId in Array(grouped.keys) { + guard let bucket = grouped[workspaceId] else { continue } + grouped[workspaceId] = Array( + bucket + .sorted(by: TerminalNotificationStore.notificationSortPrecedes) + .prefix(Self.contextMenuNotificationLimit) + ) + } + notificationsByWorkspaceId = grouped + } + + func hasNotification(workspaceId: UUID) -> Bool { + notificationsByWorkspaceId[workspaceId]?.isEmpty == false + } + + func hasNotification(workspaceIds: [UUID]) -> Bool { + workspaceIds.contains { hasNotification(workspaceId: $0) } + } + + func contextMenuNotifications(workspaceIds: [UUID]) -> [TerminalNotification] { + var seenWorkspaceIds = Set() + let activeWorkspaceIds = workspaceIds.filter { workspaceId in + seenWorkspaceIds.insert(workspaceId).inserted + && notificationsByWorkspaceId[workspaceId]?.isEmpty == false + } + guard !activeWorkspaceIds.isEmpty else { return [] } + + var offsetsByWorkspaceId: [UUID: Int] = [:] + offsetsByWorkspaceId.reserveCapacity(activeWorkspaceIds.count) + var result: [TerminalNotification] = [] + result.reserveCapacity(Self.contextMenuNotificationLimit) + + while result.count < Self.contextMenuNotificationLimit { + var newestWorkspaceId: UUID? + var newestNotification: TerminalNotification? + for workspaceId in activeWorkspaceIds { + let offset = offsetsByWorkspaceId[workspaceId, default: 0] + guard let bucket = notificationsByWorkspaceId[workspaceId], + offset < bucket.count else { + continue + } + let candidate = bucket[offset] + if newestNotification.map({ + TerminalNotificationStore.notificationSortPrecedes(candidate, $0) + }) ?? true { + newestWorkspaceId = workspaceId + newestNotification = candidate + } + } + guard let newestWorkspaceId, let newestNotification else { break } + result.append(newestNotification) + offsetsByWorkspaceId[newestWorkspaceId, default: 0] += 1 + } + + return result + } +} diff --git a/Sources/SidebarWorkspaceRenderItem.swift b/Sources/SidebarWorkspaceRenderItem.swift index a759484ec158..ca2ec231e0af 100644 --- a/Sources/SidebarWorkspaceRenderItem.swift +++ b/Sources/SidebarWorkspaceRenderItem.swift @@ -1,27 +1,34 @@ import CmuxWorkspaces import Foundation -/// One drawable item in the workspace sidebar. +/// Stable value identity for one drawable item in the workspace sidebar. +/// +/// Keep live `Workspace` / `WorkspaceGroup` references out of this value. A +/// `LazyVStack` copies and diffs its `ForEach` data while placing rows; carrying +/// the models through that path made scrolling copy the live sidebar graph and +/// blurred the ownership boundary between layout data and observed state. +/// Models are resolved from the parent-owned render context only when SwiftUI +/// asks to realize a row. @MainActor enum SidebarWorkspaceRenderItem { - case groupHeader(WorkspaceGroup, memberWorkspaceIds: [UUID]) - case workspace(Workspace) + case groupHeader(groupId: UUID, anchorWorkspaceId: UUID) + case workspace(workspaceId: UUID) var id: SidebarWorkspaceRenderItemID { switch self { - case .groupHeader(let group, _): - return .group(group.id) - case .workspace(let workspace): - return .workspace(workspace.id) + case .groupHeader(let groupId, _): + return .group(groupId) + case .workspace(let workspaceId): + return .workspace(workspaceId) } } var rowWorkspaceId: UUID { switch self { - case .groupHeader(let group, _): - return group.anchorWorkspaceId - case .workspace(let workspace): - return workspace.id + case .groupHeader(_, let anchorWorkspaceId): + return anchorWorkspaceId + case .workspace(let workspaceId): + return workspaceId } } @@ -30,12 +37,6 @@ enum SidebarWorkspaceRenderItem { groupsById: [UUID: WorkspaceGroup] ) -> [SidebarWorkspaceRenderItem] { guard !tabs.isEmpty else { return [] } - var memberWorkspaceIdsByGroupId: [UUID: [UUID]] = [:] - for tab in tabs { - if let gid = tab.groupId { - memberWorkspaceIdsByGroupId[gid, default: []].append(tab.id) - } - } var items: [SidebarWorkspaceRenderItem] = [] items.reserveCapacity(tabs.count + groupsById.count) var lastEmittedGroupId: UUID? = nil @@ -49,8 +50,10 @@ enum SidebarWorkspaceRenderItem { skipChildrenUntilNextGroup = false if let groupId, let group = groupsById[groupId] { if !emittedHeaders.contains(groupId) { - let memberWorkspaceIds = memberWorkspaceIdsByGroupId[groupId] ?? [] - items.append(.groupHeader(group, memberWorkspaceIds: memberWorkspaceIds)) + items.append(.groupHeader( + groupId: group.id, + anchorWorkspaceId: group.anchorWorkspaceId + )) emittedHeaders.insert(groupId) collapsedByGroupId[groupId] = group.isCollapsed } @@ -64,9 +67,19 @@ enum SidebarWorkspaceRenderItem { continue } if groupId == nil || !skipChildrenUntilNextGroup { - items.append(.workspace(tab)) + items.append(.workspace(workspaceId: tab.id)) } } return items } + + static func memberWorkspaceIdsByGroupId(tabs: [Workspace]) -> [UUID: [UUID]] { + var result: [UUID: [UUID]] = [:] + for tab in tabs { + if let groupId = tab.groupId { + result[groupId, default: []].append(tab.id) + } + } + return result + } } diff --git a/Sources/SidebarWorkspaceRowActions.swift b/Sources/SidebarWorkspaceRowActions.swift new file mode 100644 index 000000000000..55d6a17ff25e --- /dev/null +++ b/Sources/SidebarWorkspaceRowActions.swift @@ -0,0 +1,62 @@ +import AppKit +import CmuxWorkspaces +import Foundation + +/// Closure capabilities for a workspace row. +/// +/// Actions may capture parent-owned models, but the row can neither retain nor +/// observe those models directly. This makes the lazy-list snapshot boundary a +/// type-level rule instead of a convention at each call site. +@MainActor +struct SidebarWorkspaceRowActions { + let select: (NSEvent.ModifierFlags) -> Void + let setCustomTitle: (String) -> Void + let clearCustomTitle: () -> Void + let clearCustomDescription: () -> Void + let editDescription: () -> Void + let closeWorkspace: () -> Void + let moveBy: (Int) -> Void + let moveTargetsToTop: ([UUID]) -> Void + /// Resolves volatile app-window topology when the deferred menu is presented. + let currentWindowMoveTargets: () -> [SidebarWorkspaceWindowMoveTarget] + let moveTargetsToWindow: ([UUID], UUID) -> Void + let moveTargetsToNewWindow: ([UUID]) -> Void + let closeTargets: ([UUID], Bool) -> Void + let closeOtherTargets: ([UUID]) -> Void + let closeTargetsBelow: () -> Void + let closeTargetsAbove: () -> Void + let performPin: () -> Void + let createEmptyGroup: () -> Void + let createGroup: ([UUID]) -> Void + let addTargetsToGroup: ([UUID], UUID) -> Void + let removeTargetsFromGroup: ([UUID]) -> Void + let reconnectTargets: ([UUID]) -> Void + let disconnectTargets: ([UUID]) -> Void + let applyColor: (String?, [UUID]) -> Void + let applyTodoStatus: (WorkspaceTaskStatus?, [UUID]) -> Void + let hideTodoStatus: ([UUID]) -> Void + let requestChecklistAdd: () -> Void + let markRead: ([UUID]) -> Void + let markUnread: ([UUID]) -> Void + let clearLatestNotifications: ([UUID]) -> Void + let openNotification: (TerminalNotification) -> Void + let copyWorkspaceLinks: ([UUID]) -> Void + let openPullRequest: (URL) -> Void + let openPort: (Int) -> Void + let checklist: SidebarWorkspaceChecklistActions + let onDragStart: () -> NSItemProvider + let bonsplitSourceWorkspaceId: (UUID) -> UUID? + let moveBonsplitTabToWorkspace: (BonsplitTabDragPayload.Transfer, UUID) -> Bool + let syncAfterBonsplitDrop: () -> Void + let selectAfterBonsplitDrop: () -> Void + let onToggleChecklistExpansion: () -> Void + let onConsumeChecklistAddFieldActivation: () -> Void + let onChecklistPopoverPresentedChange: (Bool) -> Void + let onContextMenuAppear: () -> Void + let onContextMenuDisappear: () -> Void + let onPointerFrameChange: (CGRect) -> Void + let onPointerFrameDisappear: () -> Void +} + +/// Binds parent-owned action capabilities to one lazily realized row input. +typealias SidebarWorkspaceRowActionFactory = @MainActor (SidebarWorkspaceRowInput) -> SidebarWorkspaceRowActions diff --git a/Sources/SidebarWorkspaceRowInput.swift b/Sources/SidebarWorkspaceRowInput.swift new file mode 100644 index 000000000000..2b805433009e --- /dev/null +++ b/Sources/SidebarWorkspaceRowInput.swift @@ -0,0 +1,103 @@ +import CmuxCore +import CmuxSidebar +import CmuxWorkspaces +import Foundation + +/// Immutable inputs used to assemble one workspace row when a lazy stack realizes it. +/// +/// The sidebar owner resolves every live model read into this value before the +/// `LazyVStack`. Context-menu notifications and row-specific action closures +/// are intentionally assembled later so parent invalidations stay O(values), +/// not O(full row subtrees). +struct SidebarWorkspaceRowInput { + let workspaceId: UUID + let groupId: UUID? + let index: Int + let workspaceCount: Int + let workspace: SidebarWorkspaceSnapshotBuilder.Snapshot + let isActive: Bool + let isMultiSelected: Bool + let hasUserCustomTitle: Bool + let hasCustomTitle: Bool + let hasCustomDescription: Bool + let customTitle: String? + let workspaceShortcutDigit: Int? + let workspaceShortcutModifierSymbol: String + let canCloseWorkspace: Bool + let unreadCount: Int + let latestNotificationText: String? + let showsAgentActivity: Bool + let rowSpacing: CGFloat + let showsModifierShortcutHints: Bool + let isPointerHovering: Bool + let isBeingDragged: Bool + let topDropIndicatorVisible: Bool + let bottomDropIndicatorVisible: Bool + let isBonsplitWorkspaceDropActive: Bool + let settings: SidebarTabItemSettingsSnapshot + let isChecklistExpanded: Bool + let checklistAddFieldActivationToken: Int + let isChecklistPopoverPresented: Bool + let isRemoteContextMenuEligible: Bool + let remoteConnectionState: WorkspaceRemoteConnectionState + let contextMenuPinState: WorkspaceActionDispatcher.PinState? + let inferredTaskStatus: WorkspaceTaskStatus + let activeTodoOverride: WorkspaceTaskStatus? + let isTodoStatusHidden: Bool + + @MainActor + func rowSnapshot(list: SidebarWorkspaceRowsSnapshot) -> SidebarWorkspaceRowSnapshot { + let targetAggregate = list.contextMenuTargetAggregate(for: self) + return SidebarWorkspaceRowSnapshot( + workspaceId: workspaceId, + groupId: groupId, + index: index, + workspaceCount: workspaceCount, + workspace: workspace, + isActive: isActive, + isMultiSelected: isMultiSelected, + hasUserCustomTitle: hasUserCustomTitle, + hasCustomTitle: hasCustomTitle, + hasCustomDescription: hasCustomDescription, + customTitle: customTitle, + workspaceShortcutDigit: workspaceShortcutDigit, + workspaceShortcutModifierSymbol: workspaceShortcutModifierSymbol, + canCloseWorkspace: canCloseWorkspace, + unreadCount: unreadCount, + latestNotificationText: latestNotificationText, + showsAgentActivity: showsAgentActivity, + rowSpacing: rowSpacing, + showsModifierShortcutHints: showsModifierShortcutHints, + isPointerHovering: isPointerHovering, + isBeingDragged: isBeingDragged, + topDropIndicatorVisible: topDropIndicatorVisible, + bottomDropIndicatorVisible: bottomDropIndicatorVisible, + isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropActive, + settings: settings, + isChecklistExpanded: isChecklistExpanded, + checklistAddFieldActivationToken: checklistAddFieldActivationToken, + isChecklistPopoverPresented: isChecklistPopoverPresented, + contextMenu: SidebarWorkspaceContextMenuSnapshot( + targetWorkspaceIds: targetAggregate.targetWorkspaceIds, + remoteTargetWorkspaceIds: targetAggregate.remoteTargetWorkspaceIds, + allRemoteTargetsConnecting: targetAggregate.allRemoteTargetsConnecting, + allRemoteTargetsDisconnected: targetAggregate.allRemoteTargetsDisconnected, + pinState: contextMenuPinState, + groupMenuSnapshot: list.workspaceGroupMenuSnapshot, + canCreateEmptyGroup: list.canCreateEmptyGroup, + eligibleGroupTargetIds: targetAggregate.eligibleGroupTargetIds, + allEligibleTargetsGroupId: targetAggregate.allEligibleTargetsGroupId, + hasGroupedEligibleTarget: targetAggregate.hasGroupedEligibleTarget, + todoStatusLanes: WorkspaceTodoStatusLane.lanes( + inferred: inferredTaskStatus, + activeOverride: activeTodoOverride, + isHidden: isTodoStatusHidden + ), + canMarkRead: targetAggregate.canMarkRead, + canMarkUnread: targetAggregate.canMarkUnread, + hasLatestNotification: targetAggregate.hasLatestNotification, + notifications: targetAggregate.notifications + ) + ) + } +} diff --git a/Sources/SidebarWorkspaceRowSnapshot.swift b/Sources/SidebarWorkspaceRowSnapshot.swift new file mode 100644 index 000000000000..57a9bbf7395f --- /dev/null +++ b/Sources/SidebarWorkspaceRowSnapshot.swift @@ -0,0 +1,39 @@ +import CmuxSidebar +import Foundation + +/// Complete immutable render value for one workspace row. +/// +/// No observable model reference crosses the lazy-list boundary. A change in +/// workspace state rebuilds these values once in the sidebar owner; Equatable +/// rows then re-render only when their own value changed. +struct SidebarWorkspaceRowSnapshot: Equatable { + let workspaceId: UUID + let groupId: UUID? + let index: Int + let workspaceCount: Int + let workspace: SidebarWorkspaceSnapshotBuilder.Snapshot + let isActive: Bool + let isMultiSelected: Bool + let hasUserCustomTitle: Bool + let hasCustomTitle: Bool + let hasCustomDescription: Bool + let customTitle: String? + let workspaceShortcutDigit: Int? + let workspaceShortcutModifierSymbol: String + let canCloseWorkspace: Bool + let unreadCount: Int + let latestNotificationText: String? + let showsAgentActivity: Bool + let rowSpacing: CGFloat + let showsModifierShortcutHints: Bool + let isPointerHovering: Bool + let isBeingDragged: Bool + let topDropIndicatorVisible: Bool + let bottomDropIndicatorVisible: Bool + let isBonsplitWorkspaceDropActive: Bool + let settings: SidebarTabItemSettingsSnapshot + let isChecklistExpanded: Bool + let checklistAddFieldActivationToken: Int + let isChecklistPopoverPresented: Bool + let contextMenu: SidebarWorkspaceContextMenuSnapshot +} diff --git a/Sources/SidebarWorkspaceRowView.swift b/Sources/SidebarWorkspaceRowView.swift new file mode 100644 index 000000000000..49f5a09b3874 --- /dev/null +++ b/Sources/SidebarWorkspaceRowView.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftUI + +/// Mounts one immutable workspace-row projection below the lazy-list boundary. +struct SidebarWorkspaceRowView: View { + let snapshot: SidebarWorkspaceRowSnapshot + let actions: SidebarWorkspaceRowActions + let shouldCollectWorkspaceDropTargets: Bool + + var body: some View { + TabItemView(snapshot: snapshot, actions: actions) + .equatable() + .id(snapshot.workspaceId) + .accessibilityIdentifier("sidebarWorkspace.\(snapshot.workspaceId.uuidString)") + .sidebarWorkspaceFrameAnchor( + id: snapshot.workspaceId, + isEnabled: shouldCollectWorkspaceDropTargets + ) + .sidebarPointerFrameReporting( + onFrameChange: actions.onPointerFrameChange, + onDisappear: actions.onPointerFrameDisappear + ) + .padding( + .leading, + snapshot.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0 + ) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } +} diff --git a/Sources/SidebarWorkspaceRowsSnapshot.swift b/Sources/SidebarWorkspaceRowsSnapshot.swift new file mode 100644 index 000000000000..df2241832a81 --- /dev/null +++ b/Sources/SidebarWorkspaceRowsSnapshot.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Parent-owned immutable values consumed by the workspace sidebar's lazy rows. +/// +/// The dictionaries and shared selection aggregate are value projections built +/// before `LazyVStack`. Row/action construction operates only on these copied +/// values, never on observable stores. +struct SidebarWorkspaceRowsSnapshot { + let workspaceRowsById: [UUID: SidebarWorkspaceRowInput] + let groupRowsById: [UUID: SidebarWorkspaceGroupRowSnapshot] + let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot + let canCreateEmptyGroup: Bool + let selectedContextMenuTargetAggregate: SidebarWorkspaceContextMenuTargetAggregate + + private let anchorWorkspaceIds: Set + private let notificationIndex: SidebarWorkspaceNotificationIndex + + @MainActor + init( + workspaceRowsById: [UUID: SidebarWorkspaceRowInput], + groupRowsById: [UUID: SidebarWorkspaceGroupRowSnapshot], + selectedContextTargetIds: [UUID], + anchorWorkspaceIds: Set, + workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot, + canCreateEmptyGroup: Bool, + notificationIndex: SidebarWorkspaceNotificationIndex + ) { + self.workspaceRowsById = workspaceRowsById + self.groupRowsById = groupRowsById + self.workspaceGroupMenuSnapshot = workspaceGroupMenuSnapshot + self.canCreateEmptyGroup = canCreateEmptyGroup + self.anchorWorkspaceIds = anchorWorkspaceIds + self.notificationIndex = notificationIndex + selectedContextMenuTargetAggregate = SidebarWorkspaceContextMenuTargetAggregate( + targetWorkspaceIds: selectedContextTargetIds, + workspaceRowsById: workspaceRowsById, + anchorWorkspaceIds: anchorWorkspaceIds, + notificationIndex: notificationIndex + ) + } + + @MainActor + func contextMenuTargetAggregate( + for input: SidebarWorkspaceRowInput + ) -> SidebarWorkspaceContextMenuTargetAggregate { + guard !input.isMultiSelected else { + return selectedContextMenuTargetAggregate + } + return SidebarWorkspaceContextMenuTargetAggregate( + targetWorkspaceIds: [input.workspaceId], + workspaceRowsById: workspaceRowsById, + anchorWorkspaceIds: anchorWorkspaceIds, + notificationIndex: notificationIndex + ) + } +} diff --git a/Sources/SidebarWorkspaceSnapshotFactory.swift b/Sources/SidebarWorkspaceSnapshotFactory.swift new file mode 100644 index 000000000000..1c4432219f46 --- /dev/null +++ b/Sources/SidebarWorkspaceSnapshotFactory.swift @@ -0,0 +1,339 @@ +import CmuxSidebar +import CmuxWorkspaces +import Foundation + +/// Builds the immutable value passed across the workspace sidebar's +/// `LazyVStack` boundary. +/// +/// The factory is created and consumed by the parent row builder; it is never +/// stored by a SwiftUI row. This keeps live `Workspace` state on the owning side +/// of the lazy-list boundary while preserving the existing presentation rules. +@MainActor +struct SidebarWorkspaceSnapshotFactory { + private static let legacyVMWebSocketDescription = "VM WebSocket PTY" + + let workspace: Workspace + let settings: SidebarTabItemSettingsSnapshot + let showsAgentActivity: Bool + + func makeSnapshot() -> SidebarWorkspaceSnapshotBuilder.Snapshot { + let detailVisibility = settings.visibleAuxiliaryDetails + let orderedPanelIds: [UUID]? = + (detailVisibility.showsBranchDirectory || detailVisibility.showsPullRequests) + ? workspace.sidebarOrderedPanelIds() + : nil + let compactGitBranchSummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !settings.usesVerticalBranchLayout, + settings.showsGitBranch, + let orderedPanelIds else { + return nil + } + return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactDirectoryCandidates: [String] = { + guard detailVisibility.showsBranchDirectory, + !settings.usesVerticalBranchLayout, + let orderedPanelIds else { + return [] + } + return compactDirectoryCandidatesList(orderedPanelIds: orderedPanelIds) + }() + let compactBranchDirectoryCandidates = compactBranchDirectoryCandidatesList( + gitSummary: compactGitBranchSummaryText, + directoryCandidates: compactDirectoryCandidates + ) + let branchDirectoryLines: [SidebarWorkspaceSnapshotBuilder.VerticalBranchDirectoryLine] = { + guard detailVisibility.showsBranchDirectory, + settings.usesVerticalBranchLayout, + let orderedPanelIds else { + return [] + } + return verticalBranchDirectoryLines(orderedPanelIds: orderedPanelIds) + }() + let pullRequestRows: [SidebarWorkspaceSnapshotBuilder.PullRequestDisplay] = { + guard detailVisibility.showsPullRequests, let orderedPanelIds else { return [] } + return pullRequestDisplays(orderedPanelIds: orderedPanelIds) + }() + let workspaceStatusVisible = !workspace.todoState.statusHidden + let inferredTaskStatus = workspaceStatusVisible ? workspace.inferredTaskStatus : nil + let taskStatusResolution: WorkspaceTaskStatusOverride.Resolution? = inferredTaskStatus.map { inferred in + WorkspaceTaskStatusOverride.effectiveStatus( + override: workspace.todoState.statusOverride, + inferred: inferred + ) + } + let checklistProgress = workspace.checklistProgressSummary + + return SidebarWorkspaceSnapshotBuilder.Snapshot( + presentationKey: presentationKey, + title: workspace.title, + customDescription: settings.showsWorkspaceDescription ? visibleCustomDescription : nil, + isPinned: workspace.isPinned, + customColorHex: workspace.customColor, + remoteWorkspaceSidebarText: remoteWorkspaceSidebarText, + remoteConnectionStatusText: remoteConnectionStatusText, + remoteStateHelpText: remoteStateHelpText, + showsRemoteReconnectAffordance: !workspace.isManagedCloudVMWorkspace + && (workspace.remoteConnectionState == .suspended + || workspace.remoteConnectionState == .disconnected), + copyableSidebarSSHError: copyableSidebarSSHError, + latestConversationMessage: workspace.latestConversationMessage, + metadataEntries: detailVisibility.showsMetadata + ? workspace.sidebarStatusEntriesInDisplayOrder() + : [], + metadataBlocks: detailVisibility.showsMetadata + ? workspace.sidebarMetadataBlocksInDisplayOrder() + : [], + latestLog: detailVisibility.showsLog ? workspace.logEntries.last : nil, + progress: detailVisibility.showsProgress ? workspace.progress : nil, + activeCodingAgentCount: SidebarAgentActivitySummary.visibleActiveCodingAgentCount( + showsAgentActivity: showsAgentActivity, + statesByPanelId: workspace.agentLifecycleStatesByPanelId + ), + compactGitBranchSummaryText: compactGitBranchSummaryText, + compactDirectoryCandidates: compactDirectoryCandidates, + compactBranchDirectoryCandidates: compactBranchDirectoryCandidates, + branchDirectoryLines: branchDirectoryLines, + branchLinesContainBranch: settings.showsGitBranch + && branchDirectoryLines.contains { $0.branch != nil }, + pullRequestRows: pullRequestRows, + listeningPorts: detailVisibility.showsPorts ? workspace.listeningPorts : [], + finderDirectoryPath: WorkspaceFinderDirectoryResolver.path(for: workspace), + mediaActivity: workspace.browserMediaActivity, + taskStatus: taskStatusResolution?.effective, + checklistItems: workspace.todoState.checklist, + checklistCompletedCount: checklistProgress.completedCount, + checklistTotalCount: checklistProgress.totalCount, + checklistFirstUncheckedText: checklistProgress.firstUncheckedText + ) + } + + private var presentationKey: SidebarWorkspaceSnapshotBuilder.PresentationKey { + Self.presentationKey(settings: settings, showsAgentActivity: showsAgentActivity) + } + + static func presentationKey( + settings: SidebarTabItemSettingsSnapshot, + showsAgentActivity: Bool + ) -> SidebarWorkspaceSnapshotBuilder.PresentationKey { + SidebarWorkspaceSnapshotBuilder.PresentationKey( + showsWorkspaceDescription: settings.showsWorkspaceDescription, + usesVerticalBranchLayout: settings.usesVerticalBranchLayout, + showsGitBranch: settings.showsGitBranch, + usesViewportAwarePath: settings.usesLastSegmentPath, + showsAgentActivity: showsAgentActivity, + visibleAuxiliaryDetails: settings.visibleAuxiliaryDetails + ) + } + + private var visibleCustomDescription: String? { + guard let description = workspace.customDescription else { return nil } + if workspace.title.hasPrefix("vm:"), + description.trimmingCharacters(in: .whitespacesAndNewlines) + == Self.legacyVMWebSocketDescription { + return nil + } + return description + } + + private var remoteWorkspaceSidebarText: String? { + guard workspace.isRemoteWorkspace else { return nil } + let target = workspace.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let target, !target.isEmpty { return target } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "Remote workspace") + } + + private var copyableSidebarSSHError: String? { + let target = workspace.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = workspace.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if workspace.remoteConnectionState == .error || workspace.remoteConnectionState == .suspended, + let detail, + !detail.isEmpty { + return SidebarRemoteErrorCopySupport.clipboardText(for: [SidebarRemoteErrorCopyEntry( + workspaceTitle: workspace.title, + target: target, + detail: detail + )]) + } + if let statusValue = workspace.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + return SidebarRemoteErrorCopySupport.clipboardText(for: [SidebarRemoteErrorCopyEntry( + workspaceTitle: workspace.title, + target: target, + detail: statusValue + )]) + } + return nil + } + + private var remoteConnectionStatusText: String { + switch workspace.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .reconnecting: + return String(localized: "remote.status.reconnecting", defaultValue: "Reconnecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + case .suspended: + return String(localized: "remote.status.suspended", defaultValue: "Unreachable") + } + } + + private var remoteStateHelpText: String { + let target = workspace.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = workspace.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch workspace.remoteConnectionState { + case .connected: + return remoteHelp("sidebar.remote.help.connected", "Remote connected to %@", target) + case .connecting: + return remoteHelp("sidebar.remote.help.connecting", "Remote connecting to %@", target) + case .reconnecting: + return remoteHelp("sidebar.remote.help.reconnecting", "Remote reconnecting to %@", target) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "Remote error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return remoteHelp("sidebar.remote.help.error", "Remote error for %@", target) + case .disconnected: + return remoteHelp("sidebar.remote.help.disconnected", "Remote disconnected from %@", target) + case .suspended: + return remoteHelp( + "sidebar.remote.help.suspended", + "SSH host %@ is unreachable. Automatic reconnect is paused — use Reconnect to retry.", + target + ) + } + } + + private func remoteHelp( + _ key: StaticString, + _ fallback: String.LocalizationValue, + _ target: String + ) -> String { + String( + format: String(localized: key, defaultValue: fallback), + locale: .current, + target + ) + } + + private func compactBranchDirectoryCandidatesList( + gitSummary: String?, + directoryCandidates: [String] + ) -> [String] { + if directoryCandidates.isEmpty { + return gitSummary.flatMap { $0.isEmpty ? nil : [$0] } ?? [] + } + guard let gitSummary, !gitSummary.isEmpty else { return directoryCandidates } + return directoryCandidates.map { "\(gitSummary) · \($0)" } + } + + private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { + let lines = workspace.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { + "\($0.branch)\($0.isDirty ? "*" : "")" + } + return lines.isEmpty ? nil : lines.joined(separator: " | ") + } + + private func verticalBranchDirectoryLines( + orderedPanelIds: [UUID] + ) -> [SidebarWorkspaceSnapshotBuilder.VerticalBranchDirectoryLine] { + let entries = workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) + let home = SidebarPathFormatter.homeDirectoryPath + return entries.compactMap { entry in + let branch: String? = settings.showsGitBranch + ? entry.branch.map { "\($0)\(entry.isDirty ? "*" : "")" } + : nil + let directories: [String] + if let directory = entry.directory { + if entry.directoryIsDisplayLabel { + directories = [directory] + } else if settings.usesLastSegmentPath { + directories = SidebarPathFormatter.pathCandidates(directory, homeDirectoryPath: home) + } else { + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + directories = shortened.isEmpty ? [] : [shortened] + } + } else { + directories = [] + } + guard branch != nil || !directories.isEmpty else { return nil } + return SidebarWorkspaceSnapshotBuilder.VerticalBranchDirectoryLine( + branch: branch, + directoryCandidates: directories + ) + } + } + + private func compactDirectoryCandidatesList(orderedPanelIds: [UUID]) -> [String] { + let home = SidebarPathFormatter.homeDirectoryPath + let directories = workspace.sidebarDisplayedDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds) + guard !directories.isEmpty else { return [] } + if !settings.usesLastSegmentPath { + let joined = directories + .map { + $0.isDisplayLabel + ? $0.text + : SidebarPathFormatter.shortenedPath($0.text, homeDirectoryPath: home) + } + .filter { !$0.isEmpty } + .joined(separator: " | ") + return joined.isEmpty ? [] : [joined] + } + let candidates = directories + .map { + $0.isDisplayLabel + ? [$0.text] + : SidebarPathFormatter.pathCandidates($0.text, homeDirectoryPath: home) + } + .filter { !$0.isEmpty } + guard !candidates.isEmpty else { return [] } + + var indices = Array(repeating: 0, count: candidates.count) + var result: [String] = [] + while true { + let joined = zip(indices, candidates).map { $1[$0] }.joined(separator: " | ") + if !joined.isEmpty, result.last != joined { result.append(joined) } + guard let index = indices.indices.last(where: { + indices[$0] < candidates[$0].count - 1 + }) else { break } + indices[index] += 1 + } + return result + } + + private func pullRequestDisplays( + orderedPanelIds: [UUID] + ) -> [SidebarWorkspaceSnapshotBuilder.PullRequestDisplay] { + workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { + SidebarWorkspaceSnapshotBuilder.PullRequestDisplay( + id: "\($0.label.lowercased())#\($0.number)|\($0.url.absoluteString)", + number: $0.number, + label: $0.label, + url: $0.url, + status: $0.status, + isStale: $0.isStale + ) + } + } +} diff --git a/Sources/SidebarWorkspaceSnapshotRefreshCoalescer.swift b/Sources/SidebarWorkspaceSnapshotRefreshCoalescer.swift new file mode 100644 index 000000000000..216f8742f952 --- /dev/null +++ b/Sources/SidebarWorkspaceSnapshotRefreshCoalescer.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Batches keyed workspace refresh requests without publishing intermediate state. +/// +/// Pending ids live in this non-observable owner. One callback on the next +/// common-mode run-loop turn lets concurrent workspace publishers converge +/// before the sidebar crosses its single `@State` publication boundary. +@MainActor +final class SidebarWorkspaceSnapshotRefreshCoalescer { + private var pendingWorkspaceIds: Set = [] + private var scheduledGeneration: UInt64? + private var generation: UInt64 = 0 + + func schedule( + workspaceId: UUID, + flush: @MainActor @escaping (Set) -> Void + ) { + pendingWorkspaceIds.insert(workspaceId) + guard scheduledGeneration == nil else { return } + + generation &+= 1 + let scheduledGeneration = generation + self.scheduledGeneration = scheduledGeneration + RunLoop.main.perform(inModes: [.common]) { [weak self] in + // `RunLoop.main.perform` guarantees main-run-loop delivery, but + // Foundation's closure is not annotated with `@MainActor`. + MainActor.assumeIsolated { + guard let self, + self.scheduledGeneration == scheduledGeneration else { + return + } + let workspaceIds = self.pendingWorkspaceIds + self.pendingWorkspaceIds.removeAll(keepingCapacity: true) + self.scheduledGeneration = nil + guard !workspaceIds.isEmpty else { return } + flush(workspaceIds) + } + } + } + + /// Invalidates an enqueued callback and discards ids from the old lifecycle. + func cancel() { + generation &+= 1 + scheduledGeneration = nil + pendingWorkspaceIds.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/SidebarWorkspaceWindowMoveTarget.swift b/Sources/SidebarWorkspaceWindowMoveTarget.swift new file mode 100644 index 000000000000..d4832a3c6911 --- /dev/null +++ b/Sources/SidebarWorkspaceWindowMoveTarget.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Value projection of an app window shown by the workspace-row move menu. +struct SidebarWorkspaceWindowMoveTarget: Equatable, Identifiable { + let windowId: UUID + let label: String + let isCurrentWindow: Bool + + var id: UUID { windowId } +} diff --git a/Sources/TabItemView+WorkspaceContextMenu.swift b/Sources/TabItemView+WorkspaceContextMenu.swift index 510882c5722b..02ed711c7576 100644 --- a/Sources/TabItemView+WorkspaceContextMenu.swift +++ b/Sources/TabItemView+WorkspaceContextMenu.swift @@ -20,19 +20,13 @@ extension TabItemView { isMulti ? multi : single } - private func remoteContextMenuWorkspaces() -> [Workspace] { - guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } - return remoteContextMenuWorkspaceIds.compactMap { workspaceId in - tabManager.tabs.first(where: { $0.id == workspaceId }) - } - } - @ViewBuilder var workspaceContextMenu: some View { let workspaceSnapshot = self.workspaceSnapshot - let targetIds = contextMenuWorkspaceIds + let context = snapshot.contextMenu + let targetIds = context.targetWorkspaceIds let isMulti = targetIds.count > 1 - let shouldPin = contextMenuPinState?.pinned ?? !tab.isPinned + let shouldPin = context.pinState?.pinned ?? !workspaceSnapshot.isPinned let reconnectLabel = contextMenuLabel( multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), @@ -77,24 +71,15 @@ extension TabItemView { let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) - let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) - let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let windowMoveTargets = actions.currentWindowMoveTargets() let moveMenuTitle = targetIds.count > 1 ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") Button(pinLabel) { - guard let contextMenuPinState else { - NSSound.beep() - return - } - let result = WorkspaceActionDispatcher.performPinAction(contextMenuPinState, in: tabManager) - if result.changedWorkspaceIds.isEmpty { - refreshWorkspaceSnapshot(force: true) - } - syncSelectionAfterMutation() + actions.performPin() } - .disabled(contextMenuPinState == nil) + .disabled(context.pinState == nil) workspaceGroupContextMenuSection(targetIds: targetIds, isMulti: isMulti) @@ -115,9 +100,9 @@ extension TabItemView { } } - if tab.hasCustomTitle { + if snapshot.hasCustomTitle { Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { - tabManager.clearCustomTitle(tabId: tab.id) + actions.clearCustomTitle() } } @@ -133,35 +118,31 @@ extension TabItemView { } } - if tab.hasCustomDescription { + if snapshot.hasCustomDescription { Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { - tabManager.clearCustomDescription(tabId: tab.id) + actions.clearCustomDescription() } } } - if !remoteContextMenuWorkspaceIds.isEmpty { + if !context.remoteTargetWorkspaceIds.isEmpty { Divider() Button(reconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.reconnectRemoteConnection() - } + actions.reconnectTargets(context.remoteTargetWorkspaceIds) } - .disabled(allRemoteContextMenuTargetsConnecting) + .disabled(context.allRemoteTargetsConnecting) Button(disconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } + actions.disconnectTargets(context.remoteTargetWorkspaceIds) } - .disabled(allRemoteContextMenuTargetsDisconnected) + .disabled(context.allRemoteTargetsDisconnected) } Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { let tabColorPalette = WorkspaceTabColorSettings.palette() - if tab.customColor != nil { + if workspaceSnapshot.customColorHex != nil { Button { applyTabColor(nil, targetIds: targetIds) } label: { @@ -210,17 +191,16 @@ extension TabItemView { Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { moveBy(1) } - .disabled(index >= tabManager.tabs.count - 1) + .disabled(index >= snapshot.workspaceCount - 1) Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { - tabManager.moveTabsToTop(Set(targetIds)) - syncSelectionAfterMutation() + actions.moveTargetsToTop(targetIds) } .disabled(targetIds.isEmpty) Menu(moveMenuTitle) { Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { - moveWorkspacesToNewWindow(targetIds) + actions.moveTargetsToNewWindow(targetIds) } .disabled(targetIds.isEmpty) @@ -230,7 +210,7 @@ extension TabItemView { ForEach(windowMoveTargets) { target in Button(target.label) { - moveWorkspaces(targetIds, toWindow: target.windowId) + actions.moveTargetsToWindow(targetIds, target.windowId) } .disabled(target.isCurrentWindow || targetIds.isEmpty) } @@ -241,48 +221,48 @@ extension TabItemView { if let key = closeWorkspaceShortcut.keyEquivalent { Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) + actions.closeTargets(targetIds, true) } .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) .disabled(targetIds.isEmpty) } else { Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) + actions.closeTargets(targetIds, true) } .disabled(targetIds.isEmpty) } Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { - closeOtherTabs(targetIds) + actions.closeOtherTargets(targetIds) } - .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) + .disabled(snapshot.workspaceCount <= 1 || targetIds.count == snapshot.workspaceCount) Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { - closeTabsBelow(tabId: tab.id) + actions.closeTargetsBelow() } - .disabled(index >= tabManager.tabs.count - 1) + .disabled(index >= snapshot.workspaceCount - 1) Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { - closeTabsAbove(tabId: tab.id) + actions.closeTargetsAbove() } .disabled(index == 0) Divider() Button(markReadLabel) { - markTabsRead(targetIds) + actions.markRead(targetIds) } - .disabled(!notificationStore.canMarkWorkspaceRead(forTabIds: targetIds)) + .disabled(!context.canMarkRead) Button(markUnreadLabel) { - markTabsUnread(targetIds) + actions.markUnread(targetIds) } - .disabled(!notificationStore.canMarkWorkspaceUnread(forTabIds: targetIds)) + .disabled(!context.canMarkUnread) Button(clearLatestNotificationLabel) { - clearLatestNotifications(targetIds) + actions.clearLatestNotifications(targetIds) } - .disabled(!hasLatestNotifications(in: targetIds)) + .disabled(!context.hasLatestNotification) workspaceNotificationsContextMenu(targetIds) Divider() diff --git a/Sources/TabItemView+WorkspaceGroups.swift b/Sources/TabItemView+WorkspaceGroups.swift index 2d186c8516bd..926ab2d63704 100644 --- a/Sources/TabItemView+WorkspaceGroups.swift +++ b/Sources/TabItemView+WorkspaceGroups.swift @@ -11,37 +11,27 @@ extension TabItemView { localized: "contextMenu.workspaceGroup.newEmpty", defaultValue: "New Empty Workspace Group" ) - let canCreateEmptyWorkspaceGroup = tabManager.selectedTab?.isRemoteTmuxMirror != true + let context = snapshot.contextMenu + let canCreateEmptyWorkspaceGroup = context.canCreateEmptyGroup if let key = newWorkspaceGroupShortcut.keyEquivalent { Button(newWorkspaceGroupLabel) { - _ = AppDelegate.shared?.createEmptyWorkspaceGroup(tabManager: tabManager) + actions.createEmptyGroup() } .keyboardShortcut(key, modifiers: newWorkspaceGroupShortcut.eventModifiers) .disabled(!canCreateEmptyWorkspaceGroup) } else { Button(newWorkspaceGroupLabel) { - _ = AppDelegate.shared?.createEmptyWorkspaceGroup(tabManager: tabManager) + actions.createEmptyGroup() } .disabled(!canCreateEmptyWorkspaceGroup) } - let targetWorkspaces = targetIds.compactMap { id in - tabManager.tabs.first(where: { $0.id == id }) - } - let existingAnchorIds = Set(tabManager.workspaceGroups.map(\.anchorWorkspaceId)) - let eligibleTargets = targetWorkspaces.filter { !existingAnchorIds.contains($0.id) } - let eligibleTargetIds = eligibleTargets.map(\.id) + let eligibleTargetIds = context.eligibleGroupTargetIds if !eligibleTargetIds.isEmpty { - let groups = workspaceGroupMenuSnapshot.items + let groups = context.groupMenuSnapshot.items let moveToGroupMenuState = WorkspaceGroupMoveToMenuState(groups: groups) - let allTargetsInSameGroup: UUID? = { - let groupIds = eligibleTargets.map(\.groupId) - guard let first = groupIds.first, groupIds.allSatisfy({ $0 == first }) else { - return nil - } - return first - }() - let hasAnyGroupedTarget = eligibleTargets.contains { $0.groupId != nil } + let allTargetsInSameGroup = context.allEligibleTargetsGroupId + let hasAnyGroupedTarget = context.hasGroupedEligibleTarget let groupSelectedShortcut = KeyboardShortcutSettings.shortcut(for: .groupSelectedWorkspaces) let groupSelectedLabel = isMulti @@ -72,9 +62,7 @@ extension TabItemView { Menu(moveToGroupLabel) { ForEach(groups) { group in Button(group.name) { - for id in eligibleTargetIds { - tabManager.addWorkspaceToGroup(workspaceId: id, groupId: group.id) - } + actions.addTargetsToGroup(eligibleTargetIds, group.id) } .disabled(allTargetsInSameGroup == group.id) } @@ -91,9 +79,7 @@ extension TabItemView { defaultValue: "Remove from Group" ) ) { - for id in eligibleTargetIds { - tabManager.removeWorkspaceFromGroup(workspaceId: id) - } + actions.removeTargetsFromGroup(eligibleTargetIds) } } } @@ -101,6 +87,6 @@ extension TabItemView { func promptNewWorkspaceGroup(workspaceIds: [UUID]) { guard !workspaceIds.isEmpty else { return } - tabManager.createWorkspaceGroup(name: "", childWorkspaceIds: workspaceIds) + actions.createGroup(workspaceIds) } } diff --git a/Sources/TabItemView+WorkspaceNotificationsMenu.swift b/Sources/TabItemView+WorkspaceNotificationsMenu.swift index 2344a961dff8..fcd3e19a4148 100644 --- a/Sources/TabItemView+WorkspaceNotificationsMenu.swift +++ b/Sources/TabItemView+WorkspaceNotificationsMenu.swift @@ -21,7 +21,7 @@ extension TabItemView { } private func workspaceNotificationMenuItems(_ targetIds: [UUID]) -> [TerminalNotification] { - notificationStore.notifications(forTabIds: targetIds) + snapshot.contextMenu.notifications } private func workspaceNotificationMenuTitle(_ notification: TerminalNotification) -> String { @@ -48,10 +48,6 @@ extension TabItemView { } private func openWorkspaceContextMenuNotification(_ notification: TerminalNotification) { - guard AppDelegate.shared?.openTerminalNotification(notification) == true else { - NSSound.beep() - return - } - refreshWorkspaceSnapshot(force: true) + actions.openNotification(notification) } } diff --git a/Sources/TabItemView+WorkspaceTodo.swift b/Sources/TabItemView+WorkspaceTodo.swift index 325122f94f31..42fce3dde5d3 100644 --- a/Sources/TabItemView+WorkspaceTodo.swift +++ b/Sources/TabItemView+WorkspaceTodo.swift @@ -7,21 +7,11 @@ import SwiftUI /// The workspace-todo entries of the sidebar row's context menu. Lives in /// its own file because `Sources/ContentView.swift` sits at its file-length -/// budget; the menu builder runs on demand (outside the row's render hot -/// path), so reading `tab`/`tabManager` here is allowed. +/// budget; the menu builder runs on demand using values frozen in the +/// parent-built context-menu snapshot. extension TabItemView { @ViewBuilder var workspaceTodoContextMenuSection: some View { - let inferred = tab.inferredTaskStatus - let resolution = WorkspaceTaskStatusOverride.effectiveStatus( - override: tab.todoState.statusOverride, - inferred: inferred - ) - let activeOverride: WorkspaceTaskStatus? = { - guard let override = tab.todoState.statusOverride, - !resolution.shouldClearOverride else { return nil } - return override.status - }() let isMulti = contextMenuWorkspaceIds.count > 1 let markDoneLabel = isMulti ? String(localized: "contextMenu.markWorkspacesDone", defaultValue: "Mark Workspaces as Done") @@ -30,11 +20,7 @@ extension TabItemView { // The lane list is shared with the todo pane's status popover (one // model, one apply path) so both surfaces stay in lockstep. - let statusLanes = WorkspaceTodoStatusLane.lanes( - inferred: inferred, - activeOverride: activeOverride, - isHidden: tab.todoState.statusHidden - ) + let statusLanes = snapshot.contextMenu.todoStatusLanes Menu(String(localized: "contextMenu.workspaceStatus", defaultValue: "Status")) { ForEach(statusLanes) { lane in // Divider before the None row (separates opt-out from lanes). @@ -46,9 +32,9 @@ extension TabItemView { isSelected: lane.isSelected ) { if lane.isNone { - WorkspaceTodoActions.hideStatus(for: workspaceTodoTargetWorkspaces()) + actions.hideTodoStatus(contextMenuWorkspaceIds) } else { - WorkspaceTodoActions.applyStatusOverride(lane.status, to: workspaceTodoTargetWorkspaces()) + actions.applyTodoStatus(lane.status, contextMenuWorkspaceIds) } } // Divider after the Auto row (first lane, nil status, not None). @@ -60,30 +46,17 @@ extension TabItemView { if let key = markWorkspaceDoneShortcut.keyEquivalent { Button(markDoneLabel) { - WorkspaceTodoActions.applyStatusOverride(.done, to: workspaceTodoTargetWorkspaces()) + actions.applyTodoStatus(.done, contextMenuWorkspaceIds) } .keyboardShortcut(key, modifiers: markWorkspaceDoneShortcut.eventModifiers) } else { Button(markDoneLabel) { - WorkspaceTodoActions.applyStatusOverride(.done, to: workspaceTodoTargetWorkspaces()) + actions.applyTodoStatus(.done, contextMenuWorkspaceIds) } } Button(String(localized: "contextMenu.addChecklistItem", defaultValue: "Add Checklist Item…")) { - WorkspaceTodoActions.requestChecklistAddField(workspaceId: tab.id) - } - } - - /// Mirrors pin's multi-selection behavior: acts on every workspace in the - /// row's context-menu target set (the sidebar selection when this row is - /// part of it, otherwise just this row). - private func workspaceTodoTargetWorkspaces() -> [Workspace] { - let workspaceById = Dictionary( - tabManager.tabs.map { ($0.id, $0) }, - uniquingKeysWith: { first, _ in first } - ) - return contextMenuWorkspaceIds.compactMap { workspaceId in - workspaceById[workspaceId] + actions.requestChecklistAdd() } } @@ -101,30 +74,6 @@ extension TabItemView { } } - /// The closure bundle the checklist subviews receive (value snapshots + - /// closures only below the snapshot boundary). - var workspaceTodoChecklistActions: SidebarWorkspaceChecklistActions { - SidebarWorkspaceChecklistActions( - setItemState: { [tab] itemId, state in - WorkspaceTodoActions.setChecklistItemState(id: itemId, state: state, in: tab) - }, - removeItem: { [tab] itemId in - WorkspaceTodoActions.removeChecklistItem(id: itemId, from: tab) - }, - addItem: { [tab] text in - WorkspaceTodoActions.addChecklistItem(text: text, to: tab) - }, - editItem: { [tab] itemId, text in - WorkspaceTodoActions.editChecklistItem(id: itemId, text: text, in: tab) - }, - moveItem: { [tab] itemId, toIndex in - WorkspaceTodoActions.moveChecklistItem(id: itemId, toIndex: toIndex, in: tab) - }, - openPane: { [tab] in - WorkspaceTodoActions.openTodoPane(for: tab) - } - ) - } } // MARK: - Command palette entries diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 826752e4a1f7..59d61e94a01f 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -5,14 +5,15 @@ import CmuxSettings import CmuxWorkspaces extension VerticalTabsSidebar { - @ViewBuilder - func sidebarWorkspaceGroupHeader( + func sidebarWorkspaceGroupRowSnapshot( group: WorkspaceGroup, memberWorkspaceIds: [UUID], renderContext: WorkspaceListRenderContext, + unreadSummariesByWorkspaceId: [UUID: SidebarWorkspaceUnreadSummary], + notificationIndex: SidebarWorkspaceNotificationIndex, shouldCollectWorkspaceDropTargets: Bool, showModifierHoldHints: Bool - ) -> some View { + ) -> SidebarWorkspaceGroupRowSnapshot { let settings = renderContext.tabItemSettings let isAnchorActive = tabManager.selectedTabId == group.anchorWorkspaceId let anchorCwd = renderContext.workspaceById[group.anchorWorkspaceId]?.currentDirectory @@ -27,21 +28,31 @@ extension VerticalTabsSidebar { let anchorUnreadCount: Int = { if group.isCollapsed { return memberWorkspaceIds.reduce(0) { partial, workspaceId in - partial + notificationStore.unreadCount(forTabId: workspaceId) + partial + (unreadSummariesByWorkspaceId[workspaceId]?.unreadCount ?? 0) } } - return notificationStore.unreadCount(forTabId: group.anchorWorkspaceId) + return unreadSummariesByWorkspaceId[group.anchorWorkspaceId]?.unreadCount ?? 0 }() let anchorIds = [group.anchorWorkspaceId] - let canMarkAnchorRead = notificationStore.canMarkWorkspaceRead(forTabIds: anchorIds) - let canMarkAnchorUnread = notificationStore.canMarkWorkspaceUnread(forTabIds: anchorIds) - let anchorHasLatestNotification = notificationStore.latestNotification(forTabId: group.anchorWorkspaceId) != nil + let canMarkAnchorRead = anchorIds.contains { + (unreadSummariesByWorkspaceId[$0]?.unreadCount ?? 0) > 0 + } + let canMarkAnchorUnread = anchorIds.contains { + (unreadSummariesByWorkspaceId[$0]?.unreadCount ?? 0) == 0 + } + let anchorHasLatestNotification = notificationIndex.hasNotification( + workspaceId: group.anchorWorkspaceId + ) // "Mark all workspaces in group" targets the contained workspaces only, // never the anchor: the anchor is the group's own row, whose read status // is owned by the separate "Mark Group as Read/Unread" actions. let nonAnchorMemberIds = memberWorkspaceIds.filter { $0 != group.anchorWorkspaceId } - let canMarkAllRead = notificationStore.canMarkWorkspaceRead(forTabIds: nonAnchorMemberIds) - let canMarkAllUnread = notificationStore.canMarkWorkspaceUnread(forTabIds: nonAnchorMemberIds) + let canMarkAllRead = nonAnchorMemberIds.contains { + (unreadSummariesByWorkspaceId[$0]?.unreadCount ?? 0) > 0 + } + let canMarkAllUnread = nonAnchorMemberIds.contains { + (unreadSummariesByWorkspaceId[$0]?.unreadCount ?? 0) == 0 + } let anchorIndex = renderContext.tabIndexById[group.anchorWorkspaceId] ?? 0 let shortcutDigit = WorkspaceShortcutMapper.digitForWorkspace( at: anchorIndex, @@ -64,14 +75,7 @@ extension VerticalTabsSidebar { tabIds: renderContext.sidebarReorderIds, indicatorScope: dragState.dropIndicatorScope ) - let onDragStart: () -> NSItemProvider = { [anchorId = group.anchorWorkspaceId] in - #if DEBUG - cmuxDebugLog("sidebar.onDrag groupAnchor=\(anchorId.uuidString.prefix(5))") - #endif - dragState.beginDragging(tabId: anchorId) - return SidebarTabDragPayload(tabId: anchorId).provider() - } - let header = SidebarWorkspaceGroupHeaderView( + return SidebarWorkspaceGroupRowSnapshot( groupId: group.id, anchorWorkspaceId: group.anchorWorkspaceId, name: group.name, @@ -101,11 +105,59 @@ extension VerticalTabsSidebar { isBeingDragged: dragState.draggedTabId == group.anchorWorkspaceId, topDropIndicatorVisible: topDropIndicatorVisible, bottomDropIndicatorVisible: bottomDropIndicatorVisible, + shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets + ) + } + + /// Assembles one group row from immutable values when the lazy stack asks + /// for it. Model references appear only inside user-invoked action + /// closures; row realization performs no observable reads or mutations. + func sidebarWorkspaceGroupRow( + snapshot: SidebarWorkspaceGroupRowSnapshot + ) -> SidebarWorkspaceGroupRowView { + let rowId = SidebarWorkspaceRenderItemID.group(snapshot.groupId) + let onDragStart: () -> NSItemProvider = { [anchorId = snapshot.anchorWorkspaceId] in +#if DEBUG + cmuxDebugLog("sidebar.onDrag groupAnchor=\(anchorId.uuidString.prefix(5))") +#endif + dragState.beginDragging(tabId: anchorId) + return SidebarTabDragPayload(tabId: anchorId).provider() + } + let header = SidebarWorkspaceGroupHeaderView( + groupId: snapshot.groupId, + anchorWorkspaceId: snapshot.anchorWorkspaceId, + name: snapshot.name, + iconSymbol: snapshot.iconSymbol, + tintHex: snapshot.tintHex, + isCollapsed: snapshot.isCollapsed, + isPinned: snapshot.isPinned, + isAnchorActive: snapshot.isAnchorActive, + memberCount: snapshot.memberCount, + anchorUnreadCount: snapshot.anchorUnreadCount, + canMarkRead: snapshot.canMarkRead, + canMarkUnread: snapshot.canMarkUnread, + hasLatestNotifications: snapshot.hasLatestNotifications, + canMarkAllRead: snapshot.canMarkAllRead, + canMarkAllUnread: snapshot.canMarkAllUnread, + shortcutDigit: snapshot.shortcutDigit, + shortcutModifierSymbol: snapshot.shortcutModifierSymbol, + showsShortcutHint: snapshot.showsShortcutHint, + isPointerHovering: snapshot.isPointerHovering, + shortcutHintXOffset: snapshot.shortcutHintXOffset, + shortcutHintYOffset: snapshot.shortcutHintYOffset, + fontScale: snapshot.fontScale, + cwdContextMenuItems: snapshot.cwdContextMenuItems, + newWorkspacePlacement: snapshot.newWorkspacePlacement, + rowSpacing: snapshot.rowSpacing, + isFirstRow: snapshot.isFirstRow, + isBeingDragged: snapshot.isBeingDragged, + topDropIndicatorVisible: snapshot.topDropIndicatorVisible, + bottomDropIndicatorVisible: snapshot.bottomDropIndicatorVisible, onDragStart: onDragStart, - onToggleCollapsed: { [weak tabManager, groupId = group.id] in + onToggleCollapsed: { [weak tabManager, groupId = snapshot.groupId] in tabManager?.toggleWorkspaceGroupCollapsed(groupId: groupId) }, - onFocusAnchor: { [weak tabManager, anchorId = group.anchorWorkspaceId, selectedTabIds = $selectedTabIds, lastSidebarSelectionIndex = $lastSidebarSelectionIndex] in + onFocusAnchor: { [weak tabManager, anchorId = snapshot.anchorWorkspaceId, selectedTabIds = $selectedTabIds, lastSidebarSelectionIndex = $lastSidebarSelectionIndex] in guard let tabManager else { return } guard let anchorTab = tabManager.tabs.first(where: { $0.id == anchorId }) else { return } tabManager.selectWorkspace(anchorTab) @@ -116,13 +168,13 @@ extension VerticalTabsSidebar { lastSidebarSelectionIndex.wrappedValue = anchorIndex } }, - onTapPlus: { [weak tabManager, groupId = group.id, placement = newWorkspacePlacement] in + onTapPlus: { [weak tabManager, groupId = snapshot.groupId, placement = snapshot.newWorkspacePlacement] in guard let tabManager else { return } let resolved = placement ?? UserDefaultsSettingsClient(defaults: .standard).value(for: SettingCatalog().workspaceGroups.newWorkspacePlacement) _ = tabManager.createWorkspaceInGroup(groupId: groupId, placement: resolved) }, - onRunResolvedItem: { [weak tabManager, groupId = group.id] item in + onRunResolvedItem: { [weak tabManager, groupId = snapshot.groupId] item in guard let tabManager else { return } SidebarWorkspaceGroupContextMenuRunner.run( item: item, @@ -130,7 +182,7 @@ extension VerticalTabsSidebar { groupId: groupId ) }, - onRename: { [weak tabManager, groupId = group.id, currentName = group.name] in + onRename: { [weak tabManager, groupId = snapshot.groupId, currentName = snapshot.name] in guard let tabManager else { return } presentSidebarWorkspaceGroupRenamePrompt( tabManager: tabManager, @@ -138,19 +190,27 @@ extension VerticalTabsSidebar { currentName: currentName ) }, - onTogglePinned: { [weak tabManager, groupId = group.id] in + onTogglePinned: { [weak tabManager, groupId = snapshot.groupId] in tabManager?.toggleWorkspaceGroupPinned(groupId: groupId) }, - onMarkRead: { [weak notificationStore, anchorId = group.anchorWorkspaceId] in - notificationStore?.markRead(forTabId: anchorId) + onMarkRead: { [weak notificationStore, anchorId = snapshot.anchorWorkspaceId] in + guard let notificationStore, + notificationStore.canMarkWorkspaceRead(forTabIds: [anchorId]) else { + return + } + notificationStore.markRead(forTabId: anchorId) }, - onMarkUnread: { [weak notificationStore, anchorId = group.anchorWorkspaceId] in - notificationStore?.markUnread(forTabId: anchorId) + onMarkUnread: { [weak notificationStore, anchorId = snapshot.anchorWorkspaceId] in + guard let notificationStore, + notificationStore.canMarkWorkspaceUnread(forTabIds: [anchorId]) else { + return + } + notificationStore.markUnread(forTabId: anchorId) }, - onClearLatestNotifications: { [weak notificationStore, anchorId = group.anchorWorkspaceId] in + onClearLatestNotifications: { [weak notificationStore, anchorId = snapshot.anchorWorkspaceId] in notificationStore?.clearLatestNotification(forTabId: anchorId) }, - onMarkAllRead: { [weak tabManager, weak notificationStore, groupId = group.id, anchorId = group.anchorWorkspaceId] in + onMarkAllRead: { [weak tabManager, weak notificationStore, groupId = snapshot.groupId, anchorId = snapshot.anchorWorkspaceId] in guard let tabManager, let notificationStore else { return } // Resolve members live at action time: the header is .equatable() // and closures are excluded from ==, so a captured ID list could @@ -162,7 +222,7 @@ extension VerticalTabsSidebar { notificationStore.markRead(forTabId: id) } }, - onMarkAllUnread: { [weak tabManager, weak notificationStore, groupId = group.id, anchorId = group.anchorWorkspaceId] in + onMarkAllUnread: { [weak tabManager, weak notificationStore, groupId = snapshot.groupId, anchorId = snapshot.anchorWorkspaceId] in guard let tabManager, let notificationStore else { return } let ids = tabManager.tabs.compactMap { $0.groupId == groupId && $0.id != anchorId ? $0.id : nil } // Only mark members that are not already unread. Calling @@ -173,15 +233,15 @@ extension VerticalTabsSidebar { notificationStore.markUnread(forTabId: id) } }, - onUngroup: { [weak tabManager, groupId = group.id] in + onUngroup: { [weak tabManager, groupId = snapshot.groupId] in tabManager?.ungroupWorkspaceGroup(groupId: groupId) }, - onDelete: { [weak tabManager, groupId = group.id] in + onDelete: { [weak tabManager, groupId = snapshot.groupId, fallbackName = snapshot.name, fallbackAnchorId = snapshot.anchorWorkspaceId] in guard let tabManager, let confirmation = tabManager.workspaceGrouping.deletionConfirmation( groupId: groupId, - fallbackGroupName: group.name, - fallbackAnchorWorkspaceId: group.anchorWorkspaceId + fallbackGroupName: fallbackName, + fallbackAnchorWorkspaceId: fallbackAnchorId ) else { return } if confirmation.containedWorkspaceCount > 0 { guard confirmDeleteWorkspaceGroup( @@ -198,22 +258,18 @@ extension VerticalTabsSidebar { SidebarWorkspaceGroupConfigOpener.openWorkspaceGroupsDocs() } ) - .equatable() - .id(group.anchorWorkspaceId) - .accessibilityIdentifier("sidebarWorkspaceGroup.\(group.id.uuidString)") - header - .sidebarWorkspaceFrameAnchor( - id: group.anchorWorkspaceId, - isEnabled: shouldCollectWorkspaceDropTargets - ) - .sidebarPointerFrameReporting( - onFrameChange: { [pointerInteractionMonitor, workspaceId = group.anchorWorkspaceId] frame in - pointerInteractionMonitor.updateFrame(frame, for: rowId, workspaceId: workspaceId) - }, - onDisappear: { [pointerInteractionMonitor] in - pointerInteractionMonitor.removeFrame(for: rowId) - } - ) + return SidebarWorkspaceGroupRowView( + header: header, + groupId: snapshot.groupId, + anchorWorkspaceId: snapshot.anchorWorkspaceId, + shouldCollectWorkspaceDropTargets: snapshot.shouldCollectWorkspaceDropTargets, + onPointerFrameChange: { [pointerInteractionMonitor, workspaceId = snapshot.anchorWorkspaceId] frame in + pointerInteractionMonitor.updateFrame(frame, for: rowId, workspaceId: workspaceId) + }, + onPointerFrameDisappear: { [pointerInteractionMonitor] in + pointerInteractionMonitor.removeFrame(for: rowId) + } + ) } } diff --git a/Sources/WorkspaceSidebarObservation.swift b/Sources/WorkspaceSidebarObservation.swift index fd36088161f2..daa1bbdb62f1 100644 --- a/Sources/WorkspaceSidebarObservation.swift +++ b/Sources/WorkspaceSidebarObservation.swift @@ -14,6 +14,46 @@ private struct SidebarPanelObservationState: Equatable { } extension View { + /// Observes row-affecting workspace publishers above the lazy-list boundary. + /// + /// Each task retains the workspace identity that produced a change, so a + /// status/metadata/title update rebuilds one immutable row projection + /// instead of walking every workspace. The initial CombineLatest value is + /// intentionally delivered: it closes the gap between the owner's first + /// snapshot and subscription setup, while the snapshot equality guard makes + /// an unchanged initial value a no-op. + func sidebarWorkspaceObservations( + ids: [UUID], + workspaces: [Workspace], + debouncedInterval: RunLoop.SchedulerTimeType.Stride, + onChange: @MainActor @escaping (UUID) -> Void + ) -> some View { + task(id: ids) { @MainActor in + await withTaskGroup(of: Void.self) { group in + for (id, workspace) in zip(ids, workspaces) { + let immediateChanges = workspace.sidebarImmediateObservationPublisher + .values + let debouncedChanges = workspace.sidebarObservationPublisher + .receive(on: RunLoop.main) + .debounce(for: debouncedInterval, scheduler: RunLoop.main) + .values + group.addTask { @MainActor in + for await _ in immediateChanges { + if Task.isCancelled { break } + onChange(id) + } + } + group.addTask { @MainActor in + for await _ in debouncedChanges { + if Task.isCancelled { break } + onChange(id) + } + } + } + } + } + } + func sidebarAgentRuntimeObservation( id: UUID, model: WorkspaceSidebarAgentRuntimeObservationModel, @@ -69,6 +109,50 @@ extension View { } } } + + /// Observes every default-sidebar workspace above the lazy row boundary. + /// The callback identifies the changed workspace so the owner can rebuild + /// its immutable projection without mounting an observation task per row. + func sidebarProcessTitleObservations( + ids: [UUID], + models: [WorkspaceSidebarProcessTitleObservationModel], + onChange: @MainActor @escaping (UUID) -> Void + ) -> some View { + task(id: ids) { @MainActor in + await withTaskGroup(of: Void.self) { group in + for (id, model) in zip(ids, models) { + let changes = model.changes() + group.addTask { @MainActor in + for await _ in changes { + if Task.isCancelled { break } + onChange(id) + } + } + } + } + } + } + + /// Agent-runtime counterpart to ``sidebarProcessTitleObservations(ids:models:onChange:)``. + func sidebarAgentRuntimeObservations( + ids: [UUID], + models: [WorkspaceSidebarAgentRuntimeObservationModel], + onChange: @MainActor @escaping (UUID) -> Void + ) -> some View { + task(id: ids) { @MainActor in + await withTaskGroup(of: Void.self) { group in + for (id, model) in zip(ids, models) { + let changes = model.changes() + group.addTask { @MainActor in + for await _ in changes { + if Task.isCancelled { break } + onChange(id) + } + } + } + } + } + } } private struct SidebarImmediateObservationState: Equatable { diff --git a/Sources/WorkspaceSidebarProcessTitleObservationModel.swift b/Sources/WorkspaceSidebarProcessTitleObservationModel.swift index 696fd6ecfff4..41479b435134 100644 --- a/Sources/WorkspaceSidebarProcessTitleObservationModel.swift +++ b/Sources/WorkspaceSidebarProcessTitleObservationModel.swift @@ -70,7 +70,7 @@ final class WorkspaceSidebarProcessTitleObservationModel { AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in let id = UUID() changeObservers[id] = continuation - // A row's onAppear snapshot and this subscription are not atomic: + // A row's first pure snapshot and this subscription are not atomic: // an automatic title change in that gap found no observers and was // not scheduled. Replay it so the first subscriber refreshes once; // rows refresh by re-reading current state, so the replay is diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bfe8aa475a19..0cd86fd30a3a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1477,6 +1477,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */; }; B290FAD92C810ACB2D9CD9C6 /* SidebarWorkspaceChecklistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E7BB6EF7C9F8D2C681999D /* SidebarWorkspaceChecklistView.swift */; }; + 6707F0036707F0036707F003 /* SidebarWorkspaceContextMenuSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0046707F0046707F004 /* SidebarWorkspaceContextMenuSnapshot.swift */; }; + 6707F0196707F0196707F019 /* SidebarWorkspaceContextMenuTargetAggregate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0206707F0206707F020 /* SidebarWorkspaceContextMenuTargetAggregate.swift */; }; + 6707F0176707F0176707F017 /* SidebarWorkspaceContextMenuWindowTargetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0186707F0186707F018 /* SidebarWorkspaceContextMenuWindowTargetsTests.swift */; }; D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; }; C9A57605C9A57605C9A57605 /* SidebarWorkspaceDropTargetWriters.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57606C9A57606C9A57606 /* SidebarWorkspaceDropTargetWriters.swift */; }; C9A57103C9A57103C9A57103 /* SidebarWorkspaceGroupConfigOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */; }; @@ -1487,8 +1490,12 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */; }; C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */; }; C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */; }; + 6707F00F6707F00F6707F00F /* SidebarWorkspaceGroupRowSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0106707F0106707F010 /* SidebarWorkspaceGroupRowSnapshot.swift */; }; + 6707F0056707F0056707F005 /* SidebarWorkspaceGroupRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0066707F0066707F006 /* SidebarWorkspaceGroupRowView.swift */; }; A6AC73310000000000000001 /* SidebarWorkspaceLeadingStatusSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73310000000000000002 /* SidebarWorkspaceLeadingStatusSlot.swift */; }; A6AC73320000000000000001 /* SidebarWorkspaceLoadingSpinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73320000000000000002 /* SidebarWorkspaceLoadingSpinner.swift */; }; + 6707F0216707F0216707F021 /* SidebarWorkspaceNotificationIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0226707F0226707F022 /* SidebarWorkspaceNotificationIndex.swift */; }; + 6707F0236707F0236707F023 /* SidebarWorkspaceNotificationIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0246707F0246707F024 /* SidebarWorkspaceNotificationIndexTests.swift */; }; C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; C9A57211C9A57211C9A57211 /* SidebarWorkspaceRenderItemID.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57212C9A57212C9A57212 /* SidebarWorkspaceRenderItemID.swift */; }; C9A5760DC9A5760DC9A5760D /* SidebarWorkspaceReorderDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */; }; @@ -1497,11 +1504,18 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9A57609C9A57609C9A57609 /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */; }; C9A5760FC9A5760FC9A5760F /* SidebarWorkspaceReorderDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */; }; C9A5760BC9A5760BC9A5760B /* SidebarWorkspaceReorderPendingDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */; }; + 6707F0076707F0076707F007 /* SidebarWorkspaceRowActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0086707F0086707F008 /* SidebarWorkspaceRowActions.swift */; }; + 6707F0116707F0116707F011 /* SidebarWorkspaceRowInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0126707F0126707F012 /* SidebarWorkspaceRowInput.swift */; }; + 6707F0096707F0096707F009 /* SidebarWorkspaceRowSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F00A6707F00A6707F00A /* SidebarWorkspaceRowSnapshot.swift */; }; + 6707F0136707F0136707F013 /* SidebarWorkspaceRowsSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0146707F0146707F014 /* SidebarWorkspaceRowsSnapshot.swift */; }; 2E057C19B76F8034F30969BF /* SidebarWorkspaceRowStatusGlyphRemovalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE71A0F7E02A07A155241E4 /* SidebarWorkspaceRowStatusGlyphRemovalTests.swift */; }; + 6707F00B6707F00B6707F00B /* SidebarWorkspaceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F00C6707F00C6707F00C /* SidebarWorkspaceRowView.swift */; }; C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */; }; C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */; }; A6AC73040000000000000001 /* SidebarWorkspaceSnapshotAgentActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73040000000000000002 /* SidebarWorkspaceSnapshotAgentActivityTests.swift */; }; F7216005000000000000001 /* SidebarWorkspaceSnapshotBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7216005000000000000002 /* SidebarWorkspaceSnapshotBuilder.swift */; }; + 6707F0016707F0016707F001 /* SidebarWorkspaceSnapshotFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0026707F0026707F002 /* SidebarWorkspaceSnapshotFactory.swift */; }; + 6707F0156707F0156707F015 /* SidebarWorkspaceSnapshotRefreshCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0166707F0166707F016 /* SidebarWorkspaceSnapshotRefreshCoalescer.swift */; }; 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */; }; 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; 4F80E2D0A6377A1D8A97D411 /* SidebarWorkspaceStatusPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896D9D4AD4D31A54882813E /* SidebarWorkspaceStatusPopover.swift */; }; @@ -1511,6 +1525,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9A57601C9A57601C9A57601 /* SidebarWorkspaceTopDropIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */; }; A6AC73330000000000000001 /* SidebarWorkspaceTrailingStatusSlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73330000000000000002 /* SidebarWorkspaceTrailingStatusSlot.swift */; }; A6AC73340000000000000001 /* SidebarWorkspaceUnreadBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73340000000000000002 /* SidebarWorkspaceUnreadBadge.swift */; }; + 6707F00D6707F00D6707F00D /* SidebarWorkspaceWindowMoveTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F00E6707F00E6707F00E /* SidebarWorkspaceWindowMoveTarget.swift */; }; C7B0000000000000000000F2 /* SleepyAgentCensus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B0000000000000000000F1 /* SleepyAgentCensus.swift */; }; C7B0FACE0000000000000004 /* SleepyAgentCensusing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B0FACE0000000000000005 /* SleepyAgentCensusing.swift */; }; C7B0FACE0000000000000002 /* SleepyAgentCounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B0FACE0000000000000003 /* SleepyAgentCounts.swift */; }; @@ -3415,6 +3430,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceChecklistPopover.swift"; sourceTree = ""; }; E9E7BB6EF7C9F8D2C681999D /* SidebarWorkspaceChecklistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceChecklistView.swift; sourceTree = ""; }; + 6707F0046707F0046707F004 /* SidebarWorkspaceContextMenuSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceContextMenuSnapshot.swift; sourceTree = ""; }; + 6707F0206707F0206707F020 /* SidebarWorkspaceContextMenuTargetAggregate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceContextMenuTargetAggregate.swift; sourceTree = ""; }; + 6707F0186707F0186707F018 /* SidebarWorkspaceContextMenuWindowTargetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceContextMenuWindowTargetsTests.swift; sourceTree = ""; }; D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropPlannerTests.swift; sourceTree = ""; }; C9A57606C9A57606C9A57606 /* SidebarWorkspaceDropTargetWriters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropTargetWriters.swift; sourceTree = ""; }; C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpener.swift; sourceTree = ""; }; @@ -3425,8 +3443,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderMetricsTests.swift; sourceTree = ""; }; C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderView.swift; sourceTree = ""; }; C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupingMetrics.swift; sourceTree = ""; }; + 6707F0106707F0106707F010 /* SidebarWorkspaceGroupRowSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupRowSnapshot.swift; sourceTree = ""; }; + 6707F0066707F0066707F006 /* SidebarWorkspaceGroupRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupRowView.swift; sourceTree = ""; }; A6AC73310000000000000002 /* SidebarWorkspaceLeadingStatusSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceLeadingStatusSlot.swift; sourceTree = ""; }; A6AC73320000000000000002 /* SidebarWorkspaceLoadingSpinner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceLoadingSpinner.swift; sourceTree = ""; }; + 6707F0226707F0226707F022 /* SidebarWorkspaceNotificationIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceNotificationIndex.swift; sourceTree = ""; }; + 6707F0246707F0246707F024 /* SidebarWorkspaceNotificationIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceNotificationIndexTests.swift; sourceTree = ""; }; C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; C9A57212C9A57212C9A57212 /* SidebarWorkspaceRenderItemID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItemID.swift; sourceTree = ""; }; C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlay.swift; sourceTree = ""; }; @@ -3435,11 +3457,18 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayTargetBridge.swift; sourceTree = ""; }; C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropView.swift; sourceTree = ""; }; C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderPendingDrop.swift; sourceTree = ""; }; + 6707F0086707F0086707F008 /* SidebarWorkspaceRowActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowActions.swift; sourceTree = ""; }; + 6707F0126707F0126707F012 /* SidebarWorkspaceRowInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowInput.swift; sourceTree = ""; }; + 6707F00A6707F00A6707F00A /* SidebarWorkspaceRowSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowSnapshot.swift; sourceTree = ""; }; + 6707F0146707F0146707F014 /* SidebarWorkspaceRowsSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowsSnapshot.swift; sourceTree = ""; }; DCE71A0F7E02A07A155241E4 /* SidebarWorkspaceRowStatusGlyphRemovalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowStatusGlyphRemovalTests.swift; sourceTree = ""; }; + 6707F00C6707F00C6707F00C /* SidebarWorkspaceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowView.swift; sourceTree = ""; }; C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceScrollLayoutTests.swift; sourceTree = ""; }; C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSelectionAnchorPolicyTests.swift; sourceTree = ""; }; A6AC73040000000000000002 /* SidebarWorkspaceSnapshotAgentActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotAgentActivityTests.swift; sourceTree = ""; }; F7216005000000000000002 /* SidebarWorkspaceSnapshotBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotBuilder.swift; sourceTree = ""; }; + 6707F0026707F0026707F002 /* SidebarWorkspaceSnapshotFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotFactory.swift; sourceTree = ""; }; + 6707F0166707F0166707F016 /* SidebarWorkspaceSnapshotRefreshCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotRefreshCoalescer.swift; sourceTree = ""; }; D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift; sourceTree = ""; }; F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotRefreshPolicyTests.swift; sourceTree = ""; }; 3896D9D4AD4D31A54882813E /* SidebarWorkspaceStatusPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceStatusPopover.swift"; sourceTree = ""; }; @@ -3449,6 +3478,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceTopDropIndicator.swift; sourceTree = ""; }; A6AC73330000000000000002 /* SidebarWorkspaceTrailingStatusSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceTrailingStatusSlot.swift; sourceTree = ""; }; A6AC73340000000000000002 /* SidebarWorkspaceUnreadBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceUnreadBadge.swift; sourceTree = ""; }; + 6707F00E6707F00E6707F00E /* SidebarWorkspaceWindowMoveTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceWindowMoveTarget.swift; sourceTree = ""; }; C7B0000000000000000000F1 /* SleepyAgentCensus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepyAgentCensus.swift; sourceTree = ""; }; C7B0FACE0000000000000005 /* SleepyAgentCensusing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepyAgentCensusing.swift; sourceTree = ""; }; C7B0FACE0000000000000003 /* SleepyAgentCounts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepyAgentCounts.swift; sourceTree = ""; }; @@ -4244,6 +4274,19 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001012 /* ContentView.swift */, F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */, F7216005000000000000002 /* SidebarWorkspaceSnapshotBuilder.swift */, + 6707F0026707F0026707F002 /* SidebarWorkspaceSnapshotFactory.swift */, + 6707F0166707F0166707F016 /* SidebarWorkspaceSnapshotRefreshCoalescer.swift */, + 6707F0046707F0046707F004 /* SidebarWorkspaceContextMenuSnapshot.swift */, + 6707F0206707F0206707F020 /* SidebarWorkspaceContextMenuTargetAggregate.swift */, + 6707F0066707F0066707F006 /* SidebarWorkspaceGroupRowView.swift */, + 6707F0106707F0106707F010 /* SidebarWorkspaceGroupRowSnapshot.swift */, + 6707F0086707F0086707F008 /* SidebarWorkspaceRowActions.swift */, + 6707F0126707F0126707F012 /* SidebarWorkspaceRowInput.swift */, + 6707F00A6707F00A6707F00A /* SidebarWorkspaceRowSnapshot.swift */, + 6707F00C6707F00C6707F00C /* SidebarWorkspaceRowView.swift */, + 6707F0146707F0146707F014 /* SidebarWorkspaceRowsSnapshot.swift */, + 6707F0226707F0226707F022 /* SidebarWorkspaceNotificationIndex.swift */, + 6707F00E6707F00E6707F00E /* SidebarWorkspaceWindowMoveTarget.swift */, F7216006000000000000002 /* SidebarTabItemContextMenuState.swift */, A5F100000000000000000006 /* TabItemView+WorkspaceNotificationsMenu.swift */, D75280040000000000000002 /* DynamicTracingSignpostInterval.swift */, @@ -5657,6 +5700,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */, C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */, D41A7C02D41A7C02D41A7C02 /* SidebarLazyLayoutScaleTests.swift */, + 6707F0186707F0186707F018 /* SidebarWorkspaceContextMenuWindowTargetsTests.swift */, + 6707F0246707F0246707F024 /* SidebarWorkspaceNotificationIndexTests.swift */, A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */, A80040120000000000000001 /* SidebarPointerInteractionScaleTests.swift */, C4A570030000000000000002 /* CanvasShortcutContextTests.swift */, @@ -7243,6 +7288,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */, EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */, B290FAD92C810ACB2D9CD9C6 /* SidebarWorkspaceChecklistView.swift in Sources */, + 6707F0036707F0036707F003 /* SidebarWorkspaceContextMenuSnapshot.swift in Sources */, + 6707F0196707F0196707F019 /* SidebarWorkspaceContextMenuTargetAggregate.swift in Sources */, C9A57605C9A57605C9A57605 /* SidebarWorkspaceDropTargetWriters.swift in Sources */, C9A57103C9A57103C9A57103 /* SidebarWorkspaceGroupConfigOpener.swift in Sources */, C9A57105C9A57105C9A57105 /* SidebarWorkspaceGroupContextMenuRunner.swift in Sources */, @@ -7250,8 +7297,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A57301C9A57301C9A57301 /* SidebarWorkspaceGroupHeaderMetrics.swift in Sources */, C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */, C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, + 6707F00F6707F00F6707F00F /* SidebarWorkspaceGroupRowSnapshot.swift in Sources */, + 6707F0056707F0056707F005 /* SidebarWorkspaceGroupRowView.swift in Sources */, A6AC73310000000000000001 /* SidebarWorkspaceLeadingStatusSlot.swift in Sources */, A6AC73320000000000000001 /* SidebarWorkspaceLoadingSpinner.swift in Sources */, + 6707F0216707F0216707F021 /* SidebarWorkspaceNotificationIndex.swift in Sources */, C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, C9A57211C9A57211C9A57211 /* SidebarWorkspaceRenderItemID.swift in Sources */, C9A5760DC9A5760DC9A5760D /* SidebarWorkspaceReorderDropOverlay.swift in Sources */, @@ -7259,7 +7309,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A57609C9A57609C9A57609 /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift in Sources */, C9A5760FC9A5760FC9A5760F /* SidebarWorkspaceReorderDropView.swift in Sources */, C9A5760BC9A5760BC9A5760B /* SidebarWorkspaceReorderPendingDrop.swift in Sources */, + 6707F0076707F0076707F007 /* SidebarWorkspaceRowActions.swift in Sources */, + 6707F0116707F0116707F011 /* SidebarWorkspaceRowInput.swift in Sources */, + 6707F0096707F0096707F009 /* SidebarWorkspaceRowSnapshot.swift in Sources */, + 6707F0136707F0136707F013 /* SidebarWorkspaceRowsSnapshot.swift in Sources */, + 6707F00B6707F00B6707F00B /* SidebarWorkspaceRowView.swift in Sources */, F7216005000000000000001 /* SidebarWorkspaceSnapshotBuilder.swift in Sources */, + 6707F0016707F0016707F001 /* SidebarWorkspaceSnapshotFactory.swift in Sources */, + 6707F0156707F0156707F015 /* SidebarWorkspaceSnapshotRefreshCoalescer.swift in Sources */, 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */, 4F80E2D0A6377A1D8A97D411 /* SidebarWorkspaceStatusPopover.swift in Sources */, A6AC73010000000000000001 /* SidebarWorkspaceStatusSlots.swift in Sources */, @@ -7268,6 +7325,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A57601C9A57601C9A57601 /* SidebarWorkspaceTopDropIndicator.swift in Sources */, A6AC73330000000000000001 /* SidebarWorkspaceTrailingStatusSlot.swift in Sources */, A6AC73340000000000000001 /* SidebarWorkspaceUnreadBadge.swift in Sources */, + 6707F00D6707F00D6707F00D /* SidebarWorkspaceWindowMoveTarget.swift in Sources */, C7B0000000000000000000F2 /* SleepyAgentCensus.swift in Sources */, C7B0FACE0000000000000004 /* SleepyAgentCensusing.swift in Sources */, C7B0FACE0000000000000002 /* SleepyAgentCounts.swift in Sources */, @@ -8147,9 +8205,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D73440010000000000000001 /* SidebarTabDragPayloadProviderTests.swift in Sources */, D7AB34300000000000000105 /* SidebarTabDropIndicatorPredicateTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, + 6707F0176707F0176707F017 /* SidebarWorkspaceContextMenuWindowTargetsTests.swift in Sources */, D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */, C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */, + 6707F0236707F0236707F023 /* SidebarWorkspaceNotificationIndexTests.swift in Sources */, C9A57305C9A57305C9A57305 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift in Sources */, 2E057C19B76F8034F30969BF /* SidebarWorkspaceRowStatusGlyphRemovalTests.swift in Sources */, C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */, diff --git a/cmuxTests/SidebarLazyLayoutScaleTests.swift b/cmuxTests/SidebarLazyLayoutScaleTests.swift index 958da8fa78db..d1b18fced588 100644 --- a/cmuxTests/SidebarLazyLayoutScaleTests.swift +++ b/cmuxTests/SidebarLazyLayoutScaleTests.swift @@ -1,5 +1,6 @@ import Testing import AppKit +import CmuxSidebar import CmuxUpdater import SwiftUI @@ -48,6 +49,7 @@ final class SidebarLazyLayoutScaleTests { var workspaceRowBodies = 0 var groupHeaderBodies = 0 var workspaceSnapshotBuilds = 0 + var workspaceRowInputProjections = 0 // Snapshot builds bracketed by workspaceRowBody/workspaceRowBodyEnd, // i.e. synchronous work inside a single TabItemView.body evaluation. // Builds outside the bracket (onAppear refresh, observation publishers) @@ -59,6 +61,7 @@ final class SidebarLazyLayoutScaleTests { workspaceRowBodies = 0 groupHeaderBodies = 0 workspaceSnapshotBuilds = 0 + workspaceRowInputProjections = 0 insideWorkspaceRowBody = false snapshotBuildsInCurrentRowBody = 0 maxSnapshotBuildsInOneRowBody = 0 @@ -122,7 +125,7 @@ final class SidebarLazyLayoutScaleTests { } // Group the first workspaces (top of the list, inside the viewport) so - // group-header rows — assembled by sidebarWorkspaceGroupHeader(...) in + // group-header rows — assembled by sidebarWorkspaceGroupRow(...) in // VerticalTabsSidebar+WorkspaceGroups.swift, a historical regression // site (#4385) — are exercised by the same realization bounds. let groupCandidates = Array(tabManager.tabs.prefix(20).map(\.id)) @@ -180,6 +183,9 @@ final class SidebarLazyLayoutScaleTests { counter.maxSnapshotBuildsInOneRowBody, counter.snapshotBuildsInCurrentRowBody ) + }, + workspaceRowInputProjection: { + counter.workspaceRowInputProjections += 1 } ) ) @@ -266,23 +272,20 @@ final class SidebarLazyLayoutScaleTests { headerRealized < Self.realizedRowCeiling, """ \(headerRealized) group-header bodies evaluated for 5 groups in one viewport. \ - The group-header row wrapper (sidebarWorkspaceGroupHeader) is defeating \ + The group-header row wrapper (sidebarWorkspaceGroupRow) is defeating \ virtualization or re-evaluating without bound — the #4385 regression site. """ ) } - /// One TabItemView.body evaluation must build the workspace snapshot at - /// most once. The snapshot is a full per-workspace projection (bonsplit - /// tree walk, git branch summaries, PR rows); until `onAppear` seeds - /// `workspaceSnapshotStorage`, every `workspaceSnapshot` access in the - /// first body evaluation used to rebuild it from scratch, so each row a - /// scroll mounts paid the walk several times over. Builds outside body - /// evaluations (onAppear refresh, observation publishers) are legitimate - /// and excluded by the probe bracket. + /// TabItemView.body must never build a workspace snapshot. The parent owns + /// the full per-workspace projection (bonsplit tree walk, git summaries, + /// PR rows) and passes the resulting value across the LazyVStack boundary. + /// Building it while a row is being realized would read the live workspace + /// graph from inside SwiftUI layout and recreate the #6707 reentry path. @Test @MainActor - func testRowBodyEvaluationBuildsWorkspaceSnapshotAtMostOnce() async throws { + func testRowBodyEvaluationNeverBuildsWorkspaceSnapshot() async throws { let harness = try await Self.mountSidebar(workspaceCount: Self.workspaceCount) defer { harness.tearDown() } @@ -294,12 +297,74 @@ final class SidebarLazyLayoutScaleTests { ) let worstBody = harness.counter.maxSnapshotBuildsInOneRowBody #expect( - worstBody <= 1, + worstBody == 0, """ A single TabItemView.body evaluation built the workspace snapshot \(worstBody) \ - times. The snapshot fallback in the `workspaceSnapshot` getter must memoize \ - within a body evaluation; N accesses before onAppear seeds storage must not \ - mean N bonsplit tree walks per mounted row. + times. Workspace snapshots must be built by VerticalTabsSidebar before the \ + LazyVStack realization closure and passed to rows as immutable values. + """ + ) + } + + /// A simultaneous workspace-publisher burst must cross the parent snapshot + /// boundary once per batch. Re-projecting all 300 row inputs once per + /// emitting workspace is O(N²) main-actor work even though LazyVStack only + /// realizes the viewport rows. + @Test + @MainActor + func testWorkspacePublisherBatchProjectsParentListLinearly() async throws { + let harness = try await Self.mountSidebar(workspaceCount: Self.workspaceCount) + defer { harness.tearDown() } + + await Self.drainMainRunLoop(for: harness.window) + + // The default sidebar intentionally accepts the initial value from + // both workspace publisher families. Wait for those known initial + // projections before isolating the operation count for this burst. + // Initial mount builds fallback + owner snapshots, then each of the + // two keyed publisher families delivers its accepted initial value. + let initialObservationBuildFloor = Self.workspaceCount * 4 + let initialDeadline = ProcessInfo.processInfo.systemUptime + 3 + while harness.counter.workspaceSnapshotBuilds < initialObservationBuildFloor, + ProcessInfo.processInfo.systemUptime < initialDeadline { + Self.turnMainRunLoopOnce(layingOut: harness.window) + await Task.yield() + } + #expect( + harness.counter.workspaceSnapshotBuilds >= initialObservationBuildFloor, + "Initial keyed workspace publisher values did not reach the snapshot owner." + ) + + harness.counter.reset() + let targets = Array(harness.tabManager.tabs.suffix(80)) + for (index, workspace) in targets.enumerated() { + workspace.statusEntries["issue-6707.batch"] = SidebarStatusEntry( + key: "issue-6707.batch", + value: "batch update \(index)", + icon: "bolt.fill" + ) + } + + let refreshDeadline = ProcessInfo.processInfo.systemUptime + 3 + while harness.counter.workspaceSnapshotBuilds < targets.count, + ProcessInfo.processInfo.systemUptime < refreshDeadline { + Self.turnMainRunLoopOnce(layingOut: harness.window) + await Task.yield() + } + #expect( + harness.counter.workspaceSnapshotBuilds >= targets.count, + "The \(targets.count)-workspace publisher batch did not reach the snapshot owner." + ) + await Self.drainMainRunLoop(for: harness.window) + + let projections = harness.counter.workspaceRowInputProjections + #expect(projections > 0, "The parent row-input projection probe did not run.") + #expect( + projections <= Self.workspaceCount * 4, + """ + \(projections) parent row-input projections ran for one \(targets.count)-workspace \ + event batch at \(Self.workspaceCount) workspaces. The batch must cause O(N) parent \ + projection work, not O(N²) work from one parent invalidation per emitter. """ ) } diff --git a/cmuxTests/SidebarPointerInteractionScaleTests.swift b/cmuxTests/SidebarPointerInteractionScaleTests.swift index 50a98d0fff43..fbf46752ff8d 100644 --- a/cmuxTests/SidebarPointerInteractionScaleTests.swift +++ b/cmuxTests/SidebarPointerInteractionScaleTests.swift @@ -1,4 +1,6 @@ import AppKit +import CmuxSidebar +import CoreGraphics import OSLog import Testing #if canImport(cmux_DEV) @@ -9,7 +11,7 @@ import Testing extension SidebarLazyLayoutScaleTests { @MainActor - private static func firstScrollView(in rootView: NSView) -> NSScrollView? { + fileprivate static func firstScrollView(in rootView: NSView) -> NSScrollView? { var pendingViews = [rootView] while let view = pendingViews.popLast() { if let scrollView = view as? NSScrollView { return scrollView } @@ -32,7 +34,7 @@ extension SidebarLazyLayoutScaleTests { )) } - private static func viewUpdateFaultMessages(since startDate: Date) throws -> [String] { + fileprivate static func viewUpdateFaultMessages(since startDate: Date) throws -> [String] { let store = try OSLogStore(scope: .currentProcessIdentifier) let entries = try store.getEntries(at: store.position(date: startDate)) let faultFragments = [ @@ -41,7 +43,11 @@ extension SidebarLazyLayoutScaleTests { "laid out reentrantly", ] return entries.compactMap { entry in - guard let message = (entry as? OSLogEntryLog)?.composedMessage, + // OSLogStore positions are coarse and may begin before the exact + // requested Date. Recheck the entry timestamp so one-time app-host + // mount warnings cannot be attributed to the later stress phase. + guard entry.date >= startDate, + let message = (entry as? OSLogEntryLog)?.composedMessage, faultFragments.contains(where: message.localizedCaseInsensitiveContains) else { return nil } @@ -146,4 +152,132 @@ extension SidebarLazyLayoutScaleTests { """ ) } + +} + +/// Reporter-shaped regression suite for #6707, amplified beyond LazyVStack's +/// prefetch range so every scroll cycle must realize and retire rows. It is +/// separate from the broader scale suite so CI can run this workload alone; a +/// method-level `-only-testing` selector does not select Swift Testing cases. +@Suite(.serialized) +final class SidebarOverflowingScrollStatusChurnTests { + private static func scrollWheelEvent( + deltaY: Int32, + phase: Int64, + at pointInWindow: NSPoint, + window: NSWindow + ) throws -> NSEvent { + let event = try #require(CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 1, + wheel1: deltaY, + wheel2: 0, + wheel3: 0 + )) + event.location = window.convertPoint(toScreen: pointInWindow) + event.setIntegerValueField(.scrollWheelEventIsContinuous, value: 1) + event.setIntegerValueField(.scrollWheelEventScrollPhase, value: phase) + return try #require(NSEvent(cgEvent: event)) + } + + @Test + @MainActor + func testOverflowingScrollWithStatusChurnHasNoLayoutReentryAndConverges() async throws { + let harness = try await SidebarLazyLayoutScaleTests.mountSidebar(workspaceCount: 120) + defer { harness.tearDown() } + + await SidebarLazyLayoutScaleTests.drainMainRunLoop(for: harness.window) + let rootView = try #require(harness.window.contentView) + let scrollView = try #require(SidebarLazyLayoutScaleTests.firstScrollView(in: rootView)) + let eventPoint = scrollView.convert( + NSPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY), + to: nil + ) + let statusTargets = Array(harness.tabManager.tabs.suffix(8)) + #expect(!statusTargets.isEmpty) + // App-host startup mounts the full application around the test view + // and can emit unrelated one-time hosting warnings. The reporter + // workload begins only after this sidebar has mounted and converged. + let logStart = Date() + + harness.counter.reset() + for iteration in 0..<32 { + let target = statusTargets[iteration % statusTargets.count] + let key = "issue-6707.status" + let snapshotBuildsBeforeMutation = harness.counter.workspaceSnapshotBuilds + if (iteration / statusTargets.count).isMultiple(of: 2) { + target.statusEntries[key] = SidebarStatusEntry( + key: key, + value: "CLI status update \(iteration)", + icon: "bolt.fill" + ) + } else { + target.statusEntries.removeValue(forKey: key) + } + + // Re-read the live document height because adding/removing a + // status row changes it. The sawtooth repeatedly crosses both lazy + // realization boundaries instead of only adjusting one offset. + let documentHeight = scrollView.documentView?.bounds.height ?? 0 + let maximumOffset = max(0, documentHeight - scrollView.contentView.bounds.height) + let phase = CGFloat(iteration % 8) / 7 + let requestedOffset = iteration.isMultiple(of: 2) + ? maximumOffset * phase + : maximumOffset * (1 - phase) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: requestedOffset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + // Absolute offsets make the test deterministic; a continuous + // wheel event then drives AppKit's live-scroll transaction around + // that lazy-realization boundary, matching the reporter gesture. + let scrollPhase: Int64 + switch iteration % 8 { + case 0: scrollPhase = 1 // kCGScrollPhaseBegan + case 7: scrollPhase = 4 // kCGScrollPhaseEnded + default: scrollPhase = 2 // kCGScrollPhaseChanged + } + scrollView.scrollWheel(with: try Self.scrollWheelEvent( + deltaY: iteration.isMultiple(of: 2) ? -48 : 48, + phase: scrollPhase, + at: eventPoint, + window: harness.window + )) + + // Wait on the keyed per-workspace refresh itself, not a scheduler + // delay. This proves every mutation reached the parent snapshot + // boundary while the live-scroll transaction was active. + let refreshDeadline = ProcessInfo.processInfo.systemUptime + 2 + while harness.counter.workspaceSnapshotBuilds <= snapshotBuildsBeforeMutation, + ProcessInfo.processInfo.systemUptime < refreshDeadline { + SidebarLazyLayoutScaleTests.turnMainRunLoopOnce(layingOut: harness.window) + await Task.yield() + } + #expect( + harness.counter.workspaceSnapshotBuilds > snapshotBuildsBeforeMutation, + "Workspace \(target.id) did not publish a keyed sidebar snapshot refresh for iteration \(iteration)." + ) + } + await SidebarLazyLayoutScaleTests.drainMainRunLoop(for: harness.window) + + let faultMessages = try SidebarLazyLayoutScaleTests.viewUpdateFaultMessages(since: logStart) + #expect( + faultMessages.isEmpty, + """ + Overflowing sidebar scroll + status churn emitted \(faultMessages.count) SwiftUI/AppKit \ + view-update faults:\n\(faultMessages.joined(separator: "\n")) + """ + ) + + harness.counter.reset() + await SidebarLazyLayoutScaleTests.drainMainRunLoop(for: harness.window, iterations: 40) + let quietEvals = harness.counter.workspaceRowBodies + harness.counter.groupHeaderBodies + #expect( + quietEvals < 20, + """ + \(quietEvals) row bodies evaluated after scrolling and status churn ended. The sidebar \ + did not converge and is still feeding lazy layout back into view state. + """ + ) + } } diff --git a/cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift b/cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift new file mode 100644 index 000000000000..64675cbbf945 --- /dev/null +++ b/cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift @@ -0,0 +1,172 @@ +import AppKit +import Foundation +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct SidebarWorkspaceContextMenuWindowTargetsTests { + @Test + @MainActor + func menuPresentationResolvesWindowTargetsAfterRowRender() throws { + let firstWindowId = UUID() + let laterWindowId = UUID() + var currentTargets = [ + SidebarWorkspaceWindowMoveTarget( + windowId: firstWindowId, + label: "Window 1", + isCurrentWindow: true + ) + ] + var resolvedTopologies: [[UUID]] = [] + let actions = Self.actions { + resolvedTopologies.append(currentTargets.map(\.windowId)) + return currentTargets + } + let row = TabItemView( + snapshot: try Self.rowSnapshot(), + actions: actions + ) + + // Rendering the lazy row must not freeze or resolve app-window state. + _ = row.body + #expect(resolvedTopologies.isEmpty) + + currentTargets = [ + SidebarWorkspaceWindowMoveTarget( + windowId: firstWindowId, + label: "Window 1", + isCurrentWindow: true + ), + SidebarWorkspaceWindowMoveTarget( + windowId: laterWindowId, + label: "Window 2", + isCurrentWindow: false + ) + ] + + // SwiftUI evaluates this deferred wrapper when presenting the menu. + _ = TabItemWorkspaceContextMenuContent(row: row).body + #expect(resolvedTopologies == [[firstWindowId, laterWindowId]]) + } + + @MainActor + private static func rowSnapshot() throws -> SidebarWorkspaceRowSnapshot { + let suiteName = "SidebarWorkspaceContextMenuWindowTargetsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + return SidebarWorkspaceRowSnapshot( + workspaceId: UUID(), + groupId: nil, + index: 0, + workspaceCount: 1, + workspace: SidebarWorkspaceSnapshotRefreshPolicyTests.snapshot(), + isActive: true, + isMultiSelected: false, + hasUserCustomTitle: false, + hasCustomTitle: false, + hasCustomDescription: false, + customTitle: nil, + workspaceShortcutDigit: nil, + workspaceShortcutModifierSymbol: "⌘", + canCloseWorkspace: false, + unreadCount: 0, + latestNotificationText: nil, + showsAgentActivity: false, + rowSpacing: 0, + showsModifierShortcutHints: false, + isPointerHovering: false, + isBeingDragged: false, + topDropIndicatorVisible: false, + bottomDropIndicatorVisible: false, + isBonsplitWorkspaceDropActive: false, + settings: SidebarTabItemSettingsSnapshot(defaults: defaults), + isChecklistExpanded: false, + checklistAddFieldActivationToken: 0, + isChecklistPopoverPresented: false, + contextMenu: SidebarWorkspaceContextMenuSnapshot( + targetWorkspaceIds: [], + remoteTargetWorkspaceIds: [], + allRemoteTargetsConnecting: false, + allRemoteTargetsDisconnected: false, + pinState: nil, + groupMenuSnapshot: WorkspaceGroupMenuSnapshot(items: []), + canCreateEmptyGroup: true, + eligibleGroupTargetIds: [], + allEligibleTargetsGroupId: nil, + hasGroupedEligibleTarget: false, + todoStatusLanes: [], + canMarkRead: false, + canMarkUnread: false, + hasLatestNotification: false, + notifications: [] + ) + ) + } + + @MainActor + private static func actions( + currentWindowMoveTargets: @escaping () -> [SidebarWorkspaceWindowMoveTarget] + ) -> SidebarWorkspaceRowActions { + SidebarWorkspaceRowActions( + select: { _ in }, + setCustomTitle: { _ in }, + clearCustomTitle: {}, + clearCustomDescription: {}, + editDescription: {}, + closeWorkspace: {}, + moveBy: { _ in }, + moveTargetsToTop: { _ in }, + currentWindowMoveTargets: currentWindowMoveTargets, + moveTargetsToWindow: { _, _ in }, + moveTargetsToNewWindow: { _ in }, + closeTargets: { _, _ in }, + closeOtherTargets: { _ in }, + closeTargetsBelow: {}, + closeTargetsAbove: {}, + performPin: {}, + createEmptyGroup: {}, + createGroup: { _ in }, + addTargetsToGroup: { _, _ in }, + removeTargetsFromGroup: { _ in }, + reconnectTargets: { _ in }, + disconnectTargets: { _ in }, + applyColor: { _, _ in }, + applyTodoStatus: { _, _ in }, + hideTodoStatus: { _ in }, + requestChecklistAdd: {}, + markRead: { _ in }, + markUnread: { _ in }, + clearLatestNotifications: { _ in }, + openNotification: { _ in }, + copyWorkspaceLinks: { _ in }, + openPullRequest: { _ in }, + openPort: { _ in }, + checklist: SidebarWorkspaceChecklistActions( + setItemState: { _, _ in }, + removeItem: { _ in }, + addItem: { _ in }, + editItem: { _, _ in }, + moveItem: { _, _ in }, + openPane: {} + ), + onDragStart: { NSItemProvider() }, + bonsplitSourceWorkspaceId: { _ in nil }, + moveBonsplitTabToWorkspace: { _, _ in false }, + syncAfterBonsplitDrop: {}, + selectAfterBonsplitDrop: {}, + onToggleChecklistExpansion: {}, + onConsumeChecklistAddFieldActivation: {}, + onChecklistPopoverPresentedChange: { _ in }, + onContextMenuAppear: {}, + onContextMenuDisappear: {}, + onPointerFrameChange: { _ in }, + onPointerFrameDisappear: {} + ) + } +} diff --git a/cmuxTests/SidebarWorkspaceNotificationIndexTests.swift b/cmuxTests/SidebarWorkspaceNotificationIndexTests.swift new file mode 100644 index 000000000000..4b8eebe3de0c --- /dev/null +++ b/cmuxTests/SidebarWorkspaceNotificationIndexTests.swift @@ -0,0 +1,264 @@ +import Foundation +import CmuxCore +import CmuxWorkspaces +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite +struct SidebarWorkspaceNotificationIndexTests { + @Test + func presenceUsesWorkspaceBuckets() { + let firstWorkspaceId = UUID() + let secondWorkspaceId = UUID() + let missingWorkspaceId = UUID() + let index = SidebarWorkspaceNotificationIndex(notifications: [ + Self.notification(workspaceId: firstWorkspaceId, timestamp: 1), + Self.notification(workspaceId: secondWorkspaceId, timestamp: 2) + ]) + + #expect(index.hasNotification(workspaceId: firstWorkspaceId)) + #expect(index.hasNotification(workspaceId: secondWorkspaceId)) + #expect(!index.hasNotification(workspaceId: missingWorkspaceId)) + #expect(index.hasNotification(workspaceIds: [missingWorkspaceId, secondWorkspaceId])) + #expect(!index.hasNotification(workspaceIds: [missingWorkspaceId])) + #expect(!index.hasNotification(workspaceIds: [])) + } + + @Test + func contextMenuMergeIsNewestFirstAcrossWorkspaceBuckets() { + let firstWorkspaceId = UUID() + let secondWorkspaceId = UUID() + let unrelatedWorkspaceId = UUID() + let selected = [ + Self.notification(workspaceId: firstWorkspaceId, timestamp: 100), + Self.notification(workspaceId: secondWorkspaceId, timestamp: 400), + Self.notification(workspaceId: firstWorkspaceId, timestamp: 300), + Self.notification(workspaceId: secondWorkspaceId, timestamp: 200) + ] + let index = SidebarWorkspaceNotificationIndex(notifications: [ + selected[0], + Self.notification(workspaceId: unrelatedWorkspaceId, timestamp: 500), + selected[1], + selected[2], + selected[3] + ]) + let expected = selected.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + + let actual = index.contextMenuNotifications( + workspaceIds: [firstWorkspaceId, secondWorkspaceId, firstWorkspaceId] + ) + + #expect(actual.map(\.id) == expected.map(\.id)) + } + + @Test + func contextMenuMergeStopsAtLimitWithoutIncludingUnrelatedBuckets() { + let firstWorkspaceId = UUID() + let secondWorkspaceId = UUID() + let unrelatedWorkspaceId = UUID() + let selected = (0 ..< 80).map { index in + Self.notification( + workspaceId: index.isMultiple(of: 2) ? firstWorkspaceId : secondWorkspaceId, + timestamp: TimeInterval(index) + ) + } + let unrelated = (0 ..< 200).map { index in + Self.notification( + workspaceId: unrelatedWorkspaceId, + timestamp: TimeInterval(1_000 + index) + ) + } + let notificationIndex = SidebarWorkspaceNotificationIndex( + notifications: Array((selected + unrelated).reversed()) + ) + let expected = Array( + selected + .sorted(by: TerminalNotificationStore.notificationSortPrecedes) + .prefix(SidebarWorkspaceNotificationIndex.contextMenuNotificationLimit) + ) + + let actual = notificationIndex.contextMenuNotifications( + workspaceIds: [firstWorkspaceId, secondWorkspaceId] + ) + + #expect(actual.count == SidebarWorkspaceNotificationIndex.contextMenuNotificationLimit) + #expect(actual.map(\.id) == expected.map(\.id)) + #expect(actual.allSatisfy { $0.tabId != unrelatedWorkspaceId }) + } + + private static func notification( + workspaceId: UUID, + timestamp: TimeInterval + ) -> TerminalNotification { + TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: nil, + title: "notification", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: timestamp), + isRead: false + ) + } +} + +@MainActor +@Suite +struct SidebarWorkspaceContextMenuTargetAggregateTests { + @Test + func selectedRowsReuseCorrectParentAggregateAndSingleRowsStayScoped() throws { + let groupId = UUID() + let connectingWorkspaceId = UUID() + let disconnectedWorkspaceId = UUID() + let anchorWorkspaceId = disconnectedWorkspaceId + let singleWorkspaceId = UUID() + let suiteName = "SidebarWorkspaceContextMenuTargetAggregateTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let settings = SidebarTabItemSettingsSnapshot(defaults: defaults) + let rows = [ + connectingWorkspaceId: Self.rowInput( + workspaceId: connectingWorkspaceId, + groupId: groupId, + unreadCount: 2, + isMultiSelected: true, + isRemote: true, + remoteState: .connecting, + settings: settings + ), + disconnectedWorkspaceId: Self.rowInput( + workspaceId: disconnectedWorkspaceId, + groupId: groupId, + unreadCount: 0, + isMultiSelected: true, + isRemote: true, + remoteState: .disconnected, + settings: settings + ), + singleWorkspaceId: Self.rowInput( + workspaceId: singleWorkspaceId, + groupId: nil, + unreadCount: 0, + isMultiSelected: false, + isRemote: false, + remoteState: .connected, + settings: settings + ) + ] + let selectedNotification = Self.notification( + workspaceId: connectingWorkspaceId, + timestamp: 200 + ) + let singleNotification = Self.notification( + workspaceId: singleWorkspaceId, + timestamp: 300 + ) + let notificationIndex = SidebarWorkspaceNotificationIndex( + notifications: [singleNotification, selectedNotification] + ) + let list = SidebarWorkspaceRowsSnapshot( + workspaceRowsById: rows, + groupRowsById: [:], + selectedContextTargetIds: [connectingWorkspaceId, disconnectedWorkspaceId], + anchorWorkspaceIds: [anchorWorkspaceId], + workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot(items: []), + canCreateEmptyGroup: true, + notificationIndex: notificationIndex + ) + + let selected = list.selectedContextMenuTargetAggregate + #expect(selected.targetWorkspaceIds == [connectingWorkspaceId, disconnectedWorkspaceId]) + #expect(selected.remoteTargetWorkspaceIds == [connectingWorkspaceId, disconnectedWorkspaceId]) + #expect(!selected.allRemoteTargetsConnecting) + #expect(!selected.allRemoteTargetsDisconnected) + #expect(selected.eligibleGroupTargetIds == [connectingWorkspaceId]) + #expect(selected.allEligibleTargetsGroupId == groupId) + #expect(selected.hasGroupedEligibleTarget) + #expect(selected.canMarkRead) + #expect(selected.canMarkUnread) + #expect(selected.hasLatestNotification) + #expect(selected.notifications.map(\.id) == [selectedNotification.id]) + + let secondSelected = try #require(rows[disconnectedWorkspaceId]) + #expect(list.contextMenuTargetAggregate(for: secondSelected) == selected) + + let singleInput = try #require(rows[singleWorkspaceId]) + let single = list.contextMenuTargetAggregate(for: singleInput) + #expect(single.targetWorkspaceIds == [singleWorkspaceId]) + #expect(single.remoteTargetWorkspaceIds.isEmpty) + #expect(!single.canMarkRead) + #expect(single.canMarkUnread) + #expect(single.hasLatestNotification) + #expect(single.notifications.map(\.id) == [singleNotification.id]) + } + + private static func rowInput( + workspaceId: UUID, + groupId: UUID?, + unreadCount: Int, + isMultiSelected: Bool, + isRemote: Bool, + remoteState: WorkspaceRemoteConnectionState, + settings: SidebarTabItemSettingsSnapshot + ) -> SidebarWorkspaceRowInput { + SidebarWorkspaceRowInput( + workspaceId: workspaceId, + groupId: groupId, + index: 0, + workspaceCount: 3, + workspace: SidebarWorkspaceSnapshotRefreshPolicyTests.snapshot(), + isActive: false, + isMultiSelected: isMultiSelected, + hasUserCustomTitle: false, + hasCustomTitle: false, + hasCustomDescription: false, + customTitle: nil, + workspaceShortcutDigit: nil, + workspaceShortcutModifierSymbol: "⌘", + canCloseWorkspace: true, + unreadCount: unreadCount, + latestNotificationText: nil, + showsAgentActivity: false, + rowSpacing: 0, + showsModifierShortcutHints: false, + isPointerHovering: false, + isBeingDragged: false, + topDropIndicatorVisible: false, + bottomDropIndicatorVisible: false, + isBonsplitWorkspaceDropActive: false, + settings: settings, + isChecklistExpanded: false, + checklistAddFieldActivationToken: 0, + isChecklistPopoverPresented: false, + isRemoteContextMenuEligible: isRemote, + remoteConnectionState: remoteState, + contextMenuPinState: nil, + inferredTaskStatus: .todo, + activeTodoOverride: nil, + isTodoStatusHidden: false + ) + } + + private static func notification( + workspaceId: UUID, + timestamp: TimeInterval + ) -> TerminalNotification { + TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: nil, + title: "notification", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: timestamp), + isRead: false + ) + } +} diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index bcbdc04a8f48..cd1269c0e8d7 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -196,19 +196,19 @@ struct WorkspaceGroupTests { tabs: manager.tabs, groupsById: Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) ) - + let memberWorkspaceIdsByGroupId = SidebarWorkspaceRenderItem.memberWorkspaceIdsByGroupId(tabs: manager.tabs) var groupMemberIds: [UUID] = [] var visibleWorkspaceIds: [UUID] = [] var visibleRowIds: [UUID] = [] for item in items { visibleRowIds.append(item.rowWorkspaceId) switch item { - case .groupHeader(let renderedGroup, let memberWorkspaceIds) where renderedGroup.id == groupId: - groupMemberIds = memberWorkspaceIds + case .groupHeader(let renderedGroupId, _) where renderedGroupId == groupId: + groupMemberIds = memberWorkspaceIdsByGroupId[renderedGroupId] ?? [] case .groupHeader: break - case .workspace(let workspace): - visibleWorkspaceIds.append(workspace.id) + case .workspace(let workspaceId): + visibleWorkspaceIds.append(workspaceId) } } diff --git a/cmuxUITests/WorkspaceSidebarScrollUITests.swift b/cmuxUITests/WorkspaceSidebarScrollUITests.swift index 172a336d359f..74b0e48c93a1 100644 --- a/cmuxUITests/WorkspaceSidebarScrollUITests.swift +++ b/cmuxUITests/WorkspaceSidebarScrollUITests.swift @@ -1,3 +1,5 @@ +import Darwin +import Foundation import XCTest final class WorkspaceSidebarScrollUITests: XCTestCase { @@ -110,6 +112,101 @@ final class WorkspaceSidebarScrollUITests: XCTestCase { ) } + /// Reporter-shaped recurrence harness for #6707. Status commands reply on + /// the socket worker before their main-actor mutation is applied, so each + /// real swipe overlaps the same deferred sidebar update produced by + /// `cmux set status` in the field. A main-hop watchdog after every gesture + /// turns the AttributeGraph spin into a bounded test failure. + func testOverflowingSidebarScrollRemainsResponsiveDuringStatusChurn() { + let app = XCUIApplication() + let token = UUID().uuidString + let socketPath = "/tmp/cmux-ui-sidebar-livelock-\(token).sock" + let diagnosticsPath = "/tmp/cmux-ui-sidebar-livelock-\(token).json" + defer { + app.terminate() + try? FileManager.default.removeItem(atPath: socketPath) + try? FileManager.default.removeItem(atPath: diagnosticsPath) + } + + configureLaunch(app) + app.launchArguments += ["-socketControlMode", "allowAll", "-NSAppSleepDisabled", "YES"] + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath + app.launchEnvironment["CMUX_TAG"] = "ui-sidebar-livelock-\(token.prefix(8))" + + launchAndEnsureRunning(app) + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0), "Expected a main window") + + XCTAssertTrue( + pollUntil(timeout: 10.0) { self.sendSocketLine("ping", to: socketPath) == "PONG" }, + "Expected the isolated control socket to become ready" + ) + + let workspaceCount = 28 + for index in 2...workspaceCount { + let reply = sendSocketLine("new_workspace issue-6707-\(index)", to: socketPath) + XCTAssertTrue( + reply?.hasPrefix("OK ") == true, + "Expected workspace \(index) creation to succeed; reply=\(reply ?? "nil")" + ) + } + + var workspaceIDs: [UUID] = [] + XCTAssertTrue( + pollUntil(timeout: 12.0) { + workspaceIDs = self.workspaceIDs(from: self.sendSocketLine("list_workspaces", to: socketPath)) + return workspaceIDs.count == workspaceCount + }, + "Expected \(workspaceCount) workspaces; observed \(workspaceIDs.count)" + ) + + app.activate() + let sidebar = app.descendants(matching: .any)["Sidebar"].firstMatch + XCTAssertTrue(sidebar.waitForExistence(timeout: 5.0), "Expected the workspace sidebar to exist") + XCTAssertTrue( + revealSidebarVerticalScroller(app: app, sidebar: sidebar, timeout: 3.0), + "Expected \(workspaceCount) workspaces to overflow the sidebar" + ) + + for iteration in 0..<48 { + // Exercise a real height transition at every lazy realization + // boundary: add the status, then remove it from the same row on + // the following gesture. + let target = workspaceIDs[(iteration / 2) % workspaceIDs.count] + let command = iteration.isMultiple(of: 2) + ? "set_status issue-6707 churn-\(iteration) --icon=bolt.fill --tab=\(target.uuidString)" + : "clear_status issue-6707 --tab=\(target.uuidString)" + XCTAssertEqual( + sendSocketLine(command, to: socketPath), + "OK", + "Expected CLI-equivalent sidebar mutation \(iteration) to be accepted" + ) + + if (iteration / 6).isMultiple(of: 2) { + sidebar.swipeUp() + } else { + sidebar.swipeDown() + } + + let currentWorkspace = sendSocketLine("current_workspace", to: socketPath) + XCTAssertNotNil( + currentWorkspace.flatMap(UUID.init(uuidString:)), + "Main-hop watchdog did not respond after scroll/status iteration \(iteration)" + ) + XCTAssertNotEqual(app.state, .notRunning, "cmux exited during scroll/status iteration \(iteration)") + } + + app.typeKey("1", modifierFlags: [.command]) + XCTAssertTrue( + waitForWorkspaceRowHittable(index: 1, count: workspaceCount, app: app, timeout: 8.0), + "Expected the sidebar to accept input and converge back to the first workspace after stress" + ) + } + private func configureLaunch(_ app: XCUIApplication) { app.launchArguments += ["-newWorkspacePlacement", "end"] app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] @@ -150,6 +247,15 @@ final class WorkspaceSidebarScrollUITests: XCTestCase { .firstMatch } + private func workspaceIDs(from listWorkspacesReply: String?) -> [UUID] { + guard let listWorkspacesReply else { return [] } + return listWorkspacesReply.split(separator: "\n").compactMap { line in + line.split(whereSeparator: \.isWhitespace).lazy + .compactMap { UUID(uuidString: String($0)) } + .first + } + } + private func sidebarOverflowProbeStartCount(app: XCUIApplication, sidebar: XCUIElement) -> Int { let firstRow = workspaceRow(index: 1, count: 1, app: app) guard sidebar.exists, firstRow.exists else { return 8 } @@ -275,4 +381,91 @@ final class WorkspaceSidebarScrollUITests: XCTestCase { RunLoop.current.run(until: Date().addingTimeInterval(interval)) } } + + private func sendSocketLine( + _ line: String, + to path: String, + responseTimeout: TimeInterval = 2.0 + ) -> String? { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + defer { close(descriptor) } + + var noSigPipe: Int32 = 1 + _ = withUnsafePointer(to: &noSigPipe) { pointer in + setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + pointer, + socklen_t(MemoryLayout.size) + ) + } + var timeout = timeval( + tv_sec: Int(responseTimeout), + tv_usec: Int32((responseTimeout - floor(responseTimeout)) * 1_000_000) + ) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt( + descriptor, + SOL_SOCKET, + SO_RCVTIMEO, + pointer, + socklen_t(MemoryLayout.size) + ) + _ = setsockopt( + descriptor, + SOL_SOCKET, + SO_SNDTIMEO, + pointer, + socklen_t(MemoryLayout.size) + ) + } + + var address = sockaddr_un() + memset(&address, 0, MemoryLayout.size) + address.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = Array(path.utf8CString) + let maximumPathLength = MemoryLayout.size(ofValue: address.sun_path) + guard pathBytes.count <= maximumPathLength else { return nil } + withUnsafeMutablePointer(to: &address.sun_path) { pointer in + let raw = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self) + for index in pathBytes.indices { + raw[index] = pathBytes[index] + } + } + + let pathOffset = MemoryLayout.offset(of: \.sun_path) ?? 0 + let addressLength = socklen_t(pathOffset + pathBytes.count) + address.sun_len = UInt8(min(Int(addressLength), 255)) + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.connect(descriptor, socketAddress, addressLength) + } + } + guard connected == 0 else { return nil } + + let payload = Array((line + "\n").utf8) + let wrotePayload = payload.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { return false } + return Darwin.write(descriptor, baseAddress, buffer.count) == buffer.count + } + guard wrotePayload else { return nil } + _ = shutdown(descriptor, SHUT_WR) + + var buffer = [UInt8](repeating: 0, count: 4096) + var response = "" + while true { + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count < 0 { + guard errno == EAGAIN || errno == EWOULDBLOCK else { return nil } + break + } + guard count > 0 else { break } + response += String(decoding: buffer[0..