From 7dc221b0ae0c1a6982056940ad280464fbcfc877 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 02:04:29 -0400 Subject: [PATCH 1/4] Test that cmux.json workspaceColors.subtleSelection resolves the setting Co-Authored-By: Claude Opus 5.5 (1M context) --- cmuxTests/WorkspaceUnitTests.swift | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 82d4795e62e6..97f9d405b2f9 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2403,6 +2403,70 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(palette.map(\.hex), ["#2244FF", "#00F5D4"]) } + @MainActor + func testSettingsFileStoreResolvesWorkspaceColorsSubtleSelection() throws { + let defaults = UserDefaults.standard + let managedKey = SettingCatalog().workspaceColors.subtleSelection.userDefaultsKey + let previousValue = defaults.object(forKey: managedKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: managedKey) + } else { + defaults.removeObject(forKey: managedKey) + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + defaults.removeObject(forKey: managedKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + XCTAssertFalse(SidebarTabItemSettingsSnapshot(defaults: defaults).subtleSelection) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "workspaceColors": { + "subtleSelection": true + } + } + """, + to: settingsFileURL + ) + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + XCTAssertTrue(SidebarTabItemSettingsSnapshot(defaults: defaults).subtleSelection) + + // A non-boolean value is rejected, so the setting reverts to its default. + let invalidSettingsURL = directoryURL.appendingPathComponent("invalid.json", isDirectory: false) + try writeSettingsFile( + """ + { + "workspaceColors": { + "subtleSelection": "yes" + } + } + """, + to: invalidSettingsURL + ) + _ = KeyboardShortcutSettingsFileStore( + primaryPath: invalidSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + XCTAssertFalse(SidebarTabItemSettingsSnapshot(defaults: defaults).subtleSelection) + } + func testManagedWorkspaceColorsRestoreLegacyPaletteWhenFileSettingIsRemoved() throws { let defaults = UserDefaults.standard let previousPalette = defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey) as? [String: String] From 58f847d6252d92333372ee79920613b249a7eb8e Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 02:04:29 -0400 Subject: [PATCH 2/4] Give selected group headers the subtle selection hairline and keep legacy rows off window activation Group headers now paint the same 1 pt edge selected workspace rows get in subtle-selection mode (neutral edge for the anchor-active header's neutral wash, the multi-selection style's edge for multi-selected headers), in both the AppKit and SwiftUI header paths including optimistic press paint. TabItemView no longer holds @Environment(\.controlActiveState). A small reader view reads window activation only around the selection background of a selected subtle-selection row, so focus changes stop invalidating every legacy sidebar row. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/ContentView.swift | 99 +++++++++++++------ .../Cells/SidebarGroupHeaderRowModel.swift | 3 + .../Cells/SidebarGroupHeaderRowView.swift | 20 ++++ .../Sidebar/SidebarAppearanceSupport.swift | 62 +++++++++++- Sources/SidebarWorkspaceGroupHeaderView.swift | 23 ++++- .../SidebarWorkspaceGroupRowSnapshot.swift | 3 + .../VerticalTabsSidebar+WorkspaceGroups.swift | 17 ++++ cmuxTests/SidebarAppKitRowCellTests.swift | 75 ++++++++++++++ 8 files changed, 267 insertions(+), 35 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1d4e28fbcabd..09b3e94318d1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -15862,10 +15862,11 @@ 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 - // Window activation and Increase Contrast only: the selection wash dims to - // neutral when the window is inactive, like Finder. Neither changes per - // keystroke. - @Environment(\.controlActiveState) private var controlActiveState + // Increase Contrast strengthens the multi-selection and subtle selection + // washes; it changes only when the accessibility option does. Window + // activation is read by `SidebarSelectionWindowActivationReader`, and + // only for subtle-selection rows that are selected, so focus changes + // don't invalidate every row. @Environment(\.colorSchemeContrast) private var colorSchemeContrast #if DEBUG // Plain-value environment probe (closure struct, not an object reference): @@ -16166,10 +16167,6 @@ struct TabItemView: View, Equatable { #endif let signpost = SidebarProfilingSignposts.begin("sidebar-tab-item-body", "index=\(index) workspace=\(sidebarShortTabId(workspaceId)) active=\(isActive) unread=\(unreadCount)") let workspaceSnapshot = self.workspaceSnapshot - let rowBackgroundStyle = backgroundStyle(for: workspaceSnapshot) - let rowBackgroundColor = rowBackgroundStyle.color.map { - Color(nsColor: $0).opacity(rowBackgroundStyle.opacity) - } ?? .clear let rowRailColor = railColor(for: workspaceSnapshot) let accessibilityTitle = workspaceSnapshot.accessibilityLabel(index: index, workspaceCount: accessibilityWorkspaceCount) let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") @@ -16599,27 +16596,7 @@ struct TabItemView: View, Equatable { // they appear; content changes now apply in one discrete layout pass. .padding(.horizontal, SidebarWorkspaceListMetrics.rowContentHorizontalPadding) .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(rowBackgroundColor) - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder( - activeBorderColor(for: rowBackgroundStyle), - lineWidth: activeBorderLineWidth(for: rowBackgroundStyle) - ) - } - .overlay(alignment: .leading) { - if showsLeadingRail(for: workspaceSnapshot) { - Capsule(style: .continuous) - .fill(rowRailColor) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) - .offset(x: -1) - } - } - ) + .background(rowBackground(for: workspaceSnapshot, railColor: rowRailColor)) .sidebarShortcutHintOverlay( text: showsWorkspaceShortcutHint ? workspaceShortcutLabel : nil, emphasis: shortcutHintEmphasis, @@ -16719,8 +16696,68 @@ struct TabItemView: View, Equatable { isEditing = true } + /// Only a selected row painted with the subtle selection changes with + /// window activation. Legacy and unselected rows never read it. + private var selectionChromeTracksWindowActivation: Bool { + (isActive || isMultiSelected) + && sidebarUsesSubtleSelection( + activeTabIndicatorStyle: activeTabIndicatorStyle, + subtleSelection: settings.subtleSelection, + sidebarSelectionColorHex: sidebarSelectionColorHex + ) + } + + @ViewBuilder + private func rowBackground( + for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + railColor: Color + ) -> some View { + if selectionChromeTracksWindowActivation { + SidebarSelectionWindowActivationReader { isEmphasized in + rowBackgroundShape( + style: backgroundStyle(for: workspaceSnapshot, isEmphasized: isEmphasized), + workspaceSnapshot: workspaceSnapshot, + railColor: railColor + ) + } + } else { + rowBackgroundShape( + style: backgroundStyle(for: workspaceSnapshot, isEmphasized: true), + workspaceSnapshot: workspaceSnapshot, + railColor: railColor + ) + } + } + + private func rowBackgroundShape( + style: SidebarWorkspaceRowBackgroundStyle, + workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + railColor: Color + ) -> some View { + RoundedRectangle(cornerRadius: 6) + .fill(style.color.map { Color(nsColor: $0).opacity(style.opacity) } ?? .clear) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder( + activeBorderColor(for: style), + lineWidth: activeBorderLineWidth(for: style) + ) + } + .overlay(alignment: .leading) { + if showsLeadingRail(for: workspaceSnapshot) { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } + } + private func backgroundStyle( - for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot + for workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + isEmphasized: Bool ) -> SidebarWorkspaceRowBackgroundStyle { sidebarWorkspaceRowBackgroundStyle( activeTabIndicatorStyle: activeTabIndicatorStyle, @@ -16730,7 +16767,7 @@ struct TabItemView: View, Equatable { colorScheme: colorScheme, sidebarSelectionColorHex: sidebarSelectionColorHex, subtleSelection: settings.subtleSelection, - isEmphasized: controlActiveState != .inactive, + isEmphasized: isEmphasized, increaseContrast: colorSchemeContrast == .increased, accent: settings.accentColor ) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift index 002b912e19ca..d38d540d6f1e 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift @@ -20,6 +20,9 @@ struct SidebarGroupHeaderRowModel: Equatable, Hashable { let isAnchorActive: Bool let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle + /// Subtle-selection hairline for the anchor-active header; nil when the + /// header paints no edge. + var anchorActiveEdgeColor: NSColor? = nil let memberCount: Int var anchorUnreadCount: Int var canMarkRead: Bool diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index a657f5f61b9f..f1382e01b2b0 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -225,6 +225,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { ? 6 : 4 backgroundView.layer?.backgroundColor = headerBackgroundColor(for: model).cgColor + applySelectionEdge(headerSelectionEdgeColor(for: model)) topDropIndicator.accentColor = model.accentColor bottomDropIndicator.accentColor = model.accentColor @@ -260,6 +261,10 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { } #if DEBUG + var selectionEdgeWidthForTesting: CGFloat { + backgroundView.layer?.borderWidth ?? 0 + } + var dropIndicatorPaintForTesting: (top: Bool, bottom: Bool) { (!topDropIndicator.isHidden, !bottomDropIndicator.isHidden) } @@ -290,6 +295,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { CATransaction.setDisableActions(true) backgroundView.layer?.cornerRadius = 4 backgroundView.layer?.backgroundColor = labelColor.withAlphaComponent(0.08).cgColor + applySelectionEdge(model.anchorActiveEdgeColor) CATransaction.commit() nameField.textColor = labelColor } @@ -302,6 +308,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { CATransaction.setDisableActions(true) backgroundView.layer?.cornerRadius = 6 backgroundView.layer?.backgroundColor = headerMultiSelectionBackgroundColor(for: model).cgColor + applySelectionEdge(model.multiSelectionBackgroundStyle.edgeColor) CATransaction.commit() } @@ -313,6 +320,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { CATransaction.setDisableActions(true) backgroundView.layer?.cornerRadius = 4 backgroundView.layer?.backgroundColor = NSColor.clear.cgColor + applySelectionEdge(nil) CATransaction.commit() let colorScheme: ColorScheme = model.colorSchemeIsDark ? .dark : .light nameField.textColor = SidebarAppearanceColorResolver().resolvedColor( @@ -348,6 +356,18 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { return .clear } + /// Subtle-selection hairline, matching selected workspace rows. + private func headerSelectionEdgeColor(for model: SidebarGroupHeaderRowModel) -> NSColor? { + if model.isAnchorActive { return model.anchorActiveEdgeColor } + if model.isMultiSelected { return model.multiSelectionBackgroundStyle.edgeColor } + return nil + } + + private func applySelectionEdge(_ edgeColor: NSColor?) { + backgroundView.layer?.borderWidth = edgeColor == nil ? 0 : 1 + backgroundView.layer?.borderColor = edgeColor?.cgColor + } + private func headerMultiSelectionBackgroundColor( for model: SidebarGroupHeaderRowModel ) -> NSColor { diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 751719119ef8..93269adb9d47 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -353,6 +353,42 @@ func sidebarSelectedWorkspaceForegroundNSColor( return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: clampedOpacity) } +/// Whether selected rows paint the subtle tint and hairline. Only the +/// left-rail indicator style uses it, and a configured selection color is an +/// explicit request for a solid fill. +func sidebarUsesSubtleSelection( + activeTabIndicatorStyle: WorkspaceIndicatorStyle, + subtleSelection: Bool, + sidebarSelectionColorHex: String? +) -> Bool { + subtleSelection + && activeTabIndicatorStyle == .leftRail + && sidebarSelectionColorHex.flatMap { NSColor(hex: $0) } == nil +} + +/// Hairline for a group header whose anchor workspace is selected, so group +/// headers carry the same edge as selected workspace rows in subtle-selection +/// mode. The header keeps its neutral wash, so the edge is the neutral +/// selection edge. Nil when subtle selection is off. +func sidebarGroupHeaderAnchorActiveEdgeNSColor( + activeTabIndicatorStyle: WorkspaceIndicatorStyle, + subtleSelection: Bool, + sidebarSelectionColorHex: String?, + colorScheme: ColorScheme, + increaseContrast: Bool +) -> NSColor? { + guard sidebarUsesSubtleSelection( + activeTabIndicatorStyle: activeTabIndicatorStyle, + subtleSelection: subtleSelection, + sidebarSelectionColorHex: sidebarSelectionColorHex + ) else { return nil } + return CmuxSelectionFill.resolve( + colorScheme: colorScheme, + isEmphasized: false, + increaseContrast: increaseContrast + ).edgeColor +} + struct SidebarWorkspaceRowBackgroundStyle: Equatable, Hashable { let color: NSColor? let opacity: Double @@ -402,9 +438,11 @@ func sidebarWorkspaceRowBackgroundStyle( accent: accent ) let accentBackground = accent.nsColor(for: colorScheme) - // A configured selection color is an explicit request for a solid fill. - let usesSubtleSelection = subtleSelection - && sidebarSelectionColorHex.flatMap { NSColor(hex: $0) } == nil + let usesSubtleSelection = sidebarUsesSubtleSelection( + activeTabIndicatorStyle: activeTabIndicatorStyle, + subtleSelection: subtleSelection, + sidebarSelectionColorHex: sidebarSelectionColorHex + ) func calmFill(isSecondary: Bool) -> SidebarWorkspaceRowBackgroundStyle { let fill = CmuxSelectionFill.resolve( colorScheme: colorScheme, @@ -465,3 +503,21 @@ extension WorkspaceIndicatorStyle { isActive && (self == .solidFill || increaseContrast) } } + +/// Reads window activation for the subtle selection wash, which dims to +/// neutral when the window is inactive the way Finder's selection does. +/// Rows wrap only their selection background in this reader, and only while +/// the subtle selection paints them, so activation changes invalidate that +/// background instead of every sidebar row. +struct SidebarSelectionWindowActivationReader: View { + @Environment(\.controlActiveState) private var controlActiveState + let content: (_ isEmphasized: Bool) -> Content + + init(@ViewBuilder content: @escaping (_ isEmphasized: Bool) -> Content) { + self.content = content + } + + var body: some View { + content(controlActiveState != .inactive) + } +} diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index efe6161e5374..1cc9ce4b51c8 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -21,6 +21,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { lhs.isAnchorActive == rhs.isAnchorActive && lhs.isMultiSelected == rhs.isMultiSelected && lhs.multiSelectionBackgroundStyle == rhs.multiSelectionBackgroundStyle && + lhs.anchorActiveEdgeColor == rhs.anchorActiveEdgeColor && lhs.memberCount == rhs.memberCount && lhs.anchorUnreadCount == rhs.anchorUnreadCount && lhs.canMarkRead == rhs.canMarkRead && @@ -55,6 +56,9 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let isAnchorActive: Bool let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle + /// Subtle-selection hairline for the anchor-active header; nil when the + /// header paints no edge. + var anchorActiveEdgeColor: NSColor? = nil let memberCount: Int let anchorUnreadCount: Int let canMarkRead: Bool @@ -127,6 +131,17 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { return Color(nsColor: color).opacity(multiSelectionBackgroundStyle.opacity) } + /// Subtle-selection hairline, matching selected workspace rows. + private var selectionEdgeColor: NSColor? { + if isAnchorActive { return anchorActiveEdgeColor } + if isMultiSelected { return multiSelectionBackgroundStyle.edgeColor } + return nil + } + + private var selectionCornerRadius: CGFloat { + isMultiSelected && !isAnchorActive ? 6 : 4 + } + var body: some View { #if DEBUG let _ = { sidebarLazyContractProbe.groupHeaderRowBody?() }() @@ -280,9 +295,15 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { : Color.clear ) .clipShape(RoundedRectangle( - cornerRadius: isMultiSelected && !isAnchorActive ? 6 : 4, + cornerRadius: selectionCornerRadius, style: .continuous )) + .overlay { + if let selectionEdgeColor { + RoundedRectangle(cornerRadius: selectionCornerRadius, style: .continuous) + .strokeBorder(Color(nsColor: selectionEdgeColor), lineWidth: 1) + } + } .sidebarShortcutHintOverlay( text: shortcutHintPillText, emphasis: isAnchorActive ? 1.0 : 0.9, diff --git a/Sources/SidebarWorkspaceGroupRowSnapshot.swift b/Sources/SidebarWorkspaceGroupRowSnapshot.swift index 8622aa0b73e7..cec4cf2283be 100644 --- a/Sources/SidebarWorkspaceGroupRowSnapshot.swift +++ b/Sources/SidebarWorkspaceGroupRowSnapshot.swift @@ -18,6 +18,9 @@ struct SidebarWorkspaceGroupRowSnapshot { let isAnchorActive: Bool let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle + /// Subtle-selection hairline for the anchor-active header; nil when the + /// header paints no edge. + var anchorActiveEdgeColor: NSColor? = nil let memberCount: Int let anchorUnreadCount: Int let canMarkRead: Bool diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 72ad794b0061..31531add1daa 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -41,6 +41,13 @@ extension VerticalTabsSidebar { increaseContrast: renderContext.environment.displayAccessibility.increaseContrast, accent: settings.accentColor ) + let anchorActiveEdgeColor = sidebarGroupHeaderAnchorActiveEdgeNSColor( + activeTabIndicatorStyle: settings.activeTabIndicatorStyle, + subtleSelection: settings.subtleSelection, + sidebarSelectionColorHex: settings.selectionColorHex, + colorScheme: renderContext.environment.colorScheme, + increaseContrast: renderContext.environment.displayAccessibility.increaseContrast + ) let cwdContextMenuItems = resolvedConfig?.contextMenuItems ?? [] let newWorkspacePlacement = resolvedConfig?.newWorkspacePlacement // The AppKit controller applies the current unread snapshot after row @@ -96,6 +103,7 @@ extension VerticalTabsSidebar { isAnchorActive: isAnchorActive, isMultiSelected: isMultiSelected, multiSelectionBackgroundStyle: multiSelectionBackgroundStyle, + anchorActiveEdgeColor: anchorActiveEdgeColor, memberCount: memberWorkspaceIds.count, anchorUnreadCount: anchorUnreadCount, canMarkRead: canMarkAnchorRead, @@ -199,6 +207,13 @@ extension VerticalTabsSidebar { increaseContrast: renderContext.environment.displayAccessibility.increaseContrast, accent: settings.accentColor ) + let anchorActiveEdgeColor = sidebarGroupHeaderAnchorActiveEdgeNSColor( + activeTabIndicatorStyle: settings.activeTabIndicatorStyle, + subtleSelection: settings.subtleSelection, + sidebarSelectionColorHex: settings.selectionColorHex, + colorScheme: renderContext.environment.colorScheme, + increaseContrast: renderContext.environment.displayAccessibility.increaseContrast + ) let cwdContextMenuItems = resolvedConfig?.contextMenuItems ?? [] let newWorkspacePlacement = resolvedConfig?.newWorkspacePlacement let anchorUnreadCount: Int = { @@ -256,6 +271,7 @@ extension VerticalTabsSidebar { isAnchorActive: isAnchorActive, isMultiSelected: isMultiSelected, multiSelectionBackgroundStyle: multiSelectionBackgroundStyle, + anchorActiveEdgeColor: anchorActiveEdgeColor, memberCount: memberWorkspaceIds.count, anchorUnreadCount: anchorUnreadCount, canMarkRead: canMarkAnchorRead, @@ -308,6 +324,7 @@ extension VerticalTabsSidebar { isAnchorActive: snapshot.isAnchorActive, isMultiSelected: snapshot.isMultiSelected, multiSelectionBackgroundStyle: snapshot.multiSelectionBackgroundStyle, + anchorActiveEdgeColor: snapshot.anchorActiveEdgeColor, memberCount: snapshot.memberCount, anchorUnreadCount: snapshot.anchorUnreadCount, canMarkRead: snapshot.canMarkRead, diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 7cf287623478..a7a65d3a1d51 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -2274,6 +2274,81 @@ struct SidebarPinnedIndicatorColorTests { } } +@Suite +@MainActor +struct SidebarGroupHeaderSelectionEdgeTests { + private static func edgeWidth( + subtleSelection: Bool, + isAnchorActive: Bool, + isMultiSelected: Bool + ) -> CGFloat { + let multiSelectionStyle = sidebarWorkspaceRowBackgroundStyle( + activeTabIndicatorStyle: .leftRail, + isActive: false, + isMultiSelected: true, + customColorHex: nil, + colorScheme: .dark, + sidebarSelectionColorHex: nil, + subtleSelection: subtleSelection + ) + let anchorActiveEdgeColor = sidebarGroupHeaderAnchorActiveEdgeNSColor( + activeTabIndicatorStyle: .leftRail, + subtleSelection: subtleSelection, + sidebarSelectionColorHex: nil, + colorScheme: .dark, + increaseContrast: false + ) + let cell = SidebarGroupHeaderTableCellView() + cell.configurePresentation(model: SidebarGroupHeaderRowModel( + groupId: UUID(), + anchorWorkspaceId: UUID(), + name: "Group", + iconSymbol: "folder", + tintHex: nil, + isCollapsed: false, + isPinned: false, + isAnchorActive: isAnchorActive, + isMultiSelected: isMultiSelected, + multiSelectionBackgroundStyle: multiSelectionStyle, + anchorActiveEdgeColor: anchorActiveEdgeColor, + memberCount: 1, + anchorUnreadCount: 0, + canMarkRead: false, + canMarkUnread: false, + hasLatestNotifications: false, + canMarkAllRead: false, + canMarkAllUnread: false, + shortcutHintText: nil, + shortcutHintXOffset: 0, + shortcutHintYOffset: 0, + fontScale: 1, + globalFontMagnificationPercent: 100, + cwdContextMenuItems: [], + rowSpacing: 2, + isFirstRow: true, + isBeingDragged: false, + topDropIndicatorVisible: false, + bottomDropIndicatorVisible: false, + colorSchemeIsDark: true, + notificationBadgeColorHex: nil + )) + return cell.selectionEdgeWidthForTesting + } + + @Test + func selectedGroupHeadersDrawTheSubtleSelectionHairline() { + #expect(Self.edgeWidth(subtleSelection: true, isAnchorActive: true, isMultiSelected: false) == 1) + #expect(Self.edgeWidth(subtleSelection: true, isAnchorActive: false, isMultiSelected: true) == 1) + #expect(Self.edgeWidth(subtleSelection: true, isAnchorActive: false, isMultiSelected: false) == 0) + } + + @Test + func legacySelectionGroupHeadersDrawNoHairline() { + #expect(Self.edgeWidth(subtleSelection: false, isAnchorActive: true, isMultiSelected: false) == 0) + #expect(Self.edgeWidth(subtleSelection: false, isAnchorActive: false, isMultiSelected: true) == 0) + } +} + @Suite @MainActor struct SidebarGroupHeaderBadgeColorTests { From 7ffc1ca93b3c41445b287679db624370c4e1fafe Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 02:32:45 -0400 Subject: [PATCH 3/4] Share the subtle selection predicate with the AppKit row and isolate the settings-file test defaults Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Cells/SidebarGroupHeaderRowModel.swift | 4 +-- .../Cells/SidebarWorkspaceRowCellView.swift | 8 +++--- Sources/SidebarWorkspaceGroupHeaderView.swift | 4 +-- .../SidebarWorkspaceGroupRowSnapshot.swift | 4 +-- cmuxTests/WorkspaceUnitTests.swift | 25 +++++++++---------- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift index d38d540d6f1e..282f12759ffc 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift @@ -20,8 +20,8 @@ struct SidebarGroupHeaderRowModel: Equatable, Hashable { let isAnchorActive: Bool let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle - /// Subtle-selection hairline for the anchor-active header; nil when the - /// header paints no edge. + /// Hairline painted while this header is anchor-active; nil when subtle + /// selection is off. var anchorActiveEdgeColor: NSColor? = nil let memberCount: Int var anchorUnreadCount: Int diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 5bc31d644ed4..240835ae8167 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -249,9 +249,11 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { // Only the subtle left-rail treatment depends on window activation and // Increase Contrast; the default solid fill never changes here. let settings = painted.settings - guard settings.subtleSelection, - settings.activeTabIndicatorStyle != .solidFill, - settings.selectionColorHex.flatMap({ NSColor(hex: $0) }) == nil else { return } + guard sidebarUsesSubtleSelection( + activeTabIndicatorStyle: settings.activeTabIndicatorStyle, + subtleSelection: settings.subtleSelection, + sidebarSelectionColorHex: settings.selectionColorHex + ) else { return } // Selection-derived foregrounds must resolve from the same window and // accessibility state as the fill. These notifications are rare, so // repaint the row from its existing model instead of leaving text diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 1cc9ce4b51c8..c96d537d8dc4 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -56,8 +56,8 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let isAnchorActive: Bool let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle - /// Subtle-selection hairline for the anchor-active header; nil when the - /// header paints no edge. + /// Hairline painted while this header is anchor-active; nil when subtle + /// selection is off. var anchorActiveEdgeColor: NSColor? = nil let memberCount: Int let anchorUnreadCount: Int diff --git a/Sources/SidebarWorkspaceGroupRowSnapshot.swift b/Sources/SidebarWorkspaceGroupRowSnapshot.swift index cec4cf2283be..d4372b977369 100644 --- a/Sources/SidebarWorkspaceGroupRowSnapshot.swift +++ b/Sources/SidebarWorkspaceGroupRowSnapshot.swift @@ -18,8 +18,8 @@ struct SidebarWorkspaceGroupRowSnapshot { let isAnchorActive: Bool let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle - /// Subtle-selection hairline for the anchor-active header; nil when the - /// header paints no edge. + /// Hairline painted while this header is anchor-active; nil when subtle + /// selection is off. var anchorActiveEdgeColor: NSColor? = nil let memberCount: Int let anchorUnreadCount: Int diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 97f9d405b2f9..9029e1af37ee 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2407,23 +2407,22 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { func testSettingsFileStoreResolvesWorkspaceColorsSubtleSelection() throws { let defaults = UserDefaults.standard let managedKey = SettingCatalog().workspaceColors.subtleSelection.userDefaultsKey - let previousValue = defaults.object(forKey: managedKey) - let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + let importedManagedDefaultsKey = "cmux.settingsFile.importedManagedDefaults.v1" + let isolatedKeys = [managedKey, settingsFileBackupsDefaultsKey, importedManagedDefaultsKey] + let previousValues = isolatedKeys.reduce(into: [String: Any]()) { values, key in + values[key] = defaults.object(forKey: key) + } defer { - if let previousValue { - defaults.set(previousValue, forKey: managedKey) - } else { - defaults.removeObject(forKey: managedKey) - } - if let previousBackups { - defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) - } else { - defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + for key in isolatedKeys { + if let value = previousValues[key] { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } } } - defaults.removeObject(forKey: managedKey) - defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + isolatedKeys.forEach { defaults.removeObject(forKey: $0) } XCTAssertFalse(SidebarTabItemSettingsSnapshot(defaults: defaults).subtleSelection) let directoryURL = try makeTemporaryDirectory() From f0213292e4157de5d0943731bba98d787d0a5c88 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 03:19:07 -0400 Subject: [PATCH 4/4] Read the group header edge from its layer in tests instead of a DEBUG accessor Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AppKitList/Cells/SidebarGroupHeaderRowView.swift | 7 ++----- cmuxTests/SidebarAppKitRowCellTests.swift | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index f1382e01b2b0..685173c5d120 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -12,7 +12,8 @@ import SwiftUI final class SidebarGroupHeaderTableCellView: NSTableCellView { static let reuseIdentifier = NSUserInterfaceItemIdentifier("SidebarGroupHeaderTableCellView") - private let backgroundView = NSView() + /// Selection fill and edge layer. Internal so tests read its paint directly. + let backgroundView = NSView() private let pinImageView = NSImageView() private let chevronButton = SidebarHeaderGlyphButton() private let iconImageView = NSImageView() @@ -261,10 +262,6 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { } #if DEBUG - var selectionEdgeWidthForTesting: CGFloat { - backgroundView.layer?.borderWidth ?? 0 - } - var dropIndicatorPaintForTesting: (top: Bool, bottom: Bool) { (!topDropIndicator.isHidden, !bottomDropIndicator.isHidden) } diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index a7a65d3a1d51..cefb243aedc0 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -2332,7 +2332,7 @@ struct SidebarGroupHeaderSelectionEdgeTests { colorSchemeIsDark: true, notificationBadgeColorHex: nil )) - return cell.selectionEdgeWidthForTesting + return cell.backgroundView.layer?.borderWidth ?? 0 } @Test