Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0836bcc
Draw resting sidebar rows lighter than selected and unread ones
teamleaderleo Sep 28, 2026
a1b4b0c
Add a sidebar row-state tour for judging row text weight
teamleaderleo Sep 28, 2026
4366c25
Keep the selection preview from changing sidebar title weight
teamleaderleo Sep 28, 2026
3ec4865
Give sidebar workspace titles room to read past a dozen characters
teamleaderleo Sep 28, 2026
0ee648c
Use real workspace titles in the sidebar row tour
teamleaderleo Sep 28, 2026
ad8b0f3
Measure title room against titles that already fitted
teamleaderleo Sep 28, 2026
896422c
Keep titles clear of the shortcut pill, and fix three review findings
teamleaderleo Sep 28, 2026
984885d
Merge main (56ec600c2816) into feat/sidebar-lighter-text-weight
teamleaderleo Sep 28, 2026
1405f1d
Merge main (b681e7e943ba) into feat/sidebar-lighter-text-weight
teamleaderleo Sep 28, 2026
b6350b9
test: read the cloud badge row's truncation from the title metrics
teamleaderleo Sep 28, 2026
05aedc3
dogfood: give the sidebar row tour path globs and a note
teamleaderleo Sep 28, 2026
dda0455
Merge main (56eacd43798f) into feat/sidebar-lighter-text-weight
teamleaderleo Sep 28, 2026
3c7a5df
Merge main (8b23dd7a7650) into feat/sidebar-lighter-text-weight
teamleaderleo Sep 28, 2026
d0fcc99
Merge main (0b2d3e06fdf8) into feat/sidebar-lighter-text-weight
teamleaderleo Sep 28, 2026
d2f59c2
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 28, 2026
d8bc6e0
Pin the sidebar tour's row settings to their defaults
teamleaderleo Sep 28, 2026
d975e86
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
fc40bad
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
98b7994
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
3d9a2f4
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
3bf7ffa
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
4343d4a
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
8d6749c
Merge origin/main into feat/sidebar-lighter-text-weight
teamleaderleo Sep 30, 2026
5a2242c
Merge main (cc302537b37b) into feat/sidebar-lighter-text-weight
teamleaderleo Sep 30, 2026
4fdba68
Merge main (6118a6181a97) into feat/sidebar-lighter-text-weight
teamleaderleo Sep 30, 2026
978614a
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
a844a96
Merge remote-tracking branch 'origin/main' into feat/sidebar-lighter-…
teamleaderleo Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,11 @@ enum CmuxEmbeddedConfigSchema {
"default": false,
"description": "Allow workspace titles in the sidebar to wrap to multiple lines instead of truncating after one line."
},
"twoLineWorkspaceTitles": {
"type": "boolean",
"default": false,
"description": "Allow a workspace title in the sidebar a second line before it is truncated, instead of showing it in full like wrapWorkspaceTitles."
},
"showWorkspaceDescription": {
"type": "boolean",
"default": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ public struct SidebarCatalogSection: SettingCatalogSection {
userDefaultsKey: "sidebarWrapWorkspaceTitles"
)

/// Allows a workspace title a second line before it is truncated. A middle
/// ground between one line and `wrapWorkspaceTitles`, which shows a title in
/// full however many lines that takes. Off by default: one line per row
/// keeps the list scannable.
public let twoLineWorkspaceTitles = DefaultsKey<Bool>(
id: "sidebar.twoLineWorkspaceTitles",
defaultValue: false,
userDefaultsKey: "sidebarTwoLineWorkspaceTitles"
)

public let showWorkspaceDescription = DefaultsKey<Bool>(
id: "sidebar.showWorkspaceDescription",
defaultValue: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .sidebarAppearance, id: "match-terminal", title: String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), synonyms: "Match Terminal Background sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"),
.init(section: .sidebarAppearance, id: "hide-sidebar-details", title: String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), synonyms: "Hide All Sidebar Details sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"),
.init(section: .sidebarAppearance, id: "wrap-workspace-titles", title: String(localized: "settings.app.wrapWorkspaceTitles", defaultValue: "Wrap Workspace Titles in Sidebar"), synonyms: "Wrap Workspace Titles in Sidebar sidebar.wrapWorkspaceTitles workspace title wrap multiline pr pull request"),
.init(section: .sidebarAppearance, id: "two-line-workspace-titles", title: String(localized: "settings.app.twoLineWorkspaceTitles", defaultValue: "Allow Two Lines for Workspace Titles"), synonyms: "Allow Two Lines for Workspace Titles sidebar.twoLineWorkspaceTitles workspace title two lines second line truncate shorten"),
.init(section: .sidebarAppearance, id: "show-workspace-description", title: String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), synonyms: "Show Workspace Description in Sidebar sidebar.showWorkspaceDescription workspace description notes markdown sidebar"),
.init(section: .sidebarAppearance, id: "workspace-description-color", title: String(localized: "settings.app.workspaceDescriptionColor", defaultValue: "Workspace Description Color"), synonyms: "Workspace Description Color sidebar.workspaceDescriptionColor description text color notes markdown sidebar"),
.init(section: .sidebarAppearance, id: "sidebar-branch-layout", title: String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), synonyms: "Sidebar Branch Layout sidebar.branchLayout sidebar.branchVerticalLayout git branch layout vertical inline cwd directory"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public struct SidebarSection: View {
@State private var matchTerminal: DefaultsValueModel<Bool>
@State var hideAll: DefaultsValueModel<Bool>
@State private var wrapTitles: DefaultsValueModel<Bool>
@State private var twoLineTitles: DefaultsValueModel<Bool>
@State private var showDesc: DefaultsValueModel<Bool>
@State private var workspaceDescriptionHex: DefaultsValueModel<String>
@State private var branchVerticalLayout: DefaultsValueModel<Bool>
Expand Down Expand Up @@ -45,6 +46,7 @@ public struct SidebarSection: View {
_matchTerminal = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.matchTerminalBackground))
_hideAll = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.hideAllDetails))
_wrapTitles = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.wrapWorkspaceTitles))
_twoLineTitles = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.twoLineWorkspaceTitles))
_showDesc = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.showWorkspaceDescription))
_workspaceDescriptionHex = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.workspaceDescriptionColorHex))
_branchVerticalLayout = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.branchVerticalLayout))
Expand Down Expand Up @@ -90,6 +92,7 @@ public struct SidebarSection: View {
matchTerminal,
hideAll,
wrapTitles,
twoLineTitles,
showDesc,
workspaceDescriptionHex,
branchVerticalLayout,
Expand Down Expand Up @@ -280,6 +283,18 @@ public struct SidebarSection: View {
}
SettingsCardDivider()

SettingsCardRow(
configurationReview: .json("sidebar.twoLineWorkspaceTitles"),
String(localized: "settings.app.twoLineWorkspaceTitles", defaultValue: "Allow Two Lines for Workspace Titles"),
subtitle: String(localized: "settings.app.twoLineWorkspaceTitles.subtitle", defaultValue: "Gives a long workspace title a second line before it is shortened.")
) {
Toggle("", isOn: Binding(get: { twoLineTitles.current }, set: { twoLineTitles.set($0) }))
.labelsHidden()
.controlSize(.small)
}
.disabled(wrapTitles.current)
SettingsCardDivider()

SettingsCardRow(
configurationReview: .json("sidebar.showWorkspaceDescription"),
String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ struct SettingsRowAnchorResolutionTests {
"sidebar.showWorkspaceDescription",
"sidebar.workspaceDescriptionColor",
"sidebar.stackBranchDirectory",
"sidebar.twoLineWorkspaceTitles",
"sidebar.watchGitStatus",
"sidebar.wrapWorkspaceTitles",
"sidebar.beta.workspaceTodos.controls.enabled",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
public import AppKit
public import SwiftUI

/// Font weights the workspace sidebar draws its row text with.
///
/// The workspace list has two renderers: the AppKit table cells and the
/// SwiftUI rows. Both resolve weight here, so a row reads the same whichever
/// one draws it, and a weight change cannot land in one path only.
///
/// Resting rows are `regular`: a sidebar where every title is semibold has no
/// spare emphasis left for the rows that matter. Selected rows and rows with
/// unread notifications keep `semibold`, so the two states still stand out
/// without the whole list shouting.
public enum SidebarRowTextWeight: Sendable, Hashable, CaseIterable {
case regular
case medium
case semibold

/// Weight for a workspace row's title.
///
/// `isSelected` covers both the active workspace and a row that joined a
/// multi-selection; both are rows the user is acting on.
public static func workspaceTitle(isSelected: Bool, hasUnread: Bool) -> SidebarRowTextWeight {
isSelected || hasUnread ? .semibold : .regular
}

/// Weight for a workspace group header's name. Constant, so the header's
/// measured height never depends on selection.
public static let workspaceGroupHeaderName: SidebarRowTextWeight = .medium

public var appKitWeight: NSFont.Weight {
switch self {
case .regular:
return .regular
case .medium:
return .medium
case .semibold:
return .semibold
}
}

public var swiftUIWeight: Font.Weight {
switch self {
case .regular:
return .regular
case .medium:
return .medium
case .semibold:
return .semibold
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
public import AppKit
public import SwiftUI

/// Type metrics for one workspace row title in the sidebar.
///
/// Both sidebar renderers (the AppKit table cells and the SwiftUI rows) build
/// this from the two title settings, so a title occupies the same box and
/// truncates the same way whichever one draws it.
///
/// The line limit is the value the rest of the metrics are derived from rather
/// than an argument passed alongside them: a title truncated at its end while
/// being laid out on one line, or in the middle while wrapping, is a mismatch
/// the two renderers used to be free to make independently.
public struct SidebarRowTitleMetrics: Equatable, Sendable {
/// Base point size for a workspace row title, before the sidebar font scale
/// and the global font magnification.
///
/// Half a point smaller than the size cmux shipped before: at the default
/// sidebar width the old size cut most titles inside a dozen characters,
/// and the row is a label, not body text.
public static let fontSize: CGFloat = 12

/// Lines a wrapped title may occupy when `sidebar.wrapWorkspaceTitles` is on.
public static let maxWrappedLines = 8

/// Lines a title may occupy when only `sidebar.twoLineWorkspaceTitles` is on.
public static let twoLineLines = 2

/// Lines this title may occupy.
public let lineLimit: Int

/// `wrapsTitles` keeps its shipped meaning (show the whole title, up to
/// `maxWrappedLines`). `usesTwoLines` is the middle setting: a second line
/// for the titles that need one, still bounded so a long title cannot push
/// its neighbours off screen.
public init(wrapsTitles: Bool, usesTwoLines: Bool) {
if wrapsTitles {
lineLimit = Self.maxWrappedLines
} else {
lineLimit = usesTwoLines ? Self.twoLineLines : 1
}
}

/// Metrics for a title already known to be laid out on `lineLimit` lines.
public init(lineLimit: Int) {
self.lineLimit = max(1, lineLimit)
}

/// Whether this title is shortened in its middle.
///
/// A single line is: the start of a title and its distinctive tail are both
/// worth more than the words in between, so "Fix cmux pane focus indicator
/// flicker" reads as "Fix cmux pa…r flicker" and "cmux-remote-status @host"
/// keeps the host it points at. Titles on more than one line truncate at the
/// end of the last line, where the earlier lines already carry the start.
public var truncatesMiddle: Bool {
lineLimit == 1
}

public var appKitLineBreakMode: NSLineBreakMode {
if truncatesMiddle {
return .byTruncatingMiddle
}
// Word wrapping with no truncation is only safe when the limit is high
// enough to show a whole title; a two-line title has to show the reader
// that something was cut.
return lineLimit >= Self.maxWrappedLines ? .byWordWrapping : .byTruncatingTail
}

public var swiftUITruncationMode: Text.TruncationMode {
truncatesMiddle ? .middle : .tail
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AppKit
import SwiftUI
import Testing

@testable import CmuxSidebar

@Suite("SidebarRowTextWeight")
struct SidebarRowTextWeightTests {
@Test("A resting workspace row title is lighter than semibold")
func restingTitleIsLight() {
let weight = SidebarRowTextWeight.workspaceTitle(isSelected: false, hasUnread: false)
#expect(weight == .regular)
#expect(weight.appKitWeight.rawValue < SidebarRowTextWeight.semibold.appKitWeight.rawValue)
}

@Test("Selected and unread workspace row titles stay semibold")
func selectedAndUnreadTitlesAreEmphasized() {
#expect(SidebarRowTextWeight.workspaceTitle(isSelected: true, hasUnread: false) == .semibold)
#expect(SidebarRowTextWeight.workspaceTitle(isSelected: false, hasUnread: true) == .semibold)
#expect(SidebarRowTextWeight.workspaceTitle(isSelected: true, hasUnread: true) == .semibold)
}

@Test("Group header names use one constant weight")
func groupHeaderNameWeightIsConstant() {
#expect(SidebarRowTextWeight.workspaceGroupHeaderName == .medium)
}

/// The AppKit table cells and the SwiftUI rows read these two properties
/// separately, so a row would look different depending on which renderer
/// drew it if the two mappings ever drifted apart.
@Test("The AppKit and SwiftUI mappings agree for every weight")
func appKitAndSwiftUIMappingsAgree() {
let expected: [(SidebarRowTextWeight, NSFont.Weight, Font.Weight)] = [
(.regular, .regular, .regular),
(.medium, .medium, .medium),
(.semibold, .semibold, .semibold),
]
#expect(expected.count == SidebarRowTextWeight.allCases.count)
for (weight, appKit, swiftUI) in expected {
#expect(weight.appKitWeight == appKit)
#expect(weight.swiftUIWeight == swiftUI)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import AppKit
import SwiftUI
import Testing

@testable import CmuxSidebar

@Suite("SidebarRowTitleMetrics")
struct SidebarRowTitleMetricsTests {
@Test("One line by default, and wrapping still shows a title in full")
func lineLimits() {
#expect(SidebarRowTitleMetrics(wrapsTitles: false, usesTwoLines: false).lineLimit == 1)
#expect(SidebarRowTitleMetrics(wrapsTitles: false, usesTwoLines: true).lineLimit == 2)
#expect(
SidebarRowTitleMetrics(wrapsTitles: true, usesTwoLines: false).lineLimit
== SidebarRowTitleMetrics.maxWrappedLines
)
// Wrapping is the stronger request, so it is not reduced to two lines.
#expect(
SidebarRowTitleMetrics(wrapsTitles: true, usesTwoLines: true).lineLimit
== SidebarRowTitleMetrics.maxWrappedLines
)
}

/// The AppKit cells and the SwiftUI rows resolve truncation separately, so
/// a drift here would shorten the same title differently depending on which
/// renderer drew the sidebar.
@Test("Both renderers truncate a one-line title in the middle")
func truncationModesAgree() {
let oneLine = SidebarRowTitleMetrics(lineLimit: 1)
#expect(oneLine.truncatesMiddle)
#expect(oneLine.appKitLineBreakMode == .byTruncatingMiddle)
#expect(oneLine.swiftUITruncationMode == .middle)

for limit in [2, 3, SidebarRowTitleMetrics.maxWrappedLines] {
let metrics = SidebarRowTitleMetrics(lineLimit: limit)
#expect(!metrics.truncatesMiddle)
#expect(metrics.swiftUITruncationMode == .tail)
}
}

/// A bounded multi-line title has to show that it was cut; only the limit
/// that shows a title in full may wrap without a mark.
@Test("A two-line title truncates, a fully wrapped title does not")
func lineBreakModesMatchTheirLimits() {
#expect(SidebarRowTitleMetrics(lineLimit: 2).appKitLineBreakMode == .byTruncatingTail)
#expect(
SidebarRowTitleMetrics(lineLimit: SidebarRowTitleMetrics.maxWrappedLines).appKitLineBreakMode
== .byWordWrapping
)
}

/// A line limit is a count of lines, so the metrics cannot be built with a
/// limit that would draw no title at all.
@Test("A limit below one line is treated as one line")
func limitsBelowOneLineAreClamped() {
#expect(SidebarRowTitleMetrics(lineLimit: 0).lineLimit == 1)
#expect(SidebarRowTitleMetrics(lineLimit: -3).truncatesMiddle)
}

/// Row titles are labels in a narrow column, not body text.
@Test("The title size stays smaller than the system body size")
func titleSizeIsSmall() {
#expect(SidebarRowTitleMetrics.fontSize < NSFont.systemFontSize)
#expect(SidebarRowTitleMetrics.fontSize >= 11)
}
}
Loading
Loading