Add workspace tab color schemes and debug scheme toggle (#324)

* Add tab color feature to sidebar workspaces

Lets users assign a custom background color to any sidebar workspace tab
via a right-click "Tab Color" submenu. The primary motivation is working
across multiple projects simultaneously — coloring tabs by project makes
it instant to visually locate the right workspace without reading the title.

- Workspace: adds `customColor: String?` (@Published hex string) and
  `setCustomColor()` setter
- TabManager: adds `setTabColor(tabId:color:)` convenience method
- ContentView: 16-color dark palette (all luminance < 0.30, white text
  always readable), `Color(hex:)` extension, `coloredCircleImage(hex:)`
  helper to render bitmapped NSImage circles (needed because macOS menus
  strip SwiftUI foregroundColor from SF Symbols), updated `backgroundColor`
  to use custom color at full/70%/35% opacity for active/inactive/
  multi-selected states, "Tab Color" submenu in context menu with
  "Clear Color" option, and a 1.5pt `Color.primary` border overlay on
  the active tab for clear selection indication when custom colors are set

* Add workspace tab color schemes with settings and debug toggles

* Remove Kelly scheme and keep only original tab color palette

* Preserve neutral grayscale when brightening tab colors

* Harden UpdatePill UI test polling timeouts

---------

Co-authored-by: Andreas Fruth <andreas.fruth@gmail.com>
This commit is contained in:
Lawrence Chen 2026-02-22 17:30:30 -08:00 committed by GitHub
parent c5c27b678f
commit 0105b6256a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 995 additions and 59 deletions

View file

@ -935,6 +935,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
} }
#if DEBUG #if DEBUG
private let debugColorWorkspaceTitlePrefix = "Debug Color - "
@objc func openDebugScrollbackTab(_ sender: Any?) { @objc func openDebugScrollbackTab(_ sender: Any?) {
guard let tabManager else { return } guard let tabManager else { return }
let tab = tabManager.addTab() let tab = tabManager.addTab()
@ -958,6 +960,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sendTextWhenReady(payload, to: tab) sendTextWhenReady(payload, to: tab)
} }
@objc func openDebugColorComparisonWorkspaces(_ sender: Any?) {
guard let tabManager else { return }
let palette = WorkspaceTabColorSettings.palette()
guard !palette.isEmpty else { return }
var existingByTitle: [String: Workspace] = [:]
for tab in tabManager.tabs {
guard let title = tab.customTitle,
title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue }
existingByTitle[title] = tab
}
for entry in palette {
let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)"
let targetTab: Workspace
if let existing = existingByTitle[title] {
targetTab = existing
} else {
targetTab = tabManager.addTab()
}
tabManager.setCustomTitle(tabId: targetTab.id, title: title)
tabManager.setTabColor(tabId: targetTab.id, color: entry.hex)
}
}
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) { private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) {
let maxAttempts = 60 let maxAttempts = 60
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {

View file

@ -5,6 +5,29 @@ import ObjectiveC
import UniformTypeIdentifiers import UniformTypeIdentifiers
import WebKit import WebKit
private extension Color {
init?(hex: String) {
let hex = hex.trimmingCharacters(in: .init(charactersIn: "#"))
guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil }
self.init(
red: Double((value >> 16) & 0xFF) / 255.0,
green: Double((value >> 8) & 0xFF) / 255.0,
blue: Double( value & 0xFF) / 255.0
)
}
}
private func coloredCircleImage(color: NSColor) -> NSImage {
let size = NSSize(width: 14, height: 14)
let image = NSImage(size: size, flipped: false) { rect in
color.setFill()
NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)).fill()
return true
}
image.isTemplate = false
return image
}
struct ShortcutHintPillBackground: View { struct ShortcutHintPillBackground: View {
var emphasis: Double = 1.0 var emphasis: Double = 1.0
@ -2439,6 +2462,7 @@ private struct SidebarEmptyArea: View {
private struct TabItemView: View { private struct TabItemView: View {
@EnvironmentObject var tabManager: TabManager @EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var notificationStore: TerminalNotificationStore
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var tab: Tab @ObservedObject var tab: Tab
let index: Int let index: Int
let rowSpacing: CGFloat let rowSpacing: CGFloat
@ -2461,6 +2485,8 @@ private struct TabItemView: View {
@AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true @AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
var isActive: Bool { var isActive: Bool {
tabManager.selectedTabId == tab.id tabManager.selectedTabId == tab.id
@ -2474,6 +2500,65 @@ private struct TabItemView: View {
draggedTabId == tab.id draggedTabId == tab.id
} }
private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: activeTabIndicatorStyleRaw)
}
private var titleFontWeight: Font.Weight {
.semibold
}
private var showsLeadingRail: Bool {
explicitRailColor != nil
}
private var activeBorderLineWidth: CGFloat {
switch activeTabIndicatorStyle {
case .leftRail:
return 0
case .solidFill:
return isActive ? 1.5 : 0
}
}
private var activeBorderColor: Color {
guard isActive else { return .clear }
switch activeTabIndicatorStyle {
case .leftRail:
return .clear
case .solidFill:
return Color.primary.opacity(0.5)
}
}
private var usesInvertedActiveForeground: Bool {
isActive
}
private var activePrimaryTextColor: Color {
usesInvertedActiveForeground ? .white : .primary
}
private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color {
usesInvertedActiveForeground ? .white.opacity(opacity) : .secondary
}
private var activeUnreadBadgeFillColor: Color {
usesInvertedActiveForeground ? Color.white.opacity(0.25) : Color.accentColor
}
private var activeProgressTrackColor: Color {
usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)
}
private var activeProgressFillColor: Color {
usesInvertedActiveForeground ? Color.white.opacity(0.8) : Color.accentColor
}
private var shortcutHintEmphasis: Double {
usesInvertedActiveForeground ? 1.0 : 0.9
}
private var workspaceShortcutDigit: Int? { private var workspaceShortcutDigit: Int? {
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count) WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count)
} }
@ -2510,7 +2595,7 @@ private struct TabItemView: View {
if unreadCount > 0 { if unreadCount > 0 {
ZStack { ZStack {
Circle() Circle()
.fill(isActive ? Color.white.opacity(0.25) : Color.accentColor) .fill(activeUnreadBadgeFillColor)
Text("\(unreadCount)") Text("\(unreadCount)")
.font(.system(size: 9, weight: .semibold)) .font(.system(size: 9, weight: .semibold))
.foregroundColor(.white) .foregroundColor(.white)
@ -2521,12 +2606,12 @@ private struct TabItemView: View {
if tab.isPinned { if tab.isPinned {
Image(systemName: "pin.fill") Image(systemName: "pin.fill")
.font(.system(size: 9, weight: .semibold)) .font(.system(size: 9, weight: .semibold))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary) .foregroundColor(activeSecondaryColor(0.8))
} }
Text(tab.title) Text(tab.title)
.font(.system(size: 12.5, weight: .semibold)) .font(.system(size: 12.5, weight: titleFontWeight))
.foregroundColor(isActive ? .white : .primary) .foregroundColor(activePrimaryTextColor)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
@ -2541,7 +2626,7 @@ private struct TabItemView: View {
}) { }) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 9, weight: .medium)) .font(.system(size: 9, weight: .medium))
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary) .foregroundColor(activeSecondaryColor(0.7))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace")) .help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace"))
@ -2555,10 +2640,10 @@ private struct TabItemView: View {
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
.font(.system(size: 10, weight: .semibold, design: .rounded)) .font(.system(size: 10, weight: .semibold, design: .rounded))
.monospacedDigit() .monospacedDigit()
.foregroundColor(isActive ? .white : .primary) .foregroundColor(activePrimaryTextColor)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9)) .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis))
.offset( .offset(
x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset),
y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)
@ -2573,7 +2658,7 @@ private struct TabItemView: View {
if let subtitle = latestNotificationText { if let subtitle = latestNotificationText {
Text(subtitle) Text(subtitle)
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary) .foregroundColor(activeSecondaryColor(0.8))
.lineLimit(2) .lineLimit(2)
.truncationMode(.tail) .truncationMode(.tail)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
@ -2585,7 +2670,7 @@ private struct TabItemView: View {
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key return lhs.key < rhs.key
}), }),
isActive: isActive, isActive: usesInvertedActiveForeground,
onFocus: { updateSelection() } onFocus: { updateSelection() }
) )
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
@ -2596,10 +2681,10 @@ private struct TabItemView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: logLevelIcon(latestLog.level)) Image(systemName: logLevelIcon(latestLog.level))
.font(.system(size: 8)) .font(.system(size: 8))
.foregroundColor(logLevelColor(latestLog.level, isActive: isActive)) .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground))
Text(latestLog.message) Text(latestLog.message)
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary) .foregroundColor(activeSecondaryColor(0.8))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
@ -2612,9 +2697,9 @@ private struct TabItemView: View {
GeometryReader { geo in GeometryReader { geo in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Capsule() Capsule()
.fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)) .fill(activeProgressTrackColor)
Capsule() Capsule()
.fill(isActive ? Color.white.opacity(0.8) : Color.accentColor) .fill(activeProgressFillColor)
.frame(width: max(0, geo.size.width * CGFloat(progress.value))) .frame(width: max(0, geo.size.width * CGFloat(progress.value)))
} }
} }
@ -2623,7 +2708,7 @@ private struct TabItemView: View {
if let label = progress.label { if let label = progress.label {
Text(label) Text(label)
.font(.system(size: 9)) .font(.system(size: 9))
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary) .foregroundColor(activeSecondaryColor(0.6))
.lineLimit(1) .lineLimit(1)
} }
} }
@ -2637,7 +2722,7 @@ private struct TabItemView: View {
if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch {
Image(systemName: "arrow.triangle.branch") Image(systemName: "arrow.triangle.branch")
.font(.system(size: 9)) .font(.system(size: 9))
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary) .foregroundColor(activeSecondaryColor(0.6))
} }
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in
@ -2645,20 +2730,20 @@ private struct TabItemView: View {
if let branch = line.branch { if let branch = line.branch {
Text(branch) Text(branch)
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary) .foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
if line.branch != nil, line.directory != nil { if line.branch != nil, line.directory != nil {
Image(systemName: "circle.fill") Image(systemName: "circle.fill")
.font(.system(size: 3)) .font(.system(size: 3))
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary) .foregroundColor(activeSecondaryColor(0.6))
.padding(.horizontal, 1) .padding(.horizontal, 1)
} }
if let directory = line.directory { if let directory = line.directory {
Text(directory) Text(directory)
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary) .foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
@ -2672,11 +2757,11 @@ private struct TabItemView: View {
if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon {
Image(systemName: "arrow.triangle.branch") Image(systemName: "arrow.triangle.branch")
.font(.system(size: 9)) .font(.system(size: 9))
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary) .foregroundColor(activeSecondaryColor(0.6))
} }
Text(dirRow) Text(dirRow)
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary) .foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
@ -2686,7 +2771,7 @@ private struct TabItemView: View {
if sidebarShowPorts, !tab.listeningPorts.isEmpty { if sidebarShowPorts, !tab.listeningPorts.isEmpty {
Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", ")) Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", "))
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary) .foregroundColor(activeSecondaryColor(0.75))
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
@ -2698,6 +2783,20 @@ private struct TabItemView: View {
.background( .background(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.fill(backgroundColor) .fill(backgroundColor)
.overlay {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth)
}
.overlay(alignment: .leading) {
if showsLeadingRail {
Capsule(style: .continuous)
.fill(railColor)
.frame(width: 3)
.padding(.leading, 4)
.padding(.vertical, 5)
.offset(x: -1)
}
}
) )
.padding(.horizontal, 6) .padding(.horizontal, 6)
.background { .background {
@ -2765,6 +2864,7 @@ private struct TabItemView: View {
} }
.contextMenu { .contextMenu {
let targetIds = contextTargetIds() let targetIds = contextTargetIds()
let tabColorPalette = WorkspaceTabColorSettings.palette()
let shouldPin = !tab.isPinned let shouldPin = !tab.isPinned
let pinLabel = targetIds.count > 1 let pinLabel = targetIds.count > 1
? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces") ? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces")
@ -2800,6 +2900,38 @@ private struct TabItemView: View {
} }
} }
Menu("Tab Color") {
if tab.customColor != nil {
Button {
applyTabColor(nil, targetIds: targetIds)
} label: {
Label("Clear Color", systemImage: "xmark.circle")
}
}
Button {
promptCustomColor(targetIds: targetIds)
} label: {
Label("Choose Custom Color…", systemImage: "paintpalette")
}
if !tabColorPalette.isEmpty {
Divider()
}
ForEach(tabColorPalette, id: \.id) { entry in
Button {
applyTabColor(entry.hex, targetIds: targetIds)
} label: {
Label {
Text(entry.name)
} icon: {
Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex)))
}
}
}
}
Divider() Divider()
Button("Move Up") { Button("Move Up") {
@ -2863,13 +2995,50 @@ private struct TabItemView: View {
} }
private var backgroundColor: Color { private var backgroundColor: Color {
if isActive { switch activeTabIndicatorStyle {
return Color.accentColor case .leftRail:
} if isActive { return Color.accentColor }
if isMultiSelected { if isMultiSelected { return Color.accentColor.opacity(0.25) }
return Color.accentColor.opacity(0.25)
}
return Color.clear return Color.clear
case .solidFill:
if let custom = resolvedCustomTabColor {
if isActive { return custom }
if isMultiSelected { return custom.opacity(0.35) }
return custom.opacity(0.7)
}
if isActive { return Color.accentColor }
if isMultiSelected { return Color.accentColor.opacity(0.25) }
return Color.clear
}
}
private var railColor: Color {
explicitRailColor ?? .clear
}
private var explicitRailColor: Color? {
guard activeTabIndicatorStyle == .leftRail,
let custom = resolvedCustomTabColor else {
return nil
}
return custom.opacity(0.95)
}
private var resolvedCustomTabColor: Color? {
guard let hex = tab.customColor else { return nil }
return WorkspaceTabColorSettings.displayColor(
hex: hex,
colorScheme: colorScheme,
forceBright: activeTabIndicatorStyle == .leftRail
)
}
private func tabColorSwatchColor(for hex: String) -> NSColor {
WorkspaceTabColorSettings.displayNSColor(
hex: hex,
colorScheme: colorScheme,
forceBright: activeTabIndicatorStyle == .leftRail
) ?? NSColor(hex: hex) ?? .gray
} }
private var showsCenteredTopDropIndicator: Bool { private var showsCenteredTopDropIndicator: Bool {
@ -3142,6 +3311,55 @@ private struct TabItemView: View {
return trimmed return trimmed
} }
private func applyTabColor(_ hex: String?, targetIds: [UUID]) {
for targetId in targetIds {
tabManager.setTabColor(tabId: targetId, color: hex)
}
}
private func promptCustomColor(targetIds: [UUID]) {
let alert = NSAlert()
alert.messageText = "Custom Tab Color"
alert.informativeText = "Enter a hex color in the format #RRGGBB."
let seed = tab.customColor ?? WorkspaceTabColorSettings.customColors().first ?? ""
let input = NSTextField(string: seed)
input.placeholderString = "#1565C0"
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: "Apply")
alert.addButton(withTitle: "Cancel")
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else {
showInvalidColorAlert(input.stringValue)
return
}
applyTabColor(normalized, targetIds: targetIds)
}
private func showInvalidColorAlert(_ value: String) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Invalid Color"
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
alert.informativeText = "Enter a hex color in the format #RRGGBB."
} else {
alert.informativeText = "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB."
}
alert.addButton(withTitle: "OK")
_ = alert.runModal()
}
private func promptRename() { private func promptRename() {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Rename Workspace" alert.messageText = "Rename Workspace"

View file

@ -63,6 +63,48 @@ enum SidebarBranchLayoutSettings {
} }
} }
enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
case leftRail
case solidFill
var id: String { rawValue }
var displayName: String {
switch self {
case .leftRail:
return "Left Rail"
case .solidFill:
return "Solid Fill"
}
}
}
enum SidebarActiveTabIndicatorSettings {
static let styleKey = "sidebarActiveTabIndicatorStyle"
static let defaultStyle: SidebarActiveTabIndicatorStyle = .solidFill
static func resolvedStyle(rawValue: String?) -> SidebarActiveTabIndicatorStyle {
guard let rawValue else { return defaultStyle }
if let style = SidebarActiveTabIndicatorStyle(rawValue: rawValue) {
return style
}
// Legacy values from earlier iterations map to the closest modern option.
switch rawValue {
case "rail":
return .leftRail
case "border", "wash", "lift", "typography", "washRail", "blueWashColorRail":
return .solidFill
default:
return defaultStyle
}
}
static func current(defaults: UserDefaults = .standard) -> SidebarActiveTabIndicatorStyle {
resolvedStyle(rawValue: defaults.string(forKey: styleKey))
}
}
enum WorkspacePlacementSettings { enum WorkspacePlacementSettings {
static let placementKey = "newWorkspacePlacement" static let placementKey = "newWorkspacePlacement"
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
@ -104,6 +146,213 @@ enum WorkspacePlacementSettings {
} }
} }
struct WorkspaceTabColorEntry: Equatable, Identifiable {
let name: String
let hex: String
var id: String { "\(name)-\(hex)" }
}
enum WorkspaceTabColorSettings {
static let defaultOverridesKey = "workspaceTabColor.defaultOverrides"
static let customColorsKey = "workspaceTabColor.customColors"
static let maxCustomColors = 24
private static let originalPRPalette: [WorkspaceTabColorEntry] = [
WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"),
WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"),
WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"),
WorkspaceTabColorEntry(name: "Amber", hex: "#7D6608"),
WorkspaceTabColorEntry(name: "Olive", hex: "#4A5C18"),
WorkspaceTabColorEntry(name: "Green", hex: "#196F3D"),
WorkspaceTabColorEntry(name: "Teal", hex: "#006B6B"),
WorkspaceTabColorEntry(name: "Aqua", hex: "#0E6B8C"),
WorkspaceTabColorEntry(name: "Blue", hex: "#1565C0"),
WorkspaceTabColorEntry(name: "Navy", hex: "#1A5276"),
WorkspaceTabColorEntry(name: "Indigo", hex: "#283593"),
WorkspaceTabColorEntry(name: "Purple", hex: "#6A1B9A"),
WorkspaceTabColorEntry(name: "Magenta", hex: "#AD1457"),
WorkspaceTabColorEntry(name: "Rose", hex: "#880E4F"),
WorkspaceTabColorEntry(name: "Brown", hex: "#7B3F00"),
WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"),
]
static var defaultPalette: [WorkspaceTabColorEntry] {
originalPRPalette
}
static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults)
}
static func defaultPaletteWithOverrides(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
let palette = defaultPalette
let overrides = defaultOverrideMap(defaults: defaults)
return palette.map { entry in
WorkspaceTabColorEntry(name: entry.name, hex: overrides[entry.name] ?? entry.hex)
}
}
static func defaultColorHex(named name: String, defaults: UserDefaults = .standard) -> String {
let palette = defaultPalette
guard let entry = palette.first(where: { $0.name == name }) else {
return palette.first?.hex ?? "#1565C0"
}
return defaultOverrideMap(defaults: defaults)[name] ?? entry.hex
}
static func setDefaultColor(named name: String, hex: String, defaults: UserDefaults = .standard) {
let palette = defaultPalette
guard let entry = palette.first(where: { $0.name == name }),
let normalized = normalizedHex(hex) else { return }
var overrides = defaultOverrideMap(defaults: defaults)
if normalized == entry.hex {
overrides.removeValue(forKey: name)
} else {
overrides[name] = normalized
}
saveDefaultOverrideMap(overrides, defaults: defaults)
}
static func customColors(defaults: UserDefaults = .standard) -> [String] {
guard let raw = defaults.array(forKey: customColorsKey) as? [String] else { return [] }
var result: [String] = []
var seen: Set<String> = []
for value in raw {
guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue }
result.append(normalized)
if result.count >= maxCustomColors { break }
}
return result
}
static func customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
customColors(defaults: defaults).enumerated().map { index, hex in
WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex)
}
}
@discardableResult
static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? {
guard let normalized = normalizedHex(hex) else { return nil }
var colors = customColors(defaults: defaults)
colors.removeAll { $0 == normalized }
colors.insert(normalized, at: 0)
setCustomColors(colors, defaults: defaults)
return normalized
}
static func removeCustomColor(_ hex: String, defaults: UserDefaults = .standard) {
guard let normalized = normalizedHex(hex) else { return }
var colors = customColors(defaults: defaults)
colors.removeAll { $0 == normalized }
setCustomColors(colors, defaults: defaults)
}
static func setCustomColors(_ hexes: [String], defaults: UserDefaults = .standard) {
var normalizedColors: [String] = []
var seen: Set<String> = []
for value in hexes {
guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue }
normalizedColors.append(normalized)
if normalizedColors.count >= maxCustomColors { break }
}
if normalizedColors.isEmpty {
defaults.removeObject(forKey: customColorsKey)
} else {
defaults.set(normalizedColors, forKey: customColorsKey)
}
}
static func reset(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: defaultOverridesKey)
defaults.removeObject(forKey: customColorsKey)
}
static func normalizedHex(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let body = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard body.count == 6 else { return nil }
guard UInt64(body, radix: 16) != nil else { return nil }
return "#" + body.uppercased()
}
static func displayColor(
hex: String,
colorScheme: ColorScheme,
forceBright: Bool = false
) -> Color? {
guard let color = displayNSColor(hex: hex, colorScheme: colorScheme, forceBright: forceBright) else {
return nil
}
return Color(nsColor: color)
}
static func displayNSColor(
hex: String,
colorScheme: ColorScheme,
forceBright: Bool = false
) -> NSColor? {
guard let normalized = normalizedHex(hex),
let baseColor = NSColor(hex: normalized) else {
return nil
}
if forceBright || colorScheme == .dark {
return brightenedForDarkAppearance(baseColor)
}
return baseColor
}
private static func defaultOverrideMap(defaults: UserDefaults) -> [String: String] {
guard let raw = defaults.dictionary(forKey: defaultOverridesKey) as? [String: String] else { return [:] }
let validNames = Set(defaultPalette.map(\.name))
var normalized: [String: String] = [:]
for (name, hex) in raw {
guard validNames.contains(name),
let normalizedHex = normalizedHex(hex) else { continue }
normalized[name] = normalizedHex
}
return normalized
}
private static func saveDefaultOverrideMap(_ map: [String: String], defaults: UserDefaults) {
if map.isEmpty {
defaults.removeObject(forKey: defaultOverridesKey)
} else {
defaults.set(map, forKey: defaultOverridesKey)
}
}
private static func brightenedForDarkAppearance(_ color: NSColor) -> NSColor {
let rgbColor = color.usingColorSpace(.sRGB) ?? color
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28))
// Preserve neutral grays when brightening to avoid introducing hue shifts.
let boostedSaturation: CGFloat
if saturation <= 0.08 {
boostedSaturation = saturation
} else {
boostedSaturation = min(1, saturation + ((1 - saturation) * 0.12))
}
return NSColor(
hue: hue,
saturation: boostedSaturation,
brightness: boostedBrightness,
alpha: alpha
)
}
}
/// Coalesces repeated main-thread signals into one callback after a short delay. /// Coalesces repeated main-thread signals into one callback after a short delay.
/// Useful for notification storms where only the latest update matters. /// Useful for notification storms where only the latest update matters.
final class NotificationBurstCoalescer { final class NotificationBurstCoalescer {
@ -632,6 +881,11 @@ class TabManager: ObservableObject {
setCustomTitle(tabId: tabId, title: nil) setCustomTitle(tabId: tabId, title: nil)
} }
func setTabColor(tabId: UUID, color: String?) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.setCustomColor(color)
}
func togglePin(tabId: UUID) { func togglePin(tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index] let tab = tabs[index]

View file

@ -243,6 +243,7 @@ final class Workspace: Identifiable, ObservableObject {
@Published var title: String @Published var title: String
@Published var customTitle: String? @Published var customTitle: String?
@Published var isPinned: Bool = false @Published var isPinned: Bool = false
@Published var customColor: String? // hex string, e.g. "#C0392B"
@Published var currentDirectory: String @Published var currentDirectory: String
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
@ -755,6 +756,10 @@ final class Workspace: Identifiable, ObservableObject {
self.title = title self.title = title
} }
func setCustomColor(_ hex: String?) {
customColor = hex
}
func setCustomTitle(_ title: String?) { func setCustomTitle(_ title: String?) {
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { if trimmed.isEmpty {

View file

@ -298,6 +298,10 @@ struct cmuxApp: App {
appDelegate.openDebugScrollbackTab(nil) appDelegate.openDebugScrollbackTab(nil)
} }
Button("Open Workspaces for All Tab Colors") {
appDelegate.openDebugColorComparisonWorkspaces(nil)
}
Divider() Divider()
Menu("Debug Windows") { Menu("Debug Windows") {
Button("Debug Window Controls…") { Button("Debug Window Controls…") {
@ -1223,6 +1227,7 @@ private enum DebugWindowConfigSnapshot {
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout)) sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue))
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
@ -1319,6 +1324,8 @@ private struct DebugWindowControlsView: View {
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@ -1331,6 +1338,17 @@ private struct DebugWindowControlsView: View {
BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
} }
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
private var sidebarIndicatorStyleSelection: Binding<String> {
Binding(
get: { selectedSidebarActiveTabIndicatorStyle.rawValue },
set: { sidebarActiveTabIndicatorStyle = $0 }
)
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@ -1396,6 +1414,22 @@ private struct DebugWindowControlsView: View {
.padding(.top, 2) .padding(.top, 2)
} }
GroupBox("Active Workspace Indicator") {
VStack(alignment: .leading, spacing: 8) {
Picker("Style", selection: sidebarIndicatorStyleSelection) {
ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in
Text(style.displayName).tag(style.rawValue)
}
}
.pickerStyle(.menu)
Button("Reset Indicator Style") {
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
}
}
.padding(.top, 2)
}
GroupBox("Titlebar Spacing") { GroupBox("Titlebar Spacing") {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) { HStack(spacing: 8) {
@ -1797,6 +1831,19 @@ private struct SidebarDebugView: View {
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
private var selectedSidebarIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
private var sidebarIndicatorStyleSelection: Binding<String> {
Binding(
get: { selectedSidebarIndicatorStyle.rawValue },
set: { sidebarActiveTabIndicatorStyle = $0 }
)
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -1898,6 +1945,17 @@ private struct SidebarDebugView: View {
.padding(.top, 2) .padding(.top, 2)
} }
GroupBox("Active Workspace Indicator") {
VStack(alignment: .leading, spacing: 8) {
Picker("Style", selection: sidebarIndicatorStyleSelection) {
ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in
Text(style.displayName).tag(style.rawValue)
}
}
}
.padding(.top, 2)
}
GroupBox("Workspace Metadata") { GroupBox("Workspace Metadata") {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout) Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout)
@ -1925,6 +1983,9 @@ private struct SidebarDebugView: View {
Button("Reset Hints") { Button("Reset Hints") {
resetShortcutHintOffsets() resetShortcutHintOffsets()
} }
Button("Reset Active Indicator") {
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
}
} }
Button("Copy Config") { Button("Copy Config") {
@ -1992,6 +2053,7 @@ private struct SidebarDebugView: View {
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout) sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle)
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
@ -2505,6 +2567,8 @@ struct SettingsView: View {
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
@State private var shortcutResetToken = UUID() @State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0 @State private var topBlurOpacity: Double = 0
@State private var topBlurBaselineOffset: CGFloat? @State private var topBlurBaselineOffset: CGFloat?
@ -2517,11 +2581,24 @@ struct SettingsView: View {
@State private var socketPasswordDraft = "" @State private var socketPasswordDraft = ""
@State private var socketPasswordStatusMessage: String? @State private var socketPasswordStatusMessage: String?
@State private var socketPasswordStatusIsError = false @State private var socketPasswordStatusIsError = false
@State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides()
@State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors()
private var selectedWorkspacePlacement: NewWorkspacePlacement { private var selectedWorkspacePlacement: NewWorkspacePlacement {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
} }
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
private var sidebarIndicatorStyleSelection: Binding<String> {
Binding(
get: { selectedSidebarActiveTabIndicatorStyle.rawValue },
set: { sidebarActiveTabIndicatorStyle = $0 }
)
}
private var selectedSocketControlMode: SocketControlMode { private var selectedSocketControlMode: SocketControlMode {
SocketControlSettings.migrateMode(socketControlMode) SocketControlSettings.migrateMode(socketControlMode)
} }
@ -2683,6 +2760,97 @@ struct SettingsView: View {
.labelsHidden() .labelsHidden()
.pickerStyle(.menu) .pickerStyle(.menu)
} }
SettingsCardDivider()
SettingsCardRow(
"Active Workspace Indicator",
controlWidth: pickerColumnWidth
) {
Picker("", selection: sidebarIndicatorStyleSelection) {
ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in
Text(style.displayName).tag(style.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
}
}
SettingsSectionHeader(title: "Workspace Colors")
SettingsCard {
SettingsCardNote("Customize the workspace color palette used by Sidebar > Tab Color. \"Choose Custom Color...\" entries are persisted below.")
ForEach(Array(workspaceTabDefaultEntries.enumerated()), id: \.element.name) { index, entry in
if index > 0 {
SettingsCardDivider()
}
SettingsCardRow(
entry.name,
subtitle: "Base: \(baseTabColorHex(for: entry.name))"
) {
HStack(spacing: 8) {
ColorPicker(
"",
selection: defaultTabColorBinding(for: entry.name),
supportsOpacity: false
)
.labelsHidden()
.frame(width: 38)
Text(entry.hex)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 76, alignment: .trailing)
}
}
}
SettingsCardDivider()
if workspaceTabCustomColors.isEmpty {
SettingsCardNote("Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.")
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Custom Colors")
.font(.system(size: 13, weight: .semibold))
ForEach(workspaceTabCustomColors, id: \.self) { hex in
HStack(spacing: 8) {
Circle()
.fill(Color(nsColor: NSColor(hex: hex) ?? .gray))
.frame(width: 11, height: 11)
Text(hex)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Button("Remove") {
removeWorkspaceCustomColor(hex)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
SettingsCardDivider()
SettingsCardRow(
"Reset Palette",
subtitle: "Restore built-in defaults and clear all custom colors."
) {
Button("Reset") {
resetWorkspaceTabColors()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
} }
SettingsSectionHeader(title: "Automation") SettingsSectionHeader(title: "Automation")
@ -3081,6 +3249,7 @@ struct SettingsView: View {
browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity) browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity)
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
reloadWorkspaceTabColorSettings()
} }
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
// Keep draft in sync with external changes unless the user has local unsaved edits. // Keep draft in sync with external changes unless the user has local unsaved edits.
@ -3091,6 +3260,9 @@ struct SettingsView: View {
.onReceive(BrowserHistoryStore.shared.$entries) { entries in .onReceive(BrowserHistoryStore.shared.$entries) { entries in
browserHistoryEntryCount = entries.count browserHistoryEntryCount = entries.count
} }
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
reloadWorkspaceTabColorSettings()
}
.confirmationDialog( .confirmationDialog(
"Clear browser history?", "Clear browser history?",
isPresented: $showClearBrowserHistoryConfirmation, isPresented: $showClearBrowserHistoryConfirmation,
@ -3137,15 +3309,53 @@ struct SettingsView: View {
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
showOpenAccessConfirmation = false showOpenAccessConfirmation = false
pendingOpenAccessMode = nil pendingOpenAccessMode = nil
socketPasswordDraft = "" socketPasswordDraft = ""
socketPasswordStatusMessage = nil socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false socketPasswordStatusIsError = false
KeyboardShortcutSettings.resetAll() KeyboardShortcutSettings.resetAll()
WorkspaceTabColorSettings.reset()
reloadWorkspaceTabColorSettings()
shortcutResetToken = UUID() shortcutResetToken = UUID()
} }
private func defaultTabColorBinding(for name: String) -> Binding<Color> {
Binding(
get: {
let hex = WorkspaceTabColorSettings.defaultColorHex(named: name)
return Color(nsColor: NSColor(hex: hex) ?? .systemBlue)
},
set: { newValue in
let hex = NSColor(newValue).hexString()
WorkspaceTabColorSettings.setDefaultColor(named: name, hex: hex)
reloadWorkspaceTabColorSettings()
}
)
}
private func baseTabColorHex(for name: String) -> String {
WorkspaceTabColorSettings.defaultPalette
.first(where: { $0.name == name })?
.hex ?? "#1565C0"
}
private func removeWorkspaceCustomColor(_ hex: String) {
WorkspaceTabColorSettings.removeCustomColor(hex)
reloadWorkspaceTabColorSettings()
}
private func resetWorkspaceTabColors() {
WorkspaceTabColorSettings.reset()
reloadWorkspaceTabColorSettings()
}
private func reloadWorkspaceTabColorSettings() {
workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides()
workspaceTabCustomColors = WorkspaceTabColorSettings.customColors()
}
private func saveBrowserInsecureHTTPAllowlist() { private func saveBrowserInsecureHTTPAllowlist() {
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
} }

View file

@ -1,5 +1,6 @@
import XCTest import XCTest
import AppKit import AppKit
import SwiftUI
import WebKit import WebKit
import SwiftUI import SwiftUI
import ObjectiveC.runtime import ObjectiveC.runtime
@ -1066,6 +1067,171 @@ final class WorkspacePlacementSettingsTests: XCTestCase {
} }
} }
final class WorkspaceTabColorSettingsTests: XCTestCase {
func testNormalizedHexAcceptsAndNormalizesValidInput() {
XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123")
XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF")
XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234"))
XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234"))
}
func testBuiltInPaletteMatchesOriginalPRPalette() {
let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults)
XCTAssertEqual(palette.count, 16)
XCTAssertEqual(palette.first?.name, "Red")
XCTAssertEqual(palette.first?.hex, "#C0392B")
XCTAssertEqual(palette.last?.name, "Charcoal")
XCTAssertFalse(palette.contains(where: { $0.name == "Gold" }))
}
func testDefaultOverrideRoundTripFallsBackWhenResetToBase() {
let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let first = WorkspaceTabColorSettings.defaultPalette[0]
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
first.hex
)
WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults)
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
"#00AA33"
)
WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults)
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
first.hex
)
}
func testAddCustomColorPersistsAndDeduplicatesByMostRecent() {
let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertEqual(
WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults),
"#00AA33"
)
XCTAssertEqual(
WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults),
"#112233"
)
XCTAssertEqual(
WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults),
"#00AA33"
)
XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults))
XCTAssertEqual(
WorkspaceTabColorSettings.customColors(defaults: defaults),
["#00AA33", "#112233"]
)
}
func testPaletteIncludesCustomEntriesAndResetClearsAll() {
let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let first = WorkspaceTabColorSettings.defaultPalette[0]
WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults)
_ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults)
let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults)
XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1)
XCTAssertEqual(paletteBeforeReset[0].hex, "#334455")
XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1")
XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899")
WorkspaceTabColorSettings.reset(defaults: defaults)
XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), [])
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
first.hex
)
}
func testDisplayColorLightModeKeepsOriginalHex() {
let originalHex = "#1A5276"
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .light
)
XCTAssertEqual(rendered?.hexString(), originalHex)
}
func testDisplayColorDarkModeBrightensColor() {
let originalHex = "#1A5276"
guard let base = NSColor(hex: originalHex),
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .dark
) else {
XCTFail("Expected valid color conversion")
return
}
XCTAssertNotEqual(rendered.hexString(), originalHex)
XCTAssertGreaterThan(rendered.luminance, base.luminance)
}
func testDisplayColorDarkModeKeepsGrayscaleNeutral() {
let originalHex = "#808080"
guard let base = NSColor(hex: originalHex),
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .dark
),
let renderedSRGB = rendered.usingColorSpace(.sRGB) else {
XCTFail("Expected valid color conversion")
return
}
XCTAssertGreaterThan(rendered.luminance, base.luminance)
XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003)
XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003)
}
func testDisplayColorForceBrightensInLightMode() {
let originalHex = "#1A5276"
guard let base = NSColor(hex: originalHex),
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .light,
forceBright: true
) else {
XCTFail("Expected valid color conversion")
return
}
XCTAssertNotEqual(rendered.hexString(), originalHex)
XCTAssertGreaterThan(rendered.luminance, base.luminance)
}
}
final class WorkspaceAutoReorderSettingsTests: XCTestCase { final class WorkspaceAutoReorderSettingsTests: XCTestCase {
func testDefaultIsEnabled() { func testDefaultIsEnabled() {
let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)"
@ -1131,6 +1297,44 @@ final class SidebarBranchLayoutSettingsTests: XCTestCase {
} }
} }
final class SidebarActiveTabIndicatorSettingsTests: XCTestCase {
func testDefaultStyleWhenUnset() {
let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(
SidebarActiveTabIndicatorSettings.current(defaults: defaults),
SidebarActiveTabIndicatorSettings.defaultStyle
)
}
func testStoredStyleParsesAndInvalidFallsBack() {
let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(
SidebarActiveTabIndicatorSettings.current(defaults: defaults),
SidebarActiveTabIndicatorSettings.defaultStyle
)
}
}
final class AppearanceSettingsTests: XCTestCase { final class AppearanceSettingsTests: XCTestCase {
func testResolvedModeDefaultsToSystemWhenUnset() { func testResolvedModeDefaultsToSystemWhenUnset() {
let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)"

View file

@ -1,6 +1,24 @@
import XCTest import XCTest
import Foundation import Foundation
// UI runners can adjust wall clock time mid-test; use monotonic uptime for polling deadlines.
private func pollUntil(
timeout: TimeInterval,
pollInterval: TimeInterval = 0.05,
condition: () -> Bool
) -> Bool {
let start = ProcessInfo.processInfo.systemUptime
while true {
if condition() {
return true
}
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
}
final class UpdatePillUITests: XCTestCase { final class UpdatePillUITests: XCTestCase {
override func setUp() { override func setUp() {
super.setUp() super.setUp()
@ -131,25 +149,28 @@ final class UpdatePillUITests: XCTestCase {
} }
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) pollUntil(timeout: timeout) {
while Date() < deadline { app.windows.count >= count
if app.windows.count >= count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return app.windows.count >= count
} }
private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) { private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) {
let deadline = Date().addingTimeInterval(timeout) let pollInterval: TimeInterval = 0.05
var size = element.frame.size var size = element.frame.size
while Date() < deadline { var exists = element.exists
var hittable = element.isHittable
let visible = pollUntil(timeout: timeout, pollInterval: pollInterval) {
size = element.frame.size size = element.frame.size
if size.width > 20 && size.height > 10 { exists = element.exists
return hittable = element.isHittable
return size.width > 20 && size.height > 10
} }
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) if !visible {
XCTFail(
"Expected UpdatePill to have visible size, got \(size), exists=\(exists), hittable=\(hittable)"
)
} }
XCTFail("Expected UpdatePill to have visible size, got \(size)")
} }
private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) { private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) {
@ -197,12 +218,14 @@ final class UpdatePillUITests: XCTestCase {
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
app.launch() app.launch()
let deadline = Date().addingTimeInterval(activateTimeout) let activated = pollUntil(timeout: activateTimeout) {
while Date() < deadline, app.state != .runningForeground { guard app.state != .runningForeground else {
app.activate() return true
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
if app.state != .runningForeground { app.activate()
return app.state == .runningForeground
}
if !activated {
app.activate() app.activate()
} }
} }
@ -293,40 +316,32 @@ final class TitlebarShortcutHintsUITests: XCTestCase {
app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"] app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"]
app.launch() app.launch()
let deadline = Date().addingTimeInterval(2.0) _ = pollUntil(timeout: 2.0) {
while Date() < deadline, app.state != .runningForeground { guard app.state != .runningForeground else {
return true
}
app.activate() app.activate()
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) return app.state == .runningForeground
} }
return app return app
} }
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) pollUntil(timeout: timeout) {
while Date() < deadline { app.windows.count >= count
if app.windows.count >= count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return app.windows.count >= count
} }
private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool { private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) pollUntil(timeout: timeout) {
while Date() < deadline {
if element.exists { if element.exists {
let frame = element.frame let frame = element.frame
if frame.width > 1, frame.height > 1 { if frame.width > 1, frame.height > 1 {
return true return true
} }
} }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if element.exists {
let frame = element.frame
return frame.width > 1 && frame.height > 1
}
return false return false
} }
} }
}

View file

@ -93,6 +93,7 @@ sidebarBlurOpacity="$(format_number "$(read_value sidebarBlurOpacity 0.79)" 2)"
sidebarTintHex="$(read_value sidebarTintHex '#101010')" sidebarTintHex="$(read_value sidebarTintHex '#101010')"
sidebarTintOpacity="$(format_number "$(read_value sidebarTintOpacity 0.54)" 2)" sidebarTintOpacity="$(format_number "$(read_value sidebarTintOpacity 0.54)" 2)"
sidebarCornerRadius="$(format_number "$(read_value sidebarCornerRadius 0.0)" 1)" sidebarCornerRadius="$(format_number "$(read_value sidebarCornerRadius 0.0)" 1)"
sidebarActiveTabIndicatorStyle="$(read_value sidebarActiveTabIndicatorStyle solidFill)"
shortcutHintSidebarXOffset="$(format_number "$(read_value shortcutHintSidebarXOffset 0.0)" 1)" shortcutHintSidebarXOffset="$(format_number "$(read_value shortcutHintSidebarXOffset 0.0)" 1)"
shortcutHintSidebarYOffset="$(format_number "$(read_value shortcutHintSidebarYOffset 0.0)" 1)" shortcutHintSidebarYOffset="$(format_number "$(read_value shortcutHintSidebarYOffset 0.0)" 1)"
shortcutHintTitlebarXOffset="$(format_number "$(read_value shortcutHintTitlebarXOffset 4.0)" 1)" shortcutHintTitlebarXOffset="$(format_number "$(read_value shortcutHintTitlebarXOffset 4.0)" 1)"
@ -141,6 +142,7 @@ sidebarBlurOpacity=$sidebarBlurOpacity
sidebarTintHex=$sidebarTintHex sidebarTintHex=$sidebarTintHex
sidebarTintOpacity=$sidebarTintOpacity sidebarTintOpacity=$sidebarTintOpacity
sidebarCornerRadius=$sidebarCornerRadius sidebarCornerRadius=$sidebarCornerRadius
sidebarActiveTabIndicatorStyle=$sidebarActiveTabIndicatorStyle
shortcutHintSidebarXOffset=$shortcutHintSidebarXOffset shortcutHintSidebarXOffset=$shortcutHintSidebarXOffset
shortcutHintSidebarYOffset=$shortcutHintSidebarYOffset shortcutHintSidebarYOffset=$shortcutHintSidebarYOffset
shortcutHintTitlebarXOffset=$shortcutHintTitlebarXOffset shortcutHintTitlebarXOffset=$shortcutHintTitlebarXOffset