From 29f8932783d4077c92ab95ec841afa2594392ecd Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 21:49:48 -0700 Subject: [PATCH 1/4] Carry over AppKit sidebar table components (from stage-1 port) --- .../SidebarWorkspaceTableActions.swift | 29 + .../SidebarWorkspaceTableCellModel.swift | 28 + .../SidebarWorkspaceTableCellRootView.swift | 22 + .../SidebarWorkspaceTableCellState.swift | 7 + .../SidebarWorkspaceTableCellView.swift | 81 +++ .../SidebarWorkspaceTableClipView.swift | 18 + .../SidebarWorkspaceTableContainerView.swift | 47 ++ .../SidebarWorkspaceTableController.swift | 496 ++++++++++++++++++ ...WorkspaceTableDropTargetGeometryGate.swift | 113 ++++ ...WorkspaceTableEmptyDropIndicatorView.swift | 30 ++ ...barWorkspaceTableEnvironmentSnapshot.swift | 30 ++ .../SidebarWorkspaceTableHoverResolver.swift | 16 + ...idebarWorkspaceTableRowConfiguration.swift | 68 +++ .../SidebarWorkspaceTableRowHeightCache.swift | 120 +++++ ...barWorkspaceTableRowHeightCalculator.swift | 26 + .../SidebarWorkspaceTableView.swift | 35 ++ .../SidebarWorkspaceTableViewImpl.swift | 77 +++ cmuxTests/SidebarWorkspaceTableTests.swift | 351 +++++++++++++ 18 files changed, 1594 insertions(+) create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableActions.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellModel.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellRootView.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellState.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableHoverResolver.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCalculator.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift create mode 100644 cmuxTests/SidebarWorkspaceTableTests.swift diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableActions.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableActions.swift new file mode 100644 index 000000000000..c3e799b4621a --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableActions.swift @@ -0,0 +1,29 @@ +import Bonsplit +import AppKit +import CmuxFoundation +import Foundation + +/// Closure bundle routing table input and drag operations to existing sidebar actions. +@MainActor +struct SidebarWorkspaceTableActions { + let attachScrollView: (NSScrollView) -> Void + let closeWorkspace: (UUID) -> Void + let createWorkspaceAtEnd: () -> Void + let createEmptyWorkspaceGroup: () -> Void + let beginWorkspaceDrag: (UUID) -> Void + let endWorkspaceDrag: () -> Void + let isValidWorkspaceDrag: () -> Bool + let updateWorkspaceDrag: (CGPoint, [SidebarWorkspaceReorderDropOverlay.Target]) -> Bool + let performWorkspaceDrop: (CGPoint, [SidebarWorkspaceReorderDropOverlay.Target]) -> Bool + let clearWorkspaceDropIndicator: () -> Void + let currentDropIndicator: () -> SidebarDropIndicator? + let currentDropIndicatorScope: () -> SidebarWorkspaceReorderDropIndicatorScope + let setWorkspaceDropTargetCollectionActive: (Bool) -> Void + let canPerformBonsplitAction: (SidebarDropPlanner.WorkspaceDropAction, BonsplitTabDragPayload.Transfer) -> Bool + let moveBonsplitToExistingWorkspace: (UUID, BonsplitTabDragPayload.Transfer) -> Bool + let moveBonsplitToNewWorkspace: (Int, BonsplitTabDragPayload.Transfer) -> UUID? + let didMoveBonsplitToWorkspace: (UUID) -> Void + let updateDragAutoscroll: () -> Void + let setBonsplitDropTargetCollectionActive: (Bool) -> Void + let setBonsplitDropIndicator: (SidebarDropIndicator?) -> Void +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellModel.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellModel.swift new file mode 100644 index 000000000000..5ce2a0f7fb4c --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellModel.swift @@ -0,0 +1,28 @@ +import Observation + +/// Cell-owned observable state that never reaches into a shared sidebar store. +@MainActor +@Observable +final class SidebarWorkspaceTableCellModel { + private(set) var state: SidebarWorkspaceTableCellState? + + @discardableResult + func configure( + row: SidebarWorkspaceTableRowConfiguration, + isPointerHovering: Bool, + contextMenuActions: SidebarWorkspaceTableContextMenuActions + ) -> Bool { + if let state, + state.row.id == row.id, + state.row.hasEquivalentContent(to: row), + state.isPointerHovering == isPointerHovering { + return false + } + state = SidebarWorkspaceTableCellState( + row: row, + isPointerHovering: isPointerHovering, + contextMenuActions: contextMenuActions + ) + return true + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellRootView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellRootView.swift new file mode 100644 index 000000000000..8e8ca88b9b9c --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellRootView.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftUI + +/// Stable SwiftUI root installed once for a reusable sidebar table cell. +@MainActor +struct SidebarWorkspaceTableCellRootView: View { + let identity: UUID + let model: SidebarWorkspaceTableCellModel + + var body: some View { + Group { + if let state = model.state { + state.row.makeContent( + state.isPointerHovering, + state.contextMenuActions + ) + } else { + EmptyView() + } + } + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellState.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellState.swift new file mode 100644 index 000000000000..40323d95c8a2 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellState.swift @@ -0,0 +1,7 @@ +/// Immutable input rendered by one stable sidebar table-cell root. +@MainActor +struct SidebarWorkspaceTableCellState { + let row: SidebarWorkspaceTableRowConfiguration + let isPointerHovering: Bool + let contextMenuActions: SidebarWorkspaceTableContextMenuActions +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift new file mode 100644 index 000000000000..07c43e987ab7 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift @@ -0,0 +1,81 @@ +import AppKit +import SwiftUI + +/// Reusable table cell containing exactly one SwiftUI hosting view. +@MainActor +final class SidebarWorkspaceTableCellView: NSTableCellView { + static let reuseIdentifier = NSUserInterfaceItemIdentifier("SidebarWorkspaceTableCellView") + + private let model: SidebarWorkspaceTableCellModel + private let hostingView: NSHostingView + +#if DEBUG + var reconfigurationProbe: (() -> Void)? + var hostingViewIdentity: ObjectIdentifier { ObjectIdentifier(hostingView) } + var hostedRootIdentity: UUID { hostingView.rootView.identity } +#endif + + var representedRowId: SidebarWorkspaceRenderItemID? { + model.state?.row.id + } + + override init(frame frameRect: NSRect) { + let model = SidebarWorkspaceTableCellModel() + self.model = model + self.hostingView = NSHostingView( + rootView: SidebarWorkspaceTableCellRootView( + identity: UUID(), + model: model + ) + ) + super.init(frame: frameRect) + identifier = Self.reuseIdentifier + wantsLayer = true + hostingView.wantsLayer = true + hostingView.translatesAutoresizingMaskIntoConstraints = false + // Row heights are owned by the controller's explicit height cache, so + // this hosting view must never negotiate sizing with Auto Layout. + // Every window-wide layout pass (e.g. the terminal portal's + // synchronizeLayoutHierarchy) otherwise re-runs SwiftUI size + // negotiation in NSHostingView.layout() for every visible cell, which + // profiling showed dominating main-thread time during workspace + // switching at 200 rows. + hostingView.sizingOptions = [] + hostingView.setContentHuggingPriority(.required, for: .vertical) + hostingView.setContentCompressionResistancePriority(.required, for: .vertical) + addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingView.topAnchor.constraint(equalTo: topAnchor), + hostingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @discardableResult + func configure( + row: SidebarWorkspaceTableRowConfiguration, + isPointerHovering: Bool, + contextMenuDidOpen: @escaping () -> Void, + contextMenuDidClose: @escaping () -> Void + ) -> Bool { + let didReconfigure = model.configure( + row: row, + isPointerHovering: isPointerHovering, + contextMenuActions: SidebarWorkspaceTableContextMenuActions( + didOpen: contextMenuDidOpen, + didClose: contextMenuDidClose + ) + ) +#if DEBUG + if didReconfigure { + reconfigurationProbe?() + } +#endif + return didReconfigure + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift new file mode 100644 index 000000000000..f4698171ebf6 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift @@ -0,0 +1,18 @@ +import AppKit + +/// Scroll viewport that preserves empty-area double-click and context-menu behavior. +@MainActor +final class SidebarWorkspaceTableClipView: NSClipView { + weak var workspaceController: SidebarWorkspaceTableController? + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + if event.clickCount == 2 { + workspaceController?.doubleClickEmptyArea() + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + workspaceController?.emptyAreaMenu() + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift new file mode 100644 index 000000000000..18de0781532a --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift @@ -0,0 +1,47 @@ +import AppKit + +/// AppKit container stacking drag destinations above the virtualized table. +@MainActor +final class SidebarWorkspaceTableContainerView: NSView { + let scrollView = NSScrollView() + let clipView = SidebarWorkspaceTableClipView() + let tableView = SidebarWorkspaceTableViewImpl() + let reorderDropView = SidebarWorkspaceReorderDropView() + let bonsplitDropView = SidebarBonsplitTabWorkspaceDropView() + let emptyDropIndicatorView = SidebarWorkspaceTableEmptyDropIndicatorView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + // One layer-backed subtree keeps recycled hosted rows on AppKit's + // accelerated scroll path without per-cell layer topology changes. + wantsLayer = true + scrollView.contentView = clipView + + scrollView.translatesAutoresizingMaskIntoConstraints = false + reorderDropView.translatesAutoresizingMaskIntoConstraints = false + bonsplitDropView.translatesAutoresizingMaskIntoConstraints = false + emptyDropIndicatorView.translatesAutoresizingMaskIntoConstraints = true + addSubview(scrollView) + addSubview(reorderDropView) + addSubview(bonsplitDropView) + addSubview(emptyDropIndicatorView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + reorderDropView.leadingAnchor.constraint(equalTo: leadingAnchor), + reorderDropView.trailingAnchor.constraint(equalTo: trailingAnchor), + reorderDropView.topAnchor.constraint(equalTo: topAnchor), + reorderDropView.bottomAnchor.constraint(equalTo: bottomAnchor), + bonsplitDropView.leadingAnchor.constraint(equalTo: leadingAnchor), + bonsplitDropView.trailingAnchor.constraint(equalTo: trailingAnchor), + bonsplitDropView.topAnchor.constraint(equalTo: topAnchor), + bonsplitDropView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift new file mode 100644 index 000000000000..79f020a6b440 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -0,0 +1,496 @@ +import AppKit +import Bonsplit +import CmuxAppKitSupportUI +import CmuxFoundation +import SwiftUI + +/// Main-actor owner of the default sidebar table lifecycle and its AppKit interactions. +@MainActor +final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NSTableViewDelegate { + private weak var containerView: SidebarWorkspaceTableContainerView? + private var rows: [SidebarWorkspaceTableRowConfiguration] = [] + private var actions: SidebarWorkspaceTableActions? + private var hoveredRowId: SidebarWorkspaceRenderItemID? + private var contextMenuRowId: SidebarWorkspaceRenderItemID? + private var workspaceIds: [UUID] = [] + private var selectedScrollTargetWorkspaceId: UUID? + private var appKitDropIndicator: SidebarDropIndicator? + private var appKitDropIndicatorScope: SidebarWorkspaceReorderDropIndicatorScope = .raw + private var appKitDropIndicatorIncludesRowTargets = false + private var clipBoundsObserver: NSObjectProtocol? + private let rowHeightCache = SidebarWorkspaceTableRowHeightCache() + private let dropTargetGeometry = SidebarWorkspaceTableDropTargetGeometryGate() + +#if DEBUG + var reconfigurationProbe: (() -> Void)? + var dropTargetComputationProbe: (() -> Void)? { + get { dropTargetGeometry.computationProbe } + set { dropTargetGeometry.computationProbe = newValue } + } +#endif + + deinit { + if let clipBoundsObserver { + NotificationCenter.default.removeObserver(clipBoundsObserver) + } + } + func makeContainerView() -> SidebarWorkspaceTableContainerView { + let container = SidebarWorkspaceTableContainerView() + containerView = container + + let table = container.tableView + table.workspaceController = self + container.clipView.workspaceController = self + table.dataSource = self + table.delegate = self + table.headerView = nil + table.style = .fullWidth + table.backgroundColor = .clear + table.enclosingScrollView?.backgroundColor = .clear + table.focusRingType = .none + table.gridStyleMask = [] + table.usesAlternatingRowBackgroundColors = false + table.selectionHighlightStyle = .none + table.allowsEmptySelection = true + table.allowsMultipleSelection = false + table.allowsTypeSelect = false + table.intercellSpacing = NSSize(width: 0, height: 2) + table.usesAutomaticRowHeights = false + table.rowHeight = SidebarWorkspaceTableRowHeightCalculator().defaultWorkspaceHeight + table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + table.setDraggingSourceOperationMask(.move, forLocal: true) + table.setDraggingSourceOperationMask(.move, forLocal: false) + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("workspace")) + column.resizingMask = .autoresizingMask + table.addTableColumn(column) + + let scrollView = container.scrollView + scrollView.documentView = table + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentView.drawsBackground = false + scrollView.contentView.postsBoundsChangedNotifications = true + scrollView.contentInsets = NSEdgeInsets( + top: SidebarWorkspaceScrollInsets.workspaceList.top + + SidebarWorkspaceListMetrics.rowVerticalPadding, + left: 0, + bottom: SidebarWorkspaceScrollInsets.workspaceList.bottom + + SidebarWorkspaceListMetrics.rowVerticalPadding, + right: 0 + ) + scrollView.applySidebarOverlayScrollerConfiguration() + + container.reorderDropView.registerForDraggedTypes([ + NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier), + ]) + dropTargetGeometry.attach(containerView: container) + container.bonsplitDropView.targetBridge = dropTargetGeometry.bonsplitTargetBridge + + clipBoundsObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.viewportDidChange() + } + } + + return container + } + + func apply( + rows nextRows: [SidebarWorkspaceTableRowConfiguration], + actions: SidebarWorkspaceTableActions, + workspaceIds nextWorkspaceIds: [UUID], + selectedWorkspaceId: UUID?, + selectedScrollTargetWorkspaceId: UUID? + ) { + guard let containerView else { return } + self.actions = actions + actions.attachScrollView(containerView.scrollView) + configureDropViews(in: containerView, actions: actions) + + let previousRows = rows + let hasStructuralChanges = previousRows.map(\.id) != nextRows.map(\.id) + let contentChanges = IndexSet(nextRows.indices.filter { index in + previousRows.indices.contains(index) + && !previousRows[index].hasEquivalentContent(to: nextRows[index]) + }) + let heightChanges = rowHeightCache.prepareHostedRows(nextRows, columnWidth: currentColumnWidth()) + rows = nextRows + + if hasStructuralChanges { + containerView.tableView.reloadData() + } else { + reconfigureVisibleRows(contentChanges) + if !heightChanges.isEmpty { + containerView.tableView.noteHeightOfRows(withIndexesChanged: heightChanges) + } + } + + let shouldScrollAfterWorkspaceChange = SidebarSelectedWorkspaceScrollPolicy + .shouldScrollSelectedWorkspace( + selectedWorkspaceId: selectedWorkspaceId, + oldWorkspaceIds: workspaceIds, + newWorkspaceIds: nextWorkspaceIds + ) + workspaceIds = nextWorkspaceIds + let selectionTargetChanged = self.selectedScrollTargetWorkspaceId != selectedScrollTargetWorkspaceId + self.selectedScrollTargetWorkspaceId = selectedScrollTargetWorkspaceId + if selectionTargetChanged || shouldScrollAfterWorkspaceChange { + scrollSelectedRowToVisibleIfNeeded() + } + synchronizeAppKitDropIndicator(actions: actions) + recomputeHoveredRow() + updateDropTargets() + } + + func numberOfRows(in tableView: NSTableView) -> Int { + rows.count + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + guard rows.indices.contains(row) else { return tableView.rowHeight } + let configuration = rows[row] + let columnWidth = currentColumnWidth() + return rowHeightCache.height( + for: configuration, + columnWidth: columnWidth + ) ?? configuration.estimatedHeight + } + + func tableView( + _ tableView: NSTableView, + viewFor tableColumn: NSTableColumn?, + row: Int + ) -> NSView? { + guard rows.indices.contains(row) else { return nil } + let cell = tableView.makeView( + withIdentifier: SidebarWorkspaceTableCellView.reuseIdentifier, + owner: self + ) as? SidebarWorkspaceTableCellView ?? SidebarWorkspaceTableCellView() + configure(cell: cell, at: row) + return cell + } + + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + false + } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + guard rows.indices.contains(row), let actions else { return nil } + let workspaceId = rows[row].workspaceId + actions.beginWorkspaceDrag(workspaceId) + workspaceDragSessionDidBegin() + let item = NSPasteboardItem() + item.setString( + "\(SidebarTabDragPayload.prefix)\(workspaceId.uuidString)", + forType: NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + ) + return item + } + + func tableView( + _ tableView: NSTableView, + draggingSession session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + actions?.endWorkspaceDrag() + workspaceDragSessionDidEnd() + } + + func workspaceDragSessionDidBegin() { + if dropTargetGeometry.setWorkspaceDragSessionActive(true, rows: rows) { + positionAppKitDropIndicator() + } + } + + func workspaceDragSessionDidEnd() { + dropTargetGeometry.setWorkspaceDragSessionActive(false, rows: rows) + dropTargetGeometry.setReorderTargetCollectionActive(false, rows: rows) + } + + func middleClick(row: Int) { + guard rows.indices.contains(row) else { return } + actions?.closeWorkspace(rows[row].workspaceId) + } + + func doubleClickEmptyArea() { + actions?.createWorkspaceAtEnd() + } + + func createEmptyWorkspaceGroup() { + actions?.createEmptyWorkspaceGroup() + } + + func emptyAreaMenu() -> NSMenu { + let menu = NSMenu() + let item = NSMenuItem( + title: String( + localized: "contextMenu.workspaceGroup.newEmpty", + defaultValue: "New Empty Workspace Group" + ), + action: #selector(createEmptyWorkspaceGroupFromMenu), + keyEquivalent: "" + ) + item.target = self + let shortcut = KeyboardShortcutSettings.shortcut(for: .newWorkspaceGroup) + if let keyEquivalent = shortcut.menuItemKeyEquivalent { + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + menu.addItem(item) + return menu + } + + @objc private func createEmptyWorkspaceGroupFromMenu() { + createEmptyWorkspaceGroup() + } + + func pointerDidLeaveTable() { + guard contextMenuRowId == nil else { return } + setHoveredRowId(nil) + } + + func recomputeHoveredRow() { + guard contextMenuRowId == nil, + let table = containerView?.tableView else { + return + } + let row = SidebarWorkspaceTableHoverResolver().hoveredRow( + windowPoint: table.lastPointerWindowLocation, + convertToTable: { table.convert($0, from: nil) }, + rowAtPoint: { table.row(at: $0) }, + rowCount: rows.count + ) + setHoveredRowId(row.map { rows[$0].id }) + } + + func viewportDidChange() { + if let changed = rowHeightCache.prepareHostedRowsIfWidthChanged( + rows, + columnWidth: currentColumnWidth() + ), !changed.isEmpty { + containerView?.tableView.noteHeightOfRows(withIndexesChanged: changed) + } + recomputeHoveredRow() + updateDropTargets() + } + + private func currentColumnWidth() -> CGFloat { + guard let containerView else { return 0 } + return containerView.clipView.bounds.width + } + + private func setHoveredRowId(_ next: SidebarWorkspaceRenderItemID?) { + guard hoveredRowId != next else { return } + let previous = hoveredRowId + hoveredRowId = next + reconfigureRows(withIds: [previous, next].compactMap { $0 }) + } + + private func contextMenuDidOpen(rowId: SidebarWorkspaceRenderItemID) { + contextMenuRowId = rowId + } + + private func contextMenuDidClose(rowId: SidebarWorkspaceRenderItemID) { + guard contextMenuRowId == rowId else { return } + contextMenuRowId = nil + recomputeHoveredRow() + } + + private func reconfigureRows(withIds ids: [SidebarWorkspaceRenderItemID]) { + let idSet = Set(ids) + let indexes = IndexSet(rows.indices.filter { idSet.contains(rows[$0].id) }) + reconfigureVisibleRows(indexes) + } + + private func reconfigureVisibleRows(_ indexes: IndexSet) { + guard let table = containerView?.tableView else { return } + for row in indexes where rows.indices.contains(row) { + guard let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) + as? SidebarWorkspaceTableCellView else { + continue + } + configure(cell: cell, at: row) + } + } + + private func configure(cell: SidebarWorkspaceTableCellView, at row: Int) { + let configuration = rows[row] + let rowId = configuration.id +#if DEBUG + cell.reconfigurationProbe = reconfigurationProbe +#endif + cell.configure( + row: configuration, + isPointerHovering: hoveredRowId == rowId && contextMenuRowId != rowId, + contextMenuDidOpen: { [weak self] in + self?.contextMenuDidOpen(rowId: rowId) + }, + contextMenuDidClose: { [weak self] in + self?.contextMenuDidClose(rowId: rowId) + } + ) + } + + private func scrollSelectedRowToVisibleIfNeeded() { + guard let table = containerView?.tableView, + let selectedScrollTargetWorkspaceId, + let row = rows.firstIndex(where: { $0.workspaceId == selectedScrollTargetWorkspaceId }) else { + return + } + let visibleRect = table.visibleRect + guard !visibleRect.contains(table.rect(ofRow: row)) else { return } + table.scrollRowToVisible(row) + } + + private func configureDropViews( + in container: SidebarWorkspaceTableContainerView, + actions: SidebarWorkspaceTableActions + ) { + let reorder = container.reorderDropView + reorder.isValidDrag = actions.isValidWorkspaceDrag + reorder.updateDrag = { [weak self] point, targets in + let accepted = actions.updateWorkspaceDrag(point, targets) + self?.setAppKitDropIndicator( + actions.currentDropIndicator(), + scope: actions.currentDropIndicatorScope(), + includeRowTargets: false + ) + return accepted + } + reorder.performDropAtPoint = { [weak self] point, targets in + let performed = actions.performWorkspaceDrop(point, targets) + self?.setAppKitDropIndicator(nil, scope: .raw, includeRowTargets: false) + return performed + } + reorder.clearDropIndicator = { [weak self] in + actions.clearWorkspaceDropIndicator() + self?.setAppKitDropIndicator(nil, scope: .raw, includeRowTargets: false) + } + reorder.setWorkspaceDropTargetCollectionActive = { [weak self] isActive in + actions.setWorkspaceDropTargetCollectionActive(isActive) + guard let self else { return } + if self.dropTargetGeometry.setReorderTargetCollectionActive(isActive, rows: self.rows) { + self.positionAppKitDropIndicator() + } + } + + let bonsplit = container.bonsplitDropView + bonsplit.canPerformAction = actions.canPerformBonsplitAction + bonsplit.updateAutoscroll = actions.updateDragAutoscroll + bonsplit.setWorkspaceDropTargetCollectionActive = { [weak self] isActive in + actions.setBonsplitDropTargetCollectionActive(isActive) + guard let self else { return } + if self.dropTargetGeometry.setBonsplitTargetCollectionActive(isActive, rows: self.rows) { + self.positionAppKitDropIndicator() + } + } + bonsplit.setDropIndicator = { [weak self] indicator in + actions.setBonsplitDropIndicator(indicator) + self?.setAppKitDropIndicator(indicator, scope: .raw, includeRowTargets: true) + } + bonsplit.performExistingWorkspaceMove = { workspaceId, transfer in + guard actions.moveBonsplitToExistingWorkspace(workspaceId, transfer) else { return false } + actions.didMoveBonsplitToWorkspace(workspaceId) + return true + } + bonsplit.performNewWorkspaceMove = { insertionIndex, _, transfer in + guard let workspaceId = actions.moveBonsplitToNewWorkspace(insertionIndex, transfer) else { + return false + } + actions.didMoveBonsplitToWorkspace(workspaceId) + return true + } + } + + private func updateDropTargets() { + if dropTargetGeometry.refreshIfActive(rows: rows) { + positionAppKitDropIndicator() + } + } + + private func synchronizeAppKitDropIndicator(actions: SidebarWorkspaceTableActions) { + let current = actions.currentDropIndicator() + let currentScope = actions.currentDropIndicatorScope() + if current == nil { + setAppKitDropIndicator(nil, scope: .raw, includeRowTargets: false) + } else if current == appKitDropIndicator && currentScope == appKitDropIndicatorScope { + positionAppKitDropIndicator() + } else { + setAppKitDropIndicator( + current, + scope: currentScope, + includeRowTargets: false + ) + } + } + + private func setAppKitDropIndicator( + _ indicator: SidebarDropIndicator?, + scope: SidebarWorkspaceReorderDropIndicatorScope, + includeRowTargets: Bool + ) { + let shouldDisplay: Bool = { + guard let indicator else { return false } + if includeRowTargets { return true } + guard !scope.isGroup else { return false } + if indicator.tabId == nil { return true } + return indicator.edge == .bottom && rows.last?.workspaceId == indicator.tabId + }() + appKitDropIndicator = shouldDisplay ? indicator : nil + appKitDropIndicatorScope = scope + appKitDropIndicatorIncludesRowTargets = includeRowTargets + containerView?.emptyDropIndicatorView.isHidden = !shouldDisplay + positionAppKitDropIndicator() + } + + private func positionAppKitDropIndicator() { + guard let indicator = appKitDropIndicator, let container = containerView else { return } + let targetRow = indicator.tabId.flatMap { tabId in + rows.firstIndex { $0.workspaceId == tabId } + } + if indicator.tabId != nil, targetRow == nil { + container.emptyDropIndicatorView.isHidden = true + return + } + container.emptyDropIndicatorView.isHidden = false + let y: CGFloat + if let targetRow { + let rowFrame = container.tableView.convert( + container.tableView.rect(ofRow: targetRow), + to: container + ) + y = (indicator.edge == .top ? rowFrame.maxY : rowFrame.minY) - 1 + } else if let lastRow = rows.indices.last { + y = container.tableView.convert( + container.tableView.rect(ofRow: lastRow), + to: container + ).minY - 1 + } else { + y = container.bounds.height + - SidebarWorkspaceScrollInsets.workspaceList.top + - SidebarWorkspaceListMetrics.rowVerticalPadding + } + let leadingIndent: CGFloat = { + guard appKitDropIndicatorIncludesRowTargets, + let targetRow, + rows[targetRow].groupId != nil, + !rows[targetRow].isGroupHeader else { + return 0 + } + return SidebarWorkspaceGroupingMetrics.memberIndent + }() + container.emptyDropIndicatorView.frame = NSRect( + x: 8 + leadingIndent, + y: y, + width: max(0, container.bounds.width - 16 - leadingIndent), + height: 2 + ) + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift new file mode 100644 index 000000000000..fba4eae56712 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift @@ -0,0 +1,113 @@ +import AppKit +import CmuxFoundation + +/// Builds sidebar drop geometry only while an AppKit drag requests it. +@MainActor +final class SidebarWorkspaceTableDropTargetGeometryGate { + let bonsplitTargetBridge = SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge() + + private weak var containerView: SidebarWorkspaceTableContainerView? + private var isWorkspaceDragSessionActive = false + private var isReorderTargetCollectionActive = false + private var isBonsplitTargetCollectionActive = false + +#if DEBUG + var computationProbe: (() -> Void)? +#endif + + func attach(containerView: SidebarWorkspaceTableContainerView) { + self.containerView = containerView + } + + @discardableResult + func setWorkspaceDragSessionActive( + _ isActive: Bool, + rows: [SidebarWorkspaceTableRowConfiguration] + ) -> Bool { + let wasActive = hasActiveDrag + isWorkspaceDragSessionActive = isActive + return handleActivityChange(wasActive: wasActive, rows: rows) + } + + @discardableResult + func setReorderTargetCollectionActive( + _ isActive: Bool, + rows: [SidebarWorkspaceTableRowConfiguration] + ) -> Bool { + let wasActive = hasActiveDrag + isReorderTargetCollectionActive = isActive + return handleActivityChange(wasActive: wasActive, rows: rows) + } + + @discardableResult + func setBonsplitTargetCollectionActive( + _ isActive: Bool, + rows: [SidebarWorkspaceTableRowConfiguration] + ) -> Bool { + let wasActive = hasActiveDrag + isBonsplitTargetCollectionActive = isActive + return handleActivityChange(wasActive: wasActive, rows: rows) + } + + @discardableResult + func refreshIfActive(rows: [SidebarWorkspaceTableRowConfiguration]) -> Bool { + guard hasActiveDrag, let container = containerView else { return false } +#if DEBUG + computationProbe?() +#endif + let table = container.tableView + let visibleRange = table.rows(in: table.visibleRect) + guard visibleRange.location != NSNotFound, visibleRange.length > 0 else { + clearTargets() + return true + } + + let lower = max(0, visibleRange.location) + let upper = min(rows.count, visibleRange.location + visibleRange.length) + let visibleIndexes = lower.. Bool { + guard wasActive != hasActiveDrag else { return false } + if hasActiveDrag { + return refreshIfActive(rows: rows) + } + clearTargets() + return false + } + + private func clearTargets() { + guard let container = containerView else { return } + container.reorderDropView.targets = [] + container.reorderDropView.targetsDidUpdate() + bonsplitTargetBridge.updateTargets([]) + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift new file mode 100644 index 000000000000..c8b7cd61f2d7 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift @@ -0,0 +1,30 @@ +import AppKit + +/// AppKit counterpart of the existing two-point accent drop indicator. +@MainActor +final class SidebarWorkspaceTableEmptyDropIndicatorView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 1 + updateAccentColor() + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAccentColor() + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + private func updateAccentColor() { + layer?.backgroundColor = cmuxAccentNSColor(for: effectiveAppearance).cgColor + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift new file mode 100644 index 000000000000..a04d5802fb4d --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift @@ -0,0 +1,30 @@ +import CmuxFoundation +import SwiftUI + +/// Value-only SwiftUI environment forwarded into each independently hosted table cell. +struct SidebarWorkspaceTableEnvironmentSnapshot { + let colorScheme: ColorScheme + let globalFontMagnificationPercent: Int +#if DEBUG + let lazyContractProbe: SidebarLazyContractProbe +#endif + + func hasEquivalentPresentation(to other: Self) -> Bool { + colorScheme == other.colorScheme + && globalFontMagnificationPercent == other.globalFontMagnificationPercent + } + + @ViewBuilder + func apply(to content: Content) -> some View { +#if DEBUG + content + .environment(\.colorScheme, colorScheme) + .environment(\.cmuxGlobalFontMagnificationPercent, globalFontMagnificationPercent) + .environment(\.sidebarLazyContractProbe, lazyContractProbe) +#else + content + .environment(\.colorScheme, colorScheme) + .environment(\.cmuxGlobalFontMagnificationPercent, globalFontMagnificationPercent) +#endif + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableHoverResolver.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableHoverResolver.swift new file mode 100644 index 000000000000..a9989fb84239 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableHoverResolver.swift @@ -0,0 +1,16 @@ +import AppKit + +/// Pure hovered-row resolution shared by the table controller and unit tests. +struct SidebarWorkspaceTableHoverResolver { + func hoveredRow( + windowPoint: NSPoint?, + convertToTable: (NSPoint) -> NSPoint, + rowAtPoint: (NSPoint) -> Int, + rowCount: Int + ) -> Int? { + guard let windowPoint else { return nil } + let row = rowAtPoint(convertToTable(windowPoint)) + guard row >= 0, row < rowCount else { return nil } + return row + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift new file mode 100644 index 000000000000..c38f6fab0c68 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct SidebarWorkspaceTableContextMenuActions { + let didOpen: () -> Void + let didClose: () -> Void +} + +/// Immutable description of one AppKit-owned sidebar row. +@MainActor +struct SidebarWorkspaceTableRowConfiguration { + typealias ContentFactory = ( + _ isPointerHovering: Bool, + _ contextMenuActions: SidebarWorkspaceTableContextMenuActions + ) -> AnyView + + let id: SidebarWorkspaceRenderItemID + let workspaceId: UUID + let groupId: UUID? + let isGroupHeader: Bool + let isPinned: Bool + let makeContent: ContentFactory + + private let environment: SidebarWorkspaceTableEnvironmentSnapshot + private let equivalenceValue: Any + private let isEquivalentValue: (Any) -> Bool + + init( + id: SidebarWorkspaceRenderItemID, + workspaceId: UUID, + groupId: UUID?, + isGroupHeader: Bool, + isPinned: Bool, + environment: SidebarWorkspaceTableEnvironmentSnapshot, + equivalenceValue: Content, + makeContent: @escaping ContentFactory + ) { + self.id = id + self.workspaceId = workspaceId + self.groupId = groupId + self.isGroupHeader = isGroupHeader + self.isPinned = isPinned + self.environment = environment + self.makeContent = makeContent + self.equivalenceValue = equivalenceValue + self.isEquivalentValue = { value in + guard let value = value as? Content else { return false } + return value == equivalenceValue + } + } + + func hasEquivalentContent(to other: Self) -> Bool { + environment.hasEquivalentPresentation(to: other.environment) + && isEquivalentValue(other.equivalenceValue) + } + + var estimatedHeight: CGFloat { + let fontScale = CGFloat(environment.globalFontMagnificationPercent) / 100 + let calculator = SidebarWorkspaceTableRowHeightCalculator() + if isGroupHeader { + return calculator.estimatedGroupHeaderHeight(fontScale: fontScale) + } + return calculator.estimatedWorkspaceHeight( + fontScale: fontScale, + titleLineCount: 1, + auxiliaryLineCount: 0 + ) + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift new file mode 100644 index 000000000000..13be672547b3 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift @@ -0,0 +1,120 @@ +import AppKit +import Foundation +import SwiftUI + +/// Stores exact hosted-row heights without measuring from AppKit's layout callbacks. +@MainActor +final class SidebarWorkspaceTableRowHeightCache { + typealias Measurement = ( + _ row: SidebarWorkspaceTableRowConfiguration, + _ columnWidth: CGFloat + ) -> CGFloat + + @MainActor + private struct Entry { + let row: SidebarWorkspaceTableRowConfiguration + let columnWidth: CGFloat + let height: CGFloat + + func matches( + row candidate: SidebarWorkspaceTableRowConfiguration, + columnWidth candidateWidth: CGFloat + ) -> Bool { + columnWidth == candidateWidth && row.hasEquivalentContent(to: candidate) + } + } + + private var entries: [SidebarWorkspaceRenderItemID: Entry] = [:] + private let prototypeView = NSHostingView(rootView: AnyView(EmptyView())) + private var preparedColumnWidth: CGFloat? + + func prepareHostedRows( + _ rows: [SidebarWorkspaceTableRowConfiguration], + columnWidth: CGFloat + ) -> IndexSet { + return prepare(rows: rows, columnWidth: columnWidth, measure: measureHostedRow) + } + + func prepareHostedRowsIfWidthChanged( + _ rows: [SidebarWorkspaceTableRowConfiguration], + columnWidth: CGFloat + ) -> IndexSet? { + guard columnWidth > 0, preparedColumnWidth != columnWidth else { return nil } + return prepareHostedRows(rows, columnWidth: columnWidth) + } + + /// Measures only missing or invalid entries. Call from render updates or + /// viewport-width notifications, never from `heightOfRow`. + func prepare( + rows: [SidebarWorkspaceTableRowConfiguration], + columnWidth: CGFloat, + measure: Measurement + ) -> IndexSet { + guard columnWidth > 0 else { + entries.removeAll(keepingCapacity: true) + preparedColumnWidth = nil + return [] + } + preparedColumnWidth = columnWidth + + var nextEntries: [SidebarWorkspaceRenderItemID: Entry] = [:] + nextEntries.reserveCapacity(rows.count) + var changedHeights = IndexSet() + + for (index, row) in rows.enumerated() { + let previous = entries[row.id] + if let previous, previous.matches(row: row, columnWidth: columnWidth) { + nextEntries[row.id] = previous + continue + } + + let measuredHeight = Self.normalizedHeight(measure(row, columnWidth)) + let previousHeight = previous?.height ?? row.estimatedHeight + if previousHeight != measuredHeight { + changedHeights.insert(index) + } + nextEntries[row.id] = Entry( + row: row, + columnWidth: columnWidth, + height: measuredHeight + ) + } + + entries = nextEntries + return changedHeights + } + + /// A pure cache read used by `tableView(_:heightOfRow:)` during layout. + func height( + for row: SidebarWorkspaceTableRowConfiguration, + columnWidth: CGFloat + ) -> CGFloat? { + guard let entry = entries[row.id], + entry.matches(row: row, columnWidth: columnWidth) else { + return nil + } + return entry.height + } + + private static func normalizedHeight(_ height: CGFloat) -> CGFloat { + ceil(max(1, height)) + } + + private func measureHostedRow( + row: SidebarWorkspaceTableRowConfiguration, + columnWidth: CGFloat + ) -> CGFloat { + let contextMenuActions = SidebarWorkspaceTableContextMenuActions( + didOpen: {}, + didClose: {} + ) + prototypeView.rootView = AnyView( + row.makeContent(false, contextMenuActions) + .frame(width: columnWidth, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + ) + prototypeView.frame = NSRect(x: 0, y: 0, width: columnWidth, height: 1) + prototypeView.layoutSubtreeIfNeeded() + return prototypeView.fittingSize.height + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCalculator.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCalculator.swift new file mode 100644 index 000000000000..8b5e8def9d07 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCalculator.swift @@ -0,0 +1,26 @@ +import CoreGraphics + +/// Supplies deterministic estimates while AppKit resolves exact hosted SwiftUI row heights. +struct SidebarWorkspaceTableRowHeightCalculator { + var defaultWorkspaceHeight: CGFloat { + estimatedWorkspaceHeight(fontScale: 1, titleLineCount: 1, auxiliaryLineCount: 0) + } + + func estimatedWorkspaceHeight( + fontScale: CGFloat, + titleLineCount: Int, + auxiliaryLineCount: Int + ) -> CGFloat { + let scale = max(0.5, fontScale) + let titleLines = max(1, titleLineCount) + let auxiliaryLines = max(0, auxiliaryLineCount) + let titleHeight = CGFloat(titleLines) * 15 * scale + let auxiliaryHeight = CGFloat(auxiliaryLines) * 12 * scale + let interlineSpacing = auxiliaryLines > 0 ? CGFloat(auxiliaryLines) * 4 : 0 + return ceil(16 + titleHeight + auxiliaryHeight + interlineSpacing) + } + + func estimatedGroupHeaderHeight(fontScale: CGFloat) -> CGFloat { + ceil(26 * max(0.5, fontScale) + 10) + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift new file mode 100644 index 000000000000..e303a907e858 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// Container-level bridge mounting the AppKit-owned default workspace list once. +struct SidebarWorkspaceTableView: NSViewRepresentable { + let rows: [SidebarWorkspaceTableRowConfiguration] + let actions: SidebarWorkspaceTableActions + let workspaceIds: [UUID] + let selectedWorkspaceId: UUID? + let selectedScrollTargetWorkspaceId: UUID? + +#if DEBUG + @Environment(\.sidebarLazyContractProbe) private var sidebarLazyContractProbe +#endif + + func makeCoordinator() -> SidebarWorkspaceTableController { + SidebarWorkspaceTableController() + } + + func makeNSView(context: Context) -> SidebarWorkspaceTableContainerView { + context.coordinator.makeContainerView() + } + + func updateNSView(_ nsView: SidebarWorkspaceTableContainerView, context: Context) { +#if DEBUG + context.coordinator.reconfigurationProbe = sidebarLazyContractProbe.tableRootViewReconfigure +#endif + context.coordinator.apply( + rows: rows, + actions: actions, + workspaceIds: workspaceIds, + selectedWorkspaceId: selectedWorkspaceId, + selectedScrollTargetWorkspaceId: selectedScrollTargetWorkspaceId + ) + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift new file mode 100644 index 000000000000..916dd67bb10e --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift @@ -0,0 +1,77 @@ +import AppKit + +/// Event-owning NSTableView for the default workspace sidebar. +@MainActor +final class SidebarWorkspaceTableViewImpl: NSTableView { + weak var workspaceController: SidebarWorkspaceTableController? + private var pointerTrackingArea: NSTrackingArea? + private(set) var lastPointerWindowLocation: NSPoint? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let pointerTrackingArea { + removeTrackingArea(pointerTrackingArea) + } + let next = NSTrackingArea( + rect: bounds, + options: [.activeAlways, .inVisibleRect, .mouseEnteredAndExited, .mouseMoved], + owner: self, + userInfo: nil + ) + addTrackingArea(next) + pointerTrackingArea = next + } + + override func mouseEntered(with event: NSEvent) { + updatePointer(with: event) + } + + override func mouseMoved(with event: NSEvent) { + updatePointer(with: event) + } + + override func mouseExited(with event: NSEvent) { + setPointerWindowLocation(nil) + } + + override func otherMouseDown(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDown(with: event) + return + } + let row = row(at: convert(event.locationInWindow, from: nil)) + guard row >= 0 else { + super.otherMouseDown(with: event) + return + } + workspaceController?.middleClick(row: row) + } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + let clickedRow = row(at: point) + super.mouseDown(with: event) + if event.clickCount == 2, clickedRow < 0 { + workspaceController?.doubleClickEmptyArea() + } + } + + override func menu(for event: NSEvent) -> NSMenu? { + let row = row(at: convert(event.locationInWindow, from: nil)) + guard row < 0 else { return super.menu(for: event) } + return workspaceController?.emptyAreaMenu() + } + + private func updatePointer(with event: NSEvent) { + setPointerWindowLocation(event.locationInWindow) + } + + func setPointerWindowLocation(_ point: NSPoint?) { + lastPointerWindowLocation = point + if point == nil { + workspaceController?.pointerDidLeaveTable() + } else { + workspaceController?.recomputeHoveredRow() + } + } +} diff --git a/cmuxTests/SidebarWorkspaceTableTests.swift b/cmuxTests/SidebarWorkspaceTableTests.swift new file mode 100644 index 000000000000..bf2e3ee18f09 --- /dev/null +++ b/cmuxTests/SidebarWorkspaceTableTests.swift @@ -0,0 +1,351 @@ +import AppKit +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct SidebarWorkspaceTableTests { + @Test + @MainActor + func containerHasNoStructuralHorizontalRowInsetAndAlwaysActiveHoverTracking() throws { + let container = SidebarWorkspaceTableController().makeContainerView() + let column = try #require(container.tableView.tableColumns.first) + container.tableView.updateTrackingAreas() + let hoverTrackingArea = try #require(container.tableView.trackingAreas.first { area in + area.options.contains(.mouseEnteredAndExited) + && area.options.contains(.mouseMoved) + && area.options.contains(.inVisibleRect) + }) + + #expect(container.tableView.style == .fullWidth) + #expect(container.scrollView.contentInsets.left == 0) + #expect(container.scrollView.contentInsets.right == 0) + #expect(container.tableView.intercellSpacing.width == 0) + #expect(!container.tableView.usesAutomaticRowHeights) + #expect(container.tableView.columnAutoresizingStyle == .uniformColumnAutoresizingStyle) + #expect(column.resizingMask.contains(.autoresizingMask)) + #expect(hoverTrackingArea.options.contains(.activeAlways)) + #expect(!hoverTrackingArea.options.contains(.activeInKeyWindow)) + } + + @Test + func rowHeightEstimateAccountsForScaleWrappingAndDetails() { + let calculator = SidebarWorkspaceTableRowHeightCalculator() + let compact = calculator.estimatedWorkspaceHeight( + fontScale: 1, + titleLineCount: 1, + auxiliaryLineCount: 0 + ) + let detailed = calculator.estimatedWorkspaceHeight( + fontScale: 1.2, + titleLineCount: 3, + auxiliaryLineCount: 4 + ) + + #expect(compact == 31) + #expect(detailed == 144) + #expect(calculator.estimatedGroupHeaderHeight(fontScale: 1) == 36) + #expect(detailed > compact) + } + + @Test + @MainActor + func rowHeightCacheMeasuresOnceForEquivalentRepeatedQueries() { + let cache = SidebarWorkspaceTableRowHeightCache() + let row = makeRowConfiguration() + var measurementCount = 0 + + let initialChanges = cache.prepare(rows: [row], columnWidth: 200) { _, _ in + measurementCount += 1 + return 44 + } + let repeatedChanges = cache.prepare(rows: [row], columnWidth: 200) { _, _ in + measurementCount += 1 + return 99 + } + + #expect(measurementCount == 1) + #expect(initialChanges == IndexSet(integer: 0)) + #expect(repeatedChanges.isEmpty) + #expect(cache.height(for: row, columnWidth: 200) == 44) + } + + @Test + @MainActor + func rowHeightCacheInvalidatesWhenColumnWidthChanges() { + let cache = SidebarWorkspaceTableRowHeightCache() + let row = makeRowConfiguration() + var measurementCount = 0 + let measure: SidebarWorkspaceTableRowHeightCache.Measurement = { _, width in + measurementCount += 1 + return width / 4 + } + + _ = cache.prepare(rows: [row], columnWidth: 200, measure: measure) + let changed = cache.prepare(rows: [row], columnWidth: 240, measure: measure) + + #expect(measurementCount == 2) + #expect(changed == IndexSet(integer: 0)) + #expect(cache.height(for: row, columnWidth: 200) == nil) + #expect(cache.height(for: row, columnWidth: 240) == 60) + } + + @Test + @MainActor + func rowHeightCacheInvalidatesContentFontAndAppearanceChanges() { + let cache = SidebarWorkspaceTableRowHeightCache() + let workspaceId = UUID() + var measurementCount = 0 + let measure: SidebarWorkspaceTableRowHeightCache.Measurement = { _, _ in + measurementCount += 1 + return CGFloat(40 + measurementCount) + } + let original = makeRowConfiguration(workspaceId: workspaceId) + let changedContent = makeRowConfiguration(workspaceId: workspaceId, contentToken: 1) + let changedFont = makeRowConfiguration( + workspaceId: workspaceId, + contentToken: 1, + fontMagnificationPercent: 120 + ) + let changedAppearance = makeRowConfiguration( + workspaceId: workspaceId, + contentToken: 1, + fontMagnificationPercent: 120, + colorScheme: .dark + ) + + _ = cache.prepare(rows: [original], columnWidth: 200, measure: measure) + _ = cache.prepare(rows: [changedContent], columnWidth: 200, measure: measure) + _ = cache.prepare(rows: [changedFont], columnWidth: 200, measure: measure) + _ = cache.prepare(rows: [changedAppearance], columnWidth: 200, measure: measure) + + #expect(measurementCount == 4) + #expect(cache.height(for: changedAppearance, columnWidth: 200) == 44) + } + + @Test + @MainActor + func cachedHeightQueriesDuringScrollNeverMeasure() { + let cache = SidebarWorkspaceTableRowHeightCache() + let row = makeRowConfiguration() + var measurementCount = 0 + _ = cache.prepare(rows: [row], columnWidth: 200) { _, _ in + measurementCount += 1 + return 44 + } + + for _ in 0..<500 { + #expect(cache.prepareHostedRowsIfWidthChanged([row], columnWidth: 200) == nil) + #expect(cache.height(for: row, columnWidth: 200) == 44) + } + + #expect(measurementCount == 1) + } + +#if DEBUG + @Test + @MainActor + func equivalentCellConfigurationDoesNotRenderAgain() { + let cell = SidebarWorkspaceTableCellView() + let workspaceId = UUID() + var renders = 0 + cell.reconfigurationProbe = { renders += 1 } + + configure(cell, row: makeRowConfiguration(workspaceId: workspaceId)) + configure(cell, row: makeRowConfiguration(workspaceId: workspaceId)) + + #expect(renders == 1) + } + + @Test + @MainActor + func hoverFlipRendersOnlyTheAffectedCell() { + let firstCell = SidebarWorkspaceTableCellView() + let secondCell = SidebarWorkspaceTableCellView() + let firstRow = makeRowConfiguration() + let secondRow = makeRowConfiguration() + var firstRenders = 0 + var secondRenders = 0 + firstCell.reconfigurationProbe = { firstRenders += 1 } + secondCell.reconfigurationProbe = { secondRenders += 1 } + + configure(firstCell, row: firstRow) + configure(secondCell, row: secondRow) + configure(firstCell, row: firstRow, isPointerHovering: true) + configure(firstCell, row: firstRow, isPointerHovering: true) + + #expect(firstRenders == 2) + #expect(secondRenders == 1) + } + + @Test + @MainActor + func cellReusePreservesOneHostingViewAndStableRootIdentity() { + let cell = SidebarWorkspaceTableCellView() + let hostingIdentity = cell.hostingViewIdentity + let rootIdentity = cell.hostedRootIdentity + let reusedWorkspaceId = UUID() + + configure(cell, row: makeRowConfiguration()) + configure(cell, row: makeRowConfiguration(workspaceId: reusedWorkspaceId)) + + #expect(cell.subviews.count == 1) + #expect(cell.hostingViewIdentity == hostingIdentity) + #expect(cell.hostedRootIdentity == rootIdentity) + #expect(cell.representedRowId == .workspace(reusedWorkspaceId)) + } + + @Test + @MainActor + func dropTargetGeometryIsIdleDuringScrollAndTracksDragLifecycle() { + let controller = SidebarWorkspaceTableController() + let container = controller.makeContainerView() + let workspaceId = UUID() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = container + controller.apply( + rows: [makeRowConfiguration(workspaceId: workspaceId)], + actions: makeTableActions(), + workspaceIds: [workspaceId], + selectedWorkspaceId: nil, + selectedScrollTargetWorkspaceId: nil + ) + container.layoutSubtreeIfNeeded() + container.tableView.layoutSubtreeIfNeeded() + var computations = 0 + controller.dropTargetComputationProbe = { computations += 1 } + + controller.viewportDidChange() + controller.viewportDidChange() + #expect(computations == 0) + + controller.workspaceDragSessionDidBegin() + #expect(computations == 1) + #expect(container.reorderDropView.targets.map(\.workspaceId) == [workspaceId]) + + controller.viewportDidChange() + #expect(computations == 2) + + controller.workspaceDragSessionDidEnd() + #expect(container.reorderDropView.targets.isEmpty) + controller.viewportDidChange() + #expect(computations == 2) + } +#endif + + @Test + func hoverRecomputesFromStationaryWindowPointAfterScrollAndReorder() throws { + let resolver = SidebarWorkspaceTableHoverResolver() + let pointer = NSPoint(x: 20, y: 15) + var scrollOffset: CGFloat = 0 + var orderedIds = ["a", "b", "c", "d"] + + func resolvedId() -> String? { + let row = resolver.hoveredRow( + windowPoint: pointer, + convertToTable: { NSPoint(x: $0.x, y: $0.y + scrollOffset) }, + rowAtPoint: { Int(floor($0.y / 20)) }, + rowCount: orderedIds.count + ) + return row.map { orderedIds[$0] } + } + + #expect(resolvedId() == "a") + scrollOffset = 20 + #expect(resolvedId() == "b") + orderedIds = ["a", "c", "b", "d"] + #expect(resolvedId() == "c") + } + + @MainActor + private func makeRowConfiguration( + workspaceId: UUID = UUID(), + contentToken: Int = 0, + fontMagnificationPercent: Int = 100, + colorScheme: ColorScheme = .light + ) -> SidebarWorkspaceTableRowConfiguration { +#if DEBUG + let environment = SidebarWorkspaceTableEnvironmentSnapshot( + colorScheme: colorScheme, + globalFontMagnificationPercent: fontMagnificationPercent, + lazyContractProbe: SidebarLazyContractProbe() + ) +#else + let environment = SidebarWorkspaceTableEnvironmentSnapshot( + colorScheme: colorScheme, + globalFontMagnificationPercent: fontMagnificationPercent + ) +#endif + return SidebarWorkspaceTableRowConfiguration( + id: .workspace(workspaceId), + workspaceId: workspaceId, + groupId: nil, + isGroupHeader: false, + isPinned: false, + environment: environment, + equivalenceValue: TestRowContent(token: contentToken) + ) { _, _ in + AnyView(TestRowContent(token: contentToken)) + } + } + +#if DEBUG + @MainActor + private func configure( + _ cell: SidebarWorkspaceTableCellView, + row: SidebarWorkspaceTableRowConfiguration, + isPointerHovering: Bool = false + ) { + cell.configure( + row: row, + isPointerHovering: isPointerHovering, + contextMenuDidOpen: {}, + contextMenuDidClose: {} + ) + } + + @MainActor + private func makeTableActions() -> SidebarWorkspaceTableActions { + SidebarWorkspaceTableActions( + attachScrollView: { _ in }, + closeWorkspace: { _ in }, + createWorkspaceAtEnd: {}, + createEmptyWorkspaceGroup: {}, + beginWorkspaceDrag: { _ in }, + endWorkspaceDrag: {}, + isValidWorkspaceDrag: { true }, + updateWorkspaceDrag: { _, _ in false }, + performWorkspaceDrop: { _, _ in false }, + clearWorkspaceDropIndicator: {}, + currentDropIndicator: { nil }, + currentDropIndicatorScope: { .raw }, + setWorkspaceDropTargetCollectionActive: { _ in }, + canPerformBonsplitAction: { _, _ in false }, + moveBonsplitToExistingWorkspace: { _, _ in false }, + moveBonsplitToNewWorkspace: { _, _ in nil }, + didMoveBonsplitToWorkspace: { _ in }, + updateDragAutoscroll: {}, + setBonsplitDropTargetCollectionActive: { _ in }, + setBonsplitDropIndicator: { _ in } + ) + } +#endif + + private struct TestRowContent: View, Equatable { + let token: Int + + var body: some View { + EmptyView() + } + } +} From 40c19eab88a0230f5466b64a10ee633ef81689f3 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 22:43:46 -0700 Subject: [PATCH 2/4] Replace sidebar LazyVStack with AppKit NSTableView list core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace list becomes one container-level NSViewRepresentable mounting NSTableView with measured-once row heights (usesAutomaticRowHeights=false + explicit height cache), an explicit differ (ID-order change → reloadData; content change → reconfigure visible cells only), AppKit-owned hover via table tracking areas, and table-geometry drop targets. Cells host the existing SwiftUI row content (TabItemView / SidebarWorkspaceGroupHeaderView) through one reused NSHostingView each, fed by the same immutable snapshots as before; the snapshot boundary from #8211 is unchanged. Deletes the SwiftUI list plumbing this replaces: scroll-follow proxy state, the drag-gated row-frame preference reader, the per-row drop modifiers and drag gate, both drop-overlay representables in the default list path, and the sidebar pointer-interaction monitor (hover and middle-click are native table events now). scripts/check-sidebar-lazy-layout.py now guards the AppKit boundary: the old lazy-layout functions must stay deleted, row types must not mount platform representables, and table mutations are banned from AppKit layout callbacks. SidebarLazyLayoutScaleTests keeps the 300-workspace realization/convergence gates against the table. --- Sources/ContentView.swift | 673 +++++--------- Sources/Debug/SidebarLazyContractProbe.swift | 17 +- .../SidebarBonsplitTabDropDelegate.swift | 43 - ...ebarBonsplitWorkspaceRowDropModifier.swift | 23 - ...SidebarPointerFrameReportingModifier.swift | 33 - .../SidebarPointerInteractionMonitor.swift | 281 ------ Sources/Sidebar/SidebarRowDragGate.swift | 16 - .../SidebarWorkspaceDropTargetWriters.swift | 27 - Sources/SidebarWorkspaceGroupHeaderView.swift | 6 + .../SidebarWorkspaceGroupRowSnapshot.swift | 4 +- Sources/SidebarWorkspaceGroupRowView.swift | 27 - Sources/SidebarWorkspaceRowActions.swift | 8 +- Sources/SidebarWorkspaceRowInput.swift | 4 +- Sources/SidebarWorkspaceRowView.swift | 30 - .../VerticalTabsSidebar+WorkspaceGroups.swift | 79 +- cmux.xcodeproj/project.pbxproj | 112 ++- cmuxTests/SidebarLazyLayoutScaleTests.swift | 202 ++-- ...idebarPointerInteractionMonitorTests.swift | 221 ----- .../SidebarPointerInteractionScaleTests.swift | 283 ------ scripts/check-sidebar-lazy-layout.py | 865 ++++-------------- tests/test_ci_sidebar_lazy_layout_guard.py | 486 ++-------- 21 files changed, 823 insertions(+), 2617 deletions(-) delete mode 100644 Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift delete mode 100644 Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift delete mode 100644 Sources/Sidebar/SidebarPointerFrameReportingModifier.swift delete mode 100644 Sources/Sidebar/SidebarPointerInteractionMonitor.swift delete mode 100644 Sources/Sidebar/SidebarRowDragGate.swift delete mode 100644 Sources/SidebarWorkspaceDropTargetWriters.swift delete mode 100644 Sources/SidebarWorkspaceGroupRowView.swift delete mode 100644 Sources/SidebarWorkspaceRowView.swift delete mode 100644 cmuxTests/SidebarPointerInteractionMonitorTests.swift delete mode 100644 cmuxTests/SidebarPointerInteractionScaleTests.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 22ce1ccf1994..7661255b9269 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9890,7 +9890,6 @@ struct VerticalTabsSidebar: View { @Binding var lastSidebarSelectionIndex: Int? @Binding var sidebarRenderWorkerClient: RenderWorkerClient? @State var modifierKeyMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) - @State var pointerInteractionMonitor = SidebarPointerInteractionMonitor() @StateObject var dragAutoScrollController = SidebarDragAutoScrollController() @State private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore( @@ -9901,16 +9900,13 @@ struct VerticalTabsSidebar: View { // Bonsplit tab drags arrive through AppKit pasteboard callbacks, not // `SidebarDragState`, so they need a separate transient collection flag. @State private var isBonsplitWorkspaceDropTargetCollectionActive = false - @State private var bonsplitWorkspaceDropTargetBridge = SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge() @State private var isWorkspaceReorderDropTargetCollectionActive = false - @State private var workspaceReorderDropTargetBridge = SidebarWorkspaceReorderDropOverlay.TargetBridge() // Freezes `showsModifierShortcutHints` for the workspace whose context menu // is open. Set on the row's contextMenu.onAppear and cleared on // .onDisappear so modifier-key transitions don't flip the badges on the // row sitting behind the open menu. See `SidebarShortcutHintFreezePolicy`. @State private var frozenShortcutHintsTabId: UUID? @State private var frozenShortcutHintsValue: Bool = false - @State private var pendingSelectedWorkspaceScrollId: UUID? @State private var collapsedExtensionSidebarSectionIds: Set = [] @State private var extensionSidebarWorktreeCreationInFlightSectionIds: Set = [] // Per-workspace transient checklist UI state (never persisted): which @@ -9964,6 +9960,8 @@ struct VerticalTabsSidebar: View { @Environment(\.minimalModeInvalidationProbe) private var minimalModeInvalidationProbe @Environment(\.sidebarLazyContractProbe) private var sidebarLazyContractProbe #endif + @Environment(\.colorScheme) private var sidebarColorScheme + @Environment(\.cmuxGlobalFontMagnificationPercent) private var sidebarGlobalFontMagnificationPercent // The provider to actually render. Built-in views are always honored; only // the hosted-extension selection falls back to the default workspaces @@ -10182,65 +10180,8 @@ struct VerticalTabsSidebar: View { ) } - private func requestSelectedWorkspaceScroll( - _ proxy: ScrollViewProxy, - renderContext: WorkspaceListRenderContext - ) { - guard let selectedWorkspaceId = tabManager.selectedTabId, - renderContext.workspaceIds.contains(selectedWorkspaceId) else { - pendingSelectedWorkspaceScrollId = nil - return - } - - pendingSelectedWorkspaceScrollId = selectedWorkspaceId - flushPendingSelectedWorkspaceScroll(proxy, renderContext: renderContext) - } - - private func flushPendingSelectedWorkspaceScroll( - _ proxy: ScrollViewProxy, - renderContext: WorkspaceListRenderContext - ) { - guard let selectedWorkspaceId = pendingSelectedWorkspaceScrollId else { return } - - // Scroll unconditionally: ScrollViewProxy resolves `.id(_:)` values in - // lazy containers without requiring the row to be realized, and an - // unknown id is a harmless no-op. The previous design gated this on a - // per-row "laid-out row ids" PreferenceKey whose sidebar-wide reduce - // fed `@State` writes from inside the layout/preference update cycle, - // the cmux-owned edge in the sidebar layout livelock - // (https://github.com/manaflow-ai/cmux/issues/2586). No anchor means - // SwiftUI scrolls the minimum needed to reveal the row. - let group = renderContext.workspaceById[selectedWorkspaceId]?.groupId - .flatMap { renderContext.workspaceGroupById[$0] } - proxy.scrollTo(SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId( - selectedWorkspaceId: selectedWorkspaceId, - group: group - )) - pendingSelectedWorkspaceScrollId = nil - } - - private func shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange( - from oldWorkspaceIds: [UUID], - to newWorkspaceIds: [UUID] - ) -> Bool { - SidebarSelectedWorkspaceScrollPolicy.shouldScrollSelectedWorkspace( - selectedWorkspaceId: tabManager.selectedTabId, - oldWorkspaceIds: oldWorkspaceIds, - newWorkspaceIds: newWorkspaceIds - ) - } - - private func requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(_ notification: Notification) { - guard let manager = notification.object as? TabManager, manager === tabManager else { - return - } - guard let selectedWorkspaceId = tabManager.selectedTabId else { return } - let movedWorkspaceIds = notification.userInfo?[WorkspaceOrderChangeNotificationKey.movedWorkspaceIds] as? [UUID] ?? [] - guard movedWorkspaceIds.contains(selectedWorkspaceId) else { return } - pendingSelectedWorkspaceScrollId = selectedWorkspaceId - } - struct WorkspaceListRenderContext { + let environment: SidebarWorkspaceTableEnvironmentSnapshot let tabs: [Workspace] /// Stored `tabs.map(\.id)` snapshot so row predicates avoid O(n) work. let tabIds: [UUID] @@ -10323,7 +10264,20 @@ struct VerticalTabsSidebar: View { visibleWorkspaceRowIds: visibleWorkspaceRowIds ) } ?? [] +#if DEBUG + let tableEnvironment = SidebarWorkspaceTableEnvironmentSnapshot( + colorScheme: sidebarColorScheme, + globalFontMagnificationPercent: sidebarGlobalFontMagnificationPercent, + lazyContractProbe: sidebarLazyContractProbe + ) +#else + let tableEnvironment = SidebarWorkspaceTableEnvironmentSnapshot( + colorScheme: sidebarColorScheme, + globalFontMagnificationPercent: sidebarGlobalFontMagnificationPercent + ) +#endif let renderContext = WorkspaceListRenderContext( + environment: tableEnvironment, tabs: tabs, tabIds: tabIds, sidebarReorderIds: sidebarReorderIds, @@ -10378,13 +10332,6 @@ struct VerticalTabsSidebar: View { .frame(width: 0, height: 0) ) .onAppear { - pointerInteractionMonitor.start { workspaceId in - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } -#if DEBUG - cmuxDebugLog("sidebar.close workspace=\(workspaceId.uuidString.prefix(5)) method=middleClick") -#endif - tabManager.closeWorkspaceWithConfirmation(workspace) - } if showModifierHoldHints { modifierKeyMonitor.setHostWindow(observedWindow) modifierKeyMonitor.start() @@ -10408,7 +10355,6 @@ struct VerticalTabsSidebar: View { ) } .onDisappear { - pointerInteractionMonitor.stop() modifierKeyMonitor.stop() dragAutoScrollController.stop() dragFailsafeMonitor.stop() @@ -10488,36 +10434,31 @@ struct VerticalTabsSidebar: View { } private func workspaceScrollArea(renderContext: WorkspaceListRenderContext) -> some View { - let scrollInsets = SidebarWorkspaceScrollInsets.workspaceList - 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) + // The workspace list is an AppKit NSTableView (measured-once row + // heights, explicit diffing) rather than a SwiftUI lazy container. + // LazyVStack re-estimates unrealized row heights from the realized + // average, so the document height oscillates during every scroll of a + // mixed-height list (scroller-knob stutter), and its layout/update + // graph livelocked five separate times through five mechanisms + // (#2586 / #5764 / #5845 / #6210 / #6556 / #6707 / #8004). The table + // consumes the same immutable row snapshots as the previous rows and + // owns scrolling, hover, selection-follow, and drop-target geometry. + let selectedScrollTargetWorkspaceId: UUID? = tabManager.selectedTabId.map { selectedId in + let group = renderContext.workspaceById[selectedId]?.groupId + .flatMap { renderContext.workspaceGroupById[$0] } + return SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId( + selectedWorkspaceId: selectedId, + group: group + ) + } + return SidebarWorkspaceTableView( + rows: workspaceTableRows(renderContext: renderContext), + actions: workspaceTableActions(renderContext: renderContext), + workspaceIds: renderContext.workspaceIds, + selectedWorkspaceId: tabManager.selectedTabId, + selectedScrollTargetWorkspaceId: selectedScrollTargetWorkspaceId + ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .sidebarPointerEventHost(pointerInteractionMonitor) - .background( - SidebarScrollViewResolver { scrollView in - configureSidebarScrollView(scrollView) - dragAutoScrollController.attach(scrollView: scrollView) - } - .frame(width: 0, height: 0) - ) - .safeAreaInset(edge: .top, spacing: 0) { - Color.clear.frame(height: scrollInsets.top).allowsHitTesting(false) - } - .safeAreaInset(edge: .bottom, spacing: 0) { - Color.clear.frame(height: scrollInsets.bottom).allowsHitTesting(false) - } .mask( SidebarWorkspaceScrollEdgeFadeMask( topHeight: sidebarTopScrimHeight, @@ -10534,38 +10475,12 @@ struct VerticalTabsSidebar: View { .overlay(alignment: .topLeading) { minimalModeSidebarTitlebarControlsOverlay() } - .overlay(alignment: .top) { - workspaceReorderDropOverlay( - renderContext: renderContext, - pointOffset: CGSize(width: 0, height: -scrollInsets.top) - ) - .frame(maxWidth: .infinity) - .frame(height: scrollInsets.top) - } .background(Color.clear) - .modifier(ClearScrollBackground()) - .onAppear { - requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) - } .onChange(of: tabManager.selectedTabId) { _, _ in - requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) // Workspace switches produce no outside click for .transient auto-dismiss; close popovers explicitly. if let dismissed = checklistPopoverWorkspaceId { checklistAddFieldActivationTokens[dismissed] = nil } checklistPopoverWorkspaceId = nil } - .onChange(of: renderContext.workspaceIds) { oldWorkspaceIds, newWorkspaceIds in - guard shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange( - from: oldWorkspaceIds, - to: newWorkspaceIds - ) else { - flushPendingSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) - return - } - requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) - } - .onReceive(NotificationCenter.default.publisher(for: .workspaceOrderDidChange)) { notification in - requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(notification) - } .onReceive(NotificationCenter.default.publisher(for: .workspaceCurrentDirectoryDidChange)) { _ in // Drive a revision counter that the group-header resolver // reads. Forces SwiftUI to re-invoke `cmuxConfigStore.resolveWorkspaceGroupConfig(forCwd:)` @@ -10612,8 +10527,6 @@ struct VerticalTabsSidebar: View { lastSidebarSelectionIndex = index } } - } - } .sidebarProcessTitleObservations( ids: renderContext.workspaceIds, models: renderContext.tabs.map(\.sidebarProcessTitleObservation) @@ -10650,6 +10563,124 @@ struct VerticalTabsSidebar: View { } } + private func workspaceTableActions( + renderContext: WorkspaceListRenderContext + ) -> SidebarWorkspaceTableActions { + SidebarWorkspaceTableActions( + attachScrollView: { scrollView in + dragAutoScrollController.attach(scrollView: scrollView) + }, + closeWorkspace: { workspaceId in + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } +#if DEBUG + cmuxDebugLog("sidebar.close workspace=\(workspaceId.uuidString.prefix(5)) method=middleClick") +#endif + tabManager.closeWorkspaceWithConfirmation(workspace) + }, + createWorkspaceAtEnd: { + if tabManager.selectedTab?.isRemoteTmuxMirror == true { + _ = AppDelegate.shared?.performNewWorkspaceAction( + tabManager: tabManager, + debugSource: "sidebar.emptyArea.remoteTmux" + ) + } else { + tabManager.addWorkspace(placementOverride: .end) + } + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + selection = .tabs + }, + createEmptyWorkspaceGroup: { + _ = AppDelegate.shared?.createEmptyWorkspaceGroup(tabManager: tabManager) + }, + beginWorkspaceDrag: { workspaceId in + dragState.beginDragging(tabId: workspaceId) + }, + endWorkspaceDrag: { + dragState.clearDrag() + dragAutoScrollController.stop() + }, + isValidWorkspaceDrag: { + activateSidebarWorkspaceDragIfNeeded() + }, + updateWorkspaceDrag: { point, targets in + updateWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) + }, + performWorkspaceDrop: { point, targets in + performWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) + }, + clearWorkspaceDropIndicator: { + dragState.clearDropIndicator() + dragAutoScrollController.stop() + }, + currentDropIndicator: { + dragState.dropIndicator + }, + currentDropIndicatorScope: { + dragState.dropIndicatorScope + }, + setWorkspaceDropTargetCollectionActive: { isActive in + guard isWorkspaceReorderDropTargetCollectionActive != isActive else { return } + isWorkspaceReorderDropTargetCollectionActive = isActive + }, + canPerformBonsplitAction: { action, transfer in + guard let app = AppDelegate.shared else { return false } + switch action { + case .existingWorkspace(let workspaceId): + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == workspaceId { + return true + } + return app.canMoveBonsplitTab(tabId: transfer.tab.id, toWorkspace: workspaceId) + case .newWorkspace: + return app.canMoveBonsplitTabToNewWorkspace(tabId: transfer.tab.id) + } + }, + moveBonsplitToExistingWorkspace: { workspaceId, transfer in + guard let app = AppDelegate.shared else { return false } + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == workspaceId { + return true + } + return app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) + }, + moveBonsplitToNewWorkspace: { insertionIndex, transfer in + guard let app = AppDelegate.shared, + let result = app.moveBonsplitTabToNewWorkspace( + tabId: transfer.tab.id, + destinationManager: tabManager, + focus: true, + focusWindow: true, + insertionIndexOverride: insertionIndex + ) else { + return nil + } + return result.destinationWorkspaceId + }, + didMoveBonsplitToWorkspace: { workspaceId in + selectedTabIds = [workspaceId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == workspaceId } + }, + updateDragAutoscroll: { + dragAutoScrollController.updateFromDragLocation() + }, + setBonsplitDropTargetCollectionActive: { isActive in + guard isBonsplitWorkspaceDropTargetCollectionActive != isActive else { return } + isBonsplitWorkspaceDropTargetCollectionActive = isActive + }, + setBonsplitDropIndicator: { indicator in + dragState.setDropIndicator(indicator) + } + ) + } + // Applies one stable overlay/autohide scroller config and never toggles it. // Toggling `hasVerticalScroller`/style from SwiftUI re-renders (constant // while agents update rows) re-flashes the overlay knob so it never reaches @@ -11809,91 +11840,16 @@ struct VerticalTabsSidebar: View { } } - private func workspaceScrollContent( - renderContext: WorkspaceListRenderContext, - minHeight: CGFloat - ) -> some View { - let signpost = SidebarProfilingSignposts.begin("sidebar-scroll-content", "workspaces=\(renderContext.workspaceCount) renderItems=\(renderContext.workspaceRenderItems.count) minHeight=\(minHeight)"); defer { SidebarProfilingSignposts.end(signpost) } - let shouldCollectWorkspaceDropTargets = SidebarDropPlanner().shouldCollectWorkspaceDropTargets( - draggedTabId: dragState.draggedTabId, - isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive || - isWorkspaceReorderDropTargetCollectionActive - ) - // Rows stay lazy + pinned top; `.frame(minHeight:)` fills the viewport - // (#3241) or scrolls without measuring the LazyVStack. The prior - // SidebarRowsFillLayout measured it (`sizeThatFits(height: nil)`) every - // pass, realizing all rows and re-livelocking at scale (#2586 / #5764 / - // #5845; regressed by #6033). Drop/tap = background; indicator on rows. - let content = workspaceRows( - renderContext: renderContext, - shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets - ) - .overlay(alignment: .bottom) { - if emptyAreaTopDropIndicatorVisible() { - Rectangle() - .fill(cmuxAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: tabRowSpacing / 2) - } - } - // Neutralize ALL end-of-list empty-area interactions over the rows - // block (2pt gaps, row padding, and the entire list when it - // overflows) so none fall through to SidebarEmptyArea behind: - // workspace-reorder drops, Bonsplit new-workspace drops, and the - // double-tap-to-create gesture. Sized to the rows, so only the - // genuine blank area below the last row stays interactive. This is - // the measurement-free equivalent of physically placing the empty - // area below the rows; doing that requires asking the LazyVStack for - // its height, which realizes every row each layout pass and is the - // livelock this change removes. Per-row delegates render in front - // and still win over their own rows. - .background { - Color.clear - .contentShape(Rectangle()) - .onTapGesture(count: 2) {} - .onDrop(of: SidebarTabDragPayload.dropContentTypes, isTargeted: nil) { _ in false } - .onDrop(of: BonsplitTabDragPayload.dropContentTypes, isTargeted: nil) { _ in false } - } - .frame(minHeight: minHeight, alignment: .top) - .background(alignment: .top) { - SidebarEmptyArea( - rowSpacing: tabRowSpacing, - selection: $selection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - topDropIndicatorVisible: false, - bonsplitDropIndicator: dropIndicatorBinding, - expandsVertically: true - ) - } - - return rowsWithGatedDropTargetReader( - rows: content, - renderContext: renderContext, - shouldCollect: shouldCollectWorkspaceDropTargets - ) - .overlay { - workspaceReorderDropOverlay(renderContext: renderContext) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .overlay { - bonsplitWorkspaceDropOverlay() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - @ViewBuilder - private func workspaceRows( - renderContext: WorkspaceListRenderContext, - shouldCollectWorkspaceDropTargets: Bool - ) -> some View { - let signpost = SidebarProfilingSignposts.begin("sidebar-workspace-rows", "renderItems=\(renderContext.workspaceRenderItems.count) collectDropTargets=\(shouldCollectWorkspaceDropTargets)") - let renderItems = renderContext.workspaceRenderItems - // 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. + /// Builds the immutable per-row configurations the AppKit table consumes. + /// Live models reduce to value snapshots here, exactly as the previous + /// LazyVStack path did; the table then diffs configurations (structural + /// change → reload, content change → reconfigure visible cells) so a row + /// update never re-enters SwiftUI layout for unrealized rows. + private func workspaceTableRows( + renderContext: WorkspaceListRenderContext + ) -> [SidebarWorkspaceTableRowConfiguration] { + let signpost = SidebarProfilingSignposts.begin("sidebar-table-rows", "renderItems=\(renderContext.workspaceRenderItems.count)") + defer { SidebarProfilingSignposts.end(signpost) } let unreadSummariesByWorkspaceId = sidebarUnread.summaryByWorkspaceId let notificationIndex = SidebarWorkspaceNotificationIndex( notifications: notificationStore.notifications @@ -11908,7 +11864,7 @@ struct VerticalTabsSidebar: View { ) ) }) - let _ = anchorCwdRevision + _ = anchorCwdRevision let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in ( group.id, @@ -11918,7 +11874,7 @@ struct VerticalTabsSidebar: View { renderContext: renderContext, unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId, notificationIndex: notificationIndex, - shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets, + shouldCollectWorkspaceDropTargets: false, showModifierHoldHints: showModifierHoldHints ) ) @@ -11933,163 +11889,25 @@ struct VerticalTabsSidebar: View { notificationIndex: notificationIndex ) let actionFactory = makeWorkspaceRowActionFactory() - let rows = LazyVStack(spacing: tabRowSpacing) { - ForEach(renderItems, id: \.id) { item in - switch item { - 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 - ) - } - } + return renderContext.workspaceRenderItems.compactMap { item in + switch item { + case .groupHeader(let groupId, _): + guard let snapshot = listSnapshot.groupRowsById[groupId] else { return nil } + return sidebarWorkspaceGroupTableConfiguration( + snapshot: snapshot, + renderContext: renderContext + ) + case .workspace(let workspaceId): + guard let input = listSnapshot.workspaceRowsById[workspaceId] else { return nil } + return workspaceTableRowConfiguration( + input: input, + listSnapshot: listSnapshot, + actionFactory: actionFactory, + renderContext: renderContext + ) } } - .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - // No whole-content height measurement here: reading the LazyVStack's - // total height (GeometryReader, or a custom Layout's sizeThatFits) fed a - // non-converging relayout loop (#2586 / #5764 / #5845). Fill is handled - // by `.frame(minHeight:)` in workspaceScrollContent. - let _ = SidebarProfilingSignposts.end(signpost) - rows - } - /// Conditionally installs the row-frame `overlayPreferenceValue` reader (the part - /// that defeats `LazyVStack` virtualization) only while a drag is collecting drop - /// targets. Kept separate from the always-mounted drop-capture overlay so the gate - /// flip never changes the drop NSView's identity. (#5325 review) - @ViewBuilder - private func rowsWithGatedDropTargetReader( - rows: Rows, - renderContext: WorkspaceListRenderContext, - shouldCollect: Bool - ) -> some View { - if shouldCollect { - rows - .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in - GeometryReader { proxy in - let workspaceGroupsByAnchor = Dictionary( - uniqueKeysWithValues: renderContext.workspaceGroups.map { ($0.anchorWorkspaceId, $0) } - ) - SidebarWorkspaceDropTargetWriters( - bonsplitTargetBridge: bonsplitWorkspaceDropTargetBridge, - bonsplitTargets: renderContext.tabs.compactMap { tab in - guard let anchor = anchors[tab.id] else { return nil } - return SidebarDropPlanner.WorkspaceDropTarget( - workspaceId: tab.id, - isPinned: tab.isPinned, - frame: proxy[anchor] - ) - }, - reorderTargetBridge: workspaceReorderDropTargetBridge, - reorderTargets: renderContext.visibleWorkspaceRowIds.compactMap { workspaceId in - guard let anchor = anchors[workspaceId], - renderContext.workspaceById[workspaceId] != nil else { - return nil - } - let group = workspaceGroupsByAnchor[workspaceId] - let targetGroupId = group?.id ?? - (renderContext.workspaceGroupIdByWorkspaceId[workspaceId] ?? nil) - return SidebarWorkspaceReorderDropOverlay.Target( - workspaceId: workspaceId, - groupId: targetGroupId, - isGroupHeader: group != nil, - frame: proxy[anchor] - ) - } - ) - } - } - } else { - rows - } - } - - private func bonsplitWorkspaceDropOverlay() -> some View { - SidebarBonsplitTabWorkspaceDropOverlay( - currentSelectedTabId: { - tabManager.selectedTabId - }, - sidebarIndexForTabId: { workspaceId in - tabManager.tabs.firstIndex { $0.id == workspaceId } - }, - moveToExistingWorkspace: { workspaceId, transfer in - guard let app = AppDelegate.shared else { - return false - } - if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), - source.workspaceId == workspaceId { - return true - } - return app.moveBonsplitTab( - tabId: transfer.tab.id, - toWorkspace: workspaceId, - focus: true, - focusWindow: true - ) - }, - moveToNewWorkspace: { insertionIndex, transfer in - guard let app = AppDelegate.shared, - let result = app.moveBonsplitTabToNewWorkspace( - tabId: transfer.tab.id, - destinationManager: tabManager, - focus: true, - focusWindow: true, - insertionIndexOverride: insertionIndex - ) else { - return nil - } - return result.destinationWorkspaceId - }, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dropIndicator: dropIndicatorBinding, - updateAutoscroll: { - dragAutoScrollController.updateFromDragLocation() - }, - setWorkspaceDropTargetCollectionActive: { isActive in - guard isBonsplitWorkspaceDropTargetCollectionActive != isActive else { return } - isBonsplitWorkspaceDropTargetCollectionActive = isActive - }, - isWorkspaceDropTargetCollectionActive: isBonsplitWorkspaceDropTargetCollectionActive, - targetBridge: bonsplitWorkspaceDropTargetBridge - ) - } - - private func workspaceReorderDropOverlay( - renderContext: WorkspaceListRenderContext, - pointOffset: CGSize = .zero - ) -> some View { - SidebarWorkspaceReorderDropOverlay( - targetBridge: workspaceReorderDropTargetBridge, - isValidDrag: { - activateSidebarWorkspaceDragIfNeeded() - }, - updateDrag: { point, targets in - updateWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) - }, - performDrop: { point, targets in - performWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) - }, - clearDropIndicator: { - dragState.clearDropIndicator() - dragAutoScrollController.stop() - }, - setWorkspaceDropTargetCollectionActive: { isActive in - guard isWorkspaceReorderDropTargetCollectionActive != isActive else { return } - isWorkspaceReorderDropTargetCollectionActive = isActive - }, - pointOffset: pointOffset - ) } - private func activateSidebarWorkspaceDragIfNeeded() -> Bool { if dragState.draggedTabId != nil { return true @@ -12545,7 +12363,9 @@ struct VerticalTabsSidebar: View { frozenTabId: frozenShortcutHintsTabId, frozenValue: frozenShortcutHintsValue ) - let isPointerHovering = pointerInteractionMonitor.hoveredRowId == .workspace(tab.id) + // Hover is AppKit-owned: the table controller overlays it per cell + // configure, so the base input always carries false here. + let isPointerHovering = false // Per-row drag/drop snapshots. Reading `dragState` here in the parent // is intentional: the parent owns the @Observable store, and these @@ -12644,12 +12464,10 @@ struct VerticalTabsSidebar: View { /// 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 } } @@ -12921,64 +12739,69 @@ struct VerticalTabsSidebar: View { 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, + /// Builds one workspace row configuration for the AppKit table. Hover is + /// AppKit-owned (table tracking areas), so the content factory receives it + /// as a parameter and overlays it on the immutable row input; context-menu + /// open/close both freezes hover in the controller and runs the row's own + /// shortcut-hint freeze callbacks. + private func workspaceTableRowConfiguration( + input baseInput: 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] : [:] + actionFactory: @escaping SidebarWorkspaceRowActionFactory, + renderContext: WorkspaceListRenderContext + ) -> SidebarWorkspaceTableRowConfiguration { + let workspaceId = baseInput.workspaceId + let makeRow: (Bool, SidebarWorkspaceTableContextMenuActions) -> TabItemView = { isPointerHovering, contextMenuActions in + var input = baseInput + input.isPointerHovering = isPointerHovering + var actions = actionFactory(input) + let baseContextMenuAppear = actions.onContextMenuAppear + let baseContextMenuDisappear = actions.onContextMenuDisappear + actions.onContextMenuAppear = { + baseContextMenuAppear() + contextMenuActions.didOpen() + } + actions.onContextMenuDisappear = { + baseContextMenuDisappear() + contextMenuActions.didClose() + } + return TabItemView( + snapshot: input.rowSnapshot(list: listSnapshot), + actions: actions + ) + } + let equivalenceValue = makeRow( + false, + SidebarWorkspaceTableContextMenuActions(didOpen: {}, didClose: {}) + ) + return SidebarWorkspaceTableRowConfiguration( + id: .workspace(workspaceId), + workspaceId: workspaceId, + groupId: baseInput.groupId, + isGroupHeader: false, + isPinned: renderContext.workspaceById[workspaceId]?.isPinned ?? false, + environment: renderContext.environment, + equivalenceValue: equivalenceValue + ) { isPointerHovering, contextMenuActions in + AnyView( + renderContext.environment.apply( + to: makeRow(isPointerHovering, contextMenuActions) + .equatable() + .id(workspaceId) + .accessibilityIdentifier("sidebarWorkspace.\(workspaceId.uuidString)") + .padding(.leading, baseInput.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + ) + ) } } -} - -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 @@ -14217,16 +14040,6 @@ struct TabItemView: View, Equatable { guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } workspaceFinderDirectoryOpenRequest = nil } - .sidebarRowDragGate(isEditing: isEditing, actions.onDragStart) - .internalOnlyTabDrag() - .modifier(SidebarBonsplitWorkspaceRowDropModifier( - isEnabled: isBonsplitWorkspaceDropActive, - targetWorkspaceId: workspaceId, - bonsplitSourceWorkspaceId: actions.bonsplitSourceWorkspaceId, - moveBonsplitTabToWorkspace: actions.moveBonsplitTabToWorkspace, - syncSidebarSelectionAfterDrop: actions.syncAfterBonsplitDrop, - selectTargetAfterDrop: actions.selectAfterBonsplitDrop - )) .onTapGesture { if !isEditing { updateSelection() } } diff --git a/Sources/Debug/SidebarLazyContractProbe.swift b/Sources/Debug/SidebarLazyContractProbe.swift index d59b000089dc..852c148cc55a 100644 --- a/Sources/Debug/SidebarLazyContractProbe.swift +++ b/Sources/Debug/SidebarLazyContractProbe.swift @@ -1,14 +1,14 @@ import SwiftUI #if DEBUG -/// Test-only body-evaluation probe for the workspace sidebar's lazy-layout -/// contract: sidebar layout/diff work must stay O(visible rows), never -/// O(all workspaces). The contract has regressed five times through five -/// different mechanisms (#2586, #5764, #5845, #6210, #6556), each shipping to -/// stable before being detected at scale. `SidebarLazyLayoutScaleTests` mounts -/// the sidebar with hundreds of workspaces, injects these closures, and fails -/// if row bodies are realized without bound or keep re-evaluating after -/// updates settle — regardless of which mechanism defeats laziness next. +/// Test-only probe for the workspace sidebar virtualization contract: the +/// AppKit table must materialize viewport-many cells and reconfigure only +/// changed hosted roots, never all workspaces. The old SwiftUI lazy-layout +/// contract regressed five times through five different mechanisms (#2586, +/// #5764, #5845, #6210, #6556), each shipping to stable before being detected +/// at scale. `SidebarLazyLayoutScaleTests` mounts the sidebar with hundreds +/// of workspaces, injects these closures, and fails if row bodies are +/// realized without bound or keep re-evaluating after updates settle. /// /// Same pattern as `MinimalModeInvalidationProbe`; compiled out of Release. struct SidebarLazyContractProbe { @@ -17,5 +17,6 @@ struct SidebarLazyContractProbe { var groupHeaderRowBody: (() -> Void)? var workspaceSnapshotBuild: (() -> Void)? var workspaceRowInputProjection: (() -> Void)? + var tableRootViewReconfigure: (() -> Void)? } #endif diff --git a/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift b/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift deleted file mode 100644 index 9d5b7389ed32..000000000000 --- a/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import SwiftUI - -@MainActor -struct SidebarBonsplitTabDropDelegate: DropDelegate { - let isEnabled: Bool - let targetWorkspaceId: UUID - let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? - let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool - let syncSidebarSelectionAfterDrop: @MainActor () -> Void - let selectTargetAfterDrop: @MainActor () -> Void - - func validateDrop(info: DropInfo) -> Bool { - guard isEnabled else { return false } - guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } - return BonsplitTabDragPayload.currentTransfer() != nil - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info), - let transfer = BonsplitTabDragPayload.currentTransfer() else { - return false - } - - if bonsplitSourceWorkspaceId(transfer.tab.id) == targetWorkspaceId { - syncSidebarSelectionAfterDrop() - return true - } - - guard moveBonsplitTabToWorkspace(transfer, targetWorkspaceId) else { - return false - } - - selectTargetAfterDrop() - syncSidebarSelectionAfterDrop() - return true - } -} diff --git a/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift b/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift deleted file mode 100644 index 7275df3a2ab3..000000000000 --- a/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import SwiftUI - -struct SidebarBonsplitWorkspaceRowDropModifier: ViewModifier { - let isEnabled: Bool - let targetWorkspaceId: UUID - let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? - let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool - let syncSidebarSelectionAfterDrop: @MainActor () -> Void - let selectTargetAfterDrop: @MainActor () -> Void - - func body(content: Content) -> some View { - let delegate = SidebarBonsplitTabDropDelegate( - isEnabled: isEnabled, - targetWorkspaceId: targetWorkspaceId, - bonsplitSourceWorkspaceId: bonsplitSourceWorkspaceId, - moveBonsplitTabToWorkspace: moveBonsplitTabToWorkspace, - syncSidebarSelectionAfterDrop: syncSidebarSelectionAfterDrop, - selectTargetAfterDrop: selectTargetAfterDrop - ) - return content.onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: delegate) - } -} diff --git a/Sources/Sidebar/SidebarPointerFrameReportingModifier.swift b/Sources/Sidebar/SidebarPointerFrameReportingModifier.swift deleted file mode 100644 index 87ea4a7b922b..000000000000 --- a/Sources/Sidebar/SidebarPointerFrameReportingModifier.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI - -/// Reports a lazy sidebar row's frame as value data through parent-owned closures. -struct SidebarPointerFrameReportingModifier: ViewModifier { - let onFrameChange: (CGRect) -> Void - let onDisappear: () -> Void - - func body(content: Content) -> some View { - content - .onGeometryChange(for: CGRect.self) { proxy in - proxy.frame(in: .named(SidebarPointerInteractionMonitor.coordinateSpaceName)) - } action: { frame in - onFrameChange(frame) - } - .onDisappear(perform: onDisappear) - } -} - -extension View { - func sidebarPointerEventHost(_ monitor: SidebarPointerInteractionMonitor) -> some View { - background(SidebarPointerEventHost { monitor.attach(to: $0) }) - } - - func sidebarPointerFrameReporting( - onFrameChange: @escaping (CGRect) -> Void, - onDisappear: @escaping () -> Void - ) -> some View { - modifier(SidebarPointerFrameReportingModifier( - onFrameChange: onFrameChange, - onDisappear: onDisappear - )) - } -} diff --git a/Sources/Sidebar/SidebarPointerInteractionMonitor.swift b/Sources/Sidebar/SidebarPointerInteractionMonitor.swift deleted file mode 100644 index 8341eb701875..000000000000 --- a/Sources/Sidebar/SidebarPointerInteractionMonitor.swift +++ /dev/null @@ -1,281 +0,0 @@ -import AppKit -import Observation -import SwiftUI - -/// A SwiftUI-owned AppKit view spanning the workspace-list viewport. -/// -/// The host gives the pointer owner a stable window and coordinate source -/// without discovering or mutating SwiftUI's private scroll-view hierarchy. -@MainActor -struct SidebarPointerEventHost: NSViewRepresentable { - let onResolve: @MainActor (NSView) -> Void - - func makeNSView(context: Context) -> SidebarPointerEventHostView { - let view = SidebarPointerEventHostView() - view.onResolve = onResolve - return view - } - - func updateNSView(_ nsView: SidebarPointerEventHostView, context: Context) { - nsView.onResolve = onResolve - nsView.resolve() - } - - static func dismantleNSView(_ nsView: SidebarPointerEventHostView, coordinator: ()) { - nsView.onResolve?(nsView) - nsView.onResolve = nil - } -} - -@MainActor -final class SidebarPointerEventHostView: NSView { - var onResolve: (@MainActor (NSView) -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { nil } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - resolve() - } - - func resolve() { - onResolve?(self) - } -} - -/// Owns pointer-derived interaction data for every row in one workspace sidebar. -@MainActor -@Observable -final class SidebarPointerInteractionMonitor { - nonisolated static let coordinateSpaceName = "cmux.sidebar.workspace-pointer" - - private(set) var hoveredRowId: SidebarWorkspaceRenderItemID? - - // Geometry churn is data input, not SwiftUI render state. Keeping both - // registries ignored is load-bearing: publishing every row frame would - // invalidate the container and recreate the sidebar livelock at its root. - @ObservationIgnored private var rowFrames: [SidebarWorkspaceRenderItemID: CGRect] = [:] - @ObservationIgnored private var workspaceIdsByRowId: [SidebarWorkspaceRenderItemID: UUID] = [:] - @ObservationIgnored private var lastPointerLocation: CGPoint? - @ObservationIgnored private weak var resolvedHostView: NSView? - @ObservationIgnored private weak var hostView: NSView? - @ObservationIgnored private weak var mouseMovedWindow: NSWindow? - @ObservationIgnored private var pointerEventMonitor: Any? - @ObservationIgnored private var middleClickMonitor: Any? - @ObservationIgnored private var menuEndObserver: NSObjectProtocol? - @ObservationIgnored private var onMiddleClickWorkspace: ((UUID) -> Void)? - @ObservationIgnored private var geometryReconciliationTask: Task? - @ObservationIgnored private var geometryReconciliationGeneration: UInt = 0 - - func start(onMiddleClickWorkspace: @escaping (UUID) -> Void) { - self.onMiddleClickWorkspace = onMiddleClickWorkspace - - if pointerEventMonitor == nil { - pointerEventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.mouseMoved, .mouseEntered, .mouseExited] - ) { [weak self] event in - self?.handlePointerEvent(event) - return event - } - } - activateResolvedHost() - - if middleClickMonitor == nil { - middleClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .otherMouseDown) { [weak self] event in - self?.handleMiddleClick(event) ?? event - } - } - if menuEndObserver == nil { - menuEndObserver = NotificationCenter.default.addObserver( - forName: NSMenu.didEndTrackingNotification, - object: nil, - queue: .main - ) { [weak self] notification in - let shouldReconcile = Self.shouldReconcileMenuEnd(object: notification.object) - guard shouldReconcile else { return } - Task { @MainActor [weak self] in - self?.reconcilePointerFromHostWindow() - } - } - } - } - - func stop() { - geometryReconciliationGeneration &+= 1 - geometryReconciliationTask?.cancel() - geometryReconciliationTask = nil - if let middleClickMonitor { - NSEvent.removeMonitor(middleClickMonitor) - self.middleClickMonitor = nil - } - if let menuEndObserver { - NotificationCenter.default.removeObserver(menuEndObserver) - self.menuEndObserver = nil - } - onMiddleClickWorkspace = nil - if let pointerEventMonitor { - NSEvent.removeMonitor(pointerEventMonitor) - self.pointerEventMonitor = nil - } - deactivateResolvedHost() - lastPointerLocation = nil - setHoveredRowId(nil) - } - - func attach(to candidate: NSView) { - if candidate.window != nil { - resolvedHostView = candidate - } else if resolvedHostView === candidate { - // Ignore teardown from an older host after SwiftUI has already - // mounted and resolved its replacement. - resolvedHostView = nil - } - activateResolvedHost() - } - - private func activateResolvedHost() { - guard pointerEventMonitor != nil, let resolvedHostView else { - deactivateResolvedHost() - return - } - hostView = resolvedHostView - - let nextWindow = resolvedHostView.window - guard mouseMovedWindow !== nextWindow else { return } - if let mouseMovedWindow { - WindowMouseMovedEventsCoordinator.disable(for: mouseMovedWindow, owner: self) - } - mouseMovedWindow = nextWindow - if let nextWindow { - WindowMouseMovedEventsCoordinator.enable(for: nextWindow, owner: self) - } - } - - private func deactivateResolvedHost() { - if let mouseMovedWindow { - WindowMouseMovedEventsCoordinator.disable(for: mouseMovedWindow, owner: self) - } else { - WindowMouseMovedEventsCoordinator.disableOwner(self) - } - mouseMovedWindow = nil - hostView = nil - } - - func updateFrame( - _ frame: CGRect, - for rowId: SidebarWorkspaceRenderItemID, - workspaceId: UUID - ) { - rowFrames[rowId] = frame - workspaceIdsByRowId[rowId] = workspaceId - scheduleGeometryReconciliation() - } - - func removeFrame(for rowId: SidebarWorkspaceRenderItemID) { - rowFrames.removeValue(forKey: rowId) - workspaceIdsByRowId.removeValue(forKey: rowId) - scheduleGeometryReconciliation() - } - - /// Test seam and event-input primitive in the monitor's SwiftUI coordinate space. - func recordPointerLocation(_ point: CGPoint) { - lastPointerLocation = point - reconcileHoveredRow() - } - - func rowId(at point: CGPoint) -> SidebarWorkspaceRenderItemID? { - rowFrames.first { $0.value.contains(point) }?.key - } - - func middleClickWorkspaceId(at point: CGPoint) -> UUID? { - guard let rowId = rowId(at: point), - let workspaceId = workspaceIdsByRowId[rowId], - rowId == .workspace(workspaceId) else { return nil } - return workspaceId - } - - nonisolated static func swiftUIPoint( - fromAppKitPoint point: CGPoint, - viewportBounds: CGRect - ) -> CGPoint { - CGPoint( - x: point.x - viewportBounds.minX, - y: viewportBounds.maxY - point.y - ) - } - - nonisolated static func shouldReconcileMenuEnd(object: Any?) -> Bool { - guard let menu = object as? NSMenu else { return false } - return menu.supermenu == nil - } - - private func handlePointerEvent(_ event: NSEvent) { - guard let hostView, - let window = hostView.window, - event.windowNumber == window.windowNumber else { return } - let appKitPoint = hostView.convert(event.locationInWindow, from: nil) - guard hostView.bounds.contains(appKitPoint) else { - lastPointerLocation = nil - setHoveredRowId(nil) - return - } - recordPointerLocation(Self.swiftUIPoint( - fromAppKitPoint: appKitPoint, - viewportBounds: hostView.bounds - )) - } - - private func reconcilePointerFromHostWindow() { - guard let hostView, let window = hostView.window else { - setHoveredRowId(nil) - return - } - let appKitPoint = hostView.convert(window.mouseLocationOutsideOfEventStream, from: nil) - recordPointerLocation(Self.swiftUIPoint( - fromAppKitPoint: appKitPoint, - viewportBounds: hostView.bounds - )) - } - - private func handleMiddleClick(_ event: NSEvent) -> NSEvent? { - guard event.buttonNumber == 2, - let hostView, - let window = hostView.window, - event.windowNumber == window.windowNumber else { - return event - } - let appKitPoint = hostView.convert(event.locationInWindow, from: nil) - let point = Self.swiftUIPoint( - fromAppKitPoint: appKitPoint, - viewportBounds: hostView.bounds - ) - guard let workspaceId = middleClickWorkspaceId(at: point) else { return event } - recordPointerLocation(point) - onMiddleClickWorkspace?(workspaceId) - return nil - } - - private func reconcileHoveredRow() { - setHoveredRowId(lastPointerLocation.flatMap { rowId(at: $0) }) - } - - private func scheduleGeometryReconciliation() { - guard geometryReconciliationTask == nil else { return } - geometryReconciliationGeneration &+= 1 - let generation = geometryReconciliationGeneration - // Cross the current MainActor job boundary so onGeometryChange never - // publishes observable state inside SwiftUI's layout transaction. - geometryReconciliationTask = Task { @MainActor [weak self] in - guard let self, - !Task.isCancelled, - geometryReconciliationGeneration == generation else { return } - geometryReconciliationTask = nil - reconcileHoveredRow() - } - } - - private func setHoveredRowId(_ rowId: SidebarWorkspaceRenderItemID?) { - guard hoveredRowId != rowId else { return } - hoveredRowId = rowId - } -} diff --git a/Sources/Sidebar/SidebarRowDragGate.swift b/Sources/Sidebar/SidebarRowDragGate.swift deleted file mode 100644 index 880dba57f68f..000000000000 --- a/Sources/Sidebar/SidebarRowDragGate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation -import SwiftUI - -extension View { - /// Applies the row's drag source only when not editing inline. While - /// editing, no drag session can begin, so drag-to-select inside the rename - /// field never starts a sidebar reorder. - @ViewBuilder - func sidebarRowDragGate(isEditing: Bool, _ makeProvider: @escaping () -> NSItemProvider) -> some View { - if isEditing { - self - } else { - onDrag(makeProvider) - } - } -} diff --git a/Sources/SidebarWorkspaceDropTargetWriters.swift b/Sources/SidebarWorkspaceDropTargetWriters.swift deleted file mode 100644 index 67a3682e04bd..000000000000 --- a/Sources/SidebarWorkspaceDropTargetWriters.swift +++ /dev/null @@ -1,27 +0,0 @@ -import CmuxFoundation -import SwiftUI - -struct SidebarWorkspaceDropTargetWriters: View { - let bonsplitTargetBridge: SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge - let bonsplitTargets: [SidebarDropPlanner.WorkspaceDropTarget] - let reorderTargetBridge: SidebarWorkspaceReorderDropOverlay.TargetBridge - let reorderTargets: [SidebarWorkspaceReorderDropOverlay.Target] - - var body: some View { - Color.clear - .onAppear { - bonsplitTargetBridge.updateTargets(bonsplitTargets) - reorderTargetBridge.updateTargets(reorderTargets) - } - .onChange(of: bonsplitTargets) { _, targets in - bonsplitTargetBridge.updateTargets(targets) - } - .onChange(of: reorderTargets) { _, targets in - reorderTargetBridge.updateTargets(targets) - } - .onDisappear { - bonsplitTargetBridge.updateTargets([]) - reorderTargetBridge.updateTargets([]) - } - } -} diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 2fa754a39fb9..d92bbecc70fd 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -87,6 +87,8 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let onDelete: () -> Void let onEditConfig: () -> Void let onOpenDocs: () -> Void + let onContextMenuAppear: () -> Void + let onContextMenuDisappear: () -> Void @State private var contextMenuVisible = false @@ -226,9 +228,11 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { ) .onAppear { contextMenuVisible = true + onContextMenuAppear() } .onDisappear { contextMenuVisible = false + onContextMenuDisappear() } if !cwdContextMenuItems.isEmpty { Divider() @@ -306,9 +310,11 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { ) .onAppear { contextMenuVisible = true + onContextMenuAppear() } .onDisappear { contextMenuVisible = false + onContextMenuDisappear() } Divider() Button( diff --git a/Sources/SidebarWorkspaceGroupRowSnapshot.swift b/Sources/SidebarWorkspaceGroupRowSnapshot.swift index e9c2a16de69f..9bcfd4209308 100644 --- a/Sources/SidebarWorkspaceGroupRowSnapshot.swift +++ b/Sources/SidebarWorkspaceGroupRowSnapshot.swift @@ -26,7 +26,9 @@ struct SidebarWorkspaceGroupRowSnapshot { let shortcutDigit: Int? let shortcutModifierSymbol: String? let showsShortcutHint: Bool - let isPointerHovering: Bool + // `var`: the AppKit table's content factory overlays the controller-owned + // hover state per cell configure. + var isPointerHovering: Bool let shortcutHintXOffset: Double let shortcutHintYOffset: Double let fontScale: CGFloat diff --git a/Sources/SidebarWorkspaceGroupRowView.swift b/Sources/SidebarWorkspaceGroupRowView.swift deleted file mode 100644 index c81f33ef18c3..000000000000 --- a/Sources/SidebarWorkspaceGroupRowView.swift +++ /dev/null @@ -1,27 +0,0 @@ -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/SidebarWorkspaceRowActions.swift b/Sources/SidebarWorkspaceRowActions.swift index 55d6a17ff25e..0e84b14cdc18 100644 --- a/Sources/SidebarWorkspaceRowActions.swift +++ b/Sources/SidebarWorkspaceRowActions.swift @@ -52,10 +52,10 @@ struct SidebarWorkspaceRowActions { let onToggleChecklistExpansion: () -> Void let onConsumeChecklistAddFieldActivation: () -> Void let onChecklistPopoverPresentedChange: (Bool) -> Void - let onContextMenuAppear: () -> Void - let onContextMenuDisappear: () -> Void - let onPointerFrameChange: (CGRect) -> Void - let onPointerFrameDisappear: () -> Void + // `var`: the AppKit table's content factory chains the controller's + // hover-freeze callbacks onto the row's own context-menu callbacks. + var onContextMenuAppear: () -> Void + var onContextMenuDisappear: () -> Void } /// Binds parent-owned action capabilities to one lazily realized row input. diff --git a/Sources/SidebarWorkspaceRowInput.swift b/Sources/SidebarWorkspaceRowInput.swift index 2b805433009e..31d3b901e74f 100644 --- a/Sources/SidebarWorkspaceRowInput.swift +++ b/Sources/SidebarWorkspaceRowInput.swift @@ -29,7 +29,9 @@ struct SidebarWorkspaceRowInput { let showsAgentActivity: Bool let rowSpacing: CGFloat let showsModifierShortcutHints: Bool - let isPointerHovering: Bool + // `var`: the AppKit table's content factory overlays the controller-owned + // hover state on an otherwise immutable input value per cell configure. + var isPointerHovering: Bool let isBeingDragged: Bool let topDropIndicatorVisible: Bool let bottomDropIndicatorVisible: Bool diff --git a/Sources/SidebarWorkspaceRowView.swift b/Sources/SidebarWorkspaceRowView.swift deleted file mode 100644 index 49f5a09b3874..000000000000 --- a/Sources/SidebarWorkspaceRowView.swift +++ /dev/null @@ -1,30 +0,0 @@ -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/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 59d61e94a01f..65acae2308c3 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -60,8 +60,9 @@ extension VerticalTabsSidebar { ) let modifierSymbol = renderContext.workspaceNumberShortcut.numberedDigitHintPrefix let showsHintForAnchor = showModifierHoldHints && modifierKeyMonitor.isModifierPressed - let rowId = SidebarWorkspaceRenderItemID.group(group.id) - let isPointerHovering = pointerInteractionMonitor.hoveredRowId == rowId + // Hover is AppKit-owned: the table controller overlays it per cell + // configure, so the base snapshot always carries false here. + let isPointerHovering = false let topDropIndicatorVisible = SidebarTabDropIndicatorPredicate().topVisible( forTabId: group.anchorWorkspaceId, draggedTabId: dragState.draggedTabId, @@ -109,13 +110,56 @@ extension VerticalTabsSidebar { ) } - /// 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) + /// Builds one group-header table row configuration. Hover is AppKit-owned + /// (table tracking areas); the content factory overlays it on the + /// immutable snapshot, and context-menu open/close freezes hover in the + /// table controller. + func sidebarWorkspaceGroupTableConfiguration( + snapshot: SidebarWorkspaceGroupRowSnapshot, + renderContext: WorkspaceListRenderContext + ) -> SidebarWorkspaceTableRowConfiguration { + let makeHeader: (Bool, SidebarWorkspaceTableContextMenuActions) -> SidebarWorkspaceGroupHeaderView = { isPointerHovering, contextMenuActions in + var headerSnapshot = snapshot + headerSnapshot.isPointerHovering = isPointerHovering + return sidebarWorkspaceGroupHeader( + snapshot: headerSnapshot, + onContextMenuAppear: contextMenuActions.didOpen, + onContextMenuDisappear: contextMenuActions.didClose + ) + } + let equivalenceValue = makeHeader( + false, + SidebarWorkspaceTableContextMenuActions(didOpen: {}, didClose: {}) + ) + return SidebarWorkspaceTableRowConfiguration( + id: .group(snapshot.groupId), + workspaceId: snapshot.anchorWorkspaceId, + groupId: snapshot.groupId, + isGroupHeader: true, + isPinned: snapshot.isPinned, + environment: renderContext.environment, + equivalenceValue: equivalenceValue + ) { isPointerHovering, contextMenuActions in + AnyView( + renderContext.environment.apply( + to: makeHeader(isPointerHovering, contextMenuActions) + .equatable() + .id(snapshot.anchorWorkspaceId) + .accessibilityIdentifier("sidebarWorkspaceGroup.\(snapshot.groupId.uuidString)") + ) + ) + } + } + + /// Assembles one group header from immutable values when the table's + /// content factory asks for it. Model references appear only inside + /// user-invoked action closures; row realization performs no observable + /// reads or mutations. + func sidebarWorkspaceGroupHeader( + snapshot: SidebarWorkspaceGroupRowSnapshot, + onContextMenuAppear: @escaping () -> Void, + onContextMenuDisappear: @escaping () -> Void + ) -> SidebarWorkspaceGroupHeaderView { let onDragStart: () -> NSItemProvider = { [anchorId = snapshot.anchorWorkspaceId] in #if DEBUG cmuxDebugLog("sidebar.onDrag groupAnchor=\(anchorId.uuidString.prefix(5))") @@ -123,7 +167,7 @@ extension VerticalTabsSidebar { dragState.beginDragging(tabId: anchorId) return SidebarTabDragPayload(tabId: anchorId).provider() } - let header = SidebarWorkspaceGroupHeaderView( + return SidebarWorkspaceGroupHeaderView( groupId: snapshot.groupId, anchorWorkspaceId: snapshot.anchorWorkspaceId, name: snapshot.name, @@ -256,20 +300,9 @@ extension VerticalTabsSidebar { }, onOpenDocs: { SidebarWorkspaceGroupConfigOpener.openWorkspaceGroupsDocs() - } - ) - - 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) - } + onContextMenuAppear: onContextMenuAppear, + onContextMenuDisappear: onContextMenuDisappear ) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0cd86fd30a3a..3ae29dff1ee3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1430,9 +1430,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A6AC71020000000000000001 /* SidebarAgentActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC71030000000000000001 /* SidebarAgentActivityIndicator.swift */; }; A6AC71040000000000000001 /* SidebarAgentActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC71050000000000000001 /* SidebarAgentActivitySummary.swift */; }; 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */; }; - D7AB34310000000000000003 /* SidebarBonsplitTabDropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34310000000000000004 /* SidebarBonsplitTabDropDelegate.swift */; }; D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; - D7AB34310000000000000001 /* SidebarBonsplitWorkspaceRowDropModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34310000000000000002 /* SidebarBonsplitWorkspaceRowDropModifier.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */; }; @@ -1451,10 +1449,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000002 /* SidebarPathFormatter.swift */; }; - A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */; }; - A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */; }; - A80070010000000000000001 /* SidebarPointerInteractionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */; }; - A80040110000000000000001 /* SidebarPointerInteractionScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040120000000000000001 /* SidebarPointerInteractionScaleTests.swift */; }; C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; }; C0DEA7710000000000000005 /* SidebarProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000006 /* SidebarProBadge.swift */; }; D75280020000000000000001 /* SidebarProfilingSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280020000000000000002 /* SidebarProfilingSignposts.swift */; }; @@ -1464,7 +1458,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 5B0C00010000000000000003 /* SidebarResizerOcclusionResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C00010000000000000004 /* SidebarResizerOcclusionResolverTests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; AABBCC000000000000000208 /* SidebarRowAccessibilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC000000000000000207 /* SidebarRowAccessibilityModifier.swift */; }; - AABBCC00000000000000020A /* SidebarRowDragGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC000000000000000209 /* SidebarRowDragGate.swift */; }; C0DE35010000000000000001 /* SidebarScrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35010000000000000002 /* SidebarScrim.swift */; }; C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; @@ -1481,7 +1474,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 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 */; }; C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */; }; C9A57105C9A57105C9A57105 /* SidebarWorkspaceGroupContextMenuRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57106C9A57106C9A57106 /* SidebarWorkspaceGroupContextMenuRunner.swift */; }; @@ -1491,7 +1483,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 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 */; }; @@ -1509,7 +1500,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 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 */; }; @@ -1520,6 +1510,24 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; 4F80E2D0A6377A1D8A97D411 /* SidebarWorkspaceStatusPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896D9D4AD4D31A54882813E /* SidebarWorkspaceStatusPopover.swift */; }; A6AC73010000000000000001 /* SidebarWorkspaceStatusSlots.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73010000000000000002 /* SidebarWorkspaceStatusSlots.swift */; }; + B804A0010000000000000001 /* SidebarWorkspaceTableActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0010000000000000001 /* SidebarWorkspaceTableActions.swift */; }; + B804A00E000000000000000E /* SidebarWorkspaceTableCellModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00E000000000000000E /* SidebarWorkspaceTableCellModel.swift */; }; + B804A00F000000000000000F /* SidebarWorkspaceTableCellRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00F000000000000000F /* SidebarWorkspaceTableCellRootView.swift */; }; + B804A0100000000000000010 /* SidebarWorkspaceTableCellState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0100000000000000010 /* SidebarWorkspaceTableCellState.swift */; }; + B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */; }; + B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */; }; + B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */; }; + B804A0040000000000000004 /* SidebarWorkspaceTableController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0040000000000000004 /* SidebarWorkspaceTableController.swift */; }; + B804A0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift */; }; + B804A00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift */; }; + B804A00A000000000000000A /* SidebarWorkspaceTableEnvironmentSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00A000000000000000A /* SidebarWorkspaceTableEnvironmentSnapshot.swift */; }; + B804A0050000000000000005 /* SidebarWorkspaceTableHoverResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0050000000000000005 /* SidebarWorkspaceTableHoverResolver.swift */; }; + B804A0060000000000000006 /* SidebarWorkspaceTableRowConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0060000000000000006 /* SidebarWorkspaceTableRowConfiguration.swift */; }; + B804A00D000000000000000D /* SidebarWorkspaceTableRowHeightCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00D000000000000000D /* SidebarWorkspaceTableRowHeightCache.swift */; }; + B804A0090000000000000009 /* SidebarWorkspaceTableRowHeightCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0090000000000000009 /* SidebarWorkspaceTableRowHeightCalculator.swift */; }; + B804C0010000000000000001 /* SidebarWorkspaceTableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0010000000000000001 /* SidebarWorkspaceTableTests.swift */; }; + B804A0070000000000000007 /* SidebarWorkspaceTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0070000000000000007 /* SidebarWorkspaceTableView.swift */; }; + B804A0080000000000000008 /* SidebarWorkspaceTableViewImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0080000000000000008 /* SidebarWorkspaceTableViewImpl.swift */; }; 603C12E917E3C4877809CAD9 /* SidebarWorkspaceTaskStatusGlyph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E01DE5B078B38299582BB0 /* SidebarWorkspaceTaskStatusGlyph.swift */; }; ADA26B5F30224634707004CC /* SidebarWorkspaceTodoPopoverHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEAD128139F57CB0E5BD39E /* SidebarWorkspaceTodoPopoverHost.swift */; }; C9A57601C9A57601C9A57601 /* SidebarWorkspaceTopDropIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */; }; @@ -3383,9 +3391,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC71030000000000000001 /* SidebarAgentActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAgentActivityIndicator.swift; sourceTree = ""; }; A6AC71050000000000000001 /* SidebarAgentActivitySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAgentActivitySummary.swift; sourceTree = ""; }; 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAppearanceSupport.swift; sourceTree = ""; }; - D7AB34310000000000000004 /* SidebarBonsplitTabDropDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabDropDelegate.swift; sourceTree = ""; }; D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; - D7AB34310000000000000002 /* SidebarBonsplitWorkspaceRowDropModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = ""; }; @@ -3404,10 +3410,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMetadataMarkdownRenderer.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; EA1F00000000000000000002 /* SidebarPathFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPathFormatter.swift; sourceTree = ""; }; - A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerFrameReportingModifier.swift; sourceTree = ""; }; - A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerInteractionMonitor.swift; sourceTree = ""; }; - A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPointerInteractionMonitorTests.swift; sourceTree = ""; }; - A80040120000000000000001 /* SidebarPointerInteractionScaleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPointerInteractionScaleTests.swift; sourceTree = ""; }; C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPortDisplayText.swift; sourceTree = ""; }; C0DEA7710000000000000006 /* SidebarProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarProBadge.swift; sourceTree = ""; }; D75280020000000000000002 /* SidebarProfilingSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarProfilingSignposts.swift; sourceTree = ""; }; @@ -3417,7 +3419,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5B0C00010000000000000004 /* SidebarResizerOcclusionResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizerOcclusionResolverTests.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; AABBCC000000000000000207 /* SidebarRowAccessibilityModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarRowAccessibilityModifier.swift; sourceTree = ""; }; - AABBCC000000000000000209 /* SidebarRowDragGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarRowDragGate.swift; sourceTree = ""; }; C0DE35010000000000000002 /* SidebarScrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrim.swift; sourceTree = ""; }; C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrollViewConfiguratorTests.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; @@ -3434,7 +3435,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 = ""; }; C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpenerTests.swift; sourceTree = ""; }; C9A57106C9A57106C9A57106 /* SidebarWorkspaceGroupContextMenuRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupContextMenuRunner.swift; sourceTree = ""; }; @@ -3444,7 +3444,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 = ""; }; @@ -3462,7 +3461,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 = ""; }; @@ -3473,6 +3471,24 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotRefreshPolicyTests.swift; sourceTree = ""; }; 3896D9D4AD4D31A54882813E /* SidebarWorkspaceStatusPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceStatusPopover.swift"; sourceTree = ""; }; A6AC73010000000000000002 /* SidebarWorkspaceStatusSlots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceStatusSlots.swift; sourceTree = ""; }; + B804B0010000000000000001 /* SidebarWorkspaceTableActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableActions.swift; sourceTree = ""; }; + B804B00E000000000000000E /* SidebarWorkspaceTableCellModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableCellModel.swift; sourceTree = ""; }; + B804B00F000000000000000F /* SidebarWorkspaceTableCellRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableCellRootView.swift; sourceTree = ""; }; + B804B0100000000000000010 /* SidebarWorkspaceTableCellState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableCellState.swift; sourceTree = ""; }; + B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift; sourceTree = ""; }; + B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift; sourceTree = ""; }; + B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift; sourceTree = ""; }; + B804B0040000000000000004 /* SidebarWorkspaceTableController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableController.swift; sourceTree = ""; }; + B804B0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift; sourceTree = ""; }; + B804B00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift; sourceTree = ""; }; + B804B00A000000000000000A /* SidebarWorkspaceTableEnvironmentSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift; sourceTree = ""; }; + B804B0050000000000000005 /* SidebarWorkspaceTableHoverResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableHoverResolver.swift; sourceTree = ""; }; + B804B0060000000000000006 /* SidebarWorkspaceTableRowConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift; sourceTree = ""; }; + B804B00D000000000000000D /* SidebarWorkspaceTableRowHeightCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCache.swift; sourceTree = ""; }; + B804B0090000000000000009 /* SidebarWorkspaceTableRowHeightCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableRowHeightCalculator.swift; sourceTree = ""; }; + B804D0010000000000000001 /* SidebarWorkspaceTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceTableTests.swift; sourceTree = ""; }; + B804B0070000000000000007 /* SidebarWorkspaceTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableView.swift; sourceTree = ""; }; + B804B0080000000000000008 /* SidebarWorkspaceTableViewImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift; sourceTree = ""; }; 55E01DE5B078B38299582BB0 /* SidebarWorkspaceTaskStatusGlyph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceTaskStatusGlyph.swift; sourceTree = ""; }; 4AEAD128139F57CB0E5BD39E /* SidebarWorkspaceTodoPopoverHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceTodoPopoverHost.swift"; sourceTree = ""; }; C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceTopDropIndicator.swift; sourceTree = ""; }; @@ -4278,12 +4294,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, @@ -4335,9 +4349,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */, EA1F00000000000000000002 /* SidebarPathFormatter.swift */, EA1F00000000000000000004 /* SidebarDirectoryText.swift */, - D7AB34310000000000000004 /* SidebarBonsplitTabDropDelegate.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, - D7AB34310000000000000002 /* SidebarBonsplitWorkspaceRowDropModifier.swift */, C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, C0DE1A020000000000000002 /* SavedLayoutStore.swift */, 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, @@ -4403,14 +4415,29 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 3E3E449BD36EA0670CC7D75C /* ChecklistInputField.swift */, AABBCC000000000000000211 /* SidebarInlineRenameTextField.swift */, AABBCC000000000000000207 /* SidebarRowAccessibilityModifier.swift */, - AABBCC000000000000000209 /* SidebarRowDragGate.swift */, D7344F010000000000000002 /* SidebarTabDragPayload.swift */, C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */, C9A57106C9A57106C9A57106 /* SidebarWorkspaceGroupContextMenuRunner.swift */, C9A57108C9A57108C9A57108 /* SidebarWorkspaceGroupDialogs.swift */, CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */, - C9A57606C9A57606C9A57606 /* SidebarWorkspaceDropTargetWriters.swift */, C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */, + B804B0010000000000000001 /* SidebarWorkspaceTableActions.swift */, + B804B00E000000000000000E /* SidebarWorkspaceTableCellModel.swift */, + B804B00F000000000000000F /* SidebarWorkspaceTableCellRootView.swift */, + B804B0100000000000000010 /* SidebarWorkspaceTableCellState.swift */, + B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */, + B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */, + B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */, + B804B00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift */, + B804B0040000000000000004 /* SidebarWorkspaceTableController.swift */, + B804B0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift */, + B804B00A000000000000000A /* SidebarWorkspaceTableEnvironmentSnapshot.swift */, + B804B0050000000000000005 /* SidebarWorkspaceTableHoverResolver.swift */, + B804B0060000000000000006 /* SidebarWorkspaceTableRowConfiguration.swift */, + B804B00D000000000000000D /* SidebarWorkspaceTableRowHeightCache.swift */, + B804B0090000000000000009 /* SidebarWorkspaceTableRowHeightCalculator.swift */, + B804B0070000000000000007 /* SidebarWorkspaceTableView.swift */, + B804B0080000000000000008 /* SidebarWorkspaceTableViewImpl.swift */, C9A57302C9A57302C9A57302 /* SidebarWorkspaceGroupHeaderMetrics.swift */, C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */, D5037010000000000000002 /* RenderableSystemSymbol.swift */, @@ -4421,8 +4448,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */, C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */, C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */, - A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */, - A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */, C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */, C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, @@ -5702,8 +5727,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D41A7C02D41A7C02D41A7C02 /* SidebarLazyLayoutScaleTests.swift */, 6707F0186707F0186707F018 /* SidebarWorkspaceContextMenuWindowTargetsTests.swift */, 6707F0246707F0246707F024 /* SidebarWorkspaceNotificationIndexTests.swift */, - A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */, - A80040120000000000000001 /* SidebarPointerInteractionScaleTests.swift */, C4A570030000000000000002 /* CanvasShortcutContextTests.swift */, C0DEF0B30000000000000002 /* KeyboardShortcutSettingsFileStoreMigrationTests.swift */, E3337002E3337002E3337002 /* KeyboardShortcutSettingsFileStoreStartupTests.swift */, @@ -5886,6 +5909,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */, C7A50C000000000000000004 /* CmuxTopProcessArgumentsTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, + B804D0010000000000000001 /* SidebarWorkspaceTableTests.swift */, D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */, D73440010000000000000002 /* SidebarTabDragPayloadProviderTests.swift */, AABBCC000000000000000205 /* SidebarInlineRenameKeyResolverTests.swift */, @@ -7257,9 +7281,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC71020000000000000001 /* SidebarAgentActivityIndicator.swift in Sources */, A6AC71040000000000000001 /* SidebarAgentActivitySummary.swift in Sources */, 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */, - D7AB34310000000000000003 /* SidebarBonsplitTabDropDelegate.swift in Sources */, D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, - D7AB34310000000000000001 /* SidebarBonsplitWorkspaceRowDropModifier.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, AABBCC00000000000000020C /* SidebarInlineRenameAction.swift in Sources */, AABBCC00000000000000020E /* SidebarInlineRenameCommit.swift in Sources */, @@ -7272,14 +7294,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC73F20000000000000001 /* SidebarMediaActivityIndicators.swift in Sources */, C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */, EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, - A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */, - A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */, C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */, C0DEA7710000000000000005 /* SidebarProBadge.swift in Sources */, D75280020000000000000001 /* SidebarProfilingSignposts.swift in Sources */, 5B0C00010000000000000001 /* SidebarResizerOcclusionResolver.swift in Sources */, AABBCC000000000000000208 /* SidebarRowAccessibilityModifier.swift in Sources */, - AABBCC00000000000000020A /* SidebarRowDragGate.swift in Sources */, C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, @@ -7290,7 +7309,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, C9A57107C9A57107C9A57107 /* SidebarWorkspaceGroupDialogs.swift in Sources */, @@ -7298,7 +7316,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, @@ -7313,13 +7330,29 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, + B804A0010000000000000001 /* SidebarWorkspaceTableActions.swift in Sources */, + B804A00E000000000000000E /* SidebarWorkspaceTableCellModel.swift in Sources */, + B804A00F000000000000000F /* SidebarWorkspaceTableCellRootView.swift in Sources */, + B804A0100000000000000010 /* SidebarWorkspaceTableCellState.swift in Sources */, + B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */, + B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */, + B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */, + B804A0040000000000000004 /* SidebarWorkspaceTableController.swift in Sources */, + B804A0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift in Sources */, + B804A00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift in Sources */, + B804A00A000000000000000A /* SidebarWorkspaceTableEnvironmentSnapshot.swift in Sources */, + B804A0050000000000000005 /* SidebarWorkspaceTableHoverResolver.swift in Sources */, + B804A0060000000000000006 /* SidebarWorkspaceTableRowConfiguration.swift in Sources */, + B804A00D000000000000000D /* SidebarWorkspaceTableRowHeightCache.swift in Sources */, + B804A0090000000000000009 /* SidebarWorkspaceTableRowHeightCalculator.swift in Sources */, + B804A0070000000000000007 /* SidebarWorkspaceTableView.swift in Sources */, + B804A0080000000000000008 /* SidebarWorkspaceTableViewImpl.swift in Sources */, 603C12E917E3C4877809CAD9 /* SidebarWorkspaceTaskStatusGlyph.swift in Sources */, ADA26B5F30224634707004CC /* SidebarWorkspaceTodoPopoverHost.swift in Sources */, C9A57601C9A57601C9A57601 /* SidebarWorkspaceTopDropIndicator.swift in Sources */, @@ -8197,8 +8230,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D41A7C01D41A7C01D41A7C01 /* SidebarLazyLayoutScaleTests.swift in Sources */, 385C6BA7E78DB87460E5D930 /* SidebarMarkdownRendererTests.swift in Sources */, CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, - A80070010000000000000001 /* SidebarPointerInteractionMonitorTests.swift in Sources */, - A80040110000000000000001 /* SidebarPointerInteractionScaleTests.swift in Sources */, C0DE48000000000000000001 /* SidebarProviderMenuRegressionTests.swift in Sources */, 5B0C00010000000000000003 /* SidebarResizerOcclusionResolverTests.swift in Sources */, C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */, @@ -8216,6 +8247,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */, A6AC73040000000000000001 /* SidebarWorkspaceSnapshotAgentActivityTests.swift in Sources */, 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, + B804C0010000000000000001 /* SidebarWorkspaceTableTests.swift in Sources */, A7984AE10000000000000001 /* SocketACLReloadRegressionTests+Connections.swift in Sources */, A79840010000000000000001 /* SocketACLReloadRegressionTests.swift in Sources */, 5830CA11BAC0000000000002 /* SocketCallbackAwaiterMainThreadTests.swift in Sources */, diff --git a/cmuxTests/SidebarLazyLayoutScaleTests.swift b/cmuxTests/SidebarLazyLayoutScaleTests.swift index d1b18fced588..005fd33e1cba 100644 --- a/cmuxTests/SidebarLazyLayoutScaleTests.swift +++ b/cmuxTests/SidebarLazyLayoutScaleTests.swift @@ -1,51 +1,26 @@ import Testing import AppKit -import CmuxSidebar import CmuxUpdater +import OSLog import SwiftUI - #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux #endif - -/// Behavioral gate for the workspace sidebar's lazy-layout contract: layout -/// and diff work must stay O(visible rows) no matter how many workspaces are -/// open, and updates must converge (no self-sustaining invalidation). -/// -/// The contract has regressed five times through five different mechanisms — -/// per-row anchorPreference aggregation (#5323), per-row String ids (#5764), -/// animated row-height interpolation (#5845), a force-measuring custom Layout -/// (#6210), and GeometryReader → @State row-height feedback (#6556) — and each -/// shipped to stable before being detected, because nothing exercised the -/// sidebar at the 100+ workspace scale where O(N) per pass becomes a -/// main-thread livelock (issue #2586). These tests mount the real -/// `VerticalTabsSidebar` at that scale and count actual row body evaluations -/// through `SidebarLazyContractProbe`, so ANY future mechanism that realizes -/// the whole list or loops the AttributeGraph fails here instead of on a -/// user's machine. `scripts/check-sidebar-lazy-layout.py` bans the known -/// source shapes; this is the mechanism-independent backstop. +/// Behavioral gate for the workspace sidebar's AppKit virtualization contract. +/// Mounts the production `VerticalTabsSidebar` at 300 workspaces and verifies +/// viewport-scale materialization, row-scoped updates, convergence, and clean +/// SwiftUI/AppKit runtime logs. The source-topology companion is +/// `scripts/check-sidebar-lazy-layout.py`. @Suite(.serialized) final class SidebarLazyLayoutScaleTests { - static let workspaceCount = 300 - /// Generous ceiling for "how many rows may be realized for one viewport". - /// A 640pt window shows ~20 rows; LazyVStack prefetch and a second layout - /// pass can multiply that, but a virtualization defeat realizes all 300. - private static let realizedRowCeiling = 150 - - final class InjectableMouseLocationWindow: NSWindow { - var injectedMouseLocation = NSPoint.zero - - override var mouseLocationOutsideOfEventStream: NSPoint { - injectedMouseLocation - } - } - - // Plain class (not @MainActor) so the probe's nonisolated `() -> Void` - // closures can mutate it; bodies only run on the main thread. Same shape + private static let workspaceCount = 300 + /// A 640pt window shows ~20 rows; allow a small AppKit reuse buffer. + private static let realizedRowCeiling = 80 + // Plain class so the probe's nonisolated closures can mutate it on main. Same shape // as MinimalModeBodyProbeCounts in WorkspaceContentViewVisibilityTests. - final class RowBodyCounter { + private final class RowBodyCounter { var workspaceRowBodies = 0 var groupHeaderBodies = 0 var workspaceSnapshotBuilds = 0 @@ -57,6 +32,7 @@ final class SidebarLazyLayoutScaleTests { var insideWorkspaceRowBody = false var snapshotBuildsInCurrentRowBody = 0 var maxSnapshotBuildsInOneRowBody = 0 + var tableRootViewReconfigures = 0 func reset() { workspaceRowBodies = 0 groupHeaderBodies = 0 @@ -65,15 +41,16 @@ final class SidebarLazyLayoutScaleTests { insideWorkspaceRowBody = false snapshotBuildsInCurrentRowBody = 0 maxSnapshotBuildsInOneRowBody = 0 + tableRootViewReconfigures = 0 } } @MainActor - struct Harness { + private struct Harness { let tabManager: TabManager let unread: SidebarUnreadModel let counter: RowBodyCounter - let window: InjectableMouseLocationWindow + let window: NSWindow let defaultsSuiteName: String func tearDown() { @@ -85,7 +62,7 @@ final class SidebarLazyLayoutScaleTests { } @MainActor - static func mountSidebar(workspaceCount: Int) async throws -> Harness { + private static func mountSidebar(workspaceCount: Int) async throws -> Harness { _ = NSApplication.shared // Hermetic defaults: VerticalTabsSidebar picks between the workspace @@ -125,7 +102,7 @@ final class SidebarLazyLayoutScaleTests { } // Group the first workspaces (top of the list, inside the viewport) so - // group-header rows — assembled by sidebarWorkspaceGroupRow(...) in + // group-header rows — assembled by sidebarWorkspaceGroupTableConfiguration(...) 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)) @@ -184,14 +161,13 @@ final class SidebarLazyLayoutScaleTests { counter.snapshotBuildsInCurrentRowBody ) }, - workspaceRowInputProjection: { - counter.workspaceRowInputProjections += 1 - } + workspaceRowInputProjection: { counter.workspaceRowInputProjections += 1 }, + tableRootViewReconfigure: { counter.tableRootViewReconfigures += 1 } ) ) .defaultAppStorage(defaults) - let window = InjectableMouseLocationWindow( + let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 280, height: 640), styleMask: [.titled, .closable, .resizable], backing: .buffered, @@ -219,7 +195,7 @@ final class SidebarLazyLayoutScaleTests { /// own autorelease pool so drained main-queue work cannot pile objects /// into the enclosing job's pool. @MainActor - static func turnMainRunLoopOnce(layingOut window: NSWindow?) { + private static func turnMainRunLoopOnce(layingOut window: NSWindow?) { autoreleasepool { window?.contentView?.layoutSubtreeIfNeeded() _ = RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) @@ -227,13 +203,33 @@ final class SidebarLazyLayoutScaleTests { } @MainActor - static func drainMainRunLoop(for window: NSWindow, iterations: Int = 25) async { + private static func drainMainRunLoop(for window: NSWindow, iterations: Int = 25) async { for _ in 0.. SidebarWorkspaceTableViewImpl? { + var pendingViews = [rootView] + while let view = pendingViews.popLast() { + if let table = view as? SidebarWorkspaceTableViewImpl { return table } + pendingViews.append(contentsOf: view.subviews) + } + return nil + } + + @MainActor + private static func materializedCells(in rootView: NSView) -> [SidebarWorkspaceTableCellView] { + var result: [SidebarWorkspaceTableCellView] = [] + var pendingViews = [rootView] + while let view = pendingViews.popLast() { + result.append(contentsOf: view.subviews.compactMap { $0 as? SidebarWorkspaceTableCellView }) + pendingViews.append(contentsOf: view.subviews) + } + return result + } /// Mounting the sidebar with 300 workspaces must realize only the rows a /// single viewport needs. Realizing all of them is the #5323/#6210 defeat: @@ -247,16 +243,23 @@ final class SidebarLazyLayoutScaleTests { await Self.drainMainRunLoop(for: harness.window) + let rootView = try #require(harness.window.contentView) + let cells = Self.materializedCells(in: rootView) + #expect(!cells.isEmpty, "The production NSTableView mounted no reusable row cells.") + #expect( + cells.count < Self.realizedRowCeiling, + "NSTableView materialized \(cells.count) cells for a single viewport of \(Self.workspaceCount) rows." + ) + let realized = harness.counter.workspaceRowBodies #expect(realized > 0, "Sidebar mounted but no workspace row body ran; harness is broken.") #expect( realized < Self.realizedRowCeiling, """ \(realized) workspace row bodies evaluated for a single ~20-row viewport with \ - \(Self.workspaceCount) workspaces. The LazyVStack is being defeated (all rows \ - realized per pass) — the #2586 sidebar livelock class. Look for per-row geometry \ - feedback (GeometryReader/@State height, anchorPreference aggregation) or a \ - force-measuring Layout; see scripts/check-sidebar-lazy-layout.py. + \(Self.workspaceCount) workspaces. NSTableView virtualization is being defeated \ + (all rows realized per pass), recreating the #2586 sidebar livelock class; see \ + scripts/check-sidebar-lazy-layout.py. """ ) @@ -272,20 +275,23 @@ final class SidebarLazyLayoutScaleTests { headerRealized < Self.realizedRowCeiling, """ \(headerRealized) group-header bodies evaluated for 5 groups in one viewport. \ - The group-header row wrapper (sidebarWorkspaceGroupRow) is defeating \ + The group-header row factory (sidebarWorkspaceGroupTableConfiguration) is defeating \ virtualization or re-evaluating without bound — the #4385 regression site. """ ) } - /// 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. + /// 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. @Test @MainActor - func testRowBodyEvaluationNeverBuildsWorkspaceSnapshot() async throws { + func testRowBodyEvaluationBuildsWorkspaceSnapshotAtMostOnce() async throws { let harness = try await Self.mountSidebar(workspaceCount: Self.workspaceCount) defer { harness.tearDown() } @@ -297,19 +303,21 @@ final class SidebarLazyLayoutScaleTests { ) let worstBody = harness.counter.maxSnapshotBuildsInOneRowBody #expect( - worstBody == 0, + worstBody <= 1, """ A single TabItemView.body evaluation built the workspace snapshot \(worstBody) \ - times. Workspace snapshots must be built by VerticalTabsSidebar before the \ - LazyVStack realization closure and passed to rows as immutable values. + 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. """ ) } - /// 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. + /// A burst of unread-model updates (the agent-notification churn path, + /// the sidebar's highest-frequency whole-body invalidation) must cost + /// O(changed rows), and the sidebar must go quiet when the burst stops. + /// Unbounded or non-converging row re-evaluation here is the exact + /// signature of the #6556 GeometryReader → @State feedback livelock. @Test @MainActor func testWorkspacePublisherBatchProjectsParentListLinearly() async throws { @@ -364,16 +372,11 @@ final class SidebarLazyLayoutScaleTests { """ \(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. + projection work, not O(N\u{00B2}) work from one parent invalidation per emitter. """ ) } - /// A burst of unread-model updates (the agent-notification churn path, - /// the sidebar's highest-frequency whole-body invalidation) must cost - /// O(changed rows), and the sidebar must go quiet when the burst stops. - /// Unbounded or non-converging row re-evaluation here is the exact - /// signature of the #6556 GeometryReader → @State feedback livelock. @Test @MainActor func testUnreadStormStaysRowScopedAndConverges() async throws { @@ -413,6 +416,10 @@ final class SidebarLazyLayoutScaleTests { \(storms) updates re-realizing per pass is the #2586 livelock at scale. """ ) + #expect( + harness.counter.tableRootViewReconfigures < storms * 10, + "Unread storm replaced \(harness.counter.tableRootViewReconfigures) hosted roots; only changed visible rows may reconfigure." + ) harness.counter.reset() await Self.drainMainRunLoop(for: harness.window, iterations: 30) @@ -425,8 +432,63 @@ final class SidebarLazyLayoutScaleTests { loop (the #6556 signature). This livelocks the main thread at scale. """ ) + #expect( + harness.counter.tableRootViewReconfigures == 0, + "The table reconfigured \(harness.counter.tableRootViewReconfigures) roots during a quiet period." + ) } + /// Churn variable-height content, scroll, and synthetic table-owned hover. + @Test + @MainActor + func testTableChurnScrollAndHoverEmitNoRuntimeFaults() async throws { + let harness = try await Self.mountSidebar(workspaceCount: Self.workspaceCount) + defer { harness.tearDown() } + + await Self.drainMainRunLoop(for: harness.window) + let rootView = try #require(harness.window.contentView) + let table = try #require(Self.tableView(in: rootView)) + let logStore = try OSLogStore(scope: .currentProcessIdentifier) + let start = logStore.position(date: Date()) + + for index in 0..<40 { + let workspace = harness.tabManager.tabs[index % 8] + harness.tabManager.setCustomTitle( + tabId: workspace.id, + title: String(repeating: "variable title \(index) ", count: (index % 5) + 1) + ) + harness.unread.apply( + totalUnreadCount: index + 1, + summaries: [ + workspace.id: SidebarWorkspaceUnreadSummary( + unreadCount: index + 1, + latestNotificationText: String(repeating: "update ", count: (index % 4) + 1) + ) + ], + unreadSurfaceKeys: [], + focusedReadIndicatorByWorkspaceId: [:], + manualUnreadWorkspaceIds: [] + ) + table.scrollRowToVisible((index * 17) % Self.workspaceCount) + table.setPointerWindowLocation(table.convert( + NSPoint(x: 20, y: table.visibleRect.midY), + to: nil + )) + await Self.drainMainRunLoop(for: harness.window, iterations: 2) + } + table.setPointerWindowLocation(nil) + await Self.drainMainRunLoop(for: harness.window, iterations: 30) + + let faultNeedles = ["Modifying state during view update", + "Publishing changes from within view updates", + "laid out reentrantly"] + let faults = try logStore.getEntries(at: start).compactMap { entry -> String? in + guard let log = entry as? OSLogEntryLog else { return nil } + return faultNeedles.contains { log.composedMessage.localizedCaseInsensitiveContains($0) } + ? log.composedMessage : nil + } + #expect(faults.isEmpty, "Sidebar churn emitted runtime faults: \(faults)") + } /// Harness self-test: prove the drain loop + body counter actually detect /// a layout feedback loop. This fixture reproduces the historical diff --git a/cmuxTests/SidebarPointerInteractionMonitorTests.swift b/cmuxTests/SidebarPointerInteractionMonitorTests.swift deleted file mode 100644 index 5ed7a474ce6e..000000000000 --- a/cmuxTests/SidebarPointerInteractionMonitorTests.swift +++ /dev/null @@ -1,221 +0,0 @@ -import AppKit -import Testing -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@Suite @MainActor struct SidebarPointerInteractionMonitorTests { - @Test func pointerInputDoesNotInstallSubviewIntoSwiftUIOwnedScrollView() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 320), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.acceptsMouseMovedEvents = false - let scrollView = NSScrollView(frame: window.contentView?.bounds ?? .zero) - window.contentView = scrollView - let originalSubviews = scrollView.subviews - let monitor = SidebarPointerInteractionMonitor() - - monitor.attach(to: scrollView) - monitor.start(onMiddleClickWorkspace: { _ in }) - - #expect( - scrollView.subviews.elementsEqual(originalSubviews, by: { $0 === $1 }), - "Pointer input must not add a foreign subview that SwiftUI can remove during reconciliation." - ) - #expect(window.acceptsMouseMovedEvents) - - monitor.stop() - } - - @Test func restartReattachesToTheResolvedScrollViewWithoutAnotherResolverPass() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 320), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.acceptsMouseMovedEvents = false - let scrollView = NSScrollView(frame: window.contentView?.bounds ?? .zero) - window.contentView = scrollView - let monitor = SidebarPointerInteractionMonitor() - let workspaceId = UUID() - let rowId = SidebarWorkspaceRenderItemID.workspace(workspaceId) - monitor.updateFrame( - CGRect(x: 0, y: 0, width: 200, height: 30), - for: rowId, - workspaceId: workspaceId - ) - - monitor.attach(to: scrollView) - monitor.start(onMiddleClickWorkspace: { _ in }) - #expect(window.acceptsMouseMovedEvents) - - // SwiftUI can stop and restart the sidebar without remounting the - // resolver representable. The pointer owner must retain the last - // resolved host and restore tracking during start(). - monitor.stop() - #expect(!window.acceptsMouseMovedEvents) - #expect( - monitor.rowId(at: CGPoint(x: 100, y: 15)) == rowId, - "A transient sidebar restart must not discard row geometry that SwiftUI has not remounted." - ) - monitor.start(onMiddleClickWorkspace: { _ in }) - #expect( - window.acceptsMouseMovedEvents, - "Restarting the visible sidebar must restore hover without waiting for another resolver callback." - ) - - monitor.stop() - } - - @Test func frameChangesReconcileStationaryPointerAcrossRemountReorderAndScroll() async { - let monitor = SidebarPointerInteractionMonitor() - let firstWorkspaceId = UUID() - let secondWorkspaceId = UUID() - let firstRowId = SidebarWorkspaceRenderItemID.workspace(firstWorkspaceId) - let secondRowId = SidebarWorkspaceRenderItemID.workspace(secondWorkspaceId) - - monitor.updateFrame( - CGRect(x: 0, y: 0, width: 200, height: 30), - for: firstRowId, - workspaceId: firstWorkspaceId - ) - monitor.updateFrame( - CGRect(x: 0, y: 32, width: 200, height: 30), - for: secondRowId, - workspaceId: secondWorkspaceId - ) - monitor.recordPointerLocation(CGPoint(x: 100, y: 15)) - #expect(monitor.hoveredRowId == firstRowId) - - // A lazy remount removes the old row before its replacement reports. - monitor.removeFrame(for: firstRowId) - await Task.yield() - #expect(monitor.hoveredRowId == nil) - monitor.updateFrame( - CGRect(x: 0, y: 0, width: 200, height: 30), - for: firstRowId, - workspaceId: firstWorkspaceId - ) - await Task.yield() - #expect(monitor.hoveredRowId == firstRowId) - - // Reordering/scrolling changes only frame data. No new pointer event is - // recorded, but the stationary pointer resolves to the row now under it. - monitor.updateFrame( - CGRect(x: 0, y: 32, width: 200, height: 30), - for: firstRowId, - workspaceId: firstWorkspaceId - ) - monitor.updateFrame( - CGRect(x: 0, y: 0, width: 200, height: 30), - for: secondRowId, - workspaceId: secondWorkspaceId - ) - await Task.yield() - #expect(monitor.hoveredRowId == secondRowId) - - monitor.updateFrame( - CGRect(x: 0, y: -40, width: 200, height: 30), - for: secondRowId, - workspaceId: secondWorkspaceId - ) - await Task.yield() - #expect(monitor.hoveredRowId == nil) - } - - @Test func geometryReconciliationDoesNotPublishDuringTheLayoutCallback() async { - let monitor = SidebarPointerInteractionMonitor() - let firstWorkspaceId = UUID() - let secondWorkspaceId = UUID() - let firstRowId = SidebarWorkspaceRenderItemID.workspace(firstWorkspaceId) - let secondRowId = SidebarWorkspaceRenderItemID.workspace(secondWorkspaceId) - - monitor.updateFrame( - CGRect(x: 0, y: 0, width: 200, height: 30), - for: firstRowId, - workspaceId: firstWorkspaceId - ) - monitor.updateFrame( - CGRect(x: 0, y: 32, width: 200, height: 30), - for: secondRowId, - workspaceId: secondWorkspaceId - ) - monitor.recordPointerLocation(CGPoint(x: 100, y: 15)) - #expect(monitor.hoveredRowId == firstRowId) - - monitor.updateFrame( - CGRect(x: 0, y: 32, width: 200, height: 30), - for: firstRowId, - workspaceId: firstWorkspaceId - ) - monitor.updateFrame( - CGRect(x: 0, y: 0, width: 200, height: 30), - for: secondRowId, - workspaceId: secondWorkspaceId - ) - - #expect( - monitor.hoveredRowId == firstRowId, - "Geometry callbacks must not publish observable hover state while SwiftUI is laying out." - ) - await Task.yield() - #expect(monitor.hoveredRowId == secondRowId) - } - - @Test func convertsAppKitBottomLeftPointToSwiftUITopLeftPoint() { - let point = SidebarPointerInteractionMonitor.swiftUIPoint( - fromAppKitPoint: CGPoint(x: 45, y: 170), - viewportBounds: CGRect(x: 5, y: 20, width: 240, height: 200) - ) - - #expect(point == CGPoint(x: 40, y: 50)) - } - - @Test func hitTestingReturnsOnlyRegisteredRowWorkspace() { - let monitor = SidebarPointerInteractionMonitor() - let workspaceId = UUID() - let rowId = SidebarWorkspaceRenderItemID.workspace(workspaceId) - monitor.updateFrame( - CGRect(x: 20, y: 40, width: 180, height: 28), - for: rowId, - workspaceId: workspaceId - ) - - #expect(monitor.rowId(at: CGPoint(x: 100, y: 54)) == rowId) - #expect(monitor.middleClickWorkspaceId(at: CGPoint(x: 100, y: 54)) == workspaceId) - #expect(monitor.rowId(at: CGPoint(x: 10, y: 54)) == nil) - #expect(monitor.middleClickWorkspaceId(at: CGPoint(x: 100, y: 80)) == nil) - } - - @Test func middleClickDoesNotResolveGroupHeaderAnchorWorkspace() { - let monitor = SidebarPointerInteractionMonitor() - let groupId = UUID() - let anchorWorkspaceId = UUID() - monitor.updateFrame( - CGRect(x: 20, y: 40, width: 180, height: 28), - for: .group(groupId), - workspaceId: anchorWorkspaceId - ) - - #expect(monitor.rowId(at: CGPoint(x: 100, y: 54)) == .group(groupId)) - #expect(monitor.middleClickWorkspaceId(at: CGPoint(x: 100, y: 54)) == nil) - } - - @Test func menuTrackingReconciliationIgnoresSubmenuEndNotifications() { - let rootMenu = NSMenu() - let submenu = NSMenu() - let item = NSMenuItem(title: "submenu", action: nil, keyEquivalent: "") - rootMenu.addItem(item) - rootMenu.setSubmenu(submenu, for: item) - - #expect(SidebarPointerInteractionMonitor.shouldReconcileMenuEnd(object: rootMenu)) - #expect(!SidebarPointerInteractionMonitor.shouldReconcileMenuEnd(object: submenu)) - #expect(!SidebarPointerInteractionMonitor.shouldReconcileMenuEnd(object: nil)) - } -} diff --git a/cmuxTests/SidebarPointerInteractionScaleTests.swift b/cmuxTests/SidebarPointerInteractionScaleTests.swift deleted file mode 100644 index fbf46752ff8d..000000000000 --- a/cmuxTests/SidebarPointerInteractionScaleTests.swift +++ /dev/null @@ -1,283 +0,0 @@ -import AppKit -import CmuxSidebar -import CoreGraphics -import OSLog -import Testing -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -extension SidebarLazyLayoutScaleTests { - @MainActor - fileprivate static func firstScrollView(in rootView: NSView) -> NSScrollView? { - var pendingViews = [rootView] - while let view = pendingViews.popLast() { - if let scrollView = view as? NSScrollView { return scrollView } - pendingViews.append(contentsOf: view.subviews) - } - return nil - } - - private static func mouseMovedEvent(at pointInWindow: NSPoint, window: NSWindow) throws -> NSEvent { - try #require(NSEvent.mouseEvent( - with: .mouseMoved, - location: pointInWindow, - modifierFlags: [], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 0, - pressure: 0 - )) - } - - 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 = [ - "Modifying state during view update", - "Publishing changes from within view updates", - "laid out reentrantly", - ] - return entries.compactMap { entry in - // 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 - } - return message - } - } - - /// A stationary pointer over a row must survive the highest-risk sidebar - /// churn without producing SwiftUI view-update or NSHostingView reentrant - /// layout faults. The injectable window makes the production pointer owner - /// see a real in-row pointer without requiring a key window or physical - /// mouse, while scroll, remount, unread, and appearance changes exercise - /// the #8004 lifecycle path. - @Test - @MainActor - func testStationaryPointerChurnHasNoViewUpdateFaultsAndConverges() async throws { - let logStart = Date() - let harness = try await Self.mountSidebar(workspaceCount: Self.workspaceCount) - defer { harness.tearDown() } - - await Self.drainMainRunLoop(for: harness.window) - #expect( - harness.window.acceptsMouseMovedEvents, - "A mounted sidebar must enable mouse movement without discovering SwiftUI's private scroll-view hierarchy." - ) - let rootView = try #require(harness.window.contentView) - let scrollView = try #require(Self.firstScrollView(in: rootView)) - let pointerInScrollView = NSPoint( - x: scrollView.bounds.midX, - y: scrollView.bounds.maxY - 80 - ) - let pointerInWindow = scrollView.convert(pointerInScrollView, to: nil) - harness.window.injectedMouseLocation = pointerInWindow - - harness.counter.reset() - NSApp.sendEvent(try Self.mouseMovedEvent( - at: pointerInWindow, - window: harness.window - )) - await Self.drainMainRunLoop(for: harness.window, iterations: 4) - let hoverFlipEvals = harness.counter.workspaceRowBodies + harness.counter.groupHeaderBodies - #expect( - (1...2).contains(hoverFlipEvals), - """ - One hover-owner change evaluated \(hoverFlipEvals) row bodies. The parent may \ - recompute row values, but Equatable rows must limit body work to the old/new hover \ - targets (at most two rows). - """ - ) - - harness.counter.reset() - let stormTargets = Array(harness.tabManager.tabs.prefix(3).map(\.id)) - let groupIds = harness.tabManager.workspaceGroups.map(\.id) - for i in 1...40 { - let target = stormTargets[i % stormTargets.count] - harness.unread.apply( - totalUnreadCount: i, - summaries: [ - target: SidebarWorkspaceUnreadSummary( - unreadCount: i, - latestNotificationText: "stationary pointer churn \(i)" - ) - ], - unreadSurfaceKeys: [], - focusedReadIndicatorByWorkspaceId: [:], - manualUnreadWorkspaceIds: [] - ) - - let documentHeight = scrollView.documentView?.bounds.height ?? 0 - let maximumOffset = max(0, documentHeight - scrollView.contentView.bounds.height) - let requestedOffset = CGFloat((i % 8) * 36) - scrollView.contentView.scroll(to: NSPoint(x: 0, y: min(maximumOffset, requestedOffset))) - scrollView.reflectScrolledClipView(scrollView.contentView) - - if i.isMultiple(of: 4), let groupId = groupIds.first { - harness.tabManager.toggleWorkspaceGroupCollapsed(groupId: groupId) - } - harness.window.appearance = NSAppearance( - named: i.isMultiple(of: 2) ? .darkAqua : .aqua - ) - await Self.drainMainRunLoop(for: harness.window, iterations: 2) - } - await Self.drainMainRunLoop(for: harness.window) - - let faultMessages = try Self.viewUpdateFaultMessages(since: logStart) - #expect( - faultMessages.isEmpty, - """ - Sidebar stationary-pointer churn emitted \(faultMessages.count) SwiftUI/AppKit \ - view-update faults:\n\(faultMessages.joined(separator: "\n")) - """ - ) - - harness.counter.reset() - await Self.drainMainRunLoop(for: harness.window, iterations: 30) - let quietEvals = harness.counter.workspaceRowBodies + harness.counter.groupHeaderBodies - #expect( - quietEvals < 20, - """ - \(quietEvals) row bodies evaluated after stationary-pointer churn ended. The sidebar failed to converge \ - and is still feeding interaction or geometry changes back into layout. - """ - ) - } - -} - -/// 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/scripts/check-sidebar-lazy-layout.py b/scripts/check-sidebar-lazy-layout.py index 4a54cbb2053e..b78f2c0d2eda 100755 --- a/scripts/check-sidebar-lazy-layout.py +++ b/scripts/check-sidebar-lazy-layout.py @@ -1,67 +1,15 @@ #!/usr/bin/env python3 -"""Regression guard for the workspace-sidebar lazy-layout contract. - -The workspace sidebar renders its rows as a `LazyVStack` inside a vertical -`ScrollView`. Keeping that stack lazy at *measure time* is load-bearing: the -sidebar is re-diffed on every workspace/telemetry update, so any code that -forces SwiftUI to realize and measure the whole row list on each layout pass -turns a routine update into a multi-second `GraphHost.flushTransactions()` -main-thread livelock once enough workspaces/surfaces are open. - -This exact class of bug has regressed four times: - - * #2586 GeometryReader + PreferenceKey -> @State height feedback loop - * #5764 per-row String id allocation in the ForEach diff - * #5845 same livelock family, reintroduced - * #6033 -> #6210 `SidebarRowsFillLayout`, a custom `Layout` that called - `subview.sizeThatFits(ProposedViewSize(width:, height: nil))` on the - `LazyVStack` in both `sizeThatFits` and `placeSubviews`, realizing - every row each pass. Removed in #6188. - - * #6384 the user-reported ~1s beachball that #6188 fixed. - -Until now the contract was defended only by inline comments, which CI cannot -enforce. This guard scans the two functions that define the sidebar's -steady-state scroll layout in `Sources/ContentView.swift` -(`workspaceScrollContent` and `workspaceRows`) and fails if either: - - 1. reintroduces a whole-list measurement signature - (`GeometryReader`, `ProposedViewSize(... nil ...)`, `.sizeThatFits(`, the - deleted `SidebarRowsFillLayout`, or ANY custom `Layout`-conforming type - discovered in the codebase being applied to the rows -- so renaming the - force-measuring layout does not dodge the guard), or - 2. drops one of the lazy-fill primitives the fix relies on - (`LazyVStack(` in `workspaceRows`, `.frame(minHeight:` in - `workspaceScrollContent`). - -Comments and string literals are neutralized before scanning, so the historical -explanatory comments in those functions (which deliberately name the very -anti-patterns this guard forbids) do not trip it. - -The drag-only drop-target reader (`rowsWithGatedDropTargetReader`) is *not* -scanned: it intentionally uses a `GeometryReader` to resolve per-row drop -anchors and is gated behind an active drag (#5325), so it never runs during the -steady-state layout this guard protects. - -Scope: the guard protects the rows layout *as expressed in* -`workspaceScrollContent` / `workspaceRows`. It deliberately does not chase a -force-measure that a future refactor relocates into some other -transitively-called helper function: tracking arbitrary call graphs in a -source-pattern lint is fragile, and extracting the rows layout into a new helper -is a large enough change that it should re-review this guard directly (the -"could not locate function" failure already trips on the most common such -rename). Custom `Layout` types are the exception that *is* chased across files, -because a renamed force-measuring layout is the concrete historical regression -(#6033). - -Usage: - scripts/check-sidebar-lazy-layout.py [--file PATH] - -Exit codes: - 0 the scanned file satisfies the lazy-layout contract - 1 an anti-pattern was found, a required primitive is missing, or one of the - guarded functions could not be located (treated as a failure so the guard - cannot silently rot into a no-op when the code is renamed). +"""Guard the AppKit-owned default sidebar list boundary. + +The default workspace list must remain one container-level NSViewRepresentable +wrapping NSTableView. SwiftUI row content may be hosted in reusable cells, but +must not mount additional platform representables except the established inline +rename field and GPU spinner. Table reload/reconfiguration is forbidden from +AppKit layout lifecycle callbacks; mutations enter through render-context +updates, input events, or scroll notifications. + +The guard fails loudly when a required type/function/file is renamed so it +cannot silently become a no-op. """ import argparse @@ -69,538 +17,204 @@ import re import sys -# Functions that define the sidebar's steady-state scroll layout. Both must -# exist; a rename should fail the guard loudly rather than silently skip. -GUARDED_FUNCTIONS = ("workspaceScrollContent", "workspaceRows") - -# Tokens that mean "the whole row list is being measured/realized on every -# layout pass" if they appear in the guarded functions' code (not comments). -FORBIDDEN_PATTERNS = ( - (re.compile(r"\bGeometryReader\b"), - "GeometryReader (reading the rows' size feeds the #2586 layout feedback " - "loop / forces row realization at measure time)"), - (re.compile(r"\bSidebarRowsFillLayout\b"), - "SidebarRowsFillLayout (the custom Layout removed in #6188 that " - "force-measured the LazyVStack every pass; do not reintroduce it)"), - (re.compile(r"\.sizeThatFits\s*\("), - "manual .sizeThatFits( call (measuring a subview by hand realizes the " - "lazy rows; let SwiftUI size the stack)"), - (re.compile(r"\bProposedViewSize\s*\([^)]*\bnil\b"), - "ProposedViewSize(..., nil) (proposing nil on an axis asks the LazyVStack " - "for its natural size, realizing every row -- the #6210 force-measure)"), -) -# Declaration of a type conforming to SwiftUI's `Layout` protocol. A custom -# Layout applied to the sidebar rows is the #6033/#6210 force-measure shape no -# matter what the type is named, so the guard discovers every such type in the -# codebase and bans ALL of their names from the guarded functions -- not just the -# literal `SidebarRowsFillLayout`. This closes the "rename the layout to dodge the -# guard" bypass (#6870 review). -CUSTOM_LAYOUT_DECL = re.compile( - r"\b(?:struct|final\s+class|class|enum|extension)\s+([A-Z]\w*)\b[^{]*?\bLayout\b[^{]*?\{", - re.DOTALL, +OLD_DEFAULT_LIST_FUNCTIONS = ( + "workspaceScrollContent", + "workspaceRows", + "rowsWithGatedDropTargetReader", ) - -# An always-mounted NSViewRepresentable below the LazyVStack can run AppKit -# lifecycle callbacks while SwiftUI is updating the same row. Issue #8004's -# hover and menu helpers wrote row state from that stack and re-entered -# NSHostingView layout. Discover conformers across repo-owned sources so moving -# or renaming one cannot bypass the guard, then reject their use in row bodies. -NSVIEW_REPRESENTABLE_DECL = re.compile( - r"\b(?:struct|final\s+class|class|enum|extension)\s+([A-Z]\w*)\b[^{]*?" - r"\bNSViewRepresentable\b[^{]*?\{", - re.DOTALL, -) - -# These are condition-gated leaf controls. SidebarInlineRenameField exists only -# during inline rename, and GPUSpinner is mounted indirectly by -# SidebarWorkspaceLoadingSpinner only while agent activity is visible. Neither -# writes row state from representable lifecycle callbacks. -ROW_NSVIEW_REPRESENTABLE_ALLOWLIST = frozenset({ - "SidebarInlineRenameField", - "GPUSpinner", -}) - -# Row-view regions guarded against per-row geometry feedback. Four of the five -# historical regressions in this class entered through the row views, not the -# container functions above: the #2586/#6556 GeometryReader -> @State row-height -# probes lived in `TabItemView` and `SidebarWorkspaceGroupHeaderView` (deleted -# by #6111, reintroduced by #4385, deleted by #7117 -- and the reintroduction -# shipped in stable v0.64.17, which livelocked in the wild on 2026-07-02). The -# per-row `.anchorPreference` aggregation of #5323 is the same shape: a row -# publishing its own geometry forces SwiftUI to realize every row per pass. -# -# Rows must not measure themselves, period. Row heights are implicit; the ONLY -# sanctioned geometry path is the container's drag-gated reader -# (`rowsWithGatedDropTargetReader` + `SidebarWorkspaceFrameAnchorModifier`), -# which lives outside these regions. A future legitimate need must extend this -# guard consciously rather than slip past it. -GUARDED_ROW_TYPES = ( - "TabItemView", - "SidebarWorkspaceRowView", - "SidebarWorkspaceGroupHeaderView", - "SidebarWorkspaceGroupRowView", -) - -ROW_FORBIDDEN_PATTERNS = ( - (re.compile(r"\bGeometryReader\b"), - "GeometryReader (a row measuring itself feeds the #2586/#6556 " - "GeometryReader -> @State row-height livelock; row heights are implicit)"), - (re.compile(r"\bonGeometryChange\b"), - "onGeometryChange (geometry-driven state writes in a row re-trigger " - "layout the same way the #6556 GeometryReader probes did)"), - (re.compile(r"\.sizeThatFits\s*\("), - "manual .sizeThatFits( call (measuring from a row realizes lazy " - "siblings; let SwiftUI size the row)"), - (re.compile(r"\bProposedViewSize\s*\([^)]*\bnil\b"), - "ProposedViewSize(..., nil) (natural-size measurement realizes the lazy " - "list -- the #6210 force-measure)"), - (re.compile(r"\.anchorPreference\s*\("), - ".anchorPreference( in a row (per-row frame publication aggregated by an " - "ancestor is the #5323 virtualization defeat; only the container's " - "drag-gated SidebarWorkspaceFrameAnchorModifier may collect row frames)"), - (re.compile(r"\.overlayPreferenceValue\s*\("), - ".overlayPreferenceValue( in a row (consuming aggregated row geometry " - "inside a row is the #5323 feedback shape)"), -) - -# Lazy-fill primitives the #6188 fix depends on. Each must remain present in the -# named function (after comments/strings are stripped). -REQUIRED_PRIMITIVES = ( - ("workspaceRows", re.compile(r"\bLazyVStack\s*\("), - "LazyVStack( -- the rows must stay lazy; a plain VStack realizes every " - "row on each pass and re-livelocks at scale"), - ("workspaceScrollContent", re.compile(r"\.frame\s*\(\s*minHeight:"), - ".frame(minHeight:) -- the measurement-free fill primitive that replaced " - "SidebarRowsFillLayout; do not remove it"), +ROW_TYPES = ("TabItemView", "SidebarWorkspaceGroupHeaderView") +REPRESENTABLE_ALLOWLIST = {"SidebarInlineRenameField", "GPUSpinner"} +LAYOUT_CALLBACKS = ("layout", "updateTrackingAreas", "viewDidMoveToWindow") +LAYOUT_MUTATION_PATTERNS = ( + re.compile(r"\breloadData\s*\("), + re.compile(r"\bnoteHeightOfRows\s*\("), + re.compile(r"\breconfigure(?:Visible)?Rows\s*\("), + re.compile(r"\.rootView\s*="), ) def neutralize_swift(source): - """Return ``source`` with comment and string-literal *contents* replaced by - spaces, preserving every character's position and all newlines. - - Token and brace scanning runs on this neutralized text so that tokens which - appear only inside the explanatory comments (e.g. the words - ``SidebarRowsFillLayout`` or ``sizeThatFits(height: nil)``) are invisible to - the guard, and so braces/parens inside comments or strings never corrupt the - function-body matching. - """ + """Blank comments and string contents while preserving source positions.""" out = [] i = 0 - n = len(source) - LINE_COMMENT, BLOCK_COMMENT, STRING, MULTILINE_STRING = 1, 2, 3, 4 - state = 0 - while i < n: + state = "code" + block_depth = 0 + while i < len(source): ch = source[i] - nxt = source[i + 1] if i + 1 < n else "" - if state == 0: - if ch == "/" and nxt == "/": - out.append(" ") + pair = source[i:i + 2] + triple = source[i:i + 3] + if state == "code": + if pair == "//": + out.extend(" ") i += 2 - state = LINE_COMMENT - continue - if ch == "/" and nxt == "*": - out.append(" ") + state = "line" + elif pair == "/*": + out.extend(" ") i += 2 - state = BLOCK_COMMENT - continue - if source[i:i + 3] == '"""': - # Swift multi-line string literal: only a closing `"""` ends it, - # so a bare `"` inside must NOT toggle string state -- otherwise - # the inner quote would close the literal early and expose the - # rest (e.g. a forbidden token named in prose) as apparent code, - # tripping the guard with a false positive. (#6870 review) - out.append('"""') + state = "block" + block_depth = 1 + elif triple == '\"\"\"': + out.extend('\"\"\"') i += 3 - state = MULTILINE_STRING - continue - if ch == '"': - out.append('"') + state = "multiline" + elif ch == '\"': + out.append(ch) i += 1 - state = STRING - continue - out.append(ch) - i += 1 - continue - if state == LINE_COMMENT: - if ch == "\n": - out.append("\n") - state = 0 + state = "string" else: - out.append(" ") + out.append(ch) + i += 1 + elif state == "line": + out.append("\n" if ch == "\n" else " ") i += 1 - continue - if state == BLOCK_COMMENT: - if ch == "*" and nxt == "/": - out.append(" ") + if ch == "\n": + state = "code" + elif state == "block": + if pair == "/*": + out.extend(" ") + block_depth += 1 i += 2 - state = 0 + elif pair == "*/": + out.extend(" ") + block_depth -= 1 + i += 2 + if block_depth == 0: + state = "code" else: out.append("\n" if ch == "\n" else " ") i += 1 - continue - if state == STRING: - if ch == "\\" and nxt != "": - # Preserve the escape pair as spaces so positions stay aligned. - out.append(" ") + elif state == "string": + if ch == "\\" and i + 1 < len(source): + out.extend(" ") i += 2 - continue - if ch == '"': - out.append('"') + elif ch == '\"': + out.append(ch) i += 1 - state = 0 - continue - out.append("\n" if ch == "\n" else " ") - i += 1 - continue - if state == MULTILINE_STRING: - if source[i:i + 3] == '"""': - out.append('"""') + state = "code" + else: + out.append("\n" if ch == "\n" else " ") + i += 1 + else: + if triple == '\"\"\"': + out.extend('\"\"\"') i += 3 - state = 0 - continue - # A lone `"` does not close a multi-line string; only `"""` does. - out.append("\n" if ch == "\n" else " ") - i += 1 - continue + state = "code" + else: + out.append("\n" if ch == "\n" else " ") + i += 1 return "".join(out) -def extract_function_body(neutralized, func_name): - """Return the brace-matched body text of ``func (`` in the - neutralized source, or ``None`` if the function is not found. - - Handles multi-line signatures by walking parenthesis depth until the - parameter list closes, then brace-matching from the body's opening ``{``. - """ - match = re.search(r"\bfunc\s+" + re.escape(func_name) + r"\s*\(", neutralized) +def extract_braced_declaration(source, pattern): + match = re.search(pattern, source) if not match: return None - i = match.end() - 1 # at the opening '(' of the parameter list - n = len(neutralized) - paren_depth = 0 - # Walk until the parameter-list parens balance back to zero. - while i < n: - ch = neutralized[i] - if ch == "(": - paren_depth += 1 - elif ch == ")": - paren_depth -= 1 - if paren_depth == 0: - i += 1 - break - i += 1 - else: - return None - # Find the body's opening brace (skip the `-> some View` return clause). - while i < n and neutralized[i] != "{": - # A premature ';' or top-level '}' means there was no body. - if neutralized[i] == "}": - return None - i += 1 - if i >= n: + opening = source.find("{", match.end()) + if opening < 0: return None - brace_depth = 0 - start = i - while i < n: - ch = neutralized[i] - if ch == "{": - brace_depth += 1 - elif ch == "}": - brace_depth -= 1 - if brace_depth == 0: - return neutralized[start:i + 1] - i += 1 + depth = 0 + for index in range(opening, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[opening:index + 1] return None -def extract_type_body(neutralized, type_name): - """Return the brace-matched body text of ``struct/class `` in the - neutralized source, or ``None`` if the declaration is not found. +def extract_function_body(source, name): + return extract_braced_declaration( + source, + r"\bfunc\s+" + re.escape(name) + r"\s*\(", + ) + - Walks from the declaration keyword to the body's opening ``{`` (skipping - generic parameters and the conformance list), then brace-matches. - """ - match = re.search( - r"\b(?:struct|final\s+class|class)\s+" + re.escape(type_name) + r"\b", - neutralized, +def extract_type_body(source, name): + return extract_braced_declaration( + source, + r"\b(?:struct|class|final\s+class)\s+" + re.escape(name) + r"\b", ) - if not match: - return None - i = match.end() - n = len(neutralized) - while i < n and neutralized[i] != "{": - if neutralized[i] == ";": - return None - i += 1 - if i >= n: - return None - brace_depth = 0 - start = i - while i < n: - ch = neutralized[i] - if ch == "{": - brace_depth += 1 - elif ch == "}": - brace_depth -= 1 - if brace_depth == 0: - return neutralized[start:i + 1] - i += 1 - return None -# Directory names that hold build artifacts, VCS data, or vendored third-party -# code. Pruned from the repo-owned Swift walk: a Layout pulled from an external -# dependency is out of scope (you would have to import it), and SwiftPM checkout -# trees under `.build` are huge. -EXCLUDED_DIR_NAMES = frozenset({ - ".build", ".git", "DerivedData", "Vendor", "vendor", "ThirdParty", - "third_party", "Pods", "Carthage", "node_modules", -}) - - -def repo_owned_swift_files(repo_root): - """Yield every repo-owned Swift source path under ``Sources/`` and - ``Packages/`` (where cmux migrates app code), pruning build/VCS/vendored - directories. Scanning both keeps the custom-Layout discovery from being - bypassed by defining the force-measuring layout in a package. (#6870 review) - """ - for top in ("Sources", "Packages"): - root = os.path.join(repo_root, top) - if not os.path.isdir(root): - continue - for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIR_NAMES] - for filename in filenames: - if filename.endswith(".swift"): - yield os.path.join(dirpath, filename) - - -def find_custom_layout_type_names(paths): - """Return the set of type names conforming to SwiftUI's `Layout` protocol - across ``paths`` (comment/string-neutralized so a `: Layout` in prose is not - counted). Cheap-filtered to files that mention ``Layout`` at all. - """ +def discovered_representables(swift_sources): names = set() - seen = set() - for path in paths: - try: - real = os.path.realpath(path) - except OSError: - continue - if real in seen: - continue - seen.add(real) - try: - with open(path, "r", encoding="utf-8") as handle: - text = handle.read() - except OSError: - continue - if "Layout" not in text: - continue - for match in CUSTOM_LAYOUT_DECL.finditer(neutralize_swift(text)): - names.add(match.group(1)) + pattern = re.compile( + r"\b(?:struct|class|final\s+class)\s+([A-Za-z_]\w*)[^\{\n]*:\s*[^\{\n]*\bNSViewRepresentable\b" + ) + for source in swift_sources: + names.update(pattern.findall(neutralize_swift(source))) return names -def find_nsview_representable_type_names(paths): - """Return repo-owned NSViewRepresentable-conforming type names in ``paths``. - - Sources are comment/string-neutralized before matching, using the same scan - discipline as custom Layout discovery. - """ - names = set() - seen = set() - for path in paths: - try: - real = os.path.realpath(path) - except OSError: - continue - if real in seen: - continue - seen.add(real) - try: - with open(path, "r", encoding="utf-8") as handle: - text = handle.read() - except OSError: - continue - if "NSViewRepresentable" not in text: - continue - for match in NSVIEW_REPRESENTABLE_DECL.finditer(neutralize_swift(text)): - names.add(match.group(1)) - return names +def check_content_view(source): + clean = neutralize_swift(source) + violations = [] + body = extract_function_body(clean, "workspaceScrollArea") + if body is None: + return ["could not locate func workspaceScrollArea(...); update this guard for the renamed boundary"] + if "SidebarWorkspaceTableView" not in body: + violations.append("workspaceScrollArea does not mount SidebarWorkspaceTableView") + for token in ("ScrollView", "LazyVStack", "LazyHStack", "List"): + if re.search(r"\b" + token + r"\b", body): + violations.append("workspaceScrollArea reintroduces SwiftUI list container: " + token) + for name in OLD_DEFAULT_LIST_FUNCTIONS: + if extract_function_body(clean, name) is not None: + violations.append("obsolete default-list function still exists: " + name) + return violations -def nsview_representable_violations(body, region_name, type_names): - """Return violations for disallowed representable references in ``body``.""" +def check_row_source(source, required_type, representable_names): + clean = neutralize_swift(source) + body = extract_type_body(clean, required_type) + if body is None: + return [f"could not locate row type {required_type}; update this guard for the rename"] violations = [] - for name in sorted(type_names - ROW_NSVIEW_REPRESENTABLE_ALLOWLIST): + forbidden = representable_names - REPRESENTABLE_ALLOWLIST - {"SidebarWorkspaceTableView"} + for name in sorted(forbidden): if re.search(r"\b" + re.escape(name) + r"\b", body): - violations.append( - "{0} references always-mounted NSViewRepresentable `{1}`. " - "Sidebar row bodies must be value-only so AppKit lifecycle " - "callbacks cannot mutate row state during SwiftUI layout " - "(issue #8004).".format(region_name, name) - ) + violations.append(f"{required_type} mounts forbidden per-row NSViewRepresentable {name}") return violations -def check_source( - source, - custom_layout_names=None, - nsview_representable_names=None, - require_functions=True, - required_row_types=(), - required_row_functions=(), - scan_all_rows=False, - required_markers=(), -): - """Return a list of human-readable violation strings (empty == clean). - - ``require_functions`` controls whether the two container functions must be - present: True for ContentView.swift, False for row-view files like - SidebarWorkspaceGroupHeaderView.swift (which do not contain them), or - "auto" for ad-hoc ``--file`` runs -- container checks then apply only when - at least one guarded function exists in the source, so a row-view file - scans cleanly while a fixture that renamed ONE function still fails loudly. - ``required_row_types`` names GUARDED_ROW_TYPES that must exist in this - source -- a rename fails loudly instead of silently skipping the region. - Row types that are merely present are always scanned. - - ``scan_all_rows`` applies the row-forbidden patterns to the ENTIRE - neutralized source instead of extracted type regions. Used for row-wrapper - files (e.g. VerticalTabsSidebar+WorkspaceGroups.swift) whose modifier - sites wrap a row before it enters the LazyVStack: a GeometryReader or - anchorPreference added around the header there defeats laziness exactly - like one inside the row view. ``required_markers`` are substrings that - must appear in the source so a rename/move fails loudly. - """ - custom_layout_names = custom_layout_names or set() - nsview_representable_names = nsview_representable_names or set() - neutralized = neutralize_swift(source) +def check_appkit_sources(sources_by_name): violations = [] - bodies = {} - if require_functions == "auto": - require_functions = any( - re.search(r"\bfunc\s+" + re.escape(name) + r"\s*\(", neutralized) - for name in GUARDED_FUNCTIONS - ) - if require_functions: - for func_name in GUARDED_FUNCTIONS: - body = extract_function_body(neutralized, func_name) + required = { + "SidebarWorkspaceTableView.swift": ("NSViewRepresentable", "makeNSView", "updateNSView"), + "SidebarWorkspaceTableController.swift": ("@MainActor", "NSTableViewDataSource", "NSTableViewDelegate"), + "SidebarWorkspaceTableCellView.swift": ("NSTableCellView", "NSHostingView", "rootView"), + "SidebarWorkspaceTableViewImpl.swift": ("NSTableView", "updateTrackingAreas", "otherMouseDown"), + } + for filename, markers in required.items(): + source = sources_by_name.get(filename) + if source is None: + violations.append(f"missing required AppKit-list file {filename}") + continue + clean = neutralize_swift(source) + for marker in markers: + if marker not in clean: + violations.append(f"{filename} is missing required marker {marker}") + + for filename, source in sources_by_name.items(): + clean = neutralize_swift(source) + for callback in LAYOUT_CALLBACKS: + body = extract_function_body(clean, callback) if body is None: - violations.append( - "could not locate func {0}(...) in the source. The sidebar " - "lazy-layout guard must be updated to track the renamed " - "function (refusing to pass as a no-op).".format(func_name) - ) continue - bodies[func_name] = body - - for func_name, body in bodies.items(): - for pattern, description in FORBIDDEN_PATTERNS: - if pattern.search(body): - violations.append( - "{0}(...) reintroduces a forbidden whole-list measurement: " - "{1}".format(func_name, description) - ) - # A custom Layout (under ANY name) applied to the rows wraps the - # LazyVStack and measures it every pass -- the #6210 shape. Banning the - # whole discovered set, not just the deleted name, blocks the rename - # dodge (#6870 review). - for name in sorted(custom_layout_names): - if re.search(r"\b" + re.escape(name) + r"\b", body): - violations.append( - "{0}(...) applies the custom Layout `{1}` to the sidebar rows. " - "A custom Layout wrapping the LazyVStack measures it on every " - "pass (the #6210 force-measure shape, regardless of the type's " - "name); size the rows with .frame(minHeight:) instead.".format( - func_name, name + for pattern in LAYOUT_MUTATION_PATTERNS: + if pattern.search(body): + violations.append( + f"{filename}.{callback} mutates/reconfigures the table from a layout callback" ) - ) - - for func_name, pattern, description in REQUIRED_PRIMITIVES: - body = bodies.get(func_name) - if body is None: - continue # already reported as a missing-function violation - if not pattern.search(body): - violations.append( - "{0}(...) is missing a required lazy-fill primitive: {1}".format( - func_name, description - ) - ) - - for marker in required_markers: - if marker not in neutralized: - violations.append( - "could not locate `{0}` in the source. The sidebar lazy-layout " - "guard must be updated to track the renamed/moved row wrapper " - "(refusing to pass as a no-op).".format(marker) - ) - - for func_name in required_row_functions: - body = extract_function_body(neutralized, func_name) - if body is None: - violations.append( - "could not locate row-builder func {0}(...). The sidebar " - "NSViewRepresentable guard must be updated to track the " - "renamed function (refusing to pass as a no-op).".format(func_name) - ) - continue - violations.extend(nsview_representable_violations( - body, - "{0}(...)".format(func_name), - nsview_representable_names, - )) - - if scan_all_rows: - for pattern, description in ROW_FORBIDDEN_PATTERNS: - if pattern.search(neutralized): - violations.append( - "row-wrapper file contains forbidden per-row geometry " - "feedback: {0}".format(description) - ) - for name in sorted(custom_layout_names): - if re.search(r"\b" + re.escape(name) + r"\b", neutralized): - violations.append( - "row-wrapper file applies the custom Layout `{0}` (the " - "#6210 force-measure shape); row wrappers must stay " - "measurement-free.".format(name) - ) - - # Row-view regions: rows must never measure or publish their own geometry. - for type_name in GUARDED_ROW_TYPES: - body = extract_type_body(neutralized, type_name) - if body is None: - if type_name in required_row_types: - violations.append( - "could not locate type {0} in the source. The sidebar " - "lazy-layout guard must be updated to track the renamed " - "row view (refusing to pass as a no-op).".format(type_name) - ) - continue - for pattern, description in ROW_FORBIDDEN_PATTERNS: - if pattern.search(body): - violations.append( - "{0} contains forbidden per-row geometry feedback: " - "{1}".format(type_name, description) - ) - for name in sorted(custom_layout_names): - if re.search(r"\b" + re.escape(name) + r"\b", body): - violations.append( - "{0} applies the custom Layout `{1}`. A custom Layout in a " - "sidebar row measures its subtree every pass (the #6210 " - "force-measure shape); rows must stay measurement-free.".format( - type_name, name - ) - ) - violations.extend(nsview_representable_violations( - body, - type_name, - nsview_representable_names, - )) - + for pattern, label in ( + (r"\bObservableObject\b", "ObservableObject"), + (r"@Published\b", "@Published"), + (r"DispatchQueue\.main\.async", "DispatchQueue.main.async"), + (r"DispatchQueue\.asyncAfter", "DispatchQueue.asyncAfter"), + ): + if re.search(pattern, clean): + violations.append(f"{filename} introduces forbidden {label}") return violations @@ -608,134 +222,59 @@ def repo_root_dir(): return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -def default_targets(): - """(path, require_functions, required_row_types, required_row_functions, - scan_all_rows, required_markers) per scanned file. - - ContentView.swift holds the container functions and TabItemView; the group - header row view lives in its own file with neither container function; the - group-header row builder (`sidebarWorkspaceGroupRow(...)`) lives in a - third file whose modifier sites wrap the header before it enters the - LazyVStack. The two immutable wrapper views live in their own files and are - guarded as row regions as well. - """ - root = repo_root_dir() +def read(path): + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def default_violations(root): + appkit_dir = os.path.join(root, "Sources", "Sidebar", "AppKitList") + try: + appkit_sources = { + name: read(os.path.join(appkit_dir, name)) + for name in os.listdir(appkit_dir) + if name.endswith(".swift") + } + content = read(os.path.join(root, "Sources", "ContentView.swift")) + group = read(os.path.join(root, "Sources", "SidebarWorkspaceGroupHeaderView.swift")) + except OSError as error: + return [f"cannot read required sidebar source: {error}"] + + all_sources = list(appkit_sources.values()) + for directory, _, filenames in os.walk(os.path.join(root, "Sources")): + for filename in filenames: + if filename.endswith(".swift"): + try: + all_sources.append(read(os.path.join(directory, filename))) + except OSError: + pass + representables = discovered_representables(all_sources) return ( - ( - os.path.join(root, "Sources", "ContentView.swift"), - True, - ("TabItemView",), - ("workspaceRow",), - False, - (), - ), - ( - os.path.join(root, "Sources", "SidebarWorkspaceGroupHeaderView.swift"), - False, - ("SidebarWorkspaceGroupHeaderView",), - (), - False, - (), - ), - ( - os.path.join(root, "Sources", "VerticalTabsSidebar+WorkspaceGroups.swift"), - False, - (), - ("sidebarWorkspaceGroupRow",), - True, - ("sidebarWorkspaceGroupRow",), - ), - ( - os.path.join(root, "Sources", "SidebarWorkspaceRowView.swift"), - False, - ("SidebarWorkspaceRowView",), - (), - False, - (), - ), - ( - os.path.join(root, "Sources", "SidebarWorkspaceGroupRowView.swift"), - False, - ("SidebarWorkspaceGroupRowView",), - (), - False, - (), - ), + check_content_view(content) + + check_row_source(content, "TabItemView", representables) + + check_row_source(group, "SidebarWorkspaceGroupHeaderView", representables) + + check_appkit_sources(appkit_sources) ) def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--file", - default=None, - help="Swift source file to scan (defaults to Sources/ContentView.swift " - "+ Sources/SidebarWorkspaceGroupHeaderView.swift).", - ) + parser.add_argument("--file", help="scan one AppKit-list Swift file for layout-callback mutations") args = parser.parse_args(argv) - if args.file: - # "auto": ad-hoc scans of a row-view file (no container functions) - # skip the container checks instead of failing on their absence; a - # source containing any guarded function still has both enforced. - targets = ((args.file, "auto", (), (), False, ()),) - else: - targets = default_targets() - - # Discover custom Layout type names from the target files plus every - # repo-owned Swift source (Sources/ and Packages/), so a renamed - # force-measuring layout defined in any app file or package is still banned - # from the guarded regions. - layout_scan_paths = [target[0] for target in targets] - layout_scan_paths.extend(sorted(repo_owned_swift_files(repo_root_dir()))) - custom_layout_names = find_custom_layout_type_names(layout_scan_paths) - nsview_representable_names = find_nsview_representable_type_names(layout_scan_paths) - - exit_code = 0 - for ( - target, - require_functions, - required_row_types, - required_row_functions, - scan_all_rows, - required_markers, - ) in targets: try: - with open(target, "r", encoding="utf-8") as handle: - source = handle.read() + violations = check_appkit_sources({os.path.basename(args.file): read(args.file)}) except OSError as error: - print("check-sidebar-lazy-layout: cannot read {0}: {1}".format(target, error), - file=sys.stderr) - exit_code = 1 - continue - - violations = check_source( - source, - custom_layout_names, - nsview_representable_names, - require_functions=require_functions, - required_row_types=required_row_types, - required_row_functions=required_row_functions, - scan_all_rows=scan_all_rows, - required_markers=required_markers, - ) - if violations: - print("check-sidebar-lazy-layout: FAILED for {0}".format(target), - file=sys.stderr) - for violation in violations: - print(" - {0}".format(violation), file=sys.stderr) - print( - "\nThe workspace sidebar must keep its rows lazy at measure time " - "and its rows measurement-free. See #6188 / #6210 / #6384 / #6556 " - "and the comments in workspaceScrollContent / workspaceRows.", - file=sys.stderr, - ) - exit_code = 1 - continue - - print("check-sidebar-lazy-layout: ok ({0})".format(target)) - - return exit_code + violations = [f"cannot read {args.file}: {error}"] + else: + violations = default_violations(repo_root_dir()) + if violations: + print("check-sidebar-lazy-layout: FAILED", file=sys.stderr) + for violation in violations: + print(" - " + violation, file=sys.stderr) + return 1 + print("check-sidebar-lazy-layout: ok (AppKit NSTableView boundary)") + return 0 if __name__ == "__main__": diff --git a/tests/test_ci_sidebar_lazy_layout_guard.py b/tests/test_ci_sidebar_lazy_layout_guard.py index 02fce5dafccf..b2ed99fbf3d6 100755 --- a/tests/test_ci_sidebar_lazy_layout_guard.py +++ b/tests/test_ci_sidebar_lazy_layout_guard.py @@ -1,427 +1,127 @@ #!/usr/bin/env python3 -"""CI guard for ./scripts/check-sidebar-lazy-layout.py. - -Verifies the guard reports "ok" on the real cmux repo and correctly *fails* on -every way the workspace-sidebar lazy-layout contract can be broken. The negative -cases are what keep the guard from rotting into a no-op. - -Cases: - (a) Real cmux repo passes (Sources/ContentView.swift). - (b) A fixture whose guarded functions are clean code but whose comments and - string literals deliberately name every forbidden token still passes - (comment/string neutralization works -- this mirrors the real source, - which documents the anti-patterns it forbids). - (c) Reintroducing the #6210 force-measure (`.sizeThatFits(ProposedViewSize( - width:, height: nil))`) fails. - (d) Reintroducing the deleted `SidebarRowsFillLayout` custom Layout fails. - (e) A `GeometryReader` in the steady-state scroll content fails. - (f) Downgrading the rows from `LazyVStack` to a plain eager `VStack` fails. - (g) Dropping `.frame(minHeight:)` from `workspaceScrollContent` fails. - (h) Renaming/removing a guarded function fails loudly (no silent skip). - (j) A clean TabItemView row region passes. - (k) The #6556 rowHeightProbe shape (GeometryReader + @State height in a row) - fails — this exact code shipped in stable v0.64.17 and livelocked in the - wild on 2026-07-02. - (l) Per-row `.anchorPreference` (the #5323 virtualization defeat) fails. - (m) A required row type missing from its file fails loudly (no silent skip). -""" +"""Negative coverage for the AppKit sidebar topology guard.""" import importlib.util import os -import subprocess import sys -import tempfile -ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -GUARD = os.path.join(ROOT_DIR, "scripts", "check-sidebar-lazy-layout.py") +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GUARD_PATH = os.path.join(ROOT, "scripts", "check-sidebar-lazy-layout.py") -def load_guard_module(): - spec = importlib.util.spec_from_file_location("sidebar_lazy_layout_guard", GUARD) + +def load_guard(): + spec = importlib.util.spec_from_file_location("sidebar_guard", GUARD_PATH) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -def write_text(path, contents): - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as handle: - handle.write(contents) - +def expect(condition, label): + print(f"[{'PASS' if condition else 'FAIL'}] {label}") + return 0 if condition else 1 -def run_guard(path): - return subprocess.run( - [sys.executable, GUARD, "--file", path], - cwd=ROOT_DIR, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - -def run_guard_default(): - return subprocess.run( - [sys.executable, GUARD], - cwd=ROOT_DIR, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - -def fixture(scroll_body, rows_body): - """Assemble a minimal ContentView-shaped Swift source with the two guarded - functions. ``scroll_body`` / ``rows_body`` are the function-body statements. - """ - return ( - "import SwiftUI\n" - "struct ContentBody {\n" - " private func workspaceScrollContent(\n" - " renderContext: WorkspaceListRenderContext,\n" - " minHeight: CGFloat\n" - " ) -> some View {\n" - " // History: SidebarRowsFillLayout measured it via\n" - " // sizeThatFits(ProposedViewSize(width: width, height: nil)) and a\n" - " // GeometryReader feedback loop. Those tokens live only in this\n" - " // comment and must not trip the guard.\n" - + scroll_body - + "\n }\n" - " @ViewBuilder\n" - " private func workspaceRows(renderContext: WorkspaceListRenderContext) -> some View {\n" - + rows_body - + "\n }\n" - "}\n" - ) - - -# A clean scroll body and rows body that satisfy the contract. -GOOD_SCROLL = ( - " workspaceRows(renderContext: renderContext)\n" - " .frame(minHeight: minHeight, alignment: .top)" -) -GOOD_ROWS = ( - " let rows = LazyVStack(spacing: tabRowSpacing) {\n" - " ForEach(renderItems, id: \\.id) { item in workspaceRow(item) }\n" - " }\n" - " return rows" -) - - -def write_fixture(directory, name, contents): - path = os.path.join(directory, name) - with open(path, "w", encoding="utf-8") as handle: - handle.write(contents) - return path - - -def expect(result, should_pass, label): - ok = (result.returncode == 0) if should_pass else (result.returncode != 0) - state = "PASS" if ok else "FAIL" - print("[{0}] {1} (exit={2}, expected {3})".format( - state, label, result.returncode, "0" if should_pass else "non-zero")) - if not ok: - print("---- guard output ----") - print(result.stdout.rstrip()) - print("----------------------") - return ok +def content_fixture(body): + return f""" +import SwiftUI +struct Sidebar {{ + func workspaceScrollArea(renderContext: Context) -> some View {{ + {body} + }} +}} +struct TabItemView: View {{ + var body: some View {{ Text(\"row\") }} +}} +""" def main(): + guard = load_guard() failures = 0 - # (a) Real repo must pass. - failures += 0 if expect(run_guard_default(), True, "real repo passes") else 1 - - with tempfile.TemporaryDirectory() as workdir: - # (b) Clean code + forbidden tokens only in comments/strings still passes. - good_with_string = fixture( - GOOD_SCROLL + "\n .accessibilityLabel(\"GeometryReader sizeThatFits\")", - GOOD_ROWS, - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "Good.swift", good_with_string)), - True, "clean body, anti-patterns only in comments/strings", - ) else 1 - - # (b2) A Swift multi-line string literal that contains a bare `"` and - # forbidden tokens must not trip the guard. A naive tokenizer closes the - # literal at the inner quote and exposes `GeometryReader` as code. (#6870) - good_multiline = fixture( - GOOD_SCROLL - + "\n .help(\"\"\"\n" - + " Layout note: he said \"GeometryReader\" plus\n" - + " sizeThatFits(ProposedViewSize(width: w, height: nil)) and\n" - + " SidebarRowsFillLayout are all forbidden here.\n" - + " \"\"\")", - GOOD_ROWS, - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "GoodMultiline.swift", good_multiline)), - True, "multi-line string with bare quote + forbidden tokens passes", - ) else 1 - - # (c) Force-measure reintroduced. - bad_force = fixture( - " let h = subviews.first?.sizeThatFits(\n" - " ProposedViewSize(width: width, height: nil)).height ?? 0\n" - " return workspaceRows(renderContext: renderContext)\n" - " .frame(minHeight: max(h, minHeight), alignment: .top)", - GOOD_ROWS, - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "Force.swift", bad_force)), - False, "force-measure sizeThatFits(ProposedViewSize(height: nil)) fails", - ) else 1 + failures += expect(not guard.default_violations(ROOT), "real repository passes") - # (d) SidebarRowsFillLayout reintroduced. - bad_layout = fixture( - " SidebarRowsFillLayout(minHeight: minHeight) {\n" - " workspaceRows(renderContext: renderContext)\n" - " }", - GOOD_ROWS, - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "Layout.swift", bad_layout)), - False, "reintroduced SidebarRowsFillLayout fails", - ) else 1 + clean = content_fixture("SidebarWorkspaceTableView()") + failures += expect(not guard.check_content_view(clean), "table boundary passes") - # (d2) A *renamed* custom Layout (not the literal old name) applied to the - # rows must fail, even when the force-measure lives in the layout type - # outside the two guarded functions. The guard discovers Layout-conforming - # types and bans all their names from the functions. (#6870 review) - renamed_layout = ( - "import SwiftUI\n" - "struct RowsFillLayout: Layout {\n" - " func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {\n" - " subviews.first?.sizeThatFits(ProposedViewSize(width: 10, height: nil)) ?? .zero\n" - " }\n" - " func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {}\n" - "}\n" - + fixture( - " RowsFillLayout {\n" - " workspaceRows(renderContext: renderContext)\n" - " }\n" - " .frame(minHeight: minHeight, alignment: .top)", - GOOD_ROWS, - ) - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "RenamedLayout.swift", renamed_layout)), - False, "renamed custom Layout applied to rows fails", - ) else 1 - - # (e) GeometryReader in steady-state scroll content. - bad_geo = fixture( - " GeometryReader { proxy in\n" - " workspaceRows(renderContext: renderContext)\n" - " .frame(minHeight: proxy.size.height, alignment: .top)\n" - " }", - GOOD_ROWS, - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "Geo.swift", bad_geo)), - False, "GeometryReader in scroll content fails", - ) else 1 - - # (f) Eager VStack instead of LazyVStack. - bad_eager = fixture( - GOOD_SCROLL, - " let rows = VStack(spacing: tabRowSpacing) {\n" - " ForEach(renderItems, id: \\.id) { item in workspaceRow(item) }\n" - " }\n" - " return rows", - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "Eager.swift", bad_eager)), - False, "eager VStack (no LazyVStack) fails", - ) else 1 - - # (g) Missing .frame(minHeight:). - bad_nominheight = fixture( - " workspaceRows(renderContext: renderContext)\n" - " .frame(maxWidth: .infinity, alignment: .top)", - GOOD_ROWS, - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "NoMinHeight.swift", bad_nominheight)), - False, "missing .frame(minHeight:) fails", - ) else 1 - - # (h) A guarded function renamed away -> guard must fail loudly. - renamed = ( - "import SwiftUI\n" - "struct ContentBody {\n" - " private func workspaceScrollContent(\n" - " renderContext: WorkspaceListRenderContext, minHeight: CGFloat\n" - " ) -> some View {\n" - + GOOD_SCROLL - + "\n }\n" - " @ViewBuilder\n" - " private func workspaceRowsRenamed(renderContext: WorkspaceListRenderContext) -> some View {\n" - + GOOD_ROWS - + "\n }\n" - "}\n" - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "Renamed.swift", renamed)), - False, "renamed guarded function fails (no silent skip)", - ) else 1 - - # (j) Clean TabItemView row region passes. - def row_fixture(row_body): - return fixture(GOOD_SCROLL, GOOD_ROWS) + ( - "struct TabItemView: View, Equatable {\n" - " let tab: Tab\n" - " var body: some View {\n" - + row_body - + "\n }\n" - "}\n" - ) - - clean_row = row_fixture( - " HStack { Text(tab.title) }\n" - " .contentShape(Rectangle())" - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "CleanRow.swift", clean_row)), - True, "clean TabItemView row region passes", - ) else 1 - - # (k) The #6556 rowHeightProbe shape: GeometryReader writing @State row - # height from inside a row. This exact code shipped in stable v0.64.17 - # and livelocked in the wild (issue #2586, 2026-07-02 spindump). - probe_row = row_fixture( - " HStack { Text(tab.title) }\n" - " .background {\n" - " GeometryReader { proxy in\n" - " Color.clear.onAppear { rowHeight = proxy.size.height }\n" - " }\n" - " }" - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "ProbeRow.swift", probe_row)), - False, "#6556 rowHeightProbe (GeometryReader in row) fails", - ) else 1 - - # (l) Per-row anchorPreference publication (the #5323 defeat). - anchor_row = row_fixture( - " HStack { Text(tab.title) }\n" - " .anchorPreference(key: RowFrameKey.self, value: .bounds) { [tab.id: $0] }" - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "AnchorRow.swift", anchor_row)), - False, "per-row .anchorPreference (#5323 shape) fails", - ) else 1 - - # (n) --file on a row-view source (no container functions) must not - # emit false "could not locate func" violations; row scanning still - # applies. (Greptile P2 on #7221.) - header_like = ( - "import SwiftUI\n" - "struct SidebarWorkspaceGroupHeaderView: View, Equatable {\n" - " var body: some View {\n" - " HStack { Text(\"group\") }\n" - " }\n" - "}\n" - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "HeaderLike.swift", header_like)), - True, "--file on row-view source without container functions passes", - ) else 1 + missing = "struct Sidebar { func renamed() {} }" + failures += expect( + any("could not locate" in item for item in guard.check_content_view(missing)), + "renamed boundary fails loudly", + ) - header_like_bad = header_like.replace( - " HStack { Text(\"group\") }\n", - " HStack { Text(\"group\") }\n" - " .background { GeometryReader { p in Color.clear } }\n", - ) - failures += 0 if expect( - run_guard(write_fixture(workdir, "HeaderLikeBad.swift", header_like_bad)), - False, "--file row-view source with GeometryReader still fails", - ) else 1 + swiftui_list = content_fixture("ScrollView { LazyVStack { Text(\"x\") } }") + failures += expect( + any("SwiftUI list container" in item for item in guard.check_content_view(swiftui_list)), + "SwiftUI ScrollView/LazyVStack regression fails", + ) - # (o) Whole-file row-wrapper scan (VerticalTabsSidebar+WorkspaceGroups): - # clean passes, GeometryReader at the wrapper site fails, and a missing - # required marker fails loudly. (Codex review on #7221.) - guard_mod_rows = load_guard_module() - wrapper_clean = ( - "import SwiftUI\nextension VerticalTabsSidebar {\n" - " func sidebarWorkspaceGroupHeader(item: Item) -> some View {\n" - " SidebarWorkspaceGroupHeaderView(item: item)\n" - " .contentShape(Rectangle())\n" - " }\n}\n" - ) - v = guard_mod_rows.check_source( - wrapper_clean, set(), require_functions=False, - scan_all_rows=True, required_markers=("sidebarWorkspaceGroupHeader",), - ) - ok = not v - print("[{0}] clean row-wrapper file passes whole-file scan".format( - "PASS" if ok else "FAIL")) - failures += 0 if ok else 1 + obsolete = clean + "\nextension Sidebar { func workspaceRows() {} }\n" + failures += expect( + any("obsolete" in item for item in guard.check_content_view(obsolete)), + "obsolete workspaceRows seam fails", + ) - v = guard_mod_rows.check_source( - wrapper_clean.replace( - ".contentShape(Rectangle())", - ".background { GeometryReader { p in Color.clear } }", - ), - set(), require_functions=False, - scan_all_rows=True, required_markers=("sidebarWorkspaceGroupHeader",), - ) - ok = any("GeometryReader" in x for x in v) - print("[{0}] GeometryReader at wrapper site fails whole-file scan".format( - "PASS" if ok else "FAIL")) - failures += 0 if ok else 1 + representables = {"BadRowPortal", "SidebarInlineRenameField", "GPUSpinner"} + bad_row = """ +struct TabItemView: View { + var body: some View { BadRowPortal() } +} +""" + failures += expect( + any("BadRowPortal" in item for item in guard.check_row_source( + bad_row, "TabItemView", representables + )), + "per-row NSViewRepresentable fails", + ) - v = guard_mod_rows.check_source( - "import SwiftUI\n", set(), require_functions=False, - scan_all_rows=True, required_markers=("sidebarWorkspaceGroupHeader",), - ) - ok = any("could not locate" in x for x in v) - print("[{0}] missing wrapper marker fails loudly".format( - "PASS" if ok else "FAIL")) - failures += 0 if ok else 1 + allowed_row = """ +struct TabItemView: View { + var body: some View { SidebarInlineRenameField() } +} +""" + failures += expect( + not guard.check_row_source(allowed_row, "TabItemView", representables), + "inline rename allowlist passes", + ) - # (m) A required row type missing from its file must fail loudly. - guard_mod = load_guard_module() - missing_row_violations = guard_mod.check_source( - "import SwiftUI\nstruct SomethingElse: View { var body: some View { Text(\"x\") } }\n", - set(), - require_functions=False, - required_row_types=("SidebarWorkspaceGroupHeaderView",), - ) - row_rename_ok = any("could not locate type" in v for v in missing_row_violations) - print("[{0}] missing required row type fails loudly".format( - "PASS" if row_rename_ok else "FAIL")) - failures += 0 if row_rename_ok else 1 + bad_lifecycle = """ +final class SidebarWorkspaceTableViewImpl: NSTableView { + override func updateTrackingAreas() { + super.updateTrackingAreas() + reloadData() + } +} +""" + failures += expect( + any("layout callback" in item for item in guard.check_appkit_sources({ + "SidebarWorkspaceTableViewImpl.swift": bad_lifecycle, + })), + "reload from updateTrackingAreas fails", + ) - # (i) Custom-Layout discovery must cover repo-owned Packages/ (where cmux - # migrates app code) and exclude build/vendor trees. (#6870 review) - guard = load_guard_module() - fake_root = os.path.join(workdir, "fakerepo") - write_text(os.path.join(fake_root, "Sources", "Z.swift"), - "struct SourcesLayout: Layout {}\n") - write_text(os.path.join(fake_root, "Packages", "macOS", "CmuxSidebar", - "Sources", "X.swift"), - "struct PackagedRowsLayout: Layout {}\n") - write_text(os.path.join(fake_root, "Packages", "macOS", "Dep", ".build", - "checkouts", "ext", "Y.swift"), - "struct VendoredLayout: Layout {}\n") - scanned = list(guard.repo_owned_swift_files(fake_root)) - discovered = guard.find_custom_layout_type_names(scanned) - cov_ok = ( - "SourcesLayout" in discovered - and "PackagedRowsLayout" in discovered - and "VendoredLayout" not in discovered - ) - print("[{0}] discovery covers Sources/ + Packages/, excludes .build " - "(found {1})".format("PASS" if cov_ok else "FAIL", sorted(discovered))) - failures += 0 if cov_ok else 1 + comments_only = """ +final class SidebarWorkspaceTableViewImpl: NSTableView { + override func updateTrackingAreas() { + super.updateTrackingAreas() + // reloadData() and rootView = are forbidden, but this is prose. + let note = \"noteHeightOfRows()\" + } +} +""" + lifecycle_violations = guard.check_appkit_sources({ + "SidebarWorkspaceTableViewImpl.swift": comments_only, + }) + failures += expect( + not any("layout callback" in item for item in lifecycle_violations), + "comments and strings are neutralized", + ) if failures: - print("\ntest_ci_sidebar_lazy_layout_guard: {0} case(s) FAILED".format(failures), - file=sys.stderr) + print(f"test_ci_sidebar_lazy_layout_guard: {failures} case(s) failed", file=sys.stderr) return 1 - print("\ntest_ci_sidebar_lazy_layout_guard: all cases passed") + print("test_ci_sidebar_lazy_layout_guard: all cases passed") return 0 From 242fbec5b9e810e16fb69af82e8a0637e6dd2ed8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Wed, 15 Jul 2026 23:03:49 -0700 Subject: [PATCH 3/4] Gate the AppKit sidebar list behind a beta flag, default off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sidebar.beta.appKitList.enabled (Settings → Beta Features → AppKit Sidebar List, also settable in cmux.json) selects the list core: off (default) keeps the existing SwiftUI LazyVStack path unchanged — scroll-follow proxy, pointer-interaction monitor, drag-gated frame reader, drop overlays, and row drag mounts all restored verbatim; on mounts the NSTableView path. workspaceScrollArea branches on the flag and hosts the shared workspace-observation/snapshot-refresh stack. Table-hosted cells set a sidebarPlatformListHosted environment flag so TabItemView keeps its SwiftUI drag/drop mounts in the legacy list but skips them inside table cells, where NSTableView owns the drag session and drop-target geometry. --- .../Keys/BetaFeaturesCatalogSection.swift | 10 + .../Sections/BetaFeaturesSection.swift | 22 + Resources/Localizable.xcstrings | 51 ++ Sources/ContentView.swift | 595 +++++++++++- Sources/Debug/SidebarLazyContractProbe.swift | 16 +- ...barWorkspaceTableEnvironmentSnapshot.swift | 16 + .../SidebarBonsplitTabDropDelegate.swift | 43 + ...ebarBonsplitWorkspaceRowDropModifier.swift | 23 + ...SidebarPointerFrameReportingModifier.swift | 33 + .../SidebarPointerInteractionMonitor.swift | 281 ++++++ Sources/Sidebar/SidebarRowDragGate.swift | 16 + .../SidebarWorkspaceDropTargetWriters.swift | 27 + Sources/SidebarWorkspaceGroupRowView.swift | 27 + Sources/SidebarWorkspaceRowActions.swift | 2 + Sources/SidebarWorkspaceRowView.swift | 30 + .../VerticalTabsSidebar+WorkspaceGroups.swift | 31 +- cmuxTests/SidebarLazyLayoutScaleTests.swift | 202 ++-- ...idebarPointerInteractionMonitorTests.swift | 221 +++++ .../SidebarPointerInteractionScaleTests.swift | 283 ++++++ scripts/check-sidebar-lazy-layout.py | 865 ++++++++++++++---- tests/test_ci_sidebar_lazy_layout_guard.py | 486 ++++++++-- 21 files changed, 2838 insertions(+), 442 deletions(-) create mode 100644 Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift create mode 100644 Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift create mode 100644 Sources/Sidebar/SidebarPointerFrameReportingModifier.swift create mode 100644 Sources/Sidebar/SidebarPointerInteractionMonitor.swift create mode 100644 Sources/Sidebar/SidebarRowDragGate.swift create mode 100644 Sources/SidebarWorkspaceDropTargetWriters.swift create mode 100644 Sources/SidebarWorkspaceGroupRowView.swift create mode 100644 Sources/SidebarWorkspaceRowView.swift create mode 100644 cmuxTests/SidebarPointerInteractionMonitorTests.swift create mode 100644 cmuxTests/SidebarPointerInteractionScaleTests.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift index a9dac7bbffdc..b7248035f497 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift @@ -56,6 +56,16 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { userDefaultsKey: "sidebarWorkspaceTodosChecklistStyle" ) + /// AppKit sidebar list: renders the workspace sidebar's scrollable list + /// with a native NSTableView (measured-once row heights, explicit + /// diffing) instead of the SwiftUI LazyVStack scroll core. Defaults off; + /// while off, the existing SwiftUI list path is unchanged. + public let appKitSidebarList = DefaultsKey( + id: "sidebar.beta.appKitList.enabled", + defaultValue: false, + userDefaultsKey: "sidebar.beta.appKitList.enabled" + ) + /// Remote tmux: mirror a remote host's tmux sessions in the cmux sidebar /// over `ssh … tmux -CC` (iTerm2-style control mode). Sessions appear as /// sidebar workspaces, tmux windows as tabs, and tmux panes as splits; diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift index 3d9778a9f0a6..f79248f6b7af 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -11,6 +11,7 @@ public struct BetaFeaturesSection: View { @State private var dock: DefaultsValueModel @State private var extensions: DefaultsValueModel @State private var customSidebars: DefaultsValueModel + @State private var appKitSidebarList: DefaultsValueModel @State private var remoteTmux: DefaultsValueModel @State private var workspaceTodosChecklistStyle: DefaultsValueModel @@ -19,6 +20,7 @@ public struct BetaFeaturesSection: View { _dock = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.rightSidebarDock)) _extensions = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.extensions)) _customSidebars = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.customSidebars)) + _appKitSidebarList = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.appKitSidebarList)) _remoteTmux = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.remoteTmux)) _workspaceTodosChecklistStyle = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.betaFeatures.workspaceTodosChecklistStyle)) } @@ -39,6 +41,8 @@ public struct BetaFeaturesSection: View { SettingsCardDivider() customSidebarsRow SettingsCardDivider() + appKitSidebarListRow + SettingsCardDivider() remoteTmuxRow SettingsCardDivider() workspaceTodosChecklistStyleRow @@ -53,6 +57,7 @@ public struct BetaFeaturesSection: View { dock, extensions, customSidebars, + appKitSidebarList, remoteTmux, workspaceTodosChecklistStyle, ] @@ -151,6 +156,23 @@ public struct BetaFeaturesSection: View { } } + @ViewBuilder + private var appKitSidebarListRow: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + searchAnchorID: "setting:betaFeatures:appKitSidebarList", + String(localized: "settings.betaFeatures.appKitSidebarList", defaultValue: "AppKit Sidebar List"), + subtitle: appKitSidebarList.current + ? String(localized: "settings.betaFeatures.appKitSidebarList.subtitleOn", defaultValue: "Renders the workspace list with a native AppKit table for smoother scrolling with many workspaces.") + : String(localized: "settings.betaFeatures.appKitSidebarList.subtitleOff", defaultValue: "Uses the existing SwiftUI workspace list until you enable the native table here.") + ) { + Toggle("", isOn: Binding(get: { appKitSidebarList.current }, set: { appKitSidebarList.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsBetaAppKitSidebarListToggle") + } + } + @ViewBuilder private var remoteTmuxRow: some View { SettingsCardRow( diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0baece28fd65..6ce5daffcc90 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -153871,6 +153871,57 @@ } } }, + "settings.betaFeatures.appKitSidebarList": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AppKit Sidebar List" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AppKit サイドバーリスト" + } + } + } + }, + "settings.betaFeatures.appKitSidebarList.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uses the existing SwiftUI workspace list until you enable the native table here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ここで有効にするまで、既存の SwiftUI ワークスペースリストを使用します。" + } + } + } + }, + "settings.betaFeatures.appKitSidebarList.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Renders the workspace list with a native AppKit table for smoother scrolling with many workspaces." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースリストをネイティブの AppKit テーブルで描画し、多数のワークスペースでもスクロールを滑らかにします。" + } + } + } + }, "settings.betaFeatures.customSidebars": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7661255b9269..b1eb2a8b1e03 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9890,6 +9890,7 @@ struct VerticalTabsSidebar: View { @Binding var lastSidebarSelectionIndex: Int? @Binding var sidebarRenderWorkerClient: RenderWorkerClient? @State var modifierKeyMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) + @State var pointerInteractionMonitor = SidebarPointerInteractionMonitor() @StateObject var dragAutoScrollController = SidebarDragAutoScrollController() @State private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore( @@ -9900,13 +9901,16 @@ struct VerticalTabsSidebar: View { // Bonsplit tab drags arrive through AppKit pasteboard callbacks, not // `SidebarDragState`, so they need a separate transient collection flag. @State private var isBonsplitWorkspaceDropTargetCollectionActive = false + @State private var bonsplitWorkspaceDropTargetBridge = SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge() @State private var isWorkspaceReorderDropTargetCollectionActive = false + @State private var workspaceReorderDropTargetBridge = SidebarWorkspaceReorderDropOverlay.TargetBridge() // Freezes `showsModifierShortcutHints` for the workspace whose context menu // is open. Set on the row's contextMenu.onAppear and cleared on // .onDisappear so modifier-key transitions don't flip the badges on the // row sitting behind the open menu. See `SidebarShortcutHintFreezePolicy`. @State private var frozenShortcutHintsTabId: UUID? @State private var frozenShortcutHintsValue: Bool = false + @State private var pendingSelectedWorkspaceScrollId: UUID? @State private var collapsedExtensionSidebarSectionIds: Set = [] @State private var extensionSidebarWorktreeCreationInFlightSectionIds: Set = [] // Per-workspace transient checklist UI state (never persisted): which @@ -9956,6 +9960,7 @@ struct VerticalTabsSidebar: View { @LiveSetting(\.customSidebars.renderer) private var customSidebarRenderer @LiveSetting(\.shortcuts.showModifierHoldHints) private var showModifierHoldHints @LiveSetting(\.sidebar.showAgentActivity) private var showAgentActivity + @LiveSetting(\.betaFeatures.appKitSidebarList) private var appKitSidebarListEnabled #if DEBUG @Environment(\.minimalModeInvalidationProbe) private var minimalModeInvalidationProbe @Environment(\.sidebarLazyContractProbe) private var sidebarLazyContractProbe @@ -10180,6 +10185,64 @@ struct VerticalTabsSidebar: View { ) } + private func requestSelectedWorkspaceScroll( + _ proxy: ScrollViewProxy, + renderContext: WorkspaceListRenderContext + ) { + guard let selectedWorkspaceId = tabManager.selectedTabId, + renderContext.workspaceIds.contains(selectedWorkspaceId) else { + pendingSelectedWorkspaceScrollId = nil + return + } + + pendingSelectedWorkspaceScrollId = selectedWorkspaceId + flushPendingSelectedWorkspaceScroll(proxy, renderContext: renderContext) + } + + private func flushPendingSelectedWorkspaceScroll( + _ proxy: ScrollViewProxy, + renderContext: WorkspaceListRenderContext + ) { + guard let selectedWorkspaceId = pendingSelectedWorkspaceScrollId else { return } + + // Scroll unconditionally: ScrollViewProxy resolves `.id(_:)` values in + // lazy containers without requiring the row to be realized, and an + // unknown id is a harmless no-op. The previous design gated this on a + // per-row "laid-out row ids" PreferenceKey whose sidebar-wide reduce + // fed `@State` writes from inside the layout/preference update cycle, + // the cmux-owned edge in the sidebar layout livelock + // (https://github.com/manaflow-ai/cmux/issues/2586). No anchor means + // SwiftUI scrolls the minimum needed to reveal the row. + let group = renderContext.workspaceById[selectedWorkspaceId]?.groupId + .flatMap { renderContext.workspaceGroupById[$0] } + proxy.scrollTo(SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId( + selectedWorkspaceId: selectedWorkspaceId, + group: group + )) + pendingSelectedWorkspaceScrollId = nil + } + + private func shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange( + from oldWorkspaceIds: [UUID], + to newWorkspaceIds: [UUID] + ) -> Bool { + SidebarSelectedWorkspaceScrollPolicy.shouldScrollSelectedWorkspace( + selectedWorkspaceId: tabManager.selectedTabId, + oldWorkspaceIds: oldWorkspaceIds, + newWorkspaceIds: newWorkspaceIds + ) + } + + private func requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(_ notification: Notification) { + guard let manager = notification.object as? TabManager, manager === tabManager else { + return + } + guard let selectedWorkspaceId = tabManager.selectedTabId else { return } + let movedWorkspaceIds = notification.userInfo?[WorkspaceOrderChangeNotificationKey.movedWorkspaceIds] as? [UUID] ?? [] + guard movedWorkspaceIds.contains(selectedWorkspaceId) else { return } + pendingSelectedWorkspaceScrollId = selectedWorkspaceId + } + struct WorkspaceListRenderContext { let environment: SidebarWorkspaceTableEnvironmentSnapshot let tabs: [Workspace] @@ -10332,6 +10395,13 @@ struct VerticalTabsSidebar: View { .frame(width: 0, height: 0) ) .onAppear { + pointerInteractionMonitor.start { workspaceId in + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } +#if DEBUG + cmuxDebugLog("sidebar.close workspace=\(workspaceId.uuidString.prefix(5)) method=middleClick") +#endif + tabManager.closeWorkspaceWithConfirmation(workspace) + } if showModifierHoldHints { modifierKeyMonitor.setHostWindow(observedWindow) modifierKeyMonitor.start() @@ -10355,6 +10425,7 @@ struct VerticalTabsSidebar: View { ) } .onDisappear { + pointerInteractionMonitor.stop() modifierKeyMonitor.stop() dragAutoScrollController.stop() dragFailsafeMonitor.stop() @@ -10433,7 +10504,7 @@ struct VerticalTabsSidebar: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func workspaceScrollArea(renderContext: WorkspaceListRenderContext) -> some View { + private func workspaceTableScrollArea(renderContext: WorkspaceListRenderContext) -> some View { // The workspace list is an AppKit NSTableView (measured-once row // heights, explicit diffing) rather than a SwiftUI lazy container. // LazyVStack re-estimates unrealized row heights from the realized @@ -10527,6 +10598,145 @@ struct VerticalTabsSidebar: View { lastSidebarSelectionIndex = index } } + } + + private func workspaceLegacyScrollArea(renderContext: WorkspaceListRenderContext) -> some View { + let scrollInsets = SidebarWorkspaceScrollInsets.workspaceList + 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) + .background( + SidebarScrollViewResolver { scrollView in + configureSidebarScrollView(scrollView) + dragAutoScrollController.attach(scrollView: scrollView) + } + .frame(width: 0, height: 0) + ) + .safeAreaInset(edge: .top, spacing: 0) { + Color.clear.frame(height: scrollInsets.top).allowsHitTesting(false) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: scrollInsets.bottom).allowsHitTesting(false) + } + .mask( + SidebarWorkspaceScrollEdgeFadeMask( + topHeight: sidebarTopScrimHeight, + bottomHeight: sidebarBottomScrimHeight + ) + ) + .overlay(alignment: .top) { + // The sidebar top strip remains draggable and handles + // double-clicks with the standard titlebar action. + WindowDragHandleView() + .frame(height: sidebarTitlebarInteractionHeight) + .background(TitlebarDoubleClickMonitorView()) + } + .overlay(alignment: .topLeading) { + minimalModeSidebarTitlebarControlsOverlay() + } + .overlay(alignment: .top) { + workspaceReorderDropOverlay( + renderContext: renderContext, + pointOffset: CGSize(width: 0, height: -scrollInsets.top) + ) + .frame(maxWidth: .infinity) + .frame(height: scrollInsets.top) + } + .background(Color.clear) + .modifier(ClearScrollBackground()) + .onAppear { + requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) + } + .onChange(of: tabManager.selectedTabId) { _, _ in + requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) + // Workspace switches produce no outside click for .transient auto-dismiss; close popovers explicitly. + if let dismissed = checklistPopoverWorkspaceId { checklistAddFieldActivationTokens[dismissed] = nil } + checklistPopoverWorkspaceId = nil + } + .onChange(of: renderContext.workspaceIds) { oldWorkspaceIds, newWorkspaceIds in + guard shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange( + from: oldWorkspaceIds, + to: newWorkspaceIds + ) else { + flushPendingSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) + return + } + requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) + } + .onReceive(NotificationCenter.default.publisher(for: .workspaceOrderDidChange)) { notification in + requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(notification) + } + .onReceive(NotificationCenter.default.publisher(for: .workspaceCurrentDirectoryDidChange)) { _ in + // Drive a revision counter that the group-header resolver + // reads. Forces SwiftUI to re-invoke `cmuxConfigStore.resolveWorkspaceGroupConfig(forCwd:)` + // when the anchor's cwd changes while the anchor is not + // the selected workspace — otherwise group color/icon/menu + // and `+` placement reflect the previous cwd until some + // unrelated sidebar event fires. + anchorCwdRevision &+= 1 + } + .onReceive(NotificationCenter.default.publisher(for: SidebarMultiSelectionDidHideEvent.notificationName)) { notification in + // Group collapse hides some workspaces without changing + // focus or wiping the rest of the multi-selection. Strip + // only the hidden ids; if focus moved, make sure the new + // focused id is still represented. + guard let model = notification.object as? SidebarMultiSelectionModel, + model === tabManager.sidebarMultiSelection, + let event = SidebarMultiSelectionDidHideEvent(notification) else { return } + var next = selectedTabIds.subtracting(event.hiddenWorkspaceIds) + if let movedFocus = event.focusedWorkspaceId { + next.insert(movedFocus) + if let index = tabManager.tabs.firstIndex(where: { $0.id == movedFocus }) { + lastSidebarSelectionIndex = index + } + } + if next != selectedTabIds { + selectedTabIds = next + } + } + .onReceive(NotificationCenter.default.publisher(for: SidebarMultiSelectionShouldCollapseEvent.notificationName)) { notification in + // Keyboard nav (selectNextTab/selectPreviousTab) posts + // this so any stale Shift-click range in the sidebar's + // SwiftUI selectedTabIds collapses to just the newly- + // focused workspace. Without this, batch context-menu / + // shortcut actions would still target the stale range. + guard let model = notification.object as? SidebarMultiSelectionModel, + model === tabManager.sidebarMultiSelection, + let event = SidebarMultiSelectionShouldCollapseEvent(notification) else { return } + let focusedId = event.focusedWorkspaceId + let next: Set = tabManager.tabs.contains(where: { $0.id == focusedId }) ? [focusedId] : [] + if selectedTabIds != next { + selectedTabIds = next + } + if let index = tabManager.tabs.firstIndex(where: { $0.id == focusedId }) { + lastSidebarSelectionIndex = index + } + } + } + } + } + + private func workspaceScrollArea(renderContext: WorkspaceListRenderContext) -> some View { + Group { + if appKitSidebarListEnabled { + workspaceTableScrollArea(renderContext: renderContext) + } else { + workspaceLegacyScrollArea(renderContext: renderContext) + } + } .sidebarProcessTitleObservations( ids: renderContext.workspaceIds, models: renderContext.tabs.map(\.sidebarProcessTitleObservation) @@ -10563,6 +10773,7 @@ struct VerticalTabsSidebar: View { } } + private func workspaceTableActions( renderContext: WorkspaceListRenderContext ) -> SidebarWorkspaceTableActions { @@ -11840,6 +12051,287 @@ struct VerticalTabsSidebar: View { } } + private func workspaceScrollContent( + renderContext: WorkspaceListRenderContext, + minHeight: CGFloat + ) -> some View { + let signpost = SidebarProfilingSignposts.begin("sidebar-scroll-content", "workspaces=\(renderContext.workspaceCount) renderItems=\(renderContext.workspaceRenderItems.count) minHeight=\(minHeight)"); defer { SidebarProfilingSignposts.end(signpost) } + let shouldCollectWorkspaceDropTargets = SidebarDropPlanner().shouldCollectWorkspaceDropTargets( + draggedTabId: dragState.draggedTabId, + isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive || + isWorkspaceReorderDropTargetCollectionActive + ) + // Rows stay lazy + pinned top; `.frame(minHeight:)` fills the viewport + // (#3241) or scrolls without measuring the LazyVStack. The prior + // SidebarRowsFillLayout measured it (`sizeThatFits(height: nil)`) every + // pass, realizing all rows and re-livelocking at scale (#2586 / #5764 / + // #5845; regressed by #6033). Drop/tap = background; indicator on rows. + let content = workspaceRows( + renderContext: renderContext, + shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets + ) + .overlay(alignment: .bottom) { + if emptyAreaTopDropIndicatorVisible() { + Rectangle() + .fill(cmuxAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: tabRowSpacing / 2) + } + } + // Neutralize ALL end-of-list empty-area interactions over the rows + // block (2pt gaps, row padding, and the entire list when it + // overflows) so none fall through to SidebarEmptyArea behind: + // workspace-reorder drops, Bonsplit new-workspace drops, and the + // double-tap-to-create gesture. Sized to the rows, so only the + // genuine blank area below the last row stays interactive. This is + // the measurement-free equivalent of physically placing the empty + // area below the rows; doing that requires asking the LazyVStack for + // its height, which realizes every row each layout pass and is the + // livelock this change removes. Per-row delegates render in front + // and still win over their own rows. + .background { + Color.clear + .contentShape(Rectangle()) + .onTapGesture(count: 2) {} + .onDrop(of: SidebarTabDragPayload.dropContentTypes, isTargeted: nil) { _ in false } + .onDrop(of: BonsplitTabDragPayload.dropContentTypes, isTargeted: nil) { _ in false } + } + .frame(minHeight: minHeight, alignment: .top) + .background(alignment: .top) { + SidebarEmptyArea( + rowSpacing: tabRowSpacing, + selection: $selection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + topDropIndicatorVisible: false, + bonsplitDropIndicator: dropIndicatorBinding, + expandsVertically: true + ) + } + + return rowsWithGatedDropTargetReader( + rows: content, + renderContext: renderContext, + shouldCollect: shouldCollectWorkspaceDropTargets + ) + .overlay { + workspaceReorderDropOverlay(renderContext: renderContext) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .overlay { + bonsplitWorkspaceDropOverlay() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private func workspaceRows( + renderContext: WorkspaceListRenderContext, + shouldCollectWorkspaceDropTargets: Bool + ) -> some View { + let signpost = SidebarProfilingSignposts.begin("sidebar-workspace-rows", "renderItems=\(renderContext.workspaceRenderItems.count) collectDropTargets=\(shouldCollectWorkspaceDropTargets)") + let renderItems = renderContext.workspaceRenderItems + // 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 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 + ) + } + } + } + } + .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + // No whole-content height measurement here: reading the LazyVStack's + // total height (GeometryReader, or a custom Layout's sizeThatFits) fed a + // non-converging relayout loop (#2586 / #5764 / #5845). Fill is handled + // by `.frame(minHeight:)` in workspaceScrollContent. + let _ = SidebarProfilingSignposts.end(signpost) + rows + } + /// Conditionally installs the row-frame `overlayPreferenceValue` reader (the part + /// that defeats `LazyVStack` virtualization) only while a drag is collecting drop + /// targets. Kept separate from the always-mounted drop-capture overlay so the gate + /// flip never changes the drop NSView's identity. (#5325 review) + @ViewBuilder + private func rowsWithGatedDropTargetReader( + rows: Rows, + renderContext: WorkspaceListRenderContext, + shouldCollect: Bool + ) -> some View { + if shouldCollect { + rows + .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in + GeometryReader { proxy in + let workspaceGroupsByAnchor = Dictionary( + uniqueKeysWithValues: renderContext.workspaceGroups.map { ($0.anchorWorkspaceId, $0) } + ) + SidebarWorkspaceDropTargetWriters( + bonsplitTargetBridge: bonsplitWorkspaceDropTargetBridge, + bonsplitTargets: renderContext.tabs.compactMap { tab in + guard let anchor = anchors[tab.id] else { return nil } + return SidebarDropPlanner.WorkspaceDropTarget( + workspaceId: tab.id, + isPinned: tab.isPinned, + frame: proxy[anchor] + ) + }, + reorderTargetBridge: workspaceReorderDropTargetBridge, + reorderTargets: renderContext.visibleWorkspaceRowIds.compactMap { workspaceId in + guard let anchor = anchors[workspaceId], + renderContext.workspaceById[workspaceId] != nil else { + return nil + } + let group = workspaceGroupsByAnchor[workspaceId] + let targetGroupId = group?.id ?? + (renderContext.workspaceGroupIdByWorkspaceId[workspaceId] ?? nil) + return SidebarWorkspaceReorderDropOverlay.Target( + workspaceId: workspaceId, + groupId: targetGroupId, + isGroupHeader: group != nil, + frame: proxy[anchor] + ) + } + ) + } + } + } else { + rows + } + } + + private func bonsplitWorkspaceDropOverlay() -> some View { + SidebarBonsplitTabWorkspaceDropOverlay( + currentSelectedTabId: { + tabManager.selectedTabId + }, + sidebarIndexForTabId: { workspaceId in + tabManager.tabs.firstIndex { $0.id == workspaceId } + }, + moveToExistingWorkspace: { workspaceId, transfer in + guard let app = AppDelegate.shared else { + return false + } + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == workspaceId { + return true + } + return app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) + }, + moveToNewWorkspace: { insertionIndex, transfer in + guard let app = AppDelegate.shared, + let result = app.moveBonsplitTabToNewWorkspace( + tabId: transfer.tab.id, + destinationManager: tabManager, + focus: true, + focusWindow: true, + insertionIndexOverride: insertionIndex + ) else { + return nil + } + return result.destinationWorkspaceId + }, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dropIndicator: dropIndicatorBinding, + updateAutoscroll: { + dragAutoScrollController.updateFromDragLocation() + }, + setWorkspaceDropTargetCollectionActive: { isActive in + guard isBonsplitWorkspaceDropTargetCollectionActive != isActive else { return } + isBonsplitWorkspaceDropTargetCollectionActive = isActive + }, + isWorkspaceDropTargetCollectionActive: isBonsplitWorkspaceDropTargetCollectionActive, + targetBridge: bonsplitWorkspaceDropTargetBridge + ) + } + + private func workspaceReorderDropOverlay( + renderContext: WorkspaceListRenderContext, + pointOffset: CGSize = .zero + ) -> some View { + SidebarWorkspaceReorderDropOverlay( + targetBridge: workspaceReorderDropTargetBridge, + isValidDrag: { + activateSidebarWorkspaceDragIfNeeded() + }, + updateDrag: { point, targets in + updateWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) + }, + performDrop: { point, targets in + performWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) + }, + clearDropIndicator: { + dragState.clearDropIndicator() + dragAutoScrollController.stop() + }, + setWorkspaceDropTargetCollectionActive: { isActive in + guard isWorkspaceReorderDropTargetCollectionActive != isActive else { return } + isWorkspaceReorderDropTargetCollectionActive = isActive + }, + pointOffset: pointOffset + ) + } + /// Builds the immutable per-row configurations the AppKit table consumes. /// Live models reduce to value snapshots here, exactly as the previous /// LazyVStack path did; the table then diffs configurations (structural @@ -12363,9 +12855,7 @@ struct VerticalTabsSidebar: View { frozenTabId: frozenShortcutHintsTabId, frozenValue: frozenShortcutHintsValue ) - // Hover is AppKit-owned: the table controller overlays it per cell - // configure, so the base input always carries false here. - let isPointerHovering = false + let isPointerHovering = pointerInteractionMonitor.hoveredRowId == .workspace(tab.id) // Per-row drag/drop snapshots. Reading `dragState` here in the parent // is intentional: the parent owns the @Observable store, and these @@ -12464,10 +12954,12 @@ struct VerticalTabsSidebar: View { /// 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 } } @@ -12739,11 +13231,34 @@ struct VerticalTabsSidebar: View { 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 + ) + } + /// Builds one workspace row configuration for the AppKit table. Hover is /// AppKit-owned (table tracking areas), so the content factory receives it /// as a parameter and overlays it on the immutable row input; context-menu @@ -12804,6 +13319,67 @@ struct VerticalTabsSidebar: View { } +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 } + } +} + +/// Applies the SwiftUI list path's row drag/drop mounts. AppKit-table-hosted +/// cells skip them: NSTableView owns the drag session (pasteboardWriterForRow) +/// and bonsplit drops resolve against table row rects, so a SwiftUI onDrag / +/// onDrop on the same row would fight the native handling. +private struct SidebarRowLegacyListDragMounts: ViewModifier { + let isPlatformListHosted: Bool + let isEditing: Bool + let isBonsplitWorkspaceDropActive: Bool + let workspaceId: UUID + let actions: SidebarWorkspaceRowActions + + @ViewBuilder + func body(content: Content) -> some View { + if isPlatformListHosted { + content + } else { + content + .sidebarRowDragGate(isEditing: isEditing, actions.onDragStart) + .internalOnlyTabDrag() + .modifier(SidebarBonsplitWorkspaceRowDropModifier( + isEnabled: isBonsplitWorkspaceDropActive, + targetWorkspaceId: workspaceId, + bonsplitSourceWorkspaceId: actions.bonsplitSourceWorkspaceId, + moveBonsplitTabToWorkspace: actions.moveBonsplitTabToWorkspace, + syncSidebarSelectionAfterDrop: actions.syncAfterBonsplitDrop, + selectTargetAfterDrop: actions.selectAfterBonsplitDrop + )) + } + } +} + @MainActor private final class SidebarDragFailsafeMonitor { private static let escapeKeyCode: UInt16 = 53 @@ -13318,6 +13894,10 @@ struct TabItemView: View, Equatable { // percent here and applying a primitive `.font(...)` keeps magnification // working while dropping those per-label modifier bodies. @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontMagnificationPercent + // Plain env value (excluded from == like colorScheme): true only inside + // AppKit-table-hosted cells, where the table owns drag and bonsplit drops + // so the SwiftUI mounts must stay off. + @Environment(\.sidebarPlatformListHosted) private var isPlatformListHosted #if DEBUG // Plain-value environment probe (closure struct, not an object reference): // set only by SidebarLazyLayoutScaleTests, default no-op, excluded from == @@ -14040,6 +14620,13 @@ struct TabItemView: View, Equatable { guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } workspaceFinderDirectoryOpenRequest = nil } + .modifier(SidebarRowLegacyListDragMounts( + isPlatformListHosted: isPlatformListHosted, + isEditing: isEditing, + isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropActive, + workspaceId: workspaceId, + actions: actions + )) .onTapGesture { if !isEditing { updateSelection() } } diff --git a/Sources/Debug/SidebarLazyContractProbe.swift b/Sources/Debug/SidebarLazyContractProbe.swift index 852c148cc55a..109c90583bb0 100644 --- a/Sources/Debug/SidebarLazyContractProbe.swift +++ b/Sources/Debug/SidebarLazyContractProbe.swift @@ -1,14 +1,14 @@ import SwiftUI #if DEBUG -/// Test-only probe for the workspace sidebar virtualization contract: the -/// AppKit table must materialize viewport-many cells and reconfigure only -/// changed hosted roots, never all workspaces. The old SwiftUI lazy-layout -/// contract regressed five times through five different mechanisms (#2586, -/// #5764, #5845, #6210, #6556), each shipping to stable before being detected -/// at scale. `SidebarLazyLayoutScaleTests` mounts the sidebar with hundreds -/// of workspaces, injects these closures, and fails if row bodies are -/// realized without bound or keep re-evaluating after updates settle. +/// Test-only body-evaluation probe for the workspace sidebar's lazy-layout +/// contract: sidebar layout/diff work must stay O(visible rows), never +/// O(all workspaces). The contract has regressed five times through five +/// different mechanisms (#2586, #5764, #5845, #6210, #6556), each shipping to +/// stable before being detected at scale. `SidebarLazyLayoutScaleTests` mounts +/// the sidebar with hundreds of workspaces, injects these closures, and fails +/// if row bodies are realized without bound or keep re-evaluating after +/// updates settle — regardless of which mechanism defeats laziness next. /// /// Same pattern as `MinimalModeInvalidationProbe`; compiled out of Release. struct SidebarLazyContractProbe { diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift index a04d5802fb4d..edd8649fc67e 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift @@ -1,6 +1,20 @@ import CmuxFoundation import SwiftUI +private struct SidebarPlatformListHostedKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + /// True inside cells hosted by the AppKit sidebar table. Rows read it to + /// skip the SwiftUI drag/drop mounts that the table owns natively + /// (row drag sources and per-row bonsplit drop targets). + var sidebarPlatformListHosted: Bool { + get { self[SidebarPlatformListHostedKey.self] } + set { self[SidebarPlatformListHostedKey.self] = newValue } + } +} + /// Value-only SwiftUI environment forwarded into each independently hosted table cell. struct SidebarWorkspaceTableEnvironmentSnapshot { let colorScheme: ColorScheme @@ -20,11 +34,13 @@ struct SidebarWorkspaceTableEnvironmentSnapshot { content .environment(\.colorScheme, colorScheme) .environment(\.cmuxGlobalFontMagnificationPercent, globalFontMagnificationPercent) + .environment(\.sidebarPlatformListHosted, true) .environment(\.sidebarLazyContractProbe, lazyContractProbe) #else content .environment(\.colorScheme, colorScheme) .environment(\.cmuxGlobalFontMagnificationPercent, globalFontMagnificationPercent) + .environment(\.sidebarPlatformListHosted, true) #endif } } diff --git a/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift b/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift new file mode 100644 index 000000000000..9d5b7389ed32 --- /dev/null +++ b/Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftUI + +@MainActor +struct SidebarBonsplitTabDropDelegate: DropDelegate { + let isEnabled: Bool + let targetWorkspaceId: UUID + let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? + let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool + let syncSidebarSelectionAfterDrop: @MainActor () -> Void + let selectTargetAfterDrop: @MainActor () -> Void + + func validateDrop(info: DropInfo) -> Bool { + guard isEnabled else { return false } + guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } + return BonsplitTabDragPayload.currentTransfer() != nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info), + let transfer = BonsplitTabDragPayload.currentTransfer() else { + return false + } + + if bonsplitSourceWorkspaceId(transfer.tab.id) == targetWorkspaceId { + syncSidebarSelectionAfterDrop() + return true + } + + guard moveBonsplitTabToWorkspace(transfer, targetWorkspaceId) else { + return false + } + + selectTargetAfterDrop() + syncSidebarSelectionAfterDrop() + return true + } +} diff --git a/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift b/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift new file mode 100644 index 000000000000..7275df3a2ab3 --- /dev/null +++ b/Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift @@ -0,0 +1,23 @@ +import Foundation +import SwiftUI + +struct SidebarBonsplitWorkspaceRowDropModifier: ViewModifier { + let isEnabled: Bool + let targetWorkspaceId: UUID + let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID? + let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool + let syncSidebarSelectionAfterDrop: @MainActor () -> Void + let selectTargetAfterDrop: @MainActor () -> Void + + func body(content: Content) -> some View { + let delegate = SidebarBonsplitTabDropDelegate( + isEnabled: isEnabled, + targetWorkspaceId: targetWorkspaceId, + bonsplitSourceWorkspaceId: bonsplitSourceWorkspaceId, + moveBonsplitTabToWorkspace: moveBonsplitTabToWorkspace, + syncSidebarSelectionAfterDrop: syncSidebarSelectionAfterDrop, + selectTargetAfterDrop: selectTargetAfterDrop + ) + return content.onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: delegate) + } +} diff --git a/Sources/Sidebar/SidebarPointerFrameReportingModifier.swift b/Sources/Sidebar/SidebarPointerFrameReportingModifier.swift new file mode 100644 index 000000000000..87ea4a7b922b --- /dev/null +++ b/Sources/Sidebar/SidebarPointerFrameReportingModifier.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// Reports a lazy sidebar row's frame as value data through parent-owned closures. +struct SidebarPointerFrameReportingModifier: ViewModifier { + let onFrameChange: (CGRect) -> Void + let onDisappear: () -> Void + + func body(content: Content) -> some View { + content + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named(SidebarPointerInteractionMonitor.coordinateSpaceName)) + } action: { frame in + onFrameChange(frame) + } + .onDisappear(perform: onDisappear) + } +} + +extension View { + func sidebarPointerEventHost(_ monitor: SidebarPointerInteractionMonitor) -> some View { + background(SidebarPointerEventHost { monitor.attach(to: $0) }) + } + + func sidebarPointerFrameReporting( + onFrameChange: @escaping (CGRect) -> Void, + onDisappear: @escaping () -> Void + ) -> some View { + modifier(SidebarPointerFrameReportingModifier( + onFrameChange: onFrameChange, + onDisappear: onDisappear + )) + } +} diff --git a/Sources/Sidebar/SidebarPointerInteractionMonitor.swift b/Sources/Sidebar/SidebarPointerInteractionMonitor.swift new file mode 100644 index 000000000000..8341eb701875 --- /dev/null +++ b/Sources/Sidebar/SidebarPointerInteractionMonitor.swift @@ -0,0 +1,281 @@ +import AppKit +import Observation +import SwiftUI + +/// A SwiftUI-owned AppKit view spanning the workspace-list viewport. +/// +/// The host gives the pointer owner a stable window and coordinate source +/// without discovering or mutating SwiftUI's private scroll-view hierarchy. +@MainActor +struct SidebarPointerEventHost: NSViewRepresentable { + let onResolve: @MainActor (NSView) -> Void + + func makeNSView(context: Context) -> SidebarPointerEventHostView { + let view = SidebarPointerEventHostView() + view.onResolve = onResolve + return view + } + + func updateNSView(_ nsView: SidebarPointerEventHostView, context: Context) { + nsView.onResolve = onResolve + nsView.resolve() + } + + static func dismantleNSView(_ nsView: SidebarPointerEventHostView, coordinator: ()) { + nsView.onResolve?(nsView) + nsView.onResolve = nil + } +} + +@MainActor +final class SidebarPointerEventHostView: NSView { + var onResolve: (@MainActor (NSView) -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + resolve() + } + + func resolve() { + onResolve?(self) + } +} + +/// Owns pointer-derived interaction data for every row in one workspace sidebar. +@MainActor +@Observable +final class SidebarPointerInteractionMonitor { + nonisolated static let coordinateSpaceName = "cmux.sidebar.workspace-pointer" + + private(set) var hoveredRowId: SidebarWorkspaceRenderItemID? + + // Geometry churn is data input, not SwiftUI render state. Keeping both + // registries ignored is load-bearing: publishing every row frame would + // invalidate the container and recreate the sidebar livelock at its root. + @ObservationIgnored private var rowFrames: [SidebarWorkspaceRenderItemID: CGRect] = [:] + @ObservationIgnored private var workspaceIdsByRowId: [SidebarWorkspaceRenderItemID: UUID] = [:] + @ObservationIgnored private var lastPointerLocation: CGPoint? + @ObservationIgnored private weak var resolvedHostView: NSView? + @ObservationIgnored private weak var hostView: NSView? + @ObservationIgnored private weak var mouseMovedWindow: NSWindow? + @ObservationIgnored private var pointerEventMonitor: Any? + @ObservationIgnored private var middleClickMonitor: Any? + @ObservationIgnored private var menuEndObserver: NSObjectProtocol? + @ObservationIgnored private var onMiddleClickWorkspace: ((UUID) -> Void)? + @ObservationIgnored private var geometryReconciliationTask: Task? + @ObservationIgnored private var geometryReconciliationGeneration: UInt = 0 + + func start(onMiddleClickWorkspace: @escaping (UUID) -> Void) { + self.onMiddleClickWorkspace = onMiddleClickWorkspace + + if pointerEventMonitor == nil { + pointerEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.mouseMoved, .mouseEntered, .mouseExited] + ) { [weak self] event in + self?.handlePointerEvent(event) + return event + } + } + activateResolvedHost() + + if middleClickMonitor == nil { + middleClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .otherMouseDown) { [weak self] event in + self?.handleMiddleClick(event) ?? event + } + } + if menuEndObserver == nil { + menuEndObserver = NotificationCenter.default.addObserver( + forName: NSMenu.didEndTrackingNotification, + object: nil, + queue: .main + ) { [weak self] notification in + let shouldReconcile = Self.shouldReconcileMenuEnd(object: notification.object) + guard shouldReconcile else { return } + Task { @MainActor [weak self] in + self?.reconcilePointerFromHostWindow() + } + } + } + } + + func stop() { + geometryReconciliationGeneration &+= 1 + geometryReconciliationTask?.cancel() + geometryReconciliationTask = nil + if let middleClickMonitor { + NSEvent.removeMonitor(middleClickMonitor) + self.middleClickMonitor = nil + } + if let menuEndObserver { + NotificationCenter.default.removeObserver(menuEndObserver) + self.menuEndObserver = nil + } + onMiddleClickWorkspace = nil + if let pointerEventMonitor { + NSEvent.removeMonitor(pointerEventMonitor) + self.pointerEventMonitor = nil + } + deactivateResolvedHost() + lastPointerLocation = nil + setHoveredRowId(nil) + } + + func attach(to candidate: NSView) { + if candidate.window != nil { + resolvedHostView = candidate + } else if resolvedHostView === candidate { + // Ignore teardown from an older host after SwiftUI has already + // mounted and resolved its replacement. + resolvedHostView = nil + } + activateResolvedHost() + } + + private func activateResolvedHost() { + guard pointerEventMonitor != nil, let resolvedHostView else { + deactivateResolvedHost() + return + } + hostView = resolvedHostView + + let nextWindow = resolvedHostView.window + guard mouseMovedWindow !== nextWindow else { return } + if let mouseMovedWindow { + WindowMouseMovedEventsCoordinator.disable(for: mouseMovedWindow, owner: self) + } + mouseMovedWindow = nextWindow + if let nextWindow { + WindowMouseMovedEventsCoordinator.enable(for: nextWindow, owner: self) + } + } + + private func deactivateResolvedHost() { + if let mouseMovedWindow { + WindowMouseMovedEventsCoordinator.disable(for: mouseMovedWindow, owner: self) + } else { + WindowMouseMovedEventsCoordinator.disableOwner(self) + } + mouseMovedWindow = nil + hostView = nil + } + + func updateFrame( + _ frame: CGRect, + for rowId: SidebarWorkspaceRenderItemID, + workspaceId: UUID + ) { + rowFrames[rowId] = frame + workspaceIdsByRowId[rowId] = workspaceId + scheduleGeometryReconciliation() + } + + func removeFrame(for rowId: SidebarWorkspaceRenderItemID) { + rowFrames.removeValue(forKey: rowId) + workspaceIdsByRowId.removeValue(forKey: rowId) + scheduleGeometryReconciliation() + } + + /// Test seam and event-input primitive in the monitor's SwiftUI coordinate space. + func recordPointerLocation(_ point: CGPoint) { + lastPointerLocation = point + reconcileHoveredRow() + } + + func rowId(at point: CGPoint) -> SidebarWorkspaceRenderItemID? { + rowFrames.first { $0.value.contains(point) }?.key + } + + func middleClickWorkspaceId(at point: CGPoint) -> UUID? { + guard let rowId = rowId(at: point), + let workspaceId = workspaceIdsByRowId[rowId], + rowId == .workspace(workspaceId) else { return nil } + return workspaceId + } + + nonisolated static func swiftUIPoint( + fromAppKitPoint point: CGPoint, + viewportBounds: CGRect + ) -> CGPoint { + CGPoint( + x: point.x - viewportBounds.minX, + y: viewportBounds.maxY - point.y + ) + } + + nonisolated static func shouldReconcileMenuEnd(object: Any?) -> Bool { + guard let menu = object as? NSMenu else { return false } + return menu.supermenu == nil + } + + private func handlePointerEvent(_ event: NSEvent) { + guard let hostView, + let window = hostView.window, + event.windowNumber == window.windowNumber else { return } + let appKitPoint = hostView.convert(event.locationInWindow, from: nil) + guard hostView.bounds.contains(appKitPoint) else { + lastPointerLocation = nil + setHoveredRowId(nil) + return + } + recordPointerLocation(Self.swiftUIPoint( + fromAppKitPoint: appKitPoint, + viewportBounds: hostView.bounds + )) + } + + private func reconcilePointerFromHostWindow() { + guard let hostView, let window = hostView.window else { + setHoveredRowId(nil) + return + } + let appKitPoint = hostView.convert(window.mouseLocationOutsideOfEventStream, from: nil) + recordPointerLocation(Self.swiftUIPoint( + fromAppKitPoint: appKitPoint, + viewportBounds: hostView.bounds + )) + } + + private func handleMiddleClick(_ event: NSEvent) -> NSEvent? { + guard event.buttonNumber == 2, + let hostView, + let window = hostView.window, + event.windowNumber == window.windowNumber else { + return event + } + let appKitPoint = hostView.convert(event.locationInWindow, from: nil) + let point = Self.swiftUIPoint( + fromAppKitPoint: appKitPoint, + viewportBounds: hostView.bounds + ) + guard let workspaceId = middleClickWorkspaceId(at: point) else { return event } + recordPointerLocation(point) + onMiddleClickWorkspace?(workspaceId) + return nil + } + + private func reconcileHoveredRow() { + setHoveredRowId(lastPointerLocation.flatMap { rowId(at: $0) }) + } + + private func scheduleGeometryReconciliation() { + guard geometryReconciliationTask == nil else { return } + geometryReconciliationGeneration &+= 1 + let generation = geometryReconciliationGeneration + // Cross the current MainActor job boundary so onGeometryChange never + // publishes observable state inside SwiftUI's layout transaction. + geometryReconciliationTask = Task { @MainActor [weak self] in + guard let self, + !Task.isCancelled, + geometryReconciliationGeneration == generation else { return } + geometryReconciliationTask = nil + reconcileHoveredRow() + } + } + + private func setHoveredRowId(_ rowId: SidebarWorkspaceRenderItemID?) { + guard hoveredRowId != rowId else { return } + hoveredRowId = rowId + } +} diff --git a/Sources/Sidebar/SidebarRowDragGate.swift b/Sources/Sidebar/SidebarRowDragGate.swift new file mode 100644 index 000000000000..880dba57f68f --- /dev/null +++ b/Sources/Sidebar/SidebarRowDragGate.swift @@ -0,0 +1,16 @@ +import Foundation +import SwiftUI + +extension View { + /// Applies the row's drag source only when not editing inline. While + /// editing, no drag session can begin, so drag-to-select inside the rename + /// field never starts a sidebar reorder. + @ViewBuilder + func sidebarRowDragGate(isEditing: Bool, _ makeProvider: @escaping () -> NSItemProvider) -> some View { + if isEditing { + self + } else { + onDrag(makeProvider) + } + } +} diff --git a/Sources/SidebarWorkspaceDropTargetWriters.swift b/Sources/SidebarWorkspaceDropTargetWriters.swift new file mode 100644 index 000000000000..67a3682e04bd --- /dev/null +++ b/Sources/SidebarWorkspaceDropTargetWriters.swift @@ -0,0 +1,27 @@ +import CmuxFoundation +import SwiftUI + +struct SidebarWorkspaceDropTargetWriters: View { + let bonsplitTargetBridge: SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge + let bonsplitTargets: [SidebarDropPlanner.WorkspaceDropTarget] + let reorderTargetBridge: SidebarWorkspaceReorderDropOverlay.TargetBridge + let reorderTargets: [SidebarWorkspaceReorderDropOverlay.Target] + + var body: some View { + Color.clear + .onAppear { + bonsplitTargetBridge.updateTargets(bonsplitTargets) + reorderTargetBridge.updateTargets(reorderTargets) + } + .onChange(of: bonsplitTargets) { _, targets in + bonsplitTargetBridge.updateTargets(targets) + } + .onChange(of: reorderTargets) { _, targets in + reorderTargetBridge.updateTargets(targets) + } + .onDisappear { + bonsplitTargetBridge.updateTargets([]) + reorderTargetBridge.updateTargets([]) + } + } +} 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/SidebarWorkspaceRowActions.swift b/Sources/SidebarWorkspaceRowActions.swift index 0e84b14cdc18..d5fb0156cd9e 100644 --- a/Sources/SidebarWorkspaceRowActions.swift +++ b/Sources/SidebarWorkspaceRowActions.swift @@ -56,6 +56,8 @@ struct SidebarWorkspaceRowActions { // hover-freeze callbacks onto the row's own context-menu callbacks. var onContextMenuAppear: () -> Void var onContextMenuDisappear: () -> Void + let onPointerFrameChange: (CGRect) -> Void + let onPointerFrameDisappear: () -> Void } /// Binds parent-owned action capabilities to one lazily realized row input. 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/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 65acae2308c3..1f4f7d3ac0be 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -60,9 +60,8 @@ extension VerticalTabsSidebar { ) let modifierSymbol = renderContext.workspaceNumberShortcut.numberedDigitHintPrefix let showsHintForAnchor = showModifierHoldHints && modifierKeyMonitor.isModifierPressed - // Hover is AppKit-owned: the table controller overlays it per cell - // configure, so the base snapshot always carries false here. - let isPointerHovering = false + let rowId = SidebarWorkspaceRenderItemID.group(group.id) + let isPointerHovering = pointerInteractionMonitor.hoveredRowId == rowId let topDropIndicatorVisible = SidebarTabDropIndicatorPredicate().topVisible( forTabId: group.anchorWorkspaceId, draggedTabId: dragState.draggedTabId, @@ -305,4 +304,30 @@ extension VerticalTabsSidebar { onContextMenuDisappear: onContextMenuDisappear ) } + + /// Assembles one group row for the SwiftUI lazy list from immutable + /// values. 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 header = sidebarWorkspaceGroupHeader( + snapshot: snapshot, + onContextMenuAppear: {}, + onContextMenuDisappear: {} + ) + 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/cmuxTests/SidebarLazyLayoutScaleTests.swift b/cmuxTests/SidebarLazyLayoutScaleTests.swift index 005fd33e1cba..d1b18fced588 100644 --- a/cmuxTests/SidebarLazyLayoutScaleTests.swift +++ b/cmuxTests/SidebarLazyLayoutScaleTests.swift @@ -1,26 +1,51 @@ import Testing import AppKit +import CmuxSidebar import CmuxUpdater -import OSLog import SwiftUI + #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux #endif -/// Behavioral gate for the workspace sidebar's AppKit virtualization contract. -/// Mounts the production `VerticalTabsSidebar` at 300 workspaces and verifies -/// viewport-scale materialization, row-scoped updates, convergence, and clean -/// SwiftUI/AppKit runtime logs. The source-topology companion is -/// `scripts/check-sidebar-lazy-layout.py`. + +/// Behavioral gate for the workspace sidebar's lazy-layout contract: layout +/// and diff work must stay O(visible rows) no matter how many workspaces are +/// open, and updates must converge (no self-sustaining invalidation). +/// +/// The contract has regressed five times through five different mechanisms — +/// per-row anchorPreference aggregation (#5323), per-row String ids (#5764), +/// animated row-height interpolation (#5845), a force-measuring custom Layout +/// (#6210), and GeometryReader → @State row-height feedback (#6556) — and each +/// shipped to stable before being detected, because nothing exercised the +/// sidebar at the 100+ workspace scale where O(N) per pass becomes a +/// main-thread livelock (issue #2586). These tests mount the real +/// `VerticalTabsSidebar` at that scale and count actual row body evaluations +/// through `SidebarLazyContractProbe`, so ANY future mechanism that realizes +/// the whole list or loops the AttributeGraph fails here instead of on a +/// user's machine. `scripts/check-sidebar-lazy-layout.py` bans the known +/// source shapes; this is the mechanism-independent backstop. @Suite(.serialized) final class SidebarLazyLayoutScaleTests { - private static let workspaceCount = 300 - /// A 640pt window shows ~20 rows; allow a small AppKit reuse buffer. - private static let realizedRowCeiling = 80 - // Plain class so the probe's nonisolated closures can mutate it on main. Same shape + static let workspaceCount = 300 + /// Generous ceiling for "how many rows may be realized for one viewport". + /// A 640pt window shows ~20 rows; LazyVStack prefetch and a second layout + /// pass can multiply that, but a virtualization defeat realizes all 300. + private static let realizedRowCeiling = 150 + + final class InjectableMouseLocationWindow: NSWindow { + var injectedMouseLocation = NSPoint.zero + + override var mouseLocationOutsideOfEventStream: NSPoint { + injectedMouseLocation + } + } + + // Plain class (not @MainActor) so the probe's nonisolated `() -> Void` + // closures can mutate it; bodies only run on the main thread. Same shape // as MinimalModeBodyProbeCounts in WorkspaceContentViewVisibilityTests. - private final class RowBodyCounter { + final class RowBodyCounter { var workspaceRowBodies = 0 var groupHeaderBodies = 0 var workspaceSnapshotBuilds = 0 @@ -32,7 +57,6 @@ final class SidebarLazyLayoutScaleTests { var insideWorkspaceRowBody = false var snapshotBuildsInCurrentRowBody = 0 var maxSnapshotBuildsInOneRowBody = 0 - var tableRootViewReconfigures = 0 func reset() { workspaceRowBodies = 0 groupHeaderBodies = 0 @@ -41,16 +65,15 @@ final class SidebarLazyLayoutScaleTests { insideWorkspaceRowBody = false snapshotBuildsInCurrentRowBody = 0 maxSnapshotBuildsInOneRowBody = 0 - tableRootViewReconfigures = 0 } } @MainActor - private struct Harness { + struct Harness { let tabManager: TabManager let unread: SidebarUnreadModel let counter: RowBodyCounter - let window: NSWindow + let window: InjectableMouseLocationWindow let defaultsSuiteName: String func tearDown() { @@ -62,7 +85,7 @@ final class SidebarLazyLayoutScaleTests { } @MainActor - private static func mountSidebar(workspaceCount: Int) async throws -> Harness { + static func mountSidebar(workspaceCount: Int) async throws -> Harness { _ = NSApplication.shared // Hermetic defaults: VerticalTabsSidebar picks between the workspace @@ -102,7 +125,7 @@ final class SidebarLazyLayoutScaleTests { } // Group the first workspaces (top of the list, inside the viewport) so - // group-header rows — assembled by sidebarWorkspaceGroupTableConfiguration(...) 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)) @@ -161,13 +184,14 @@ final class SidebarLazyLayoutScaleTests { counter.snapshotBuildsInCurrentRowBody ) }, - workspaceRowInputProjection: { counter.workspaceRowInputProjections += 1 }, - tableRootViewReconfigure: { counter.tableRootViewReconfigures += 1 } + workspaceRowInputProjection: { + counter.workspaceRowInputProjections += 1 + } ) ) .defaultAppStorage(defaults) - let window = NSWindow( + let window = InjectableMouseLocationWindow( contentRect: NSRect(x: 0, y: 0, width: 280, height: 640), styleMask: [.titled, .closable, .resizable], backing: .buffered, @@ -195,7 +219,7 @@ final class SidebarLazyLayoutScaleTests { /// own autorelease pool so drained main-queue work cannot pile objects /// into the enclosing job's pool. @MainActor - private static func turnMainRunLoopOnce(layingOut window: NSWindow?) { + static func turnMainRunLoopOnce(layingOut window: NSWindow?) { autoreleasepool { window?.contentView?.layoutSubtreeIfNeeded() _ = RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) @@ -203,33 +227,13 @@ final class SidebarLazyLayoutScaleTests { } @MainActor - private static func drainMainRunLoop(for window: NSWindow, iterations: Int = 25) async { + static func drainMainRunLoop(for window: NSWindow, iterations: Int = 25) async { for _ in 0.. SidebarWorkspaceTableViewImpl? { - var pendingViews = [rootView] - while let view = pendingViews.popLast() { - if let table = view as? SidebarWorkspaceTableViewImpl { return table } - pendingViews.append(contentsOf: view.subviews) - } - return nil - } - - @MainActor - private static func materializedCells(in rootView: NSView) -> [SidebarWorkspaceTableCellView] { - var result: [SidebarWorkspaceTableCellView] = [] - var pendingViews = [rootView] - while let view = pendingViews.popLast() { - result.append(contentsOf: view.subviews.compactMap { $0 as? SidebarWorkspaceTableCellView }) - pendingViews.append(contentsOf: view.subviews) - } - return result - } /// Mounting the sidebar with 300 workspaces must realize only the rows a /// single viewport needs. Realizing all of them is the #5323/#6210 defeat: @@ -243,23 +247,16 @@ final class SidebarLazyLayoutScaleTests { await Self.drainMainRunLoop(for: harness.window) - let rootView = try #require(harness.window.contentView) - let cells = Self.materializedCells(in: rootView) - #expect(!cells.isEmpty, "The production NSTableView mounted no reusable row cells.") - #expect( - cells.count < Self.realizedRowCeiling, - "NSTableView materialized \(cells.count) cells for a single viewport of \(Self.workspaceCount) rows." - ) - let realized = harness.counter.workspaceRowBodies #expect(realized > 0, "Sidebar mounted but no workspace row body ran; harness is broken.") #expect( realized < Self.realizedRowCeiling, """ \(realized) workspace row bodies evaluated for a single ~20-row viewport with \ - \(Self.workspaceCount) workspaces. NSTableView virtualization is being defeated \ - (all rows realized per pass), recreating the #2586 sidebar livelock class; see \ - scripts/check-sidebar-lazy-layout.py. + \(Self.workspaceCount) workspaces. The LazyVStack is being defeated (all rows \ + realized per pass) — the #2586 sidebar livelock class. Look for per-row geometry \ + feedback (GeometryReader/@State height, anchorPreference aggregation) or a \ + force-measuring Layout; see scripts/check-sidebar-lazy-layout.py. """ ) @@ -275,23 +272,20 @@ final class SidebarLazyLayoutScaleTests { headerRealized < Self.realizedRowCeiling, """ \(headerRealized) group-header bodies evaluated for 5 groups in one viewport. \ - The group-header row factory (sidebarWorkspaceGroupTableConfiguration) 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() } @@ -303,21 +297,19 @@ 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 burst of unread-model updates (the agent-notification churn path, - /// the sidebar's highest-frequency whole-body invalidation) must cost - /// O(changed rows), and the sidebar must go quiet when the burst stops. - /// Unbounded or non-converging row re-evaluation here is the exact - /// signature of the #6556 GeometryReader → @State feedback livelock. + /// 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 { @@ -372,11 +364,16 @@ final class SidebarLazyLayoutScaleTests { """ \(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\u{00B2}) work from one parent invalidation per emitter. + projection work, not O(N²) work from one parent invalidation per emitter. """ ) } + /// A burst of unread-model updates (the agent-notification churn path, + /// the sidebar's highest-frequency whole-body invalidation) must cost + /// O(changed rows), and the sidebar must go quiet when the burst stops. + /// Unbounded or non-converging row re-evaluation here is the exact + /// signature of the #6556 GeometryReader → @State feedback livelock. @Test @MainActor func testUnreadStormStaysRowScopedAndConverges() async throws { @@ -416,10 +413,6 @@ final class SidebarLazyLayoutScaleTests { \(storms) updates re-realizing per pass is the #2586 livelock at scale. """ ) - #expect( - harness.counter.tableRootViewReconfigures < storms * 10, - "Unread storm replaced \(harness.counter.tableRootViewReconfigures) hosted roots; only changed visible rows may reconfigure." - ) harness.counter.reset() await Self.drainMainRunLoop(for: harness.window, iterations: 30) @@ -432,63 +425,8 @@ final class SidebarLazyLayoutScaleTests { loop (the #6556 signature). This livelocks the main thread at scale. """ ) - #expect( - harness.counter.tableRootViewReconfigures == 0, - "The table reconfigured \(harness.counter.tableRootViewReconfigures) roots during a quiet period." - ) } - /// Churn variable-height content, scroll, and synthetic table-owned hover. - @Test - @MainActor - func testTableChurnScrollAndHoverEmitNoRuntimeFaults() async throws { - let harness = try await Self.mountSidebar(workspaceCount: Self.workspaceCount) - defer { harness.tearDown() } - - await Self.drainMainRunLoop(for: harness.window) - let rootView = try #require(harness.window.contentView) - let table = try #require(Self.tableView(in: rootView)) - let logStore = try OSLogStore(scope: .currentProcessIdentifier) - let start = logStore.position(date: Date()) - - for index in 0..<40 { - let workspace = harness.tabManager.tabs[index % 8] - harness.tabManager.setCustomTitle( - tabId: workspace.id, - title: String(repeating: "variable title \(index) ", count: (index % 5) + 1) - ) - harness.unread.apply( - totalUnreadCount: index + 1, - summaries: [ - workspace.id: SidebarWorkspaceUnreadSummary( - unreadCount: index + 1, - latestNotificationText: String(repeating: "update ", count: (index % 4) + 1) - ) - ], - unreadSurfaceKeys: [], - focusedReadIndicatorByWorkspaceId: [:], - manualUnreadWorkspaceIds: [] - ) - table.scrollRowToVisible((index * 17) % Self.workspaceCount) - table.setPointerWindowLocation(table.convert( - NSPoint(x: 20, y: table.visibleRect.midY), - to: nil - )) - await Self.drainMainRunLoop(for: harness.window, iterations: 2) - } - table.setPointerWindowLocation(nil) - await Self.drainMainRunLoop(for: harness.window, iterations: 30) - - let faultNeedles = ["Modifying state during view update", - "Publishing changes from within view updates", - "laid out reentrantly"] - let faults = try logStore.getEntries(at: start).compactMap { entry -> String? in - guard let log = entry as? OSLogEntryLog else { return nil } - return faultNeedles.contains { log.composedMessage.localizedCaseInsensitiveContains($0) } - ? log.composedMessage : nil - } - #expect(faults.isEmpty, "Sidebar churn emitted runtime faults: \(faults)") - } /// Harness self-test: prove the drain loop + body counter actually detect /// a layout feedback loop. This fixture reproduces the historical diff --git a/cmuxTests/SidebarPointerInteractionMonitorTests.swift b/cmuxTests/SidebarPointerInteractionMonitorTests.swift new file mode 100644 index 000000000000..5ed7a474ce6e --- /dev/null +++ b/cmuxTests/SidebarPointerInteractionMonitorTests.swift @@ -0,0 +1,221 @@ +import AppKit +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite @MainActor struct SidebarPointerInteractionMonitorTests { + @Test func pointerInputDoesNotInstallSubviewIntoSwiftUIOwnedScrollView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 320), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.acceptsMouseMovedEvents = false + let scrollView = NSScrollView(frame: window.contentView?.bounds ?? .zero) + window.contentView = scrollView + let originalSubviews = scrollView.subviews + let monitor = SidebarPointerInteractionMonitor() + + monitor.attach(to: scrollView) + monitor.start(onMiddleClickWorkspace: { _ in }) + + #expect( + scrollView.subviews.elementsEqual(originalSubviews, by: { $0 === $1 }), + "Pointer input must not add a foreign subview that SwiftUI can remove during reconciliation." + ) + #expect(window.acceptsMouseMovedEvents) + + monitor.stop() + } + + @Test func restartReattachesToTheResolvedScrollViewWithoutAnotherResolverPass() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 320), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.acceptsMouseMovedEvents = false + let scrollView = NSScrollView(frame: window.contentView?.bounds ?? .zero) + window.contentView = scrollView + let monitor = SidebarPointerInteractionMonitor() + let workspaceId = UUID() + let rowId = SidebarWorkspaceRenderItemID.workspace(workspaceId) + monitor.updateFrame( + CGRect(x: 0, y: 0, width: 200, height: 30), + for: rowId, + workspaceId: workspaceId + ) + + monitor.attach(to: scrollView) + monitor.start(onMiddleClickWorkspace: { _ in }) + #expect(window.acceptsMouseMovedEvents) + + // SwiftUI can stop and restart the sidebar without remounting the + // resolver representable. The pointer owner must retain the last + // resolved host and restore tracking during start(). + monitor.stop() + #expect(!window.acceptsMouseMovedEvents) + #expect( + monitor.rowId(at: CGPoint(x: 100, y: 15)) == rowId, + "A transient sidebar restart must not discard row geometry that SwiftUI has not remounted." + ) + monitor.start(onMiddleClickWorkspace: { _ in }) + #expect( + window.acceptsMouseMovedEvents, + "Restarting the visible sidebar must restore hover without waiting for another resolver callback." + ) + + monitor.stop() + } + + @Test func frameChangesReconcileStationaryPointerAcrossRemountReorderAndScroll() async { + let monitor = SidebarPointerInteractionMonitor() + let firstWorkspaceId = UUID() + let secondWorkspaceId = UUID() + let firstRowId = SidebarWorkspaceRenderItemID.workspace(firstWorkspaceId) + let secondRowId = SidebarWorkspaceRenderItemID.workspace(secondWorkspaceId) + + monitor.updateFrame( + CGRect(x: 0, y: 0, width: 200, height: 30), + for: firstRowId, + workspaceId: firstWorkspaceId + ) + monitor.updateFrame( + CGRect(x: 0, y: 32, width: 200, height: 30), + for: secondRowId, + workspaceId: secondWorkspaceId + ) + monitor.recordPointerLocation(CGPoint(x: 100, y: 15)) + #expect(monitor.hoveredRowId == firstRowId) + + // A lazy remount removes the old row before its replacement reports. + monitor.removeFrame(for: firstRowId) + await Task.yield() + #expect(monitor.hoveredRowId == nil) + monitor.updateFrame( + CGRect(x: 0, y: 0, width: 200, height: 30), + for: firstRowId, + workspaceId: firstWorkspaceId + ) + await Task.yield() + #expect(monitor.hoveredRowId == firstRowId) + + // Reordering/scrolling changes only frame data. No new pointer event is + // recorded, but the stationary pointer resolves to the row now under it. + monitor.updateFrame( + CGRect(x: 0, y: 32, width: 200, height: 30), + for: firstRowId, + workspaceId: firstWorkspaceId + ) + monitor.updateFrame( + CGRect(x: 0, y: 0, width: 200, height: 30), + for: secondRowId, + workspaceId: secondWorkspaceId + ) + await Task.yield() + #expect(monitor.hoveredRowId == secondRowId) + + monitor.updateFrame( + CGRect(x: 0, y: -40, width: 200, height: 30), + for: secondRowId, + workspaceId: secondWorkspaceId + ) + await Task.yield() + #expect(monitor.hoveredRowId == nil) + } + + @Test func geometryReconciliationDoesNotPublishDuringTheLayoutCallback() async { + let monitor = SidebarPointerInteractionMonitor() + let firstWorkspaceId = UUID() + let secondWorkspaceId = UUID() + let firstRowId = SidebarWorkspaceRenderItemID.workspace(firstWorkspaceId) + let secondRowId = SidebarWorkspaceRenderItemID.workspace(secondWorkspaceId) + + monitor.updateFrame( + CGRect(x: 0, y: 0, width: 200, height: 30), + for: firstRowId, + workspaceId: firstWorkspaceId + ) + monitor.updateFrame( + CGRect(x: 0, y: 32, width: 200, height: 30), + for: secondRowId, + workspaceId: secondWorkspaceId + ) + monitor.recordPointerLocation(CGPoint(x: 100, y: 15)) + #expect(monitor.hoveredRowId == firstRowId) + + monitor.updateFrame( + CGRect(x: 0, y: 32, width: 200, height: 30), + for: firstRowId, + workspaceId: firstWorkspaceId + ) + monitor.updateFrame( + CGRect(x: 0, y: 0, width: 200, height: 30), + for: secondRowId, + workspaceId: secondWorkspaceId + ) + + #expect( + monitor.hoveredRowId == firstRowId, + "Geometry callbacks must not publish observable hover state while SwiftUI is laying out." + ) + await Task.yield() + #expect(monitor.hoveredRowId == secondRowId) + } + + @Test func convertsAppKitBottomLeftPointToSwiftUITopLeftPoint() { + let point = SidebarPointerInteractionMonitor.swiftUIPoint( + fromAppKitPoint: CGPoint(x: 45, y: 170), + viewportBounds: CGRect(x: 5, y: 20, width: 240, height: 200) + ) + + #expect(point == CGPoint(x: 40, y: 50)) + } + + @Test func hitTestingReturnsOnlyRegisteredRowWorkspace() { + let monitor = SidebarPointerInteractionMonitor() + let workspaceId = UUID() + let rowId = SidebarWorkspaceRenderItemID.workspace(workspaceId) + monitor.updateFrame( + CGRect(x: 20, y: 40, width: 180, height: 28), + for: rowId, + workspaceId: workspaceId + ) + + #expect(monitor.rowId(at: CGPoint(x: 100, y: 54)) == rowId) + #expect(monitor.middleClickWorkspaceId(at: CGPoint(x: 100, y: 54)) == workspaceId) + #expect(monitor.rowId(at: CGPoint(x: 10, y: 54)) == nil) + #expect(monitor.middleClickWorkspaceId(at: CGPoint(x: 100, y: 80)) == nil) + } + + @Test func middleClickDoesNotResolveGroupHeaderAnchorWorkspace() { + let monitor = SidebarPointerInteractionMonitor() + let groupId = UUID() + let anchorWorkspaceId = UUID() + monitor.updateFrame( + CGRect(x: 20, y: 40, width: 180, height: 28), + for: .group(groupId), + workspaceId: anchorWorkspaceId + ) + + #expect(monitor.rowId(at: CGPoint(x: 100, y: 54)) == .group(groupId)) + #expect(monitor.middleClickWorkspaceId(at: CGPoint(x: 100, y: 54)) == nil) + } + + @Test func menuTrackingReconciliationIgnoresSubmenuEndNotifications() { + let rootMenu = NSMenu() + let submenu = NSMenu() + let item = NSMenuItem(title: "submenu", action: nil, keyEquivalent: "") + rootMenu.addItem(item) + rootMenu.setSubmenu(submenu, for: item) + + #expect(SidebarPointerInteractionMonitor.shouldReconcileMenuEnd(object: rootMenu)) + #expect(!SidebarPointerInteractionMonitor.shouldReconcileMenuEnd(object: submenu)) + #expect(!SidebarPointerInteractionMonitor.shouldReconcileMenuEnd(object: nil)) + } +} diff --git a/cmuxTests/SidebarPointerInteractionScaleTests.swift b/cmuxTests/SidebarPointerInteractionScaleTests.swift new file mode 100644 index 000000000000..fbf46752ff8d --- /dev/null +++ b/cmuxTests/SidebarPointerInteractionScaleTests.swift @@ -0,0 +1,283 @@ +import AppKit +import CmuxSidebar +import CoreGraphics +import OSLog +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension SidebarLazyLayoutScaleTests { + @MainActor + fileprivate static func firstScrollView(in rootView: NSView) -> NSScrollView? { + var pendingViews = [rootView] + while let view = pendingViews.popLast() { + if let scrollView = view as? NSScrollView { return scrollView } + pendingViews.append(contentsOf: view.subviews) + } + return nil + } + + private static func mouseMovedEvent(at pointInWindow: NSPoint, window: NSWindow) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: .mouseMoved, + location: pointInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 0, + pressure: 0 + )) + } + + 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 = [ + "Modifying state during view update", + "Publishing changes from within view updates", + "laid out reentrantly", + ] + return entries.compactMap { entry in + // 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 + } + return message + } + } + + /// A stationary pointer over a row must survive the highest-risk sidebar + /// churn without producing SwiftUI view-update or NSHostingView reentrant + /// layout faults. The injectable window makes the production pointer owner + /// see a real in-row pointer without requiring a key window or physical + /// mouse, while scroll, remount, unread, and appearance changes exercise + /// the #8004 lifecycle path. + @Test + @MainActor + func testStationaryPointerChurnHasNoViewUpdateFaultsAndConverges() async throws { + let logStart = Date() + let harness = try await Self.mountSidebar(workspaceCount: Self.workspaceCount) + defer { harness.tearDown() } + + await Self.drainMainRunLoop(for: harness.window) + #expect( + harness.window.acceptsMouseMovedEvents, + "A mounted sidebar must enable mouse movement without discovering SwiftUI's private scroll-view hierarchy." + ) + let rootView = try #require(harness.window.contentView) + let scrollView = try #require(Self.firstScrollView(in: rootView)) + let pointerInScrollView = NSPoint( + x: scrollView.bounds.midX, + y: scrollView.bounds.maxY - 80 + ) + let pointerInWindow = scrollView.convert(pointerInScrollView, to: nil) + harness.window.injectedMouseLocation = pointerInWindow + + harness.counter.reset() + NSApp.sendEvent(try Self.mouseMovedEvent( + at: pointerInWindow, + window: harness.window + )) + await Self.drainMainRunLoop(for: harness.window, iterations: 4) + let hoverFlipEvals = harness.counter.workspaceRowBodies + harness.counter.groupHeaderBodies + #expect( + (1...2).contains(hoverFlipEvals), + """ + One hover-owner change evaluated \(hoverFlipEvals) row bodies. The parent may \ + recompute row values, but Equatable rows must limit body work to the old/new hover \ + targets (at most two rows). + """ + ) + + harness.counter.reset() + let stormTargets = Array(harness.tabManager.tabs.prefix(3).map(\.id)) + let groupIds = harness.tabManager.workspaceGroups.map(\.id) + for i in 1...40 { + let target = stormTargets[i % stormTargets.count] + harness.unread.apply( + totalUnreadCount: i, + summaries: [ + target: SidebarWorkspaceUnreadSummary( + unreadCount: i, + latestNotificationText: "stationary pointer churn \(i)" + ) + ], + unreadSurfaceKeys: [], + focusedReadIndicatorByWorkspaceId: [:], + manualUnreadWorkspaceIds: [] + ) + + let documentHeight = scrollView.documentView?.bounds.height ?? 0 + let maximumOffset = max(0, documentHeight - scrollView.contentView.bounds.height) + let requestedOffset = CGFloat((i % 8) * 36) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: min(maximumOffset, requestedOffset))) + scrollView.reflectScrolledClipView(scrollView.contentView) + + if i.isMultiple(of: 4), let groupId = groupIds.first { + harness.tabManager.toggleWorkspaceGroupCollapsed(groupId: groupId) + } + harness.window.appearance = NSAppearance( + named: i.isMultiple(of: 2) ? .darkAqua : .aqua + ) + await Self.drainMainRunLoop(for: harness.window, iterations: 2) + } + await Self.drainMainRunLoop(for: harness.window) + + let faultMessages = try Self.viewUpdateFaultMessages(since: logStart) + #expect( + faultMessages.isEmpty, + """ + Sidebar stationary-pointer churn emitted \(faultMessages.count) SwiftUI/AppKit \ + view-update faults:\n\(faultMessages.joined(separator: "\n")) + """ + ) + + harness.counter.reset() + await Self.drainMainRunLoop(for: harness.window, iterations: 30) + let quietEvals = harness.counter.workspaceRowBodies + harness.counter.groupHeaderBodies + #expect( + quietEvals < 20, + """ + \(quietEvals) row bodies evaluated after stationary-pointer churn ended. The sidebar failed to converge \ + and is still feeding interaction or geometry changes back into layout. + """ + ) + } + +} + +/// 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/scripts/check-sidebar-lazy-layout.py b/scripts/check-sidebar-lazy-layout.py index b78f2c0d2eda..4a54cbb2053e 100755 --- a/scripts/check-sidebar-lazy-layout.py +++ b/scripts/check-sidebar-lazy-layout.py @@ -1,15 +1,67 @@ #!/usr/bin/env python3 -"""Guard the AppKit-owned default sidebar list boundary. - -The default workspace list must remain one container-level NSViewRepresentable -wrapping NSTableView. SwiftUI row content may be hosted in reusable cells, but -must not mount additional platform representables except the established inline -rename field and GPU spinner. Table reload/reconfiguration is forbidden from -AppKit layout lifecycle callbacks; mutations enter through render-context -updates, input events, or scroll notifications. - -The guard fails loudly when a required type/function/file is renamed so it -cannot silently become a no-op. +"""Regression guard for the workspace-sidebar lazy-layout contract. + +The workspace sidebar renders its rows as a `LazyVStack` inside a vertical +`ScrollView`. Keeping that stack lazy at *measure time* is load-bearing: the +sidebar is re-diffed on every workspace/telemetry update, so any code that +forces SwiftUI to realize and measure the whole row list on each layout pass +turns a routine update into a multi-second `GraphHost.flushTransactions()` +main-thread livelock once enough workspaces/surfaces are open. + +This exact class of bug has regressed four times: + + * #2586 GeometryReader + PreferenceKey -> @State height feedback loop + * #5764 per-row String id allocation in the ForEach diff + * #5845 same livelock family, reintroduced + * #6033 -> #6210 `SidebarRowsFillLayout`, a custom `Layout` that called + `subview.sizeThatFits(ProposedViewSize(width:, height: nil))` on the + `LazyVStack` in both `sizeThatFits` and `placeSubviews`, realizing + every row each pass. Removed in #6188. + + * #6384 the user-reported ~1s beachball that #6188 fixed. + +Until now the contract was defended only by inline comments, which CI cannot +enforce. This guard scans the two functions that define the sidebar's +steady-state scroll layout in `Sources/ContentView.swift` +(`workspaceScrollContent` and `workspaceRows`) and fails if either: + + 1. reintroduces a whole-list measurement signature + (`GeometryReader`, `ProposedViewSize(... nil ...)`, `.sizeThatFits(`, the + deleted `SidebarRowsFillLayout`, or ANY custom `Layout`-conforming type + discovered in the codebase being applied to the rows -- so renaming the + force-measuring layout does not dodge the guard), or + 2. drops one of the lazy-fill primitives the fix relies on + (`LazyVStack(` in `workspaceRows`, `.frame(minHeight:` in + `workspaceScrollContent`). + +Comments and string literals are neutralized before scanning, so the historical +explanatory comments in those functions (which deliberately name the very +anti-patterns this guard forbids) do not trip it. + +The drag-only drop-target reader (`rowsWithGatedDropTargetReader`) is *not* +scanned: it intentionally uses a `GeometryReader` to resolve per-row drop +anchors and is gated behind an active drag (#5325), so it never runs during the +steady-state layout this guard protects. + +Scope: the guard protects the rows layout *as expressed in* +`workspaceScrollContent` / `workspaceRows`. It deliberately does not chase a +force-measure that a future refactor relocates into some other +transitively-called helper function: tracking arbitrary call graphs in a +source-pattern lint is fragile, and extracting the rows layout into a new helper +is a large enough change that it should re-review this guard directly (the +"could not locate function" failure already trips on the most common such +rename). Custom `Layout` types are the exception that *is* chased across files, +because a renamed force-measuring layout is the concrete historical regression +(#6033). + +Usage: + scripts/check-sidebar-lazy-layout.py [--file PATH] + +Exit codes: + 0 the scanned file satisfies the lazy-layout contract + 1 an anti-pattern was found, a required primitive is missing, or one of the + guarded functions could not be located (treated as a failure so the guard + cannot silently rot into a no-op when the code is renamed). """ import argparse @@ -17,204 +69,538 @@ import re import sys +# Functions that define the sidebar's steady-state scroll layout. Both must +# exist; a rename should fail the guard loudly rather than silently skip. +GUARDED_FUNCTIONS = ("workspaceScrollContent", "workspaceRows") + +# Tokens that mean "the whole row list is being measured/realized on every +# layout pass" if they appear in the guarded functions' code (not comments). +FORBIDDEN_PATTERNS = ( + (re.compile(r"\bGeometryReader\b"), + "GeometryReader (reading the rows' size feeds the #2586 layout feedback " + "loop / forces row realization at measure time)"), + (re.compile(r"\bSidebarRowsFillLayout\b"), + "SidebarRowsFillLayout (the custom Layout removed in #6188 that " + "force-measured the LazyVStack every pass; do not reintroduce it)"), + (re.compile(r"\.sizeThatFits\s*\("), + "manual .sizeThatFits( call (measuring a subview by hand realizes the " + "lazy rows; let SwiftUI size the stack)"), + (re.compile(r"\bProposedViewSize\s*\([^)]*\bnil\b"), + "ProposedViewSize(..., nil) (proposing nil on an axis asks the LazyVStack " + "for its natural size, realizing every row -- the #6210 force-measure)"), +) -OLD_DEFAULT_LIST_FUNCTIONS = ( - "workspaceScrollContent", - "workspaceRows", - "rowsWithGatedDropTargetReader", +# Declaration of a type conforming to SwiftUI's `Layout` protocol. A custom +# Layout applied to the sidebar rows is the #6033/#6210 force-measure shape no +# matter what the type is named, so the guard discovers every such type in the +# codebase and bans ALL of their names from the guarded functions -- not just the +# literal `SidebarRowsFillLayout`. This closes the "rename the layout to dodge the +# guard" bypass (#6870 review). +CUSTOM_LAYOUT_DECL = re.compile( + r"\b(?:struct|final\s+class|class|enum|extension)\s+([A-Z]\w*)\b[^{]*?\bLayout\b[^{]*?\{", + re.DOTALL, ) -ROW_TYPES = ("TabItemView", "SidebarWorkspaceGroupHeaderView") -REPRESENTABLE_ALLOWLIST = {"SidebarInlineRenameField", "GPUSpinner"} -LAYOUT_CALLBACKS = ("layout", "updateTrackingAreas", "viewDidMoveToWindow") -LAYOUT_MUTATION_PATTERNS = ( - re.compile(r"\breloadData\s*\("), - re.compile(r"\bnoteHeightOfRows\s*\("), - re.compile(r"\breconfigure(?:Visible)?Rows\s*\("), - re.compile(r"\.rootView\s*="), + +# An always-mounted NSViewRepresentable below the LazyVStack can run AppKit +# lifecycle callbacks while SwiftUI is updating the same row. Issue #8004's +# hover and menu helpers wrote row state from that stack and re-entered +# NSHostingView layout. Discover conformers across repo-owned sources so moving +# or renaming one cannot bypass the guard, then reject their use in row bodies. +NSVIEW_REPRESENTABLE_DECL = re.compile( + r"\b(?:struct|final\s+class|class|enum|extension)\s+([A-Z]\w*)\b[^{]*?" + r"\bNSViewRepresentable\b[^{]*?\{", + re.DOTALL, +) + +# These are condition-gated leaf controls. SidebarInlineRenameField exists only +# during inline rename, and GPUSpinner is mounted indirectly by +# SidebarWorkspaceLoadingSpinner only while agent activity is visible. Neither +# writes row state from representable lifecycle callbacks. +ROW_NSVIEW_REPRESENTABLE_ALLOWLIST = frozenset({ + "SidebarInlineRenameField", + "GPUSpinner", +}) + +# Row-view regions guarded against per-row geometry feedback. Four of the five +# historical regressions in this class entered through the row views, not the +# container functions above: the #2586/#6556 GeometryReader -> @State row-height +# probes lived in `TabItemView` and `SidebarWorkspaceGroupHeaderView` (deleted +# by #6111, reintroduced by #4385, deleted by #7117 -- and the reintroduction +# shipped in stable v0.64.17, which livelocked in the wild on 2026-07-02). The +# per-row `.anchorPreference` aggregation of #5323 is the same shape: a row +# publishing its own geometry forces SwiftUI to realize every row per pass. +# +# Rows must not measure themselves, period. Row heights are implicit; the ONLY +# sanctioned geometry path is the container's drag-gated reader +# (`rowsWithGatedDropTargetReader` + `SidebarWorkspaceFrameAnchorModifier`), +# which lives outside these regions. A future legitimate need must extend this +# guard consciously rather than slip past it. +GUARDED_ROW_TYPES = ( + "TabItemView", + "SidebarWorkspaceRowView", + "SidebarWorkspaceGroupHeaderView", + "SidebarWorkspaceGroupRowView", +) + +ROW_FORBIDDEN_PATTERNS = ( + (re.compile(r"\bGeometryReader\b"), + "GeometryReader (a row measuring itself feeds the #2586/#6556 " + "GeometryReader -> @State row-height livelock; row heights are implicit)"), + (re.compile(r"\bonGeometryChange\b"), + "onGeometryChange (geometry-driven state writes in a row re-trigger " + "layout the same way the #6556 GeometryReader probes did)"), + (re.compile(r"\.sizeThatFits\s*\("), + "manual .sizeThatFits( call (measuring from a row realizes lazy " + "siblings; let SwiftUI size the row)"), + (re.compile(r"\bProposedViewSize\s*\([^)]*\bnil\b"), + "ProposedViewSize(..., nil) (natural-size measurement realizes the lazy " + "list -- the #6210 force-measure)"), + (re.compile(r"\.anchorPreference\s*\("), + ".anchorPreference( in a row (per-row frame publication aggregated by an " + "ancestor is the #5323 virtualization defeat; only the container's " + "drag-gated SidebarWorkspaceFrameAnchorModifier may collect row frames)"), + (re.compile(r"\.overlayPreferenceValue\s*\("), + ".overlayPreferenceValue( in a row (consuming aggregated row geometry " + "inside a row is the #5323 feedback shape)"), +) + +# Lazy-fill primitives the #6188 fix depends on. Each must remain present in the +# named function (after comments/strings are stripped). +REQUIRED_PRIMITIVES = ( + ("workspaceRows", re.compile(r"\bLazyVStack\s*\("), + "LazyVStack( -- the rows must stay lazy; a plain VStack realizes every " + "row on each pass and re-livelocks at scale"), + ("workspaceScrollContent", re.compile(r"\.frame\s*\(\s*minHeight:"), + ".frame(minHeight:) -- the measurement-free fill primitive that replaced " + "SidebarRowsFillLayout; do not remove it"), ) def neutralize_swift(source): - """Blank comments and string contents while preserving source positions.""" + """Return ``source`` with comment and string-literal *contents* replaced by + spaces, preserving every character's position and all newlines. + + Token and brace scanning runs on this neutralized text so that tokens which + appear only inside the explanatory comments (e.g. the words + ``SidebarRowsFillLayout`` or ``sizeThatFits(height: nil)``) are invisible to + the guard, and so braces/parens inside comments or strings never corrupt the + function-body matching. + """ out = [] i = 0 - state = "code" - block_depth = 0 - while i < len(source): + n = len(source) + LINE_COMMENT, BLOCK_COMMENT, STRING, MULTILINE_STRING = 1, 2, 3, 4 + state = 0 + while i < n: ch = source[i] - pair = source[i:i + 2] - triple = source[i:i + 3] - if state == "code": - if pair == "//": - out.extend(" ") + nxt = source[i + 1] if i + 1 < n else "" + if state == 0: + if ch == "/" and nxt == "/": + out.append(" ") i += 2 - state = "line" - elif pair == "/*": - out.extend(" ") + state = LINE_COMMENT + continue + if ch == "/" and nxt == "*": + out.append(" ") i += 2 - state = "block" - block_depth = 1 - elif triple == '\"\"\"': - out.extend('\"\"\"') + state = BLOCK_COMMENT + continue + if source[i:i + 3] == '"""': + # Swift multi-line string literal: only a closing `"""` ends it, + # so a bare `"` inside must NOT toggle string state -- otherwise + # the inner quote would close the literal early and expose the + # rest (e.g. a forbidden token named in prose) as apparent code, + # tripping the guard with a false positive. (#6870 review) + out.append('"""') i += 3 - state = "multiline" - elif ch == '\"': - out.append(ch) - i += 1 - state = "string" - else: - out.append(ch) + state = MULTILINE_STRING + continue + if ch == '"': + out.append('"') i += 1 - elif state == "line": - out.append("\n" if ch == "\n" else " ") + state = STRING + continue + out.append(ch) i += 1 + continue + if state == LINE_COMMENT: if ch == "\n": - state = "code" - elif state == "block": - if pair == "/*": - out.extend(" ") - block_depth += 1 - i += 2 - elif pair == "*/": - out.extend(" ") - block_depth -= 1 + out.append("\n") + state = 0 + else: + out.append(" ") + i += 1 + continue + if state == BLOCK_COMMENT: + if ch == "*" and nxt == "/": + out.append(" ") i += 2 - if block_depth == 0: - state = "code" + state = 0 else: out.append("\n" if ch == "\n" else " ") i += 1 - elif state == "string": - if ch == "\\" and i + 1 < len(source): - out.extend(" ") + continue + if state == STRING: + if ch == "\\" and nxt != "": + # Preserve the escape pair as spaces so positions stay aligned. + out.append(" ") i += 2 - elif ch == '\"': - out.append(ch) - i += 1 - state = "code" - else: - out.append("\n" if ch == "\n" else " ") + continue + if ch == '"': + out.append('"') i += 1 - else: - if triple == '\"\"\"': - out.extend('\"\"\"') + state = 0 + continue + out.append("\n" if ch == "\n" else " ") + i += 1 + continue + if state == MULTILINE_STRING: + if source[i:i + 3] == '"""': + out.append('"""') i += 3 - state = "code" - else: - out.append("\n" if ch == "\n" else " ") - i += 1 + state = 0 + continue + # A lone `"` does not close a multi-line string; only `"""` does. + out.append("\n" if ch == "\n" else " ") + i += 1 + continue return "".join(out) -def extract_braced_declaration(source, pattern): - match = re.search(pattern, source) +def extract_function_body(neutralized, func_name): + """Return the brace-matched body text of ``func (`` in the + neutralized source, or ``None`` if the function is not found. + + Handles multi-line signatures by walking parenthesis depth until the + parameter list closes, then brace-matching from the body's opening ``{``. + """ + match = re.search(r"\bfunc\s+" + re.escape(func_name) + r"\s*\(", neutralized) if not match: return None - opening = source.find("{", match.end()) - if opening < 0: + i = match.end() - 1 # at the opening '(' of the parameter list + n = len(neutralized) + paren_depth = 0 + # Walk until the parameter-list parens balance back to zero. + while i < n: + ch = neutralized[i] + if ch == "(": + paren_depth += 1 + elif ch == ")": + paren_depth -= 1 + if paren_depth == 0: + i += 1 + break + i += 1 + else: + return None + # Find the body's opening brace (skip the `-> some View` return clause). + while i < n and neutralized[i] != "{": + # A premature ';' or top-level '}' means there was no body. + if neutralized[i] == "}": + return None + i += 1 + if i >= n: return None - depth = 0 - for index in range(opening, len(source)): - if source[index] == "{": - depth += 1 - elif source[index] == "}": - depth -= 1 - if depth == 0: - return source[opening:index + 1] + brace_depth = 0 + start = i + while i < n: + ch = neutralized[i] + if ch == "{": + brace_depth += 1 + elif ch == "}": + brace_depth -= 1 + if brace_depth == 0: + return neutralized[start:i + 1] + i += 1 return None -def extract_function_body(source, name): - return extract_braced_declaration( - source, - r"\bfunc\s+" + re.escape(name) + r"\s*\(", - ) - +def extract_type_body(neutralized, type_name): + """Return the brace-matched body text of ``struct/class `` in the + neutralized source, or ``None`` if the declaration is not found. -def extract_type_body(source, name): - return extract_braced_declaration( - source, - r"\b(?:struct|class|final\s+class)\s+" + re.escape(name) + r"\b", + Walks from the declaration keyword to the body's opening ``{`` (skipping + generic parameters and the conformance list), then brace-matches. + """ + match = re.search( + r"\b(?:struct|final\s+class|class)\s+" + re.escape(type_name) + r"\b", + neutralized, ) + if not match: + return None + i = match.end() + n = len(neutralized) + while i < n and neutralized[i] != "{": + if neutralized[i] == ";": + return None + i += 1 + if i >= n: + return None + brace_depth = 0 + start = i + while i < n: + ch = neutralized[i] + if ch == "{": + brace_depth += 1 + elif ch == "}": + brace_depth -= 1 + if brace_depth == 0: + return neutralized[start:i + 1] + i += 1 + return None -def discovered_representables(swift_sources): +# Directory names that hold build artifacts, VCS data, or vendored third-party +# code. Pruned from the repo-owned Swift walk: a Layout pulled from an external +# dependency is out of scope (you would have to import it), and SwiftPM checkout +# trees under `.build` are huge. +EXCLUDED_DIR_NAMES = frozenset({ + ".build", ".git", "DerivedData", "Vendor", "vendor", "ThirdParty", + "third_party", "Pods", "Carthage", "node_modules", +}) + + +def repo_owned_swift_files(repo_root): + """Yield every repo-owned Swift source path under ``Sources/`` and + ``Packages/`` (where cmux migrates app code), pruning build/VCS/vendored + directories. Scanning both keeps the custom-Layout discovery from being + bypassed by defining the force-measuring layout in a package. (#6870 review) + """ + for top in ("Sources", "Packages"): + root = os.path.join(repo_root, top) + if not os.path.isdir(root): + continue + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIR_NAMES] + for filename in filenames: + if filename.endswith(".swift"): + yield os.path.join(dirpath, filename) + + +def find_custom_layout_type_names(paths): + """Return the set of type names conforming to SwiftUI's `Layout` protocol + across ``paths`` (comment/string-neutralized so a `: Layout` in prose is not + counted). Cheap-filtered to files that mention ``Layout`` at all. + """ names = set() - pattern = re.compile( - r"\b(?:struct|class|final\s+class)\s+([A-Za-z_]\w*)[^\{\n]*:\s*[^\{\n]*\bNSViewRepresentable\b" - ) - for source in swift_sources: - names.update(pattern.findall(neutralize_swift(source))) + seen = set() + for path in paths: + try: + real = os.path.realpath(path) + except OSError: + continue + if real in seen: + continue + seen.add(real) + try: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + except OSError: + continue + if "Layout" not in text: + continue + for match in CUSTOM_LAYOUT_DECL.finditer(neutralize_swift(text)): + names.add(match.group(1)) return names -def check_content_view(source): - clean = neutralize_swift(source) - violations = [] - body = extract_function_body(clean, "workspaceScrollArea") - if body is None: - return ["could not locate func workspaceScrollArea(...); update this guard for the renamed boundary"] - if "SidebarWorkspaceTableView" not in body: - violations.append("workspaceScrollArea does not mount SidebarWorkspaceTableView") - for token in ("ScrollView", "LazyVStack", "LazyHStack", "List"): - if re.search(r"\b" + token + r"\b", body): - violations.append("workspaceScrollArea reintroduces SwiftUI list container: " + token) - for name in OLD_DEFAULT_LIST_FUNCTIONS: - if extract_function_body(clean, name) is not None: - violations.append("obsolete default-list function still exists: " + name) - return violations +def find_nsview_representable_type_names(paths): + """Return repo-owned NSViewRepresentable-conforming type names in ``paths``. + + Sources are comment/string-neutralized before matching, using the same scan + discipline as custom Layout discovery. + """ + names = set() + seen = set() + for path in paths: + try: + real = os.path.realpath(path) + except OSError: + continue + if real in seen: + continue + seen.add(real) + try: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + except OSError: + continue + if "NSViewRepresentable" not in text: + continue + for match in NSVIEW_REPRESENTABLE_DECL.finditer(neutralize_swift(text)): + names.add(match.group(1)) + return names -def check_row_source(source, required_type, representable_names): - clean = neutralize_swift(source) - body = extract_type_body(clean, required_type) - if body is None: - return [f"could not locate row type {required_type}; update this guard for the rename"] +def nsview_representable_violations(body, region_name, type_names): + """Return violations for disallowed representable references in ``body``.""" violations = [] - forbidden = representable_names - REPRESENTABLE_ALLOWLIST - {"SidebarWorkspaceTableView"} - for name in sorted(forbidden): + for name in sorted(type_names - ROW_NSVIEW_REPRESENTABLE_ALLOWLIST): if re.search(r"\b" + re.escape(name) + r"\b", body): - violations.append(f"{required_type} mounts forbidden per-row NSViewRepresentable {name}") + violations.append( + "{0} references always-mounted NSViewRepresentable `{1}`. " + "Sidebar row bodies must be value-only so AppKit lifecycle " + "callbacks cannot mutate row state during SwiftUI layout " + "(issue #8004).".format(region_name, name) + ) return violations -def check_appkit_sources(sources_by_name): +def check_source( + source, + custom_layout_names=None, + nsview_representable_names=None, + require_functions=True, + required_row_types=(), + required_row_functions=(), + scan_all_rows=False, + required_markers=(), +): + """Return a list of human-readable violation strings (empty == clean). + + ``require_functions`` controls whether the two container functions must be + present: True for ContentView.swift, False for row-view files like + SidebarWorkspaceGroupHeaderView.swift (which do not contain them), or + "auto" for ad-hoc ``--file`` runs -- container checks then apply only when + at least one guarded function exists in the source, so a row-view file + scans cleanly while a fixture that renamed ONE function still fails loudly. + ``required_row_types`` names GUARDED_ROW_TYPES that must exist in this + source -- a rename fails loudly instead of silently skipping the region. + Row types that are merely present are always scanned. + + ``scan_all_rows`` applies the row-forbidden patterns to the ENTIRE + neutralized source instead of extracted type regions. Used for row-wrapper + files (e.g. VerticalTabsSidebar+WorkspaceGroups.swift) whose modifier + sites wrap a row before it enters the LazyVStack: a GeometryReader or + anchorPreference added around the header there defeats laziness exactly + like one inside the row view. ``required_markers`` are substrings that + must appear in the source so a rename/move fails loudly. + """ + custom_layout_names = custom_layout_names or set() + nsview_representable_names = nsview_representable_names or set() + neutralized = neutralize_swift(source) violations = [] - required = { - "SidebarWorkspaceTableView.swift": ("NSViewRepresentable", "makeNSView", "updateNSView"), - "SidebarWorkspaceTableController.swift": ("@MainActor", "NSTableViewDataSource", "NSTableViewDelegate"), - "SidebarWorkspaceTableCellView.swift": ("NSTableCellView", "NSHostingView", "rootView"), - "SidebarWorkspaceTableViewImpl.swift": ("NSTableView", "updateTrackingAreas", "otherMouseDown"), - } - for filename, markers in required.items(): - source = sources_by_name.get(filename) - if source is None: - violations.append(f"missing required AppKit-list file {filename}") - continue - clean = neutralize_swift(source) - for marker in markers: - if marker not in clean: - violations.append(f"{filename} is missing required marker {marker}") - - for filename, source in sources_by_name.items(): - clean = neutralize_swift(source) - for callback in LAYOUT_CALLBACKS: - body = extract_function_body(clean, callback) + bodies = {} + if require_functions == "auto": + require_functions = any( + re.search(r"\bfunc\s+" + re.escape(name) + r"\s*\(", neutralized) + for name in GUARDED_FUNCTIONS + ) + if require_functions: + for func_name in GUARDED_FUNCTIONS: + body = extract_function_body(neutralized, func_name) if body is None: + violations.append( + "could not locate func {0}(...) in the source. The sidebar " + "lazy-layout guard must be updated to track the renamed " + "function (refusing to pass as a no-op).".format(func_name) + ) continue - for pattern in LAYOUT_MUTATION_PATTERNS: - if pattern.search(body): - violations.append( - f"{filename}.{callback} mutates/reconfigures the table from a layout callback" + bodies[func_name] = body + + for func_name, body in bodies.items(): + for pattern, description in FORBIDDEN_PATTERNS: + if pattern.search(body): + violations.append( + "{0}(...) reintroduces a forbidden whole-list measurement: " + "{1}".format(func_name, description) + ) + # A custom Layout (under ANY name) applied to the rows wraps the + # LazyVStack and measures it every pass -- the #6210 shape. Banning the + # whole discovered set, not just the deleted name, blocks the rename + # dodge (#6870 review). + for name in sorted(custom_layout_names): + if re.search(r"\b" + re.escape(name) + r"\b", body): + violations.append( + "{0}(...) applies the custom Layout `{1}` to the sidebar rows. " + "A custom Layout wrapping the LazyVStack measures it on every " + "pass (the #6210 force-measure shape, regardless of the type's " + "name); size the rows with .frame(minHeight:) instead.".format( + func_name, name ) - for pattern, label in ( - (r"\bObservableObject\b", "ObservableObject"), - (r"@Published\b", "@Published"), - (r"DispatchQueue\.main\.async", "DispatchQueue.main.async"), - (r"DispatchQueue\.asyncAfter", "DispatchQueue.asyncAfter"), - ): - if re.search(pattern, clean): - violations.append(f"{filename} introduces forbidden {label}") + ) + + for func_name, pattern, description in REQUIRED_PRIMITIVES: + body = bodies.get(func_name) + if body is None: + continue # already reported as a missing-function violation + if not pattern.search(body): + violations.append( + "{0}(...) is missing a required lazy-fill primitive: {1}".format( + func_name, description + ) + ) + + for marker in required_markers: + if marker not in neutralized: + violations.append( + "could not locate `{0}` in the source. The sidebar lazy-layout " + "guard must be updated to track the renamed/moved row wrapper " + "(refusing to pass as a no-op).".format(marker) + ) + + for func_name in required_row_functions: + body = extract_function_body(neutralized, func_name) + if body is None: + violations.append( + "could not locate row-builder func {0}(...). The sidebar " + "NSViewRepresentable guard must be updated to track the " + "renamed function (refusing to pass as a no-op).".format(func_name) + ) + continue + violations.extend(nsview_representable_violations( + body, + "{0}(...)".format(func_name), + nsview_representable_names, + )) + + if scan_all_rows: + for pattern, description in ROW_FORBIDDEN_PATTERNS: + if pattern.search(neutralized): + violations.append( + "row-wrapper file contains forbidden per-row geometry " + "feedback: {0}".format(description) + ) + for name in sorted(custom_layout_names): + if re.search(r"\b" + re.escape(name) + r"\b", neutralized): + violations.append( + "row-wrapper file applies the custom Layout `{0}` (the " + "#6210 force-measure shape); row wrappers must stay " + "measurement-free.".format(name) + ) + + # Row-view regions: rows must never measure or publish their own geometry. + for type_name in GUARDED_ROW_TYPES: + body = extract_type_body(neutralized, type_name) + if body is None: + if type_name in required_row_types: + violations.append( + "could not locate type {0} in the source. The sidebar " + "lazy-layout guard must be updated to track the renamed " + "row view (refusing to pass as a no-op).".format(type_name) + ) + continue + for pattern, description in ROW_FORBIDDEN_PATTERNS: + if pattern.search(body): + violations.append( + "{0} contains forbidden per-row geometry feedback: " + "{1}".format(type_name, description) + ) + for name in sorted(custom_layout_names): + if re.search(r"\b" + re.escape(name) + r"\b", body): + violations.append( + "{0} applies the custom Layout `{1}`. A custom Layout in a " + "sidebar row measures its subtree every pass (the #6210 " + "force-measure shape); rows must stay measurement-free.".format( + type_name, name + ) + ) + violations.extend(nsview_representable_violations( + body, + type_name, + nsview_representable_names, + )) + return violations @@ -222,59 +608,134 @@ def repo_root_dir(): return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -def read(path): - with open(path, "r", encoding="utf-8") as handle: - return handle.read() - - -def default_violations(root): - appkit_dir = os.path.join(root, "Sources", "Sidebar", "AppKitList") - try: - appkit_sources = { - name: read(os.path.join(appkit_dir, name)) - for name in os.listdir(appkit_dir) - if name.endswith(".swift") - } - content = read(os.path.join(root, "Sources", "ContentView.swift")) - group = read(os.path.join(root, "Sources", "SidebarWorkspaceGroupHeaderView.swift")) - except OSError as error: - return [f"cannot read required sidebar source: {error}"] - - all_sources = list(appkit_sources.values()) - for directory, _, filenames in os.walk(os.path.join(root, "Sources")): - for filename in filenames: - if filename.endswith(".swift"): - try: - all_sources.append(read(os.path.join(directory, filename))) - except OSError: - pass - representables = discovered_representables(all_sources) +def default_targets(): + """(path, require_functions, required_row_types, required_row_functions, + scan_all_rows, required_markers) per scanned file. + + ContentView.swift holds the container functions and TabItemView; the group + header row view lives in its own file with neither container function; the + group-header row builder (`sidebarWorkspaceGroupRow(...)`) lives in a + third file whose modifier sites wrap the header before it enters the + LazyVStack. The two immutable wrapper views live in their own files and are + guarded as row regions as well. + """ + root = repo_root_dir() return ( - check_content_view(content) - + check_row_source(content, "TabItemView", representables) - + check_row_source(group, "SidebarWorkspaceGroupHeaderView", representables) - + check_appkit_sources(appkit_sources) + ( + os.path.join(root, "Sources", "ContentView.swift"), + True, + ("TabItemView",), + ("workspaceRow",), + False, + (), + ), + ( + os.path.join(root, "Sources", "SidebarWorkspaceGroupHeaderView.swift"), + False, + ("SidebarWorkspaceGroupHeaderView",), + (), + False, + (), + ), + ( + os.path.join(root, "Sources", "VerticalTabsSidebar+WorkspaceGroups.swift"), + False, + (), + ("sidebarWorkspaceGroupRow",), + True, + ("sidebarWorkspaceGroupRow",), + ), + ( + os.path.join(root, "Sources", "SidebarWorkspaceRowView.swift"), + False, + ("SidebarWorkspaceRowView",), + (), + False, + (), + ), + ( + os.path.join(root, "Sources", "SidebarWorkspaceGroupRowView.swift"), + False, + ("SidebarWorkspaceGroupRowView",), + (), + False, + (), + ), ) def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--file", help="scan one AppKit-list Swift file for layout-callback mutations") + parser.add_argument( + "--file", + default=None, + help="Swift source file to scan (defaults to Sources/ContentView.swift " + "+ Sources/SidebarWorkspaceGroupHeaderView.swift).", + ) args = parser.parse_args(argv) + if args.file: + # "auto": ad-hoc scans of a row-view file (no container functions) + # skip the container checks instead of failing on their absence; a + # source containing any guarded function still has both enforced. + targets = ((args.file, "auto", (), (), False, ()),) + else: + targets = default_targets() + + # Discover custom Layout type names from the target files plus every + # repo-owned Swift source (Sources/ and Packages/), so a renamed + # force-measuring layout defined in any app file or package is still banned + # from the guarded regions. + layout_scan_paths = [target[0] for target in targets] + layout_scan_paths.extend(sorted(repo_owned_swift_files(repo_root_dir()))) + custom_layout_names = find_custom_layout_type_names(layout_scan_paths) + nsview_representable_names = find_nsview_representable_type_names(layout_scan_paths) + + exit_code = 0 + for ( + target, + require_functions, + required_row_types, + required_row_functions, + scan_all_rows, + required_markers, + ) in targets: try: - violations = check_appkit_sources({os.path.basename(args.file): read(args.file)}) + with open(target, "r", encoding="utf-8") as handle: + source = handle.read() except OSError as error: - violations = [f"cannot read {args.file}: {error}"] - else: - violations = default_violations(repo_root_dir()) - if violations: - print("check-sidebar-lazy-layout: FAILED", file=sys.stderr) - for violation in violations: - print(" - " + violation, file=sys.stderr) - return 1 - print("check-sidebar-lazy-layout: ok (AppKit NSTableView boundary)") - return 0 + print("check-sidebar-lazy-layout: cannot read {0}: {1}".format(target, error), + file=sys.stderr) + exit_code = 1 + continue + + violations = check_source( + source, + custom_layout_names, + nsview_representable_names, + require_functions=require_functions, + required_row_types=required_row_types, + required_row_functions=required_row_functions, + scan_all_rows=scan_all_rows, + required_markers=required_markers, + ) + if violations: + print("check-sidebar-lazy-layout: FAILED for {0}".format(target), + file=sys.stderr) + for violation in violations: + print(" - {0}".format(violation), file=sys.stderr) + print( + "\nThe workspace sidebar must keep its rows lazy at measure time " + "and its rows measurement-free. See #6188 / #6210 / #6384 / #6556 " + "and the comments in workspaceScrollContent / workspaceRows.", + file=sys.stderr, + ) + exit_code = 1 + continue + + print("check-sidebar-lazy-layout: ok ({0})".format(target)) + + return exit_code if __name__ == "__main__": diff --git a/tests/test_ci_sidebar_lazy_layout_guard.py b/tests/test_ci_sidebar_lazy_layout_guard.py index b2ed99fbf3d6..02fce5dafccf 100755 --- a/tests/test_ci_sidebar_lazy_layout_guard.py +++ b/tests/test_ci_sidebar_lazy_layout_guard.py @@ -1,127 +1,427 @@ #!/usr/bin/env python3 -"""Negative coverage for the AppKit sidebar topology guard.""" +"""CI guard for ./scripts/check-sidebar-lazy-layout.py. + +Verifies the guard reports "ok" on the real cmux repo and correctly *fails* on +every way the workspace-sidebar lazy-layout contract can be broken. The negative +cases are what keep the guard from rotting into a no-op. + +Cases: + (a) Real cmux repo passes (Sources/ContentView.swift). + (b) A fixture whose guarded functions are clean code but whose comments and + string literals deliberately name every forbidden token still passes + (comment/string neutralization works -- this mirrors the real source, + which documents the anti-patterns it forbids). + (c) Reintroducing the #6210 force-measure (`.sizeThatFits(ProposedViewSize( + width:, height: nil))`) fails. + (d) Reintroducing the deleted `SidebarRowsFillLayout` custom Layout fails. + (e) A `GeometryReader` in the steady-state scroll content fails. + (f) Downgrading the rows from `LazyVStack` to a plain eager `VStack` fails. + (g) Dropping `.frame(minHeight:)` from `workspaceScrollContent` fails. + (h) Renaming/removing a guarded function fails loudly (no silent skip). + (j) A clean TabItemView row region passes. + (k) The #6556 rowHeightProbe shape (GeometryReader + @State height in a row) + fails — this exact code shipped in stable v0.64.17 and livelocked in the + wild on 2026-07-02. + (l) Per-row `.anchorPreference` (the #5323 virtualization defeat) fails. + (m) A required row type missing from its file fails loudly (no silent skip). +""" import importlib.util import os +import subprocess import sys +import tempfile +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GUARD = os.path.join(ROOT_DIR, "scripts", "check-sidebar-lazy-layout.py") -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -GUARD_PATH = os.path.join(ROOT, "scripts", "check-sidebar-lazy-layout.py") - -def load_guard(): - spec = importlib.util.spec_from_file_location("sidebar_guard", GUARD_PATH) +def load_guard_module(): + spec = importlib.util.spec_from_file_location("sidebar_lazy_layout_guard", GUARD) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -def expect(condition, label): - print(f"[{'PASS' if condition else 'FAIL'}] {label}") - return 0 if condition else 1 +def write_text(path, contents): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(contents) -def content_fixture(body): - return f""" -import SwiftUI -struct Sidebar {{ - func workspaceScrollArea(renderContext: Context) -> some View {{ - {body} - }} -}} -struct TabItemView: View {{ - var body: some View {{ Text(\"row\") }} -}} -""" +def run_guard(path): + return subprocess.run( + [sys.executable, GUARD, "--file", path], + cwd=ROOT_DIR, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def run_guard_default(): + return subprocess.run( + [sys.executable, GUARD], + cwd=ROOT_DIR, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +def fixture(scroll_body, rows_body): + """Assemble a minimal ContentView-shaped Swift source with the two guarded + functions. ``scroll_body`` / ``rows_body`` are the function-body statements. + """ + return ( + "import SwiftUI\n" + "struct ContentBody {\n" + " private func workspaceScrollContent(\n" + " renderContext: WorkspaceListRenderContext,\n" + " minHeight: CGFloat\n" + " ) -> some View {\n" + " // History: SidebarRowsFillLayout measured it via\n" + " // sizeThatFits(ProposedViewSize(width: width, height: nil)) and a\n" + " // GeometryReader feedback loop. Those tokens live only in this\n" + " // comment and must not trip the guard.\n" + + scroll_body + + "\n }\n" + " @ViewBuilder\n" + " private func workspaceRows(renderContext: WorkspaceListRenderContext) -> some View {\n" + + rows_body + + "\n }\n" + "}\n" + ) + + +# A clean scroll body and rows body that satisfy the contract. +GOOD_SCROLL = ( + " workspaceRows(renderContext: renderContext)\n" + " .frame(minHeight: minHeight, alignment: .top)" +) +GOOD_ROWS = ( + " let rows = LazyVStack(spacing: tabRowSpacing) {\n" + " ForEach(renderItems, id: \\.id) { item in workspaceRow(item) }\n" + " }\n" + " return rows" +) + + +def write_fixture(directory, name, contents): + path = os.path.join(directory, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(contents) + return path + + +def expect(result, should_pass, label): + ok = (result.returncode == 0) if should_pass else (result.returncode != 0) + state = "PASS" if ok else "FAIL" + print("[{0}] {1} (exit={2}, expected {3})".format( + state, label, result.returncode, "0" if should_pass else "non-zero")) + if not ok: + print("---- guard output ----") + print(result.stdout.rstrip()) + print("----------------------") + return ok def main(): - guard = load_guard() failures = 0 - failures += expect(not guard.default_violations(ROOT), "real repository passes") + # (a) Real repo must pass. + failures += 0 if expect(run_guard_default(), True, "real repo passes") else 1 - clean = content_fixture("SidebarWorkspaceTableView()") - failures += expect(not guard.check_content_view(clean), "table boundary passes") + with tempfile.TemporaryDirectory() as workdir: + # (b) Clean code + forbidden tokens only in comments/strings still passes. + good_with_string = fixture( + GOOD_SCROLL + "\n .accessibilityLabel(\"GeometryReader sizeThatFits\")", + GOOD_ROWS, + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "Good.swift", good_with_string)), + True, "clean body, anti-patterns only in comments/strings", + ) else 1 - missing = "struct Sidebar { func renamed() {} }" - failures += expect( - any("could not locate" in item for item in guard.check_content_view(missing)), - "renamed boundary fails loudly", - ) + # (b2) A Swift multi-line string literal that contains a bare `"` and + # forbidden tokens must not trip the guard. A naive tokenizer closes the + # literal at the inner quote and exposes `GeometryReader` as code. (#6870) + good_multiline = fixture( + GOOD_SCROLL + + "\n .help(\"\"\"\n" + + " Layout note: he said \"GeometryReader\" plus\n" + + " sizeThatFits(ProposedViewSize(width: w, height: nil)) and\n" + + " SidebarRowsFillLayout are all forbidden here.\n" + + " \"\"\")", + GOOD_ROWS, + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "GoodMultiline.swift", good_multiline)), + True, "multi-line string with bare quote + forbidden tokens passes", + ) else 1 - swiftui_list = content_fixture("ScrollView { LazyVStack { Text(\"x\") } }") - failures += expect( - any("SwiftUI list container" in item for item in guard.check_content_view(swiftui_list)), - "SwiftUI ScrollView/LazyVStack regression fails", - ) + # (c) Force-measure reintroduced. + bad_force = fixture( + " let h = subviews.first?.sizeThatFits(\n" + " ProposedViewSize(width: width, height: nil)).height ?? 0\n" + " return workspaceRows(renderContext: renderContext)\n" + " .frame(minHeight: max(h, minHeight), alignment: .top)", + GOOD_ROWS, + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "Force.swift", bad_force)), + False, "force-measure sizeThatFits(ProposedViewSize(height: nil)) fails", + ) else 1 - obsolete = clean + "\nextension Sidebar { func workspaceRows() {} }\n" - failures += expect( - any("obsolete" in item for item in guard.check_content_view(obsolete)), - "obsolete workspaceRows seam fails", - ) + # (d) SidebarRowsFillLayout reintroduced. + bad_layout = fixture( + " SidebarRowsFillLayout(minHeight: minHeight) {\n" + " workspaceRows(renderContext: renderContext)\n" + " }", + GOOD_ROWS, + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "Layout.swift", bad_layout)), + False, "reintroduced SidebarRowsFillLayout fails", + ) else 1 - representables = {"BadRowPortal", "SidebarInlineRenameField", "GPUSpinner"} - bad_row = """ -struct TabItemView: View { - var body: some View { BadRowPortal() } -} -""" - failures += expect( - any("BadRowPortal" in item for item in guard.check_row_source( - bad_row, "TabItemView", representables - )), - "per-row NSViewRepresentable fails", - ) + # (d2) A *renamed* custom Layout (not the literal old name) applied to the + # rows must fail, even when the force-measure lives in the layout type + # outside the two guarded functions. The guard discovers Layout-conforming + # types and bans all their names from the functions. (#6870 review) + renamed_layout = ( + "import SwiftUI\n" + "struct RowsFillLayout: Layout {\n" + " func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {\n" + " subviews.first?.sizeThatFits(ProposedViewSize(width: 10, height: nil)) ?? .zero\n" + " }\n" + " func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {}\n" + "}\n" + + fixture( + " RowsFillLayout {\n" + " workspaceRows(renderContext: renderContext)\n" + " }\n" + " .frame(minHeight: minHeight, alignment: .top)", + GOOD_ROWS, + ) + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "RenamedLayout.swift", renamed_layout)), + False, "renamed custom Layout applied to rows fails", + ) else 1 - allowed_row = """ -struct TabItemView: View { - var body: some View { SidebarInlineRenameField() } -} -""" - failures += expect( - not guard.check_row_source(allowed_row, "TabItemView", representables), - "inline rename allowlist passes", - ) + # (e) GeometryReader in steady-state scroll content. + bad_geo = fixture( + " GeometryReader { proxy in\n" + " workspaceRows(renderContext: renderContext)\n" + " .frame(minHeight: proxy.size.height, alignment: .top)\n" + " }", + GOOD_ROWS, + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "Geo.swift", bad_geo)), + False, "GeometryReader in scroll content fails", + ) else 1 - bad_lifecycle = """ -final class SidebarWorkspaceTableViewImpl: NSTableView { - override func updateTrackingAreas() { - super.updateTrackingAreas() - reloadData() - } -} -""" - failures += expect( - any("layout callback" in item for item in guard.check_appkit_sources({ - "SidebarWorkspaceTableViewImpl.swift": bad_lifecycle, - })), - "reload from updateTrackingAreas fails", - ) + # (f) Eager VStack instead of LazyVStack. + bad_eager = fixture( + GOOD_SCROLL, + " let rows = VStack(spacing: tabRowSpacing) {\n" + " ForEach(renderItems, id: \\.id) { item in workspaceRow(item) }\n" + " }\n" + " return rows", + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "Eager.swift", bad_eager)), + False, "eager VStack (no LazyVStack) fails", + ) else 1 - comments_only = """ -final class SidebarWorkspaceTableViewImpl: NSTableView { - override func updateTrackingAreas() { - super.updateTrackingAreas() - // reloadData() and rootView = are forbidden, but this is prose. - let note = \"noteHeightOfRows()\" - } -} -""" - lifecycle_violations = guard.check_appkit_sources({ - "SidebarWorkspaceTableViewImpl.swift": comments_only, - }) - failures += expect( - not any("layout callback" in item for item in lifecycle_violations), - "comments and strings are neutralized", - ) + # (g) Missing .frame(minHeight:). + bad_nominheight = fixture( + " workspaceRows(renderContext: renderContext)\n" + " .frame(maxWidth: .infinity, alignment: .top)", + GOOD_ROWS, + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "NoMinHeight.swift", bad_nominheight)), + False, "missing .frame(minHeight:) fails", + ) else 1 + + # (h) A guarded function renamed away -> guard must fail loudly. + renamed = ( + "import SwiftUI\n" + "struct ContentBody {\n" + " private func workspaceScrollContent(\n" + " renderContext: WorkspaceListRenderContext, minHeight: CGFloat\n" + " ) -> some View {\n" + + GOOD_SCROLL + + "\n }\n" + " @ViewBuilder\n" + " private func workspaceRowsRenamed(renderContext: WorkspaceListRenderContext) -> some View {\n" + + GOOD_ROWS + + "\n }\n" + "}\n" + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "Renamed.swift", renamed)), + False, "renamed guarded function fails (no silent skip)", + ) else 1 + + # (j) Clean TabItemView row region passes. + def row_fixture(row_body): + return fixture(GOOD_SCROLL, GOOD_ROWS) + ( + "struct TabItemView: View, Equatable {\n" + " let tab: Tab\n" + " var body: some View {\n" + + row_body + + "\n }\n" + "}\n" + ) + + clean_row = row_fixture( + " HStack { Text(tab.title) }\n" + " .contentShape(Rectangle())" + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "CleanRow.swift", clean_row)), + True, "clean TabItemView row region passes", + ) else 1 + + # (k) The #6556 rowHeightProbe shape: GeometryReader writing @State row + # height from inside a row. This exact code shipped in stable v0.64.17 + # and livelocked in the wild (issue #2586, 2026-07-02 spindump). + probe_row = row_fixture( + " HStack { Text(tab.title) }\n" + " .background {\n" + " GeometryReader { proxy in\n" + " Color.clear.onAppear { rowHeight = proxy.size.height }\n" + " }\n" + " }" + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "ProbeRow.swift", probe_row)), + False, "#6556 rowHeightProbe (GeometryReader in row) fails", + ) else 1 + + # (l) Per-row anchorPreference publication (the #5323 defeat). + anchor_row = row_fixture( + " HStack { Text(tab.title) }\n" + " .anchorPreference(key: RowFrameKey.self, value: .bounds) { [tab.id: $0] }" + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "AnchorRow.swift", anchor_row)), + False, "per-row .anchorPreference (#5323 shape) fails", + ) else 1 + + # (n) --file on a row-view source (no container functions) must not + # emit false "could not locate func" violations; row scanning still + # applies. (Greptile P2 on #7221.) + header_like = ( + "import SwiftUI\n" + "struct SidebarWorkspaceGroupHeaderView: View, Equatable {\n" + " var body: some View {\n" + " HStack { Text(\"group\") }\n" + " }\n" + "}\n" + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "HeaderLike.swift", header_like)), + True, "--file on row-view source without container functions passes", + ) else 1 + + header_like_bad = header_like.replace( + " HStack { Text(\"group\") }\n", + " HStack { Text(\"group\") }\n" + " .background { GeometryReader { p in Color.clear } }\n", + ) + failures += 0 if expect( + run_guard(write_fixture(workdir, "HeaderLikeBad.swift", header_like_bad)), + False, "--file row-view source with GeometryReader still fails", + ) else 1 + + # (o) Whole-file row-wrapper scan (VerticalTabsSidebar+WorkspaceGroups): + # clean passes, GeometryReader at the wrapper site fails, and a missing + # required marker fails loudly. (Codex review on #7221.) + guard_mod_rows = load_guard_module() + wrapper_clean = ( + "import SwiftUI\nextension VerticalTabsSidebar {\n" + " func sidebarWorkspaceGroupHeader(item: Item) -> some View {\n" + " SidebarWorkspaceGroupHeaderView(item: item)\n" + " .contentShape(Rectangle())\n" + " }\n}\n" + ) + v = guard_mod_rows.check_source( + wrapper_clean, set(), require_functions=False, + scan_all_rows=True, required_markers=("sidebarWorkspaceGroupHeader",), + ) + ok = not v + print("[{0}] clean row-wrapper file passes whole-file scan".format( + "PASS" if ok else "FAIL")) + failures += 0 if ok else 1 + + v = guard_mod_rows.check_source( + wrapper_clean.replace( + ".contentShape(Rectangle())", + ".background { GeometryReader { p in Color.clear } }", + ), + set(), require_functions=False, + scan_all_rows=True, required_markers=("sidebarWorkspaceGroupHeader",), + ) + ok = any("GeometryReader" in x for x in v) + print("[{0}] GeometryReader at wrapper site fails whole-file scan".format( + "PASS" if ok else "FAIL")) + failures += 0 if ok else 1 + + v = guard_mod_rows.check_source( + "import SwiftUI\n", set(), require_functions=False, + scan_all_rows=True, required_markers=("sidebarWorkspaceGroupHeader",), + ) + ok = any("could not locate" in x for x in v) + print("[{0}] missing wrapper marker fails loudly".format( + "PASS" if ok else "FAIL")) + failures += 0 if ok else 1 + + # (m) A required row type missing from its file must fail loudly. + guard_mod = load_guard_module() + missing_row_violations = guard_mod.check_source( + "import SwiftUI\nstruct SomethingElse: View { var body: some View { Text(\"x\") } }\n", + set(), + require_functions=False, + required_row_types=("SidebarWorkspaceGroupHeaderView",), + ) + row_rename_ok = any("could not locate type" in v for v in missing_row_violations) + print("[{0}] missing required row type fails loudly".format( + "PASS" if row_rename_ok else "FAIL")) + failures += 0 if row_rename_ok else 1 + + # (i) Custom-Layout discovery must cover repo-owned Packages/ (where cmux + # migrates app code) and exclude build/vendor trees. (#6870 review) + guard = load_guard_module() + fake_root = os.path.join(workdir, "fakerepo") + write_text(os.path.join(fake_root, "Sources", "Z.swift"), + "struct SourcesLayout: Layout {}\n") + write_text(os.path.join(fake_root, "Packages", "macOS", "CmuxSidebar", + "Sources", "X.swift"), + "struct PackagedRowsLayout: Layout {}\n") + write_text(os.path.join(fake_root, "Packages", "macOS", "Dep", ".build", + "checkouts", "ext", "Y.swift"), + "struct VendoredLayout: Layout {}\n") + scanned = list(guard.repo_owned_swift_files(fake_root)) + discovered = guard.find_custom_layout_type_names(scanned) + cov_ok = ( + "SourcesLayout" in discovered + and "PackagedRowsLayout" in discovered + and "VendoredLayout" not in discovered + ) + print("[{0}] discovery covers Sources/ + Packages/, excludes .build " + "(found {1})".format("PASS" if cov_ok else "FAIL", sorted(discovered))) + failures += 0 if cov_ok else 1 if failures: - print(f"test_ci_sidebar_lazy_layout_guard: {failures} case(s) failed", file=sys.stderr) + print("\ntest_ci_sidebar_lazy_layout_guard: {0} case(s) FAILED".format(failures), + file=sys.stderr) return 1 - print("test_ci_sidebar_lazy_layout_guard: all cases passed") + print("\ntest_ci_sidebar_lazy_layout_guard: all cases passed") return 0 From 3ff4b534913e11501dd69f36783947e854d0f493 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 16 Jul 2026 12:08:37 -0700 Subject: [PATCH 4/4] Clamp table sizing to the proposal; compare row snapshots for equivalence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by at-scale runtime verification (128 workspaces): 1. The representable's default sizing falls back to the container's fitting size, which derives from the table's full content height. At 128 workspaces the ideal-size pass adopted ~5,200pt and the window grew to fit every row. sizeThatFits now reports exactly the proposal, with unspecified dimensions reporting zero, so content-derived metrics never escape the viewport. 2. Row-configuration equivalence was an Equatable TabItemView / SidebarWorkspaceGroupHeaderView built per row per body pass, which also built each row's ~45-closure actions bundle just to be thrown away on the next pass — destroying the previous pass's bundles dominated idle main-thread time. Equivalence now compares the immutable row/group snapshots directly (the same values the views diff on), and SidebarWorkspaceGroupRowSnapshot gains a synthesized Equatable. --- Sources/ContentView.swift | 9 +++++---- .../SidebarWorkspaceTableRowConfiguration.swift | 6 +++--- .../AppKitList/SidebarWorkspaceTableView.swift | 14 ++++++++++++++ Sources/SidebarWorkspaceGroupRowSnapshot.swift | 2 +- Sources/VerticalTabsSidebar+WorkspaceGroups.swift | 9 +++++---- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b1eb2a8b1e03..39464d1339f0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13290,10 +13290,11 @@ struct VerticalTabsSidebar: View { actions: actions ) } - let equivalenceValue = makeRow( - false, - SidebarWorkspaceTableContextMenuActions(didOpen: {}, didClose: {}) - ) + // Equivalence compares the immutable row snapshot directly. Building a + // TabItemView here would also build its ~45-closure actions bundle per + // row per body pass; destroying the previous pass's bundles dominated + // idle main-thread time at 128 workspaces. + let equivalenceValue = baseInput.rowSnapshot(list: listSnapshot) return SidebarWorkspaceTableRowConfiguration( id: .workspace(workspaceId), workspaceId: workspaceId, diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift index c38f6fab0c68..9c44ec56dc58 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableRowConfiguration.swift @@ -24,14 +24,14 @@ struct SidebarWorkspaceTableRowConfiguration { private let equivalenceValue: Any private let isEquivalentValue: (Any) -> Bool - init( + init( id: SidebarWorkspaceRenderItemID, workspaceId: UUID, groupId: UUID?, isGroupHeader: Bool, isPinned: Bool, environment: SidebarWorkspaceTableEnvironmentSnapshot, - equivalenceValue: Content, + equivalenceValue: Value, makeContent: @escaping ContentFactory ) { self.id = id @@ -43,7 +43,7 @@ struct SidebarWorkspaceTableRowConfiguration { self.makeContent = makeContent self.equivalenceValue = equivalenceValue self.isEquivalentValue = { value in - guard let value = value as? Content else { return false } + guard let value = value as? Value else { return false } return value == equivalenceValue } } diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift index e303a907e858..0638fcee4eab 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift @@ -20,6 +20,20 @@ struct SidebarWorkspaceTableView: NSViewRepresentable { context.coordinator.makeContainerView() } + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: SidebarWorkspaceTableContainerView, + context: Context + ) -> CGSize? { + // The table is a viewport, never a content-sized view. The default + // sizing falls back to the container's fitting size, which derives + // from the table's full content height and inflates the ideal size — + // at 128 workspaces the window itself grew to fit every row. Report + // exactly the proposal; unspecified dimensions report zero so ideal + // -size passes never see content-derived metrics. + CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0) + } + func updateNSView(_ nsView: SidebarWorkspaceTableContainerView, context: Context) { #if DEBUG context.coordinator.reconfigurationProbe = sidebarLazyContractProbe.tableRootViewReconfigure diff --git a/Sources/SidebarWorkspaceGroupRowSnapshot.swift b/Sources/SidebarWorkspaceGroupRowSnapshot.swift index 9bcfd4209308..e80bb5f59f39 100644 --- a/Sources/SidebarWorkspaceGroupRowSnapshot.swift +++ b/Sources/SidebarWorkspaceGroupRowSnapshot.swift @@ -7,7 +7,7 @@ import Foundation /// 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 { +struct SidebarWorkspaceGroupRowSnapshot: Equatable { let groupId: UUID let anchorWorkspaceId: UUID let name: String diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 1f4f7d3ac0be..bde6cfaa5d97 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -126,10 +126,11 @@ extension VerticalTabsSidebar { onContextMenuDisappear: contextMenuActions.didClose ) } - let equivalenceValue = makeHeader( - false, - SidebarWorkspaceTableContextMenuActions(didOpen: {}, didClose: {}) - ) + // Equivalence compares the immutable group snapshot directly (see the + // workspace-row configuration for why views are not built per pass). + var equivalenceSnapshot = snapshot + equivalenceSnapshot.isPointerHovering = false + let equivalenceValue = equivalenceSnapshot return SidebarWorkspaceTableRowConfiguration( id: .group(snapshot.groupId), workspaceId: snapshot.anchorWorkspaceId,