diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ea13c6d70701..2a262511c042 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33857 CLI/cmux.swift 17914 Sources/AppDelegate.swift -16740 Sources/ContentView.swift +16808 Sources/ContentView.swift 14612 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift @@ -13,15 +13,15 @@ 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift 6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +6512 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift 6074 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5522 cmuxTests/BrowserConfigTests.swift -4921 Sources/cmuxApp.swift 5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +4921 Sources/cmuxApp.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift @@ -32,7 +32,7 @@ 3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift 3331 cmuxTests/TabManagerSessionSnapshotTests.swift -3204 Sources/Update/UpdateTitlebarAccessory.swift +3200 Sources/Update/UpdateTitlebarAccessory.swift 2878 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift 2573 Sources/KeyboardShortcutSettings.swift @@ -57,17 +57,17 @@ 1695 cmuxTests/WorkspacePullRequestSidebarTests.swift 1677 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +1592 Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift 1574 cmuxTests/MarkdownPanelTests.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift -1498 cmuxTests/OmnibarAndToolsTests.swift +1497 cmuxTests/OmnibarAndToolsTests.swift 1496 cmuxUITests/MultiWindowNotificationsUITests.swift 1446 Sources/FileExplorerStore.swift -1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1382 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift -1292 Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1276 cmuxTests/MobileHostAuthorizationTests.swift 1270 cmuxTests/RestorableAgentSessionIndexTests.swift @@ -92,9 +92,9 @@ 937 Sources/TextBoxMentionIndexStore.swift 934 Sources/App/ShortcutRoutingSupport.swift 926 Sources/DockPanelView.swift +920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 919 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 918 cmuxTests/WorkspaceGroupTests.swift -920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 905 Sources/CmuxSSHURLRequest.swift 901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 893 Sources/WorkspaceContentView.swift @@ -117,13 +117,14 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -738 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 716 Sources/TaskManagerSnapshot.swift 715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -706 CLI/CMUXCLI+Config.swift 707 CLI/CMUXCLI+AgentHookDefinitions.swift +706 CLI/CMUXCLI+Config.swift 699 Sources/RightSidebarPanelView.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -187,7 +188,6 @@ 528 cmuxTests/CLINotifyProcessTestSupport.swift 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift -726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 523 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index a920858e22b7..61c6196d98ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,17 +38,17 @@ App path: **Claude Code** outputs: ```markdown -======================================================= +------------------------------------------------------- [cmux DEV my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app) -======================================================= +------------------------------------------------------- ``` **Codex** outputs: ```markdown -======================================================= +------------------------------------------------------- [my-tag: file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app) -======================================================= +------------------------------------------------------- ``` Never use `/tmp/cmux-/...` app links in chat output. diff --git a/Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift b/Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift index 0a8376bbad2d..2ba64329317a 100644 --- a/Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift +++ b/Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift @@ -25,6 +25,30 @@ public struct GhosttyConfig { /// Native fallback dark theme name used for fresh installs before the user /// has chosen terminal colors. public static let cmuxDefaultDarkThemeName = "Apple System Colors" + /// UserDefaults key storing the sidebar values last written from Ghostty config. + public static let sidebarAppearanceAppliedDefaultsKey = "cmux.ghosttyConfig.sidebarAppearance.appliedValues.v1" + /// UserDefaults key for the resolved sidebar tint color. + public static let sidebarTintColorHexKey = "sidebarTintHex" + /// UserDefaults key for the resolved light-mode sidebar tint color. + public static let sidebarTintLightColorHexKey = "sidebarTintHexLight" + /// UserDefaults key for the resolved dark-mode sidebar tint color. + public static let sidebarTintDarkColorHexKey = "sidebarTintHexDark" + /// UserDefaults key for the sidebar tint opacity. + public static let sidebarTintOpacityKey = "sidebarTintOpacity" + /// UserDefaults key for the resolved selected workspace background color. + public static let sidebarSelectionBackgroundColorHexKey = "sidebarSelectionColorHex" + /// UserDefaults key for the resolved sidebar foreground color. + public static let sidebarForegroundColorHexKey = "sidebarForegroundColorHex" + /// UserDefaults key for the resolved secondary sidebar foreground color. + public static let sidebarMutedForegroundColorHexKey = "sidebarMutedForegroundColorHex" + /// UserDefaults key for the resolved selected workspace foreground color. + public static let sidebarSelectionForegroundColorHexKey = "sidebarSelectionForegroundColorHex" + /// UserDefaults key for the resolved sidebar border color. + public static let sidebarBorderColorHexKey = "sidebarBorderColorHex" + /// UserDefaults key for the resolved sidebar accent color. + public static let sidebarAccentColorHexKey = "sidebarAccentColorHex" + /// UserDefaults key for the resolved unread notification badge background color. + public static let sidebarNotificationBadgeBackgroundColorHexKey = "sidebarNotificationBadgeColorHex" private static let loadCacheLock = NSLock() // Every read/write of this cache is serialized by `loadCacheLock` (see @@ -129,6 +153,34 @@ public struct GhosttyConfig { public var sidebarBackgroundDark: NSColor? /// The sidebar tint opacity (0...1), or `nil` when unset. public var sidebarTintOpacity: Double? + /// The raw `sidebar-selection-background` directive value before theme resolution. + public var rawSidebarSelectionBackground: String? + /// The raw `sidebar-foreground` directive value before theme resolution. + public var rawSidebarForeground: String? + /// The raw `sidebar-muted-foreground` directive value before theme resolution. + public var rawSidebarMutedForeground: String? + /// The raw `sidebar-selection-foreground` directive value before theme resolution. + public var rawSidebarSelectionForeground: String? + /// The raw `sidebar-border-color` directive value before theme resolution. + public var rawSidebarBorderColor: String? + /// The raw `sidebar-accent-color` directive value before theme resolution. + public var rawSidebarAccentColor: String? + /// The raw sidebar notification badge background directive value before theme resolution. + public var rawSidebarNotificationBadgeBackground: String? + /// The resolved selected workspace background color for the active color scheme. + public var sidebarSelectionBackground: NSColor? + /// The resolved sidebar foreground color for the active color scheme. + public var sidebarForeground: NSColor? + /// The resolved secondary sidebar foreground color for the active color scheme. + public var sidebarMutedForeground: NSColor? + /// The resolved selected workspace foreground color for the active color scheme. + public var sidebarSelectionForeground: NSColor? + /// The resolved sidebar border color for the active color scheme. + public var sidebarBorderColor: NSColor? + /// The resolved sidebar accent color for the active color scheme. + public var sidebarAccentColor: NSColor? + /// The resolved unread notification badge background color for the active color scheme. + public var sidebarNotificationBadgeBackground: NSColor? /// The 16-color ANSI palette, indexed 0...15. public var palette: [Int: NSColor] = [:] @@ -240,35 +292,269 @@ public struct GhosttyConfig { } } - /// Writes the resolved sidebar appearance (tint colors and opacity) into - /// `UserDefaults.standard` so the SwiftUI sidebar can read them. + /// Resolves every sidebar appearance directive for `preferredColorScheme`. + public mutating func resolveSidebarAppearance(preferredColorScheme: ColorSchemePreference) { + resolveSidebarBackground(preferredColorScheme: preferredColorScheme) + sidebarSelectionBackground = Self.resolveSidebarColor( + rawSidebarSelectionBackground, + preferredColorScheme: preferredColorScheme + ) + sidebarForeground = Self.resolveSidebarColor( + rawSidebarForeground, + preferredColorScheme: preferredColorScheme + ) + sidebarMutedForeground = Self.resolveSidebarColor( + rawSidebarMutedForeground, + preferredColorScheme: preferredColorScheme + ) + sidebarSelectionForeground = Self.resolveSidebarColor( + rawSidebarSelectionForeground, + preferredColorScheme: preferredColorScheme + ) + sidebarBorderColor = Self.resolveSidebarColor( + rawSidebarBorderColor, + preferredColorScheme: preferredColorScheme + ) + sidebarAccentColor = Self.resolveSidebarColor( + rawSidebarAccentColor, + preferredColorScheme: preferredColorScheme + ) + sidebarNotificationBadgeBackground = Self.resolveSidebarColor( + rawSidebarNotificationBadgeBackground, + preferredColorScheme: preferredColorScheme + ) + } + + private static func resolveSidebarColor( + _ raw: String?, + preferredColorScheme: ColorSchemePreference + ) -> NSColor? { + guard let raw else { return nil } + let resolved = resolveThemeName(from: raw, preferredColorScheme: preferredColorScheme) + return NSColor(hex: resolved) + } + + /// Writes the resolved sidebar appearance into `UserDefaults.standard`. + /// + /// Values managed by Ghostty config are tracked in + /// ``sidebarAppearanceAppliedDefaultsKey`` so removing a directive clears + /// stale managed values without deleting user-edited sidebar settings. public func applySidebarAppearanceToUserDefaults() { - guard rawSidebarBackground != nil else { - if let opacity = sidebarTintOpacity { - UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity") - } + let defaults = UserDefaults.standard + var appliedValues = Self.appliedSidebarAppearanceValues(defaults: defaults) + + guard hasSidebarAppearanceDirective || sidebarTintOpacity != nil || !appliedValues.isEmpty else { return } - let defaults = UserDefaults.standard - - if let light = sidebarBackgroundLight { - defaults.set(light.hexString(), forKey: "sidebarTintHexLight") + if rawSidebarBackground != nil { + let shouldClearMissingLightBackground = rawSidebarBackground.map { + Self.resolvesSidebarBackgroundColor($0, preferredColorScheme: .light) + } == true + let shouldClearMissingDarkBackground = rawSidebarBackground.map { + Self.resolvesSidebarBackgroundColor($0, preferredColorScheme: .dark) + } == true + applySidebarBackgroundColor( + sidebarBackgroundLight, + key: Self.sidebarTintLightColorHexKey, + clearsManagedValueWhenMissing: shouldClearMissingLightBackground, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarBackgroundColor( + sidebarBackgroundDark, + key: Self.sidebarTintDarkColorHexKey, + clearsManagedValueWhenMissing: shouldClearMissingDarkBackground, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarBackgroundColor( + sidebarBackground, + key: Self.sidebarTintColorHexKey, + clearsManagedValueWhenMissing: false, + appliedValues: &appliedValues, + defaults: defaults + ) } else { - defaults.removeObject(forKey: "sidebarTintHexLight") + clearManagedSidebarAppearanceValue( + key: Self.sidebarTintLightColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + clearManagedSidebarAppearanceValue( + key: Self.sidebarTintDarkColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + clearManagedSidebarAppearanceValue( + key: Self.sidebarTintColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) } - if let dark = sidebarBackgroundDark { - defaults.set(dark.hexString(), forKey: "sidebarTintHexDark") + + applySidebarColorIfConfigured( + rawSidebarSelectionBackground, + color: sidebarSelectionBackground, + key: Self.sidebarSelectionBackgroundColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarColorIfConfigured( + rawSidebarForeground, + color: sidebarForeground, + key: Self.sidebarForegroundColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarColorIfConfigured( + rawSidebarMutedForeground, + color: sidebarMutedForeground, + key: Self.sidebarMutedForegroundColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarColorIfConfigured( + rawSidebarSelectionForeground, + color: sidebarSelectionForeground, + key: Self.sidebarSelectionForegroundColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarColorIfConfigured( + rawSidebarBorderColor, + color: sidebarBorderColor, + key: Self.sidebarBorderColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarColorIfConfigured( + rawSidebarAccentColor, + color: sidebarAccentColor, + key: Self.sidebarAccentColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + applySidebarColorIfConfigured( + rawSidebarNotificationBadgeBackground, + color: sidebarNotificationBadgeBackground, + key: Self.sidebarNotificationBadgeBackgroundColorHexKey, + appliedValues: &appliedValues, + defaults: defaults + ) + + if let opacity = sidebarTintOpacity { + defaults.set(opacity, forKey: Self.sidebarTintOpacityKey) + appliedValues[Self.sidebarTintOpacityKey] = String(opacity) } else { - defaults.removeObject(forKey: "sidebarTintHexDark") + clearManagedSidebarAppearanceValue( + key: Self.sidebarTintOpacityKey, + appliedValues: &appliedValues, + defaults: defaults + ) } - if let color = sidebarBackground { - defaults.set(color.hexString(), forKey: "sidebarTintHex") + + if appliedValues.isEmpty { + defaults.removeObject(forKey: Self.sidebarAppearanceAppliedDefaultsKey) } else { - defaults.removeObject(forKey: "sidebarTintHex") + defaults.set(appliedValues, forKey: Self.sidebarAppearanceAppliedDefaultsKey) } - if let opacity = sidebarTintOpacity { - defaults.set(opacity, forKey: "sidebarTintOpacity") + } + + private var hasSidebarAppearanceDirective: Bool { + rawSidebarBackground != nil || + rawSidebarSelectionBackground != nil || + rawSidebarForeground != nil || + rawSidebarMutedForeground != nil || + rawSidebarSelectionForeground != nil || + rawSidebarBorderColor != nil || + rawSidebarAccentColor != nil || + rawSidebarNotificationBadgeBackground != nil + } + + private static func appliedSidebarAppearanceValues(defaults: UserDefaults) -> [String: String] { + defaults.dictionary(forKey: sidebarAppearanceAppliedDefaultsKey) as? [String: String] ?? [:] + } + + private static func resolvesSidebarBackgroundColor( + _ raw: String, + preferredColorScheme: ColorSchemePreference + ) -> Bool { + let resolved = resolveThemeName(from: raw, preferredColorScheme: preferredColorScheme) + return NSColor(hex: resolved) != nil + } + + private func applySidebarColorIfConfigured( + _ raw: String?, + color: NSColor?, + key: String, + appliedValues: inout [String: String], + defaults: UserDefaults + ) { + guard raw != nil else { + clearManagedSidebarAppearanceValue( + key: key, + appliedValues: &appliedValues, + defaults: defaults + ) + return + } + guard let color else { + return + } + applySidebarColor(color, key: key, appliedValues: &appliedValues, defaults: defaults) + } + + private func applySidebarColor( + _ color: NSColor, + key: String, + appliedValues: inout [String: String], + defaults: UserDefaults + ) { + let value = color.hexString() + defaults.set(value, forKey: key) + appliedValues[key] = value + } + + private func applySidebarBackgroundColor( + _ color: NSColor?, + key: String, + clearsManagedValueWhenMissing: Bool, + appliedValues: inout [String: String], + defaults: UserDefaults + ) { + guard let color else { + if clearsManagedValueWhenMissing { + clearManagedSidebarAppearanceValue( + key: key, + appliedValues: &appliedValues, + defaults: defaults + ) + } + return + } + let value = color.hexString() + defaults.set(value, forKey: key) + appliedValues[key] = value + } + + private func clearManagedSidebarAppearanceValue( + key: String, + appliedValues: inout [String: String], + defaults: UserDefaults + ) { + guard let appliedValue = appliedValues.removeValue(forKey: key) else { return } + if key == Self.sidebarTintOpacityKey { + let current = defaults.object(forKey: key) as? NSNumber + if let current, + let appliedDouble = Double(appliedValue), + abs(current.doubleValue - appliedDouble) < 0.0001 { + defaults.removeObject(forKey: key) + } + return + } + if defaults.string(forKey: key) == appliedValue { + defaults.removeObject(forKey: key) } } @@ -343,7 +629,7 @@ public struct GhosttyConfig { } #endif - config.resolveSidebarBackground(preferredColorScheme: preferredColorScheme) + config.resolveSidebarAppearance(preferredColorScheme: preferredColorScheme) config.applySidebarAppearanceToUserDefaults() return config @@ -619,6 +905,20 @@ public struct GhosttyConfig { if let opacity = Double(value) { sidebarTintOpacity = min(max(opacity, 0), 1) } + case "sidebar-selection-background": + rawSidebarSelectionBackground = value + case "sidebar-foreground": + rawSidebarForeground = value + case "sidebar-muted-foreground": + rawSidebarMutedForeground = value + case "sidebar-selection-foreground": + rawSidebarSelectionForeground = value + case "sidebar-border-color": + rawSidebarBorderColor = value + case "sidebar-accent-color": + rawSidebarAccentColor = value + case "sidebar-notification-badge-background", "sidebar-notification-badge-color": + rawSidebarNotificationBadgeBackground = value default: break } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f26e1c086307..5e1d777498c7 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9663,6 +9663,11 @@ struct SidebarTabItemSettingsSnapshot: Equatable { let activeTabIndicatorStyle: WorkspaceIndicatorStyle let selectionColorHex: String? let notificationBadgeColorHex: String? + let foregroundColorHex: String? + let mutedForegroundColorHex: String? + let selectionForegroundColorHex: String? + let borderColorHex: String? + let accentColorHex: String? let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility let iMessageModeEnabled: Bool @@ -9719,6 +9724,11 @@ struct SidebarTabItemSettingsSnapshot: Equatable { activeTabIndicatorStyle = settings.value(for: catalog.workspaceColors.indicatorStyle) selectionColorHex = defaults.string(forKey: "sidebarSelectionColorHex") notificationBadgeColorHex = defaults.string(forKey: "sidebarNotificationBadgeColorHex") + foregroundColorHex = defaults.string(forKey: GhosttyConfig.sidebarForegroundColorHexKey) + mutedForegroundColorHex = defaults.string(forKey: GhosttyConfig.sidebarMutedForegroundColorHexKey) + selectionForegroundColorHex = defaults.string(forKey: GhosttyConfig.sidebarSelectionForegroundColorHexKey) + borderColorHex = defaults.string(forKey: GhosttyConfig.sidebarBorderColorHexKey) + accentColorHex = defaults.string(forKey: GhosttyConfig.sidebarAccentColorHexKey) iMessageModeEnabled = IMessageModeSettings.isEnabled(defaults: defaults) } @@ -10161,11 +10171,12 @@ struct SidebarWorkspaceTopDropIndicator: View { let isVisible: Bool let isFirstRow: Bool let rowSpacing: CGFloat + var color: Color = cmuxAccentColor() var body: some View { if isVisible { Rectangle() - .fill(cmuxAccentColor()) + .fill(color) .frame(height: 2) .padding(.horizontal, 8) .offset(y: isFirstRow ? 0 : -(rowSpacing / 2)) @@ -10648,7 +10659,7 @@ struct VerticalTabsSidebar: View { .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .overlay(alignment: .trailing) { - SidebarTrailingBorder() + SidebarTrailingBorder(settings: tabItemSettings) } .background( WindowAccessor { window in @@ -12242,7 +12253,7 @@ struct VerticalTabsSidebar: View { dragState.beginDragging(tabId: tabId) return SidebarTabDragPayload.provider(for: tabId) } - let tabDropDelegateFactory: (CGFloat?) -> SidebarTabDropDelegate = { [ + let tabDropDelegateFactory: (CGFloat) -> SidebarTabDropDelegate = { [ tabId = tab.id, selectedTabIds = $selectedTabIds, lastSidebarSelectionIndex = $lastSidebarSelectionIndex @@ -13270,10 +13281,10 @@ struct TabItemView: View, Equatable { let isBeingDragged: Bool let topDropIndicatorVisible: Bool let onDragStart: () -> NSItemProvider - /// Factory invoked from `body` with a stable drop-hit height. Closure + /// Factory invoked from `body` with the row's measured `rowHeight`. Closure /// captures the parent's `dragState`, so TabItemView itself never holds an - /// `@Observable` store reference or layout-driven state (snapshot-boundary rule). - let tabDropDelegateFactory: (CGFloat?) -> SidebarTabDropDelegate + /// `@Observable` store reference (snapshot-boundary rule). + let tabDropDelegateFactory: (CGFloat) -> SidebarTabDropDelegate let contextMenuWorkspaceIds: [UUID] let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool @@ -13290,9 +13301,8 @@ struct TabItemView: View, Equatable { @State private var workspaceSnapshotStorage: SidebarWorkspaceSnapshotBuilder.Snapshot? @StateObject private var contextMenuState = SidebarTabItemContextMenuState() @State private var rowInteractionState = SidebarWorkspaceRowInteractionState() - @State var workspaceFinderDirectoryOpenRequest: WorkspaceFinderDirectoryOpenRequest? - @State var metadataRowsExpanded = false - @State var metadataBlocksExpanded = false + @State private var rowHeight: CGFloat = 1 + @State private var workspaceFinderDirectoryOpenRequest: WorkspaceFinderDirectoryOpenRequest? var isMultiSelected: Bool { selectedTabIds.contains(tab.id) @@ -13354,6 +13364,31 @@ struct TabItemView: View, Equatable { settings.notificationBadgeColorHex } + private var configuredForegroundColor: Color? { + configuredColor(settings.foregroundColorHex) + } + + private var configuredMutedForegroundColor: Color? { + configuredColor(settings.mutedForegroundColorHex) + } + + private var configuredSelectionForegroundNSColor: NSColor? { + settings.selectionForegroundColorHex.flatMap(NSColor.init(hex:)) + } + + private var configuredBorderColor: Color? { + configuredColor(settings.borderColorHex) + } + + private var configuredAccentColor: Color? { + configuredColor(settings.accentColorHex) + } + + private func configuredColor(_ hex: String?) -> Color? { + guard let color = hex.flatMap(NSColor.init(hex:)) else { return nil } + return Color(nsColor: color) + } + private var selectedWorkspaceBackgroundNSColor: NSColor { sidebarSelectedWorkspaceBackgroundNSColor( for: colorScheme, @@ -13362,7 +13397,10 @@ struct TabItemView: View, Equatable { } private func selectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { - sidebarSelectedWorkspaceForegroundNSColor( + if let configuredSelectionForegroundNSColor { + return configuredSelectionForegroundNSColor.withAlphaComponent(max(0, min(opacity, 1))) + } + return sidebarSelectedWorkspaceForegroundNSColor( on: selectedWorkspaceBackgroundNSColor, opacity: opacity ) @@ -13407,7 +13445,7 @@ struct TabItemView: View, Equatable { case .leftRail: return .clear case .solidFill: - return Color.primary.opacity(0.5) + return configuredBorderColor ?? Color.primary.opacity(0.5) } } @@ -13416,22 +13454,30 @@ struct TabItemView: View, Equatable { } private var activePrimaryTextColor: Color { - usesInvertedActiveForeground - ? Color(nsColor: selectedWorkspaceForegroundNSColor(opacity: 1.0)) - : .primary + if usesInvertedActiveForeground { + return Color(nsColor: selectedWorkspaceForegroundNSColor(opacity: 1.0)) + } + return configuredForegroundColor ?? .primary } private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { - usesInvertedActiveForeground - ? Color(nsColor: selectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) - : .secondary + if usesInvertedActiveForeground { + return Color(nsColor: selectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + } + if let configuredMutedForegroundColor { + return configuredMutedForegroundColor.opacity(opacity) + } + if let configuredForegroundColor { + return configuredForegroundColor.opacity(opacity) + } + return .secondary } private var activeUnreadBadgeFillColor: Color { if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { return Color(nsColor: nsColor) } - return usesInvertedActiveForeground ? activePrimaryTextColor.opacity(0.25) : cmuxAccentColor() + return usesInvertedActiveForeground ? activePrimaryTextColor.opacity(0.25) : sidebarAccentColor } private var activeUnreadBadgeTextColor: Color { @@ -13439,11 +13485,24 @@ struct TabItemView: View, Equatable { } private var activeProgressTrackColor: Color { - usesInvertedActiveForeground ? activeSecondaryColor(0.15) : Color.secondary.opacity(0.2) + if usesInvertedActiveForeground { + return activeSecondaryColor(0.15) + } + if let configuredMutedForegroundColor { + return configuredMutedForegroundColor.opacity(0.2) + } + if let configuredForegroundColor { + return configuredForegroundColor.opacity(0.2) + } + return Color.secondary.opacity(0.2) } private var activeProgressFillColor: Color { - usesInvertedActiveForeground ? activeSecondaryColor(0.8) : cmuxAccentColor() + usesInvertedActiveForeground ? activeSecondaryColor(0.8) : sidebarAccentColor + } + + private var sidebarAccentColor: Color { + configuredAccentColor ?? cmuxAccentColor() } private var shortcutHintEmphasis: Double { @@ -13525,6 +13584,18 @@ struct TabItemView: View, Equatable { } } + private var rowHeightProbe: some View { + GeometryReader { proxy in + Color.clear + .onAppear { + rowHeight = max(proxy.size.height, 1) + } + .onChange(of: proxy.size.height) { newHeight in + rowHeight = max(newHeight, 1) + } + } + } + @ViewBuilder private var remoteWorkspaceSection: some View { let workspaceSnapshot = self.workspaceSnapshot @@ -13616,13 +13687,6 @@ struct TabItemView: View, Equatable { : nil let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle let detailVisibility = visibleAuxiliaryDetails - let titleLineLimit = settings.wrapsWorkspaceTitles - ? SidebarWorkspaceRowDropMetrics.maxWrappedTitleLines - : 1 - let dropTargetHeight = workspaceDropTargetHeight( - snapshot: workspaceSnapshot, - effectiveSubtitle: effectiveSubtitle - ) let scaledUnreadBadgeSize = 16 * fontScale let scaledCloseButtonHitSize = max(16, 16 * fontScale) let scaledCloseButtonWidth = max( @@ -13653,7 +13717,7 @@ struct TabItemView: View, Equatable { Text(workspaceSnapshot.title) .font(.system(size: scaledFontSize(12.5), weight: titleFontWeight)) .foregroundColor(activePrimaryTextColor) - .lineLimit(titleLineLimit) + .lineLimit(settings.wrapsWorkspaceTitles ? SidebarWorkspaceRowTextLimits.maxWrappedTitleLines : 1) .truncationMode(.tail) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -13666,7 +13730,10 @@ struct TabItemView: View, Equatable { // row. (Matches the group-header plus-button pattern.) if canCloseWorkspace { Button(action: { - closeWorkspace(method: "button") + #if DEBUG + cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) }) { Image(systemName: "xmark") .font(.system(size: scaledFontSize(9), weight: .medium)) @@ -13685,7 +13752,6 @@ struct TabItemView: View, Equatable { if let description = workspaceSnapshot.customDescription { SidebarWorkspaceDescriptionText( markdown: description, - isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.84), fontScale: fontScale ) @@ -13708,7 +13774,6 @@ struct TabItemView: View, Equatable { if !metadataEntries.isEmpty { SidebarMetadataRows( entries: metadataEntries, - isExpanded: $metadataRowsExpanded, isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.95), activeSecondaryForegroundColor: activeSecondaryColor(0.65), @@ -13720,8 +13785,6 @@ struct TabItemView: View, Equatable { if !metadataBlocks.isEmpty { SidebarMetadataMarkdownBlocks( blocks: metadataBlocks, - isExpanded: $metadataBlocksExpanded, - isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.8), activeSecondaryForegroundColor: activeSecondaryColor(0.65), fontScale: fontScale, @@ -13954,6 +14017,7 @@ struct TabItemView: View, Equatable { ) .shortcutHintVisibilityAnimation(value: showsWorkspaceShortcutHint) .padding(.horizontal, 6) + .background { rowHeightProbe } .contentShape(Rectangle()) .opacity(isBeingDragged ? 0.6 : 1) .overlay { @@ -13961,27 +14025,44 @@ struct TabItemView: View, Equatable { } .overlay { MiddleClickCapture { - closeWorkspace(method: "middleClick") + #if DEBUG + cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) } } .overlay(alignment: .top) { SidebarWorkspaceTopDropIndicator( isVisible: topDropIndicatorVisible, isFirstRow: index == 0, - rowSpacing: rowSpacing + rowSpacing: rowSpacing, + color: sidebarAccentColor ) } .onAppear { refreshWorkspaceSnapshot(force: true) } .task(id: workspaceFinderDirectoryOpenRequest) { - await openPendingFinderDirectoryRequest() + guard let request = workspaceFinderDirectoryOpenRequest else { return } + await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) + guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } + workspaceFinderDirectoryOpenRequest = nil } .onReceive( tab.sidebarImmediateObservationPublisher .receive(on: RunLoop.main) ) { _ in - refreshWorkspaceSnapshotAfterObservation(source: "immediate") +#if DEBUG + let description = tab.customDescription ?? "" + cmuxDebugLog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=immediate " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + refreshWorkspaceSnapshot() } .onReceive( tab.sidebarObservationPublisher @@ -13991,14 +14072,24 @@ struct TabItemView: View, Equatable { // row redraws once with the settled state instead of blinking. .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) ) { _ in - refreshWorkspaceSnapshotAfterObservation(source: "debounced") +#if DEBUG + let description = tab.customDescription ?? "" + cmuxDebugLog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=debounced " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + refreshWorkspaceSnapshot() } .onChange(of: settings) { _ in refreshWorkspaceSnapshot(force: true) } .onDrag(onDragStart) .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(dropTargetHeight)) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(rowHeight)) .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( targetWorkspaceId: tab.id, tabManager: tabManager, @@ -14034,7 +14125,7 @@ struct TabItemView: View, Equatable { } } - func refreshWorkspaceSnapshot(force: Bool = false) { + private func refreshWorkspaceSnapshot(force: Bool = false) { let nextSnapshot = makeWorkspaceSnapshot() let decision = SidebarWorkspaceSnapshotRefreshPolicy.decision( current: workspaceSnapshotStorage, @@ -14839,7 +14930,7 @@ struct TabItemView: View, Equatable { } private var pullRequestForegroundColor: Color { - isActive ? activeSecondaryColor(0.75) : .secondary + activeSecondaryColor(0.75) } private func openPullRequestLink(_ url: URL) { @@ -14909,7 +15000,7 @@ struct TabItemView: View, Equatable { } } switch level { - case .info: return .secondary + case .info: return activeSecondaryColor(0.5) case .progress: return .blue case .success: return .green case .warning: return .orange @@ -15117,7 +15208,6 @@ struct TabItemView: View, Equatable { private struct SidebarWorkspaceDescriptionText: View { let markdown: String - let isActive: Bool let activeForegroundColor: Color let fontScale: CGFloat @@ -15133,7 +15223,7 @@ private struct SidebarWorkspaceDescriptionText: View { .font(.system(size: 10.5 * fontScale)) .foregroundColor(foregroundColor) .multilineTextAlignment(.leading) - .lineLimit(SidebarWorkspaceRowDropMetrics.maxDescriptionLines) + .lineLimit(SidebarWorkspaceRowTextLimits.maxDescriptionLines) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("SidebarWorkspaceDescriptionText") @@ -15167,7 +15257,7 @@ private struct SidebarWorkspaceDescriptionText: View { } private var foregroundColor: Color { - isActive ? activeForegroundColor : .secondary.opacity(0.95) + activeForegroundColor } private func accessibilityText(renderedMarkdown: AttributedString?) -> String { @@ -15180,13 +15270,15 @@ private struct SidebarWorkspaceDescriptionText: View { private struct SidebarMetadataRows: View { let entries: [SidebarStatusEntry] - @Binding var isExpanded: Bool let isActive: Bool let activeForegroundColor: Color let activeSecondaryForegroundColor: Color let fontScale: CGFloat let onFocus: () -> Void + @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 + var body: some View { VStack(alignment: .leading, spacing: 2) { ForEach(visibleEntries, id: \.key) { entry in @@ -15208,7 +15300,7 @@ private struct SidebarMetadataRows: View { } .buttonStyle(.plain) .font(.system(size: 10 * fontScale, weight: .semibold)) - .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) + .foregroundColor(activeSecondaryForegroundColor) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -15216,10 +15308,8 @@ private struct SidebarMetadataRows: View { } private var visibleEntries: [SidebarStatusEntry] { - guard !isExpanded, entries.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataEntryLimit else { - return entries - } - return Array(entries.prefix(SidebarWorkspaceRowDropMetrics.collapsedMetadataEntryLimit)) + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) } private var helpText: String { @@ -15231,7 +15321,7 @@ private struct SidebarMetadataRows: View { } private var shouldShowToggle: Bool { - entries.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataEntryLimit + entries.count > collapsedEntryLimit } } @@ -15286,7 +15376,7 @@ private struct SidebarMetadataEntryRow: View { if let raw = entry.color, let explicit = Color(hex: raw) { return explicit } - return isActive ? activeForegroundColor.opacity(0.84) : .secondary + return activeForegroundColor } private var iconView: AnyView? { @@ -15336,19 +15426,19 @@ private struct SidebarMetadataEntryRow: View { private struct SidebarMetadataMarkdownBlocks: View { let blocks: [SidebarMetadataBlock] - @Binding var isExpanded: Bool - let isActive: Bool let activeForegroundColor: Color let activeSecondaryForegroundColor: Color let fontScale: CGFloat let onFocus: () -> Void + @State private var isExpanded: Bool = false + private let collapsedBlockLimit = 1 + var body: some View { VStack(alignment: .leading, spacing: 3) { ForEach(visibleBlocks, id: \.key) { block in SidebarMetadataMarkdownBlockRow( block: block, - isActive: isActive, activeForegroundColor: activeForegroundColor, fontScale: fontScale, onFocus: onFocus @@ -15364,27 +15454,24 @@ private struct SidebarMetadataMarkdownBlocks: View { } .buttonStyle(.plain) .font(.system(size: 10 * fontScale, weight: .semibold)) - .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) + .foregroundColor(activeSecondaryForegroundColor) .frame(maxWidth: .infinity, alignment: .leading) } } } private var visibleBlocks: [SidebarMetadataBlock] { - guard !isExpanded, blocks.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataBlockLimit else { - return blocks - } - return Array(blocks.prefix(SidebarWorkspaceRowDropMetrics.collapsedMetadataBlockLimit)) + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) } private var shouldShowToggle: Bool { - blocks.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataBlockLimit + blocks.count > collapsedBlockLimit } } private struct SidebarMetadataMarkdownBlockRow: View { let block: SidebarMetadataBlock - let isActive: Bool let activeForegroundColor: Color let fontScale: CGFloat let onFocus: () -> Void @@ -15406,17 +15493,23 @@ private struct SidebarMetadataMarkdownBlockRow: View { } .font(.system(size: 10 * fontScale)) .multilineTextAlignment(.leading) - .lineLimit(SidebarWorkspaceRowDropMetrics.maxMetadataBlockLines) + .lineLimit(SidebarWorkspaceRowTextLimits.maxMetadataBlockLines) .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) .onTapGesture { onFocus() } } private var foregroundColor: Color { - isActive ? activeForegroundColor : .secondary + activeForegroundColor } } +private enum SidebarWorkspaceRowTextLimits { + static let maxWrappedTitleLines = 8 + static let maxDescriptionLines = 12 + static let maxMetadataBlockLines = 12 +} + /// Immutable, equatable snapshot of the group list a row's "Move to Group" /// submenu can offer. Computed once per parent body eval and passed into /// each TabItemView so the row's `==` covers group changes (renames, adds, @@ -16371,6 +16464,7 @@ struct WindowChromeBorder: View { let orientation: Orientation var ignoresSafeArea = true + var colorOverride: NSColor? = nil @State private var separatorColor = WindowChromeSeparatorColor.current() var body: some View { @@ -16383,7 +16477,7 @@ struct WindowChromeBorder: View { private var border: some View { Rectangle() - .fill(Color(nsColor: separatorColor)) + .fill(Color(nsColor: colorOverride ?? separatorColor)) .frame( maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil @@ -16401,10 +16495,15 @@ struct WindowChromeBorder: View { } } -/// 1px trailing border on the sidebar, derived from the terminal chrome background. +/// 1px trailing border on the sidebar, themeable with Ghostty config. private struct SidebarTrailingBorder: View { + let settings: SidebarTabItemSettingsSnapshot + var body: some View { - WindowChromeBorder(orientation: .vertical) + WindowChromeBorder( + orientation: .vertical, + colorOverride: settings.borderColorHex.flatMap(NSColor.init(hex:)) + ) } } diff --git a/Sources/SidebarWorkspaceGroupHeaderMetrics.swift b/Sources/SidebarWorkspaceGroupHeaderMetrics.swift index 6ef20d892109..ca746eb87e87 100644 --- a/Sources/SidebarWorkspaceGroupHeaderMetrics.swift +++ b/Sources/SidebarWorkspaceGroupHeaderMetrics.swift @@ -73,9 +73,4 @@ struct SidebarWorkspaceGroupHeaderMetrics: Equatable { var plusFontSize: CGFloat { Self.basePlusFontSize * fontScale } /// Scaled plus-button frame edge. var plusFrame: CGFloat { Self.basePlusFrame * fontScale } - /// Stable drop-hit height for the group header, without reading SwiftUI layout. - var dropTargetHeight: CGFloat { - let contentHeight = max(chevronFrame, iconFrame, plusFrame, nameFontSize + 4) - return max(24 * fontScale, contentHeight + 10) - } } diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 575dbc5f2a0f..f52dd701d388 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -57,9 +57,6 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let isBeingDragged: Bool let topDropIndicatorVisible: Bool let onDragStart: () -> NSItemProvider - /// Factory invoked from `body` with a stable drop-hit height. Closure - /// captures the parent's drag state while this LazyVStack row stays free of - /// layout-driven state writes. let tabDropDelegateFactory: (CGFloat) -> SidebarWorkspaceGroupHeaderDropDelegate let onToggleCollapsed: () -> Void let onFocusAnchor: () -> Void @@ -73,6 +70,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let onOpenDocs: () -> Void @State private var isHovered = false + @State private var rowHeight: CGFloat = 1 private var metrics: SidebarWorkspaceGroupHeaderMetrics { SidebarWorkspaceGroupHeaderMetrics(fontScale: fontScale) @@ -96,6 +94,18 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { return "\(shortcutModifierSymbol)\(shortcutDigit)" } + private var rowHeightProbe: some View { + GeometryReader { proxy in + Color.clear + .onAppear { + rowHeight = max(proxy.size.height, 1) + } + .onChange(of: proxy.size.height) { _, newHeight in + rowHeight = max(newHeight, 1) + } + } + } + var body: some View { HStack(spacing: 4) { Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") @@ -217,6 +227,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { offsetY: shortcutHintYOffset ) .padding(.horizontal, 6) + .background { rowHeightProbe } .shortcutHintVisibilityAnimation(value: showsShortcutHint) .opacity(isBeingDragged ? 0.6 : 1) .overlay(alignment: .top) { @@ -228,7 +239,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { } .onDrag(onDragStart) .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(metrics.dropTargetHeight)) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(rowHeight)) .onHover { hovering in isHovered = hovering } diff --git a/Sources/SidebarWorkspaceRowDropMetrics.swift b/Sources/SidebarWorkspaceRowDropMetrics.swift deleted file mode 100644 index 7d864978e1fb..000000000000 --- a/Sources/SidebarWorkspaceRowDropMetrics.swift +++ /dev/null @@ -1,208 +0,0 @@ -import CoreGraphics -import CmuxSidebar - -struct SidebarWorkspaceRowDropMetrics { - static let collapsedMetadataEntryLimit = 3 - static let collapsedMetadataBlockLimit = 1 - static let maxWrappedTitleLines = 8 - static let maxDescriptionLines = 12 - static let maxMetadataBlockLines = 12 - private static let estimatedCharactersPerLine = 42 - - static func targetHeight( - fontScale: CGFloat, - titleLineCount: Int, - descriptionLineCount: Int, - hasSubtitle: Bool, - hasRemoteStatus: Bool, - metadataEntryCount: Int, - metadataEntryIsExpanded: Bool, - metadataBlockLineCounts: [Int], - hasMetadataBlockToggle: Bool, - hasLog: Bool, - hasProgress: Bool, - branchDirectoryRowCount: Int, - pullRequestRowCount: Int, - hasPorts: Bool - ) -> CGFloat { - let scale = max(fontScale, 0.5) - var height = 16 + CGFloat(max(titleLineCount, 1)) * 16 * scale - if descriptionLineCount > 0 { - height += (CGFloat(descriptionLineCount) * 13 + 2) * scale - } - if hasSubtitle { - height += 24 * scale - } - if hasRemoteStatus { - height += 18 * scale - } - height += metadataEntriesHeight( - entryCount: metadataEntryCount, - isExpanded: metadataEntryIsExpanded, - scale: scale - ) - height += metadataBlocksHeight( - lineCounts: metadataBlockLineCounts, - hasToggle: hasMetadataBlockToggle, - scale: scale - ) - if hasLog { - height += 16 * scale - } - if hasProgress { - height += 16 * scale - } - if branchDirectoryRowCount > 0 { - height += (CGFloat(branchDirectoryRowCount) * 13 + CGFloat(max(branchDirectoryRowCount - 1, 0))) * scale - } - if pullRequestRowCount > 0 { - height += (CGFloat(pullRequestRowCount) * 14 + CGFloat(max(pullRequestRowCount - 1, 0))) * scale - } - if hasPorts { - height += 16 * scale - } - return max(34, height.rounded(.up)) - } - - static func estimatedMetadataBlockLineCounts( - _ blocks: [SidebarMetadataBlock], - isExpanded: Bool - ) -> [Int] { - let visibleBlocks = isExpanded ? blocks : Array(blocks.prefix(collapsedMetadataBlockLimit)) - return visibleBlocks.map { estimatedLineCount($0.markdown) } - } - - static func estimatedTitleLineCount(_ title: String, wraps: Bool) -> Int { - wraps ? estimatedLineCount(title, maxLines: maxWrappedTitleLines) : 1 - } - - static func estimatedDescriptionLineCount(_ description: String?) -> Int { - guard let description else { return 0 } - return estimatedLineCount(description, maxLines: maxDescriptionLines) - } - - static func shouldUsePointerEdgeHeight( - wrapsWorkspaceTitles: Bool, - hasDescription: Bool, - hasMetadataBlocks: Bool - ) -> Bool { - !wrapsWorkspaceTitles && !hasDescription && !hasMetadataBlocks - } - - private static func metadataEntriesHeight( - entryCount: Int, - isExpanded: Bool, - scale: CGFloat - ) -> CGFloat { - guard entryCount > 0 else { return 0 } - let visibleCount = isExpanded ? entryCount : min(entryCount, collapsedMetadataEntryLimit) - let toggleCount = entryCount > collapsedMetadataEntryLimit ? 1 : 0 - let rowCount = visibleCount + toggleCount - return (CGFloat(rowCount) * 13 + CGFloat(max(rowCount - 1, 0)) * 2) * scale - } - - private static func metadataBlocksHeight( - lineCounts: [Int], - hasToggle: Bool, - scale: CGFloat - ) -> CGFloat { - guard !lineCounts.isEmpty || hasToggle else { return 0 } - let visibleLineCount = lineCounts.reduce(0) { $0 + max($1, 1) } - let toggleCount = hasToggle ? 1 : 0 - let blockSpacingCount = max(lineCounts.count + toggleCount - 1, 0) - return (CGFloat(visibleLineCount) * 13 + CGFloat(toggleCount) * 13 + CGFloat(blockSpacingCount) * 3) * scale - } - - private static func estimatedLineCount(_ text: String, maxLines: Int = maxMetadataBlockLines) -> Int { - let boundedText = String(text.prefix(estimatedCharactersPerLine * maxLines)) - let lines = boundedText.components(separatedBy: .newlines) - let lineCount = lines.reduce(0) { count, line in - let characterCount = line.trimmingCharacters(in: .whitespacesAndNewlines).count - return count + max(1, Int(ceil(Double(characterCount) / Double(estimatedCharactersPerLine)))) - } - return min(max(lineCount, 1), maxLines) - } - - private static func branchDirectoryRowCount( - snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, - settings: SidebarTabItemSettingsSnapshot - ) -> Int { - guard settings.visibleAuxiliaryDetails.showsBranchDirectory else { return 0 } - if settings.usesVerticalBranchLayout { - return snapshot.branchDirectoryLines.reduce(0) { count, line in - if settings.stacksBranchAndDirectory { - let branchCount = line.branch == nil ? 0 : 1 - let directoryCount = line.directoryCandidates.isEmpty ? 0 : 1 - return count + max(branchCount + directoryCount, 1) - } - return count + 1 - } - } - if settings.stacksBranchAndDirectory, - (snapshot.compactGitBranchSummaryText != nil || !snapshot.compactDirectoryCandidates.isEmpty) { - let branchCount = snapshot.compactGitBranchSummaryText == nil ? 0 : 1 - let directoryCount = snapshot.compactDirectoryCandidates.isEmpty ? 0 : 1 - return max(branchCount + directoryCount, 1) - } - if !snapshot.compactBranchDirectoryCandidates.isEmpty { - return 1 - } - return 0 - } - - static func targetHeight( - snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, - settings: SidebarTabItemSettingsSnapshot, - effectiveSubtitle: String?, - metadataEntryIsExpanded: Bool, - metadataBlocksAreExpanded: Bool - ) -> CGFloat { - let visibleDetails = settings.visibleAuxiliaryDetails - let metadataEntryCount = visibleDetails.showsMetadata ? snapshot.metadataEntries.count : 0 - let metadataBlockLineCounts = visibleDetails.showsMetadata - ? estimatedMetadataBlockLineCounts(snapshot.metadataBlocks, isExpanded: metadataBlocksAreExpanded) - : [] - let hasMetadataBlockToggle = visibleDetails.showsMetadata && - snapshot.metadataBlocks.count > collapsedMetadataBlockLimit - return targetHeight( - fontScale: settings.sidebarFontScale, - titleLineCount: estimatedTitleLineCount(snapshot.title, wraps: settings.wrapsWorkspaceTitles), - descriptionLineCount: estimatedDescriptionLineCount(snapshot.customDescription), - hasSubtitle: effectiveSubtitle != nil, - hasRemoteStatus: !settings.hidesAllDetails && settings.showsSSH && snapshot.remoteWorkspaceSidebarText != nil, - metadataEntryCount: metadataEntryCount, - metadataEntryIsExpanded: metadataEntryIsExpanded, - metadataBlockLineCounts: metadataBlockLineCounts, - hasMetadataBlockToggle: hasMetadataBlockToggle, - hasLog: visibleDetails.showsLog && snapshot.latestLog != nil, - hasProgress: visibleDetails.showsProgress && snapshot.progress != nil, - branchDirectoryRowCount: branchDirectoryRowCount(snapshot: snapshot, settings: settings), - pullRequestRowCount: visibleDetails.showsPullRequests ? snapshot.pullRequestRows.count : 0, - hasPorts: visibleDetails.showsPorts && !snapshot.listeningPorts.isEmpty - ) - } - - static func dropTargetHeight( - snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, - settings: SidebarTabItemSettingsSnapshot, - effectiveSubtitle: String?, - metadataEntryIsExpanded: Bool, - metadataBlocksAreExpanded: Bool - ) -> CGFloat? { - let visibleDetails = settings.visibleAuxiliaryDetails - guard shouldUsePointerEdgeHeight( - wrapsWorkspaceTitles: settings.wrapsWorkspaceTitles, - hasDescription: snapshot.customDescription != nil, - hasMetadataBlocks: visibleDetails.showsMetadata && !snapshot.metadataBlocks.isEmpty - ) else { - return nil - } - return targetHeight( - snapshot: snapshot, - settings: settings, - effectiveSubtitle: effectiveSubtitle, - metadataEntryIsExpanded: metadataEntryIsExpanded, - metadataBlocksAreExpanded: metadataBlocksAreExpanded - ) - } -} diff --git a/Sources/TabItemViewDropSupport.swift b/Sources/TabItemViewDropSupport.swift deleted file mode 100644 index 73f19c743461..000000000000 --- a/Sources/TabItemViewDropSupport.swift +++ /dev/null @@ -1,67 +0,0 @@ -import CoreGraphics -import Foundation - -extension TabItemView { - func workspaceDropTargetHeight( - snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, - effectiveSubtitle: String? - ) -> CGFloat? { - SidebarWorkspaceRowDropMetrics.dropTargetHeight( - snapshot: snapshot, - settings: settings, - effectiveSubtitle: effectiveSubtitle, - metadataEntryIsExpanded: metadataRowsExpanded, - metadataBlocksAreExpanded: metadataBlocksExpanded - ) - } - - func closeWorkspace(method: StaticString) { - #if DEBUG - let workspaceDebugID = tab.id.uuidString.prefix(5) - cmuxDebugLog("sidebar.close workspace=\(workspaceDebugID) method=\(method)") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - } - - func refreshWorkspaceSnapshotAfterObservation(source: StaticString) { - logWorkspaceObservationInvalidation(source: source) - refreshWorkspaceSnapshot() - } - - func openPendingFinderDirectoryRequest() async { - guard let request = workspaceFinderDirectoryOpenRequest else { return } - await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) - guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } - workspaceFinderDirectoryOpenRequest = nil - } - - private func logWorkspaceObservationInvalidation(source: StaticString) { - #if DEBUG - let description = tab.customDescription ?? "" - let workspaceDebugID = tab.id.uuidString.prefix(8) - let titlePreview = debugSidebarTextPreview(tab.title) - let descriptionLength = (description as NSString).length - let descriptionPreview = debugSidebarTextPreview(description) - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(workspaceDebugID) " + - "source=\(source) " + - "title=\"\(titlePreview)\" " + - "descLen=\(descriptionLength) " + - "desc=\"\(descriptionPreview)\"" - ) - #endif - } - - private func debugSidebarTextPreview(_ text: String, limit: Int = 120) -> String { - let escaped = text - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - if escaped.count <= limit { - return escaped - } - let prefix = escaped.prefix(limit) - return "\(prefix)..." - } -} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ac5daf2eb73d..02d65b9857e1 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -661,7 +661,6 @@ F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; D7AB34300000000000000105 /* SidebarTabDropIndicatorPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; - C9A57403C9A57403C9A57403 /* SidebarWorkspaceDropMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57404C9A57404C9A57404 /* SidebarWorkspaceDropMetricsTests.swift */; }; D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; }; C9A57103C9A57103C9A57103 /* SidebarWorkspaceGroupConfigOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */; }; C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */; }; @@ -671,10 +670,9 @@ C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */; }; C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */; }; C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */; }; - C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; - C9A57401C9A57401C9A57401 /* SidebarWorkspaceRowDropMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57402C9A57402C9A57402 /* SidebarWorkspaceRowDropMetrics.swift */; }; - D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */; }; - C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */; }; + C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; + D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */; }; + C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */; }; C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */; }; 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */; }; 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; @@ -689,7 +687,6 @@ D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; - C9A57405C9A57405C9A57405 /* TabItemViewDropSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57406C9A57406C9A57406 /* TabItemViewDropSupport.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */; }; @@ -1512,7 +1509,6 @@ D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabDropIndicatorPredicateTests.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; - C9A57404C9A57404C9A57404 /* SidebarWorkspaceDropMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropMetricsTests.swift; sourceTree = ""; }; D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropPlannerTests.swift; sourceTree = ""; }; C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpener.swift; sourceTree = ""; }; C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpenerTests.swift; sourceTree = ""; }; @@ -1522,10 +1518,9 @@ C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderMetricsTests.swift; sourceTree = ""; }; C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderView.swift; sourceTree = ""; }; C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupingMetrics.swift; sourceTree = ""; }; - C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; - C9A57402C9A57402C9A57402 /* SidebarWorkspaceRowDropMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowDropMetrics.swift; sourceTree = ""; }; - D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceRowHoverTracker.swift; sourceTree = ""; }; - C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceScrollLayoutTests.swift; sourceTree = ""; }; + C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; + D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceRowHoverTracker.swift; sourceTree = ""; }; + C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceScrollLayoutTests.swift; sourceTree = ""; }; C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSelectionAnchorPolicyTests.swift; sourceTree = ""; }; D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift; sourceTree = ""; }; F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotRefreshPolicyTests.swift; sourceTree = ""; }; @@ -1538,7 +1533,6 @@ D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; - C9A57406C9A57406C9A57406 /* TabItemViewDropSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemViewDropSupport.swift; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+FocusHistoryHosting.swift"; sourceTree = ""; }; @@ -2045,13 +2039,11 @@ C9A57302C9A57302C9A57302 /* SidebarWorkspaceGroupHeaderMetrics.swift */, C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */, D5037010000000000000002 /* RenderableSystemSymbol.swift */, - C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, - C9A57402C9A57402C9A57402 /* SidebarWorkspaceRowDropMetrics.swift */, - C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, - C9A57502C9A57502C9A57502 /* SidebarRowsFillLayout.swift */, - C9A57406C9A57406C9A57406 /* TabItemViewDropSupport.swift */, - C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, - C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, + C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, + C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, + C9A57502C9A57502C9A57502 /* SidebarRowsFillLayout.swift */, + C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, + C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */, C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */, 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */, @@ -2665,7 +2657,6 @@ C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */, C7A5090000000000000005A1 /* CmuxTopSnapshotScopeCacheTests.swift */, C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */, - C9A57404C9A57404C9A57404 /* SidebarWorkspaceDropMetricsTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */, A47E00010000000000000002 /* AuthEnvironmentTests.swift */, @@ -3512,18 +3503,16 @@ C9A57107C9A57107C9A57107 /* SidebarWorkspaceGroupDialogs.swift in Sources */, C9A57301C9A57301C9A57301 /* SidebarWorkspaceGroupHeaderMetrics.swift in Sources */, C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */, - C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, - C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, - C9A57401C9A57401C9A57401 /* SidebarWorkspaceRowDropMetrics.swift in Sources */, - D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */, - 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */, + C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, + C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, + D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */, + 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */, A5001226 /* SocketControlMode+Display.swift in Sources */, E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */, D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */, - C9A57405C9A57405C9A57405 /* TabItemViewDropSupport.swift in Sources */, D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */, @@ -3909,7 +3898,6 @@ C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */, D7AB34300000000000000105 /* SidebarTabDropIndicatorPredicateTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, - C9A57403C9A57403C9A57403 /* SidebarWorkspaceDropMetricsTests.swift in Sources */, D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */, C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */, diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 7b0537d267d6..ddc4144fd200 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -721,70 +721,6 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54)) } - func testLoadHonorsPairedThemeAndTopLevelForegroundOverrideByColorScheme() throws { - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-ghostty-theme-pair-foreground-\(UUID().uuidString)") - let themesDir = root.appendingPathComponent("themes", isDirectory: true) - try fileManager.createDirectory(at: themesDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: root) } - - let originalFixedHome = getenv("CFFIXED_USER_HOME").map { String(cString: $0) } - let originalResourcesDir = getenv("GHOSTTY_RESOURCES_DIR").map { String(cString: $0) } - setenv("CFFIXED_USER_HOME", root.path, 1) - setenv("GHOSTTY_RESOURCES_DIR", root.path, 1) - defer { - if let originalFixedHome { - setenv("CFFIXED_USER_HOME", originalFixedHome, 1) - } else { - unsetenv("CFFIXED_USER_HOME") - } - if let originalResourcesDir { - setenv("GHOSTTY_RESOURCES_DIR", originalResourcesDir, 1) - } else { - unsetenv("GHOSTTY_RESOURCES_DIR") - } - GhosttyConfig.invalidateLoadCache() - } - - try """ - background = #fffcf0 - foreground = #100f0f - """.write( - to: themesDir.appendingPathComponent("Flexoki Light", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - try """ - background = #100f0f - foreground = #cecdc3 - """.write( - to: themesDir.appendingPathComponent("Flexoki Dark", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - - let ghosttyConfigDir = root.appendingPathComponent(".config/ghostty", isDirectory: true) - try fileManager.createDirectory(at: ghosttyConfigDir, withIntermediateDirectories: true) - try """ - window-theme = auto - theme = light:Flexoki Light,dark:Flexoki Dark - foreground = #ff0000 - """.write( - to: ghosttyConfigDir.appendingPathComponent("config", isDirectory: false), - atomically: true, - encoding: .utf8 - ) - - let lightConfig = GhosttyConfig.load(preferredColorScheme: .light, useCache: false) - let darkConfig = GhosttyConfig.load(preferredColorScheme: .dark, useCache: false) - - XCTAssertEqual(lightConfig.backgroundColor.hexString(), "#FFFCF0") - XCTAssertEqual(lightConfig.foregroundColor.hexString(), "#FF0000") - XCTAssertEqual(darkConfig.backgroundColor.hexString(), "#100F0F") - XCTAssertEqual(darkConfig.foregroundColor.hexString(), "#FF0000") - } - func testParseBackgroundOpacityReadsConfigValue() { var config = GhosttyConfig() config.parse("background-opacity = 0.42") @@ -4700,6 +4636,48 @@ final class SidebarBackgroundConfigTests: XCTestCase { XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.0, accuracy: 0.0001) } + func testParseSidebarThemeColors() { + var config = GhosttyConfig() + config.parse(""" + sidebar-selection-background = #445566 + sidebar-foreground = #ddeeff + sidebar-muted-foreground = #99aabb + sidebar-selection-foreground = #102030 + sidebar-border-color = #223344 + sidebar-accent-color = #33cc99 + sidebar-notification-badge-background = #cc3366 + """) + + XCTAssertEqual(config.rawSidebarSelectionBackground, "#445566") + XCTAssertEqual(config.rawSidebarForeground, "#ddeeff") + XCTAssertEqual(config.rawSidebarMutedForeground, "#99aabb") + XCTAssertEqual(config.rawSidebarSelectionForeground, "#102030") + XCTAssertEqual(config.rawSidebarBorderColor, "#223344") + XCTAssertEqual(config.rawSidebarAccentColor, "#33cc99") + XCTAssertEqual(config.rawSidebarNotificationBadgeBackground, "#cc3366") + } + + func testResolveSidebarThemeColorsSupportsColorSchemePairs() { + var config = GhosttyConfig() + config.rawSidebarSelectionBackground = "light:#f0f0f0,dark:#101010" + config.rawSidebarForeground = "light:#111111,dark:#eeeeee" + config.rawSidebarMutedForeground = "light:#555555,dark:#aaaaaa" + config.rawSidebarSelectionForeground = "light:#000000,dark:#ffffff" + config.rawSidebarBorderColor = "light:#cccccc,dark:#333333" + config.rawSidebarAccentColor = "light:#0066cc,dark:#66aaff" + config.rawSidebarNotificationBadgeBackground = "light:#cc0033,dark:#ff6699" + + config.resolveSidebarAppearance(preferredColorScheme: .dark) + + XCTAssertEqual(config.sidebarSelectionBackground?.hexString(), "#101010") + XCTAssertEqual(config.sidebarForeground?.hexString(), "#EEEEEE") + XCTAssertEqual(config.sidebarMutedForeground?.hexString(), "#AAAAAA") + XCTAssertEqual(config.sidebarSelectionForeground?.hexString(), "#FFFFFF") + XCTAssertEqual(config.sidebarBorderColor?.hexString(), "#333333") + XCTAssertEqual(config.sidebarAccentColor?.hexString(), "#66AAFF") + XCTAssertEqual(config.sidebarNotificationBadgeBackground?.hexString(), "#FF6699") + } + func testResolveSidebarBackgroundSingleHex() { var config = GhosttyConfig() config.rawSidebarBackground = "#336699" @@ -4731,28 +4709,37 @@ final class SidebarBackgroundConfigTests: XCTestCase { func testApplyToUserDefaultsSkipsWritesWhenNoConfig() { let defaults = UserDefaults.standard - let testKey = "sidebarTintHex" - let original = defaults.string(forKey: testKey) - defer { restoreDefaultsValue(original, key: testKey, defaults: defaults) } + let keys = ["sidebarTintHex", GhosttyConfig.sidebarAppearanceAppliedDefaultsKey] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + defaults.removeObject(forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) - defaults.set("#AAAAAA", forKey: testKey) + defaults.set("#AAAAAA", forKey: "sidebarTintHex") var config = GhosttyConfig() config.applySidebarAppearanceToUserDefaults() - XCTAssertEqual(defaults.string(forKey: testKey), "#AAAAAA", + XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#AAAAAA", "Should not overwrite UserDefaults when rawSidebarBackground is nil") } func testApplyToUserDefaultsWritesHexWhenConfigSet() { let defaults = UserDefaults.standard - let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"] + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", GhosttyConfig.sidebarAppearanceAppliedDefaultsKey] let originals = keys.map { defaults.object(forKey: $0) } defer { for (key, original) in zip(keys, originals) { restoreDefaultsValue(original, key: key, defaults: defaults) } } + defaults.removeObject(forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) + defaults.removeObject(forKey: "sidebarTintHex") + defaults.removeObject(forKey: "sidebarTintHexLight") + defaults.removeObject(forKey: "sidebarTintHexDark") var config = GhosttyConfig() config.rawSidebarBackground = "#336699" @@ -4766,16 +4753,19 @@ final class SidebarBackgroundConfigTests: XCTestCase { func testApplyToUserDefaultsClearsStaleKeysOnSwitchFromDualToSingle() { let defaults = UserDefaults.standard - let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"] + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", GhosttyConfig.sidebarAppearanceAppliedDefaultsKey] let originals = keys.map { defaults.object(forKey: $0) } defer { for (key, original) in zip(keys, originals) { restoreDefaultsValue(original, key: key, defaults: defaults) } } - defaults.set("#AAAAAA", forKey: "sidebarTintHexLight") defaults.set("#BBBBBB", forKey: "sidebarTintHexDark") + defaults.set([ + "sidebarTintHexLight": "#AAAAAA", + "sidebarTintHexDark": "#BBBBBB", + ], forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) var config = GhosttyConfig() config.rawSidebarBackground = "#222222" @@ -4789,15 +4779,99 @@ final class SidebarBackgroundConfigTests: XCTestCase { "Stale dark key should be cleared") } + func testApplyToUserDefaultsPreservesUserEditedBackgroundVariantWhenSwitchingToSingle() { + let defaults = UserDefaults.standard + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", GhosttyConfig.sidebarAppearanceAppliedDefaultsKey] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + defaults.removeObject(forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) + + defaults.set("#AAAAAA", forKey: "sidebarTintHexLight") + + var config = GhosttyConfig() + config.rawSidebarBackground = "#222222" + config.resolveSidebarBackground(preferredColorScheme: .light) + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222") + XCTAssertEqual(defaults.string(forKey: "sidebarTintHexLight"), "#AAAAAA", + "Unmanaged user-edited light tint should not be cleared") + } + + func testApplyToUserDefaultsClearsManagedBackgroundVariantWhenSwitchingToDarkOnly() { + let defaults = UserDefaults.standard + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", GhosttyConfig.sidebarAppearanceAppliedDefaultsKey] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + + defaults.set("#AAAAAA", forKey: "sidebarTintHexLight") + defaults.set("#BBBBBB", forKey: "sidebarTintHexDark") + defaults.set([ + "sidebarTintHexLight": "#AAAAAA", + "sidebarTintHexDark": "#BBBBBB", + ], forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) + + var config = GhosttyConfig() + config.rawSidebarBackground = "dark:#222222" + config.resolveSidebarBackground(preferredColorScheme: .light) + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222") + XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight")) + XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark")) + } + + func testApplyToUserDefaultsPreservesManagedColorsWhenNewConfigValueIsInvalid() { + let defaults = UserDefaults.standard + let keys = [ + "sidebarTintHex", + GhosttyConfig.sidebarForegroundColorHexKey, + GhosttyConfig.sidebarAppearanceAppliedDefaultsKey, + ] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + + defaults.set("#222222", forKey: "sidebarTintHex") + defaults.set("#DDEEFF", forKey: GhosttyConfig.sidebarForegroundColorHexKey) + defaults.set([ + "sidebarTintHex": "#222222", + GhosttyConfig.sidebarForegroundColorHexKey: "#DDEEFF", + ], forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) + + var config = GhosttyConfig() + config.parse(""" + sidebar-background = not-a-color + sidebar-foreground = still-not-a-color + """) + config.resolveSidebarAppearance(preferredColorScheme: .light) + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222") + XCTAssertEqual(defaults.string(forKey: GhosttyConfig.sidebarForegroundColorHexKey), "#DDEEFF") + } + func testApplyToUserDefaultsOnlyWritesOpacityWhenExplicit() { let defaults = UserDefaults.standard - let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity"] + let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity", GhosttyConfig.sidebarAppearanceAppliedDefaultsKey] let originals = keys.map { defaults.object(forKey: $0) } defer { for (key, original) in zip(keys, originals) { restoreDefaultsValue(original, key: key, defaults: defaults) } } + defaults.removeObject(forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) defaults.set(0.18, forKey: "sidebarTintOpacity") @@ -4810,6 +4884,81 @@ final class SidebarBackgroundConfigTests: XCTestCase { "Should not overwrite opacity when config doesn't set sidebar-tint-opacity") } + func testApplyToUserDefaultsWritesSidebarThemeColors() { + let defaults = UserDefaults.standard + let keys = [ + "sidebarSelectionColorHex", + GhosttyConfig.sidebarForegroundColorHexKey, + GhosttyConfig.sidebarMutedForegroundColorHexKey, + GhosttyConfig.sidebarSelectionForegroundColorHexKey, + GhosttyConfig.sidebarBorderColorHexKey, + GhosttyConfig.sidebarAccentColorHexKey, + "sidebarNotificationBadgeColorHex", + GhosttyConfig.sidebarAppearanceAppliedDefaultsKey, + ] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + defaults.removeObject(forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) + + var config = GhosttyConfig() + config.parse(""" + sidebar-selection-background = #445566 + sidebar-foreground = #ddeeff + sidebar-muted-foreground = #99aabb + sidebar-selection-foreground = #102030 + sidebar-border-color = #223344 + sidebar-accent-color = #33cc99 + sidebar-notification-badge-color = #cc3366 + """) + config.resolveSidebarAppearance(preferredColorScheme: .light) + config.applySidebarAppearanceToUserDefaults() + + XCTAssertEqual(defaults.string(forKey: "sidebarSelectionColorHex"), "#445566") + XCTAssertEqual(defaults.string(forKey: GhosttyConfig.sidebarForegroundColorHexKey), "#DDEEFF") + XCTAssertEqual(defaults.string(forKey: GhosttyConfig.sidebarMutedForegroundColorHexKey), "#99AABB") + XCTAssertEqual(defaults.string(forKey: GhosttyConfig.sidebarSelectionForegroundColorHexKey), "#102030") + XCTAssertEqual(defaults.string(forKey: GhosttyConfig.sidebarBorderColorHexKey), "#223344") + XCTAssertEqual(defaults.string(forKey: GhosttyConfig.sidebarAccentColorHexKey), "#33CC99") + XCTAssertEqual(defaults.string(forKey: "sidebarNotificationBadgeColorHex"), "#CC3366") + } + + func testApplyToUserDefaultsClearsPreviouslyManagedSidebarThemeColorsWhenConfigRemoved() { + let defaults = UserDefaults.standard + let keys = [ + "sidebarSelectionColorHex", + GhosttyConfig.sidebarForegroundColorHexKey, + GhosttyConfig.sidebarBorderColorHexKey, + GhosttyConfig.sidebarAppearanceAppliedDefaultsKey, + ] + let originals = keys.map { defaults.object(forKey: $0) } + defer { + for (key, original) in zip(keys, originals) { + restoreDefaultsValue(original, key: key, defaults: defaults) + } + } + + defaults.set("#445566", forKey: "sidebarSelectionColorHex") + defaults.set("#DDEEFF", forKey: GhosttyConfig.sidebarForegroundColorHexKey) + defaults.set("#111111", forKey: GhosttyConfig.sidebarBorderColorHexKey) + defaults.set([ + "sidebarSelectionColorHex": "#445566", + GhosttyConfig.sidebarForegroundColorHexKey: "#DDEEFF", + GhosttyConfig.sidebarBorderColorHexKey: "#223344", + ], forKey: GhosttyConfig.sidebarAppearanceAppliedDefaultsKey) + + var config = GhosttyConfig() + config.applySidebarAppearanceToUserDefaults() + + XCTAssertNil(defaults.string(forKey: "sidebarSelectionColorHex")) + XCTAssertNil(defaults.string(forKey: GhosttyConfig.sidebarForegroundColorHexKey)) + XCTAssertEqual(defaults.string(forKey: GhosttyConfig.sidebarBorderColorHexKey), "#111111", + "Should not clear values the user changed after the Ghostty theme write") + } + private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { if let value = value { defaults.set(value, forKey: key) diff --git a/cmuxTests/SidebarWorkspaceDropMetricsTests.swift b/cmuxTests/SidebarWorkspaceDropMetricsTests.swift deleted file mode 100644 index c9331f30c15f..000000000000 --- a/cmuxTests/SidebarWorkspaceDropMetricsTests.swift +++ /dev/null @@ -1,147 +0,0 @@ -import CoreGraphics -import CmuxSidebar -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -struct SidebarWorkspaceDropMetricsTests { - @Test - func testWorkspaceGroupHeaderDropTargetHeightScalesWithoutLayoutMeasurement() { - #expect(SidebarWorkspaceGroupHeaderMetrics(fontScale: 1).dropTargetHeight == 28) - #expect(SidebarWorkspaceGroupHeaderMetrics(fontScale: 2).dropTargetHeight == 48) - } - - @Test - func testWorkspaceRowDropTargetHeightScalesWithContentWithoutLayoutMeasurement() { - let base = workspaceRowHeight() - let rich = workspaceRowHeight( - titleLineCount: 2, - descriptionLineCount: 2, - hasSubtitle: true, - hasRemoteStatus: true, - metadataEntryCount: 4, - metadataEntryIsExpanded: true, - metadataBlockLineCounts: [2, 3], - hasMetadataBlockToggle: true, - hasLog: true, - hasProgress: true, - branchDirectoryRowCount: 2, - pullRequestRowCount: 2, - hasPorts: true - ) - let scaled = workspaceRowHeight(fontScale: 1.5) - - #expect(base == 34) - #expect(rich > base) - #expect(scaled > base) - } - - @Test - func testWorkspaceRowDropTargetHeightTracksExpandedMetadataRows() { - let collapsed = workspaceRowHeight(metadataEntryCount: 6, metadataEntryIsExpanded: false) - let expanded = workspaceRowHeight(metadataEntryCount: 6, metadataEntryIsExpanded: true) - - #expect(expanded > collapsed) - } - - @Test - func testWorkspaceRowDropTargetHeightTracksExpandedMetadataBlocks() { - let collapsed = workspaceRowHeight( - metadataBlockLineCounts: [1], - hasMetadataBlockToggle: true - ) - let expanded = workspaceRowHeight( - metadataBlockLineCounts: [1, 4, 2], - hasMetadataBlockToggle: true - ) - - #expect(expanded > collapsed) - } - - @Test - func testMetadataBlockLineEstimationOnlyScansVisibleCollapsedBlock() { - let blocks = [ - SidebarMetadataBlock(key: "visible", markdown: "one", priority: 0, timestamp: Date()), - SidebarMetadataBlock( - key: "hidden", - markdown: String(repeating: "hidden\n", count: 100), - priority: 0, - timestamp: Date() - ), - ] - - #expect(SidebarWorkspaceRowDropMetrics.estimatedMetadataBlockLineCounts(blocks, isExpanded: false) == [1]) - #expect(SidebarWorkspaceRowDropMetrics.estimatedMetadataBlockLineCounts(blocks, isExpanded: true) == [1, 12]) - } - - @Test - func testWorkspaceRowDropTargetHeightUsesLineAwareTitleAndDescriptionEstimates() { - let short = workspaceRowHeight(titleLineCount: 1, descriptionLineCount: 1) - let tall = workspaceRowHeight(titleLineCount: 4, descriptionLineCount: 5) - - #expect(tall > short) - } - - @Test - func testPointerEdgeHeightIsOnlyUsedForWidthIndependentRows() { - #expect(SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( - wrapsWorkspaceTitles: false, - hasDescription: false, - hasMetadataBlocks: false - )) - #expect(!SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( - wrapsWorkspaceTitles: true, - hasDescription: false, - hasMetadataBlocks: false - )) - #expect(!SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( - wrapsWorkspaceTitles: false, - hasDescription: true, - hasMetadataBlocks: false - )) - #expect(!SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( - wrapsWorkspaceTitles: false, - hasDescription: false, - hasMetadataBlocks: true - )) - } - - private func workspaceRowHeight( - fontScale: CGFloat = 1, - titleLineCount: Int = 1, - descriptionLineCount: Int = 0, - hasSubtitle: Bool = false, - hasRemoteStatus: Bool = false, - metadataEntryCount: Int = 0, - metadataEntryIsExpanded: Bool = false, - metadataBlockLineCounts: [Int] = [], - hasMetadataBlockToggle: Bool = false, - hasLog: Bool = false, - hasProgress: Bool = false, - branchDirectoryRowCount: Int = 0, - pullRequestRowCount: Int = 0, - hasPorts: Bool = false - ) -> CGFloat { - SidebarWorkspaceRowDropMetrics.targetHeight( - fontScale: fontScale, - titleLineCount: titleLineCount, - descriptionLineCount: descriptionLineCount, - hasSubtitle: hasSubtitle, - hasRemoteStatus: hasRemoteStatus, - metadataEntryCount: metadataEntryCount, - metadataEntryIsExpanded: metadataEntryIsExpanded, - metadataBlockLineCounts: metadataBlockLineCounts, - hasMetadataBlockToggle: hasMetadataBlockToggle, - hasLog: hasLog, - hasProgress: hasProgress, - branchDirectoryRowCount: branchDirectoryRowCount, - pullRequestRowCount: pullRequestRowCount, - hasPorts: hasPorts - ) - } -} diff --git a/docs/configuration.md b/docs/configuration.md index 10e2a5a27345..11f3949ef5d6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,6 +37,30 @@ Default: `always` for stable and nightly builds. DEV builds always behave as `ne The older boolean `app.warnBeforeQuit` still works as a fallback when `app.confirmQuit` is not set. `true` maps to `always`; `false` maps to `never`. +## Ghostty sidebar theme keys + +cmux also reads cmux-specific sidebar color keys from Ghostty config files and theme files. These are cmux extensions, not standard Ghostty keys. Values use Ghostty-style hex colors such as `#RRGGBB`, and can use `light:,dark:` pairs where noted. + +Examples: + +```ini +sidebar-background = #123ABC +sidebar-foreground = light:#111111,dark:#FFFFFF +sidebar-tint-opacity = 0.72 +``` + +- `sidebar-background`: sidebar material tint color. Supports light/dark pairs. +- `sidebar-tint-opacity`: sidebar tint opacity, clamped from `0` to `1`. +- `sidebar-selection-background`: selected workspace background. Supports light/dark pairs. +- `sidebar-selection-foreground`: selected workspace text color. Supports light/dark pairs. +- `sidebar-foreground`: default workspace title color. Supports light/dark pairs. +- `sidebar-muted-foreground`: secondary workspace detail color. Supports light/dark pairs. +- `sidebar-border-color`: sidebar divider and selected-row border color. Supports light/dark pairs. +- `sidebar-accent-color`: accent color for progress, drop indicators, and default unread badges. Supports light/dark pairs. +- `sidebar-notification-badge-background`: unread badge background. Supports light/dark pairs. + +`sidebar-notification-badge-color` is accepted as an alias for `sidebar-notification-badge-background`. + ## `app.forkConversationDefaultDestination` Controls what the tab right-click `Fork Conversation` item does. The submenu still exposes every destination.