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 22ce1ccf1994..39464d1339f0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9960,10 +9960,13 @@ 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 #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 @@ -10241,6 +10244,7 @@ struct VerticalTabsSidebar: View { } 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 +10327,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, @@ -10487,7 +10504,103 @@ 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 + // 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) + .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() + } + .background(Color.clear) + .onChange(of: tabManager.selectedTabId) { _, _ in + // Workspace switches produce no outside click for .transient auto-dismiss; close popovers explicitly. + if let dismissed = checklistPopoverWorkspaceId { checklistAddFieldActivationTokens[dismissed] = nil } + checklistPopoverWorkspaceId = nil + } + .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 workspaceLegacyScrollArea(renderContext: WorkspaceListRenderContext) -> some View { let scrollInsets = SidebarWorkspaceScrollInsets.workspaceList return GeometryReader { viewport in // Keep viewport geometry as a downward-only layout input. Writing @@ -10614,6 +10727,16 @@ struct VerticalTabsSidebar: View { } } } + } + + 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) @@ -10650,6 +10773,125 @@ 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 @@ -12090,6 +12332,74 @@ struct VerticalTabsSidebar: View { ) } + /// 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 + ) + let workspaceRowInputsById = Dictionary(uniqueKeysWithValues: renderContext.tabs.map { workspace in + ( + workspace.id, + workspaceRowInput( + workspace, + renderContext: renderContext, + unreadSummariesByWorkspaceId: unreadSummariesByWorkspaceId + ) + ) + }) + _ = 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: false, + 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() + 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 + ) + } + } + } private func activateSidebarWorkspaceDragIfNeeded() -> Bool { if dragState.draggedTabId != nil { return true @@ -12949,6 +13259,65 @@ struct VerticalTabsSidebar: View { ) } + /// 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: @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 + ) + } + // 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, + 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()) + ) + ) + } + } + } struct SidebarWorkspaceFrameAnchorModifier: ViewModifier { @@ -12981,6 +13350,37 @@ struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { } } +/// 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 @@ -13495,6 +13895,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 == @@ -14217,15 +14621,12 @@ 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 + .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 d59b000089dc..109c90583bb0 100644 --- a/Sources/Debug/SidebarLazyContractProbe.swift +++ b/Sources/Debug/SidebarLazyContractProbe.swift @@ -17,5 +17,6 @@ struct SidebarLazyContractProbe { var groupHeaderRowBody: (() -> Void)? var workspaceSnapshotBuild: (() -> Void)? var workspaceRowInputProjection: (() -> Void)? + var tableRootViewReconfigure: (() -> Void)? } #endif 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..edd8649fc67e --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableEnvironmentSnapshot.swift @@ -0,0 +1,46 @@ +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 + 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(\.sidebarPlatformListHosted, true) + .environment(\.sidebarLazyContractProbe, lazyContractProbe) +#else + content + .environment(\.colorScheme, colorScheme) + .environment(\.cmuxGlobalFontMagnificationPercent, globalFontMagnificationPercent) + .environment(\.sidebarPlatformListHosted, true) +#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..9c44ec56dc58 --- /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: Value, + 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? Value 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..0638fcee4eab --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift @@ -0,0 +1,49 @@ +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 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 +#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/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..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 @@ -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/SidebarWorkspaceRowActions.swift b/Sources/SidebarWorkspaceRowActions.swift index 55d6a17ff25e..d5fb0156cd9e 100644 --- a/Sources/SidebarWorkspaceRowActions.swift +++ b/Sources/SidebarWorkspaceRowActions.swift @@ -52,8 +52,10 @@ struct SidebarWorkspaceRowActions { let onToggleChecklistExpansion: () -> Void let onConsumeChecklistAddFieldActivation: () -> Void let onChecklistPopoverPresentedChange: (Bool) -> Void - let onContextMenuAppear: () -> Void - let onContextMenuDisappear: () -> 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 let onPointerFrameChange: (CGRect) -> Void let onPointerFrameDisappear: () -> Void } 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/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 59d61e94a01f..bde6cfaa5d97 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -109,13 +109,57 @@ 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 + ) + } + // 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, + 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,9 +300,24 @@ extension VerticalTabsSidebar { }, onOpenDocs: { SidebarWorkspaceGroupConfigOpener.openWorkspaceGroupsDocs() - } + }, + onContextMenuAppear: onContextMenuAppear, + 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, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5879e1e04b77..302dc45aa18e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1527,6 +1527,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 */; }; @@ -3487,6 +3505,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 = ""; }; @@ -4425,6 +4461,23 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, @@ -5907,6 +5960,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 */, @@ -7377,6 +7431,23 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, @@ -8277,6 +8348,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/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() + } + } +}