Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b4ea25b
test(cmux-next sidebar): rows keep one gap after groups and pins, and…
teamleaderleo Oct 21, 2026
5ca9bed
fix(cmux-next sidebar): one row spacing after groups, folders and pin…
teamleaderleo Oct 21, 2026
cbbada1
fix(cmux-next sidebar): a drop's gap alone under a block shifts rows …
teamleaderleo Oct 22, 2026
cce18bc
fix(cmux-next sidebar): a gap in an empty drop zone's place keeps the…
teamleaderleo Oct 22, 2026
c027960
Merge branch 'feat-cmux-next' into sidebar-uniform-spacing
teamleaderleo Oct 22, 2026
971e1a0
Merge origin/feat-cmux-next into sidebar-uniform-spacing
teamleaderleo Oct 22, 2026
affbe51
cmux NEXT sidebar: Group by Status sections end on the row spacing to…
teamleaderleo Oct 22, 2026
ba6883f
cmux NEXT sidebar: the folder spacing test sets Group by Folder throu…
teamleaderleo Oct 22, 2026
07d0655
Merge origin/feat-cmux-next (ababeeac877cf2e7a0126a328ef6655e9a7077cf…
teamleaderleo Oct 22, 2026
3323239
Merge remote-tracking branch 'origin/feat-cmux-next' into sidebar-uni…
teamleaderleo Oct 22, 2026
909a694
Sidebar empty group test: the next group follows by the row gap alone
teamleaderleo Oct 22, 2026
65d985e
Merge remote-tracking branch 'origin/feat-cmux-next' into sidebar-uni…
teamleaderleo Oct 26, 2026
18d0424
docs(cmux-next sidebar): log #23800's row gaps in the owner note
teamleaderleo Oct 26, 2026
47a426a
test(cmux-next sidebar): the row under the Pinned drop zone keeps the…
teamleaderleo Oct 26, 2026
d9c103d
fix(cmux-next sidebar): no section gap under the Pinned drop zone
teamleaderleo Oct 26, 2026
e0f6eab
test(cmux-next sidebar): the drop zone of an untitled block and Group…
teamleaderleo Oct 26, 2026
06251dd
fix(cmux-next sidebar): row spacing under an untitled drop zone and a…
teamleaderleo Oct 26, 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 @@ -17,11 +17,12 @@ nonisolated struct SidebarChatHistoryPlacement {
spacing = m.sectionSpacing
}

/// Pinned at `y`, once, with a section's spacing under it.
/// Pinned at `y`, once. Its rows end with the row spacing, so the first workspace row follows its last
/// pinned chat as the next row does any other (no section gap after the pins, Leo 2026-10-21).
mutating func placePinned(_ y: inout CGFloat) {
guard pinnedY == nil, pinnedHeight > 0 else { return }
pinnedY = y
y += pinnedHeight + spacing
y += pinnedHeight
}

/// Earlier a section's spacing under `y`, once; Pinned first when no computer took it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public nonisolated struct SidebarEmptyAreaTarget: Hashable, Sendable {
extension SidebarLayout {
/// The new-workspace target for a double-click at `y`, or nil on a row.
/// The row above decides: inside an expanded group (between its rows,
/// or within its bottom padding) the group; anywhere else the end of the
/// or in the row spacing under its last row) the group; anywhere else the end of the
/// section of that row. Above every row, the first machine section.
/// The pinned section takes no new workspace.
public func emptyAreaTarget(at y: CGFloat, metrics: SidebarLayoutMetrics) -> SidebarEmptyAreaTarget? {
Expand All @@ -28,8 +28,7 @@ extension SidebarLayout {
let above = rows[index]
guard above.section != .pinned else { return nil }
if let group = groupOpening(below: above) {
let endsGroup = above.isLastInGroup || above.key == .group(group) && above.childCount == 0
let extent = above.maxY + metrics.rowSpacing + (endsGroup ? metrics.groupBottomPadding : 0)
let extent = above.maxY + metrics.rowSpacing
if y < extent { return SidebarEmptyAreaTarget(section: above.section, group: group) }
}
return SidebarEmptyAreaTarget(section: above.section)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ nonisolated extension SidebarLayout {
// The group's block: its header and the member rows right after it.
var end = headerIndex + 1
while end < rows.count, rows[end].group == group.id, !rows[end].key.isGroupHeader { end += 1 }
let oldEnd = Self.blockEnd(header: header, last: end > headerIndex + 1 ? rows[end - 1] : nil, collapsed: header.isCollapsed, metrics: m)
let oldEnd = Self.blockEnd(header: header, last: end > headerIndex + 1 ? rows[end - 1] : nil, metrics: m)

let shown = group.workspaces.filter { !o.excludedWorkspaces.contains($0.id) }
let shownIDs = Set(shown.map(\.id))
Expand Down Expand Up @@ -104,7 +104,7 @@ nonisolated extension SidebarLayout {
}
}
}
let newEnd = Self.blockEnd(header: header, last: built.last, collapsed: collapsed, metrics: m)
let newEnd = Self.blockEnd(header: header, last: built.last, metrics: m)
var folded = header
folded.isCollapsed = collapsed
setRow(folded, at: headerIndex)
Expand All @@ -131,10 +131,10 @@ nonisolated extension SidebarLayout {
return layout
}

/// Where the list goes on after a group's block (`make`): an open group adds its bottom padding.
private static func blockEnd(header: SidebarRow, last: SidebarRow?, collapsed: Bool, metrics m: SidebarLayoutMetrics) -> CGFloat {
let after = (last ?? header).maxY + m.rowSpacing
return collapsed ? after : after + m.groupBottomPadding
/// Where the list goes on after a group's block (`make`): one row spacing under its last row, open or
/// folded (no padding under a group, Leo 2026-10-21).
private static func blockEnd(header: SidebarRow, last: SidebarRow?, metrics m: SidebarLayoutMetrics) -> CGFloat {
(last ?? header).maxY + m.rowSpacing
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import Foundation
/// Done shows under its state and a long section lists every row.
nonisolated extension SidebarLayout {
/// The rows of `sections`' computers (the pinned area and the document's sections lay out as before), from `y`;
/// `spaced` puts the section spacing above them.
/// `spacing` goes above them: the section spacing, the row spacing under a block, or none.
static func statusRows(
_ sections: [SidebarSection], spaced: Bool, chats: inout SidebarChatHistoryPlacement,
_ sections: [SidebarSection], spacing: CGFloat, chats: inout SidebarChatHistoryPlacement,
options o: SidebarLayoutOptions, metrics m: SidebarLayoutMetrics, y: inout CGFloat
) -> [SidebarRow] {
let machines = sections.filter { $0.machine != nil }
guard let first = machines.first else { return [] }
if spaced { y += m.sectionSpacing }
y += spacing
chats.placePinned(&y)
var members: [SidebarStatusSection: [(ws: SidebarWorkspace, section: SidebarSection, index: Int)]] = [:]
for section in machines {
Expand Down Expand Up @@ -62,7 +62,7 @@ nonisolated extension SidebarLayout {
y += m.tabRowHeight + m.rowSpacing
}
}
y += m.groupBottomPadding
// The next status group follows the last member by the row spacing alone (Leo 2026-10-21: no padding after groups).
}
return rows
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,9 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
// first one keeps its empty placeholder, a drop target).
let listsAnyMachineRow = sections.contains { $0.machine != nil && !$0.nodes.isEmpty }
var previousWasMachine = false
// A block brings the band's own padding: the list's top padding goes after the blocks, as below the band.
var previousWasBlock = false
// After a document section with no groups, what the next section adds: the row spacing under its block, or
// nothing under its drop zone (or the drop's gap there), whose row already ends with it. nil: a section gap.
var afterBlock: CGFloat?
var shownEmptyMachine = false

func openGapIfNeeded(section: SectionID, group: GroupID?, index: Int) {
Expand All @@ -170,8 +171,8 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
if let lastMachine, sectionIndex > lastMachine { chats.placeEarlier(&y) }
// Group by Status lays every computer's rows out once, in place of their sections (`SidebarLayout+Status`).
if o.grouping.byStatus && !filtering && section.machine != nil {
if !statusShown { rows += statusRows(sections, spaced: !firstSection, chats: &chats, options: o, metrics: m, y: &y) }
(statusShown, firstSection, previousWasMachine, previousWasBlock) = (true, false, false, false)
if !statusShown { rows += statusRows(sections, spacing: firstSection ? 0 : afterBlock ?? m.sectionSpacing, chats: &chats, options: o, metrics: m, y: &y) }
(statusShown, firstSection, previousWasMachine, afterBlock) = (true, false, false, nil)
continue
}
// Nodes that remain, with their visible children.
Expand Down Expand Up @@ -232,12 +233,20 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
let flat = o.flattensMachines && section.machine != nil && !Self.keepsHeaderInOneList(section)
if flat && nodes.isEmpty && !gapHere && (listsAnyMachineRow || shownEmptyMachine) { continue }
if flat && nodes.isEmpty { shownEmptyMachine = true }
// Consecutive computers in one list read as one list: no gap. A block brings its own padding.
// Consecutive computers in one list read as one list: no gap. A block brings its own top padding; under
// it the next row follows by the row spacing, as under any row (Leo 2026-10-21: no gap after the pins).
if firstSection && blockHeight > 0 { y -= m.topPadding }
if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += previousWasBlock ? m.topPadding : m.sectionSpacing }
if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += afterBlock ?? m.sectionSpacing }
firstSection = false
if section.machine != nil { chats.placePinned(&y) }
previousWasBlock = blockHeight > 0 && nodes.isEmpty
// This section's own rows under its block: its groups, or its empty drop zone (or the drop's gap in the zone's
// place, so the rows below stay put). Any other gap alone opens right under the block and moves the rows
// below by exactly `gapShift`.
let rowsUnderBlock = blockHeight > 0
&& (!nodes.isEmpty || (emptyDropZone && docID.map(o.emptyDocSections.contains) == true))
// An untitled section emptied by a drag has a 0 pt block and its drop zone (or the gap) alone.
let endsInZone = isDocSection && nodes.isEmpty && (blockHeight == 0 || rowsUnderBlock)
afterBlock = endsInZone ? 0 : blockHeight > 0 && nodes.isEmpty ? m.rowSpacing : nil
// Only a headerless computer joins the next one: rows after a headed section (a remote group,
// the Chief's) never read as its members.
previousWasMachine = section.machine != nil && flat
Expand All @@ -247,7 +256,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
group: nil, siblingIndex: 0, parentIndex: nil, isLastInGroup: false,
isCollapsed: false, childCount: 0, groupColor: nil
))
y += blockHeight
y += blockHeight + (rowsUnderBlock ? m.rowSpacing : 0)
}
// Headers stay for the pinned area and for one section per
// computer (`sidebar.groupByComputer`, opt-in), never in one list.
Expand Down Expand Up @@ -414,8 +423,8 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
}
}
}
// The next row follows the last member by the row spacing alone (Leo 2026-10-21: no padding after groups).
openGapIfNeeded(section: section.id, group: group.id, index: members.count)
y += m.groupBottomPadding
}
}
// Group by Folder lists groups, document items and every folder's rows as they are: no Done fold, no row limit.
Expand Down Expand Up @@ -451,7 +460,6 @@ public nonisolated struct SidebarLayout: Hashable, Sendable {
))
y += m.groupHeaderHeight + m.rowSpacing
for index in indices { emit(index, nodes[index]) }
y += m.groupBottomPadding
}
openGapIfNeeded(section: section.id, group: nil, index: nodes.count)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ public nonisolated struct SidebarLayoutMetrics: Hashable, Sendable {
/// Workspace row with a secondary detail line.
public var rowHeightWithSubtitle: CGFloat
public var tabRowHeight: CGFloat
/// The one gap between consecutive rows: a group's last member, a folder's last row and the
/// Pinned block's last row take it too, never a padding of their own (Leo 2026-10-21).
public var rowSpacing: CGFloat
public var groupBottomPadding: CGFloat
public var emptySectionHeight: CGFloat
/// One line of a row's status block (`WorkspaceRowContent.status`).
public var statusLineHeight: CGFloat = 14
Expand All @@ -37,7 +38,7 @@ public nonisolated struct SidebarLayoutMetrics: Hashable, Sendable {
public init(
topPadding: CGFloat, bottomPadding: CGFloat, sectionHeaderHeight: CGFloat, sectionSpacing: CGFloat,
rowHeight: CGFloat, rowHeightWithSubtitle: CGFloat, tabRowHeight: CGFloat, rowSpacing: CGFloat,
groupBottomPadding: CGFloat, emptySectionHeight: CGFloat
emptySectionHeight: CGFloat
) {
self.topPadding = topPadding
self.bottomPadding = bottomPadding
Expand All @@ -47,7 +48,6 @@ public nonisolated struct SidebarLayoutMetrics: Hashable, Sendable {
self.rowHeightWithSubtitle = rowHeightWithSubtitle
self.tabRowHeight = tabRowHeight
self.rowSpacing = rowSpacing
self.groupBottomPadding = groupBottomPadding
self.emptySectionHeight = emptySectionHeight
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ extension SidebarLayoutMetrics {
rowHeightWithSubtitle: SidebarDensityTokens.rowHeightWithSubtitle,
tabRowHeight: row,
rowSpacing: Metrics.space1,
groupBottomPadding: SidebarDensityTokens.groupBottomPadding,
emptySectionHeight: row
)
let font = SidebarStyle.subtitleFont
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,14 @@ class SidebarItemRowView: NSView {
// The glyph lines up with the text of workspace rows (their inset plus the pill inset).
// A pinned workspace row's pin leads, and its icon and title follow it.
let pinSide = Metrics.smallIconSize
let lead = (style == .chip ? Metrics.space2 : inset * 2) + (info.leadingPin ? pinSide + Metrics.space2 : 0)
let pinFrame = info.leadingPin ? pinGlyphFrame(side: pinSide, leading: inset * 2, height: b.height) : .zero
let lead = (style == .chip ? Metrics.space2 : inset * 2) + (info.leadingPin ? pinFrame.maxX - inset * 2 + Metrics.space2 : 0)
+ (style == .chip ? 0 : CGFloat(info.indent) * Metrics.space3)
let iconFrame = style.isIconOnly
? NSRect(x: (b.width - side) / 2, y: (b.height - side) / 2, width: side, height: side)
: NSRect(x: lead, y: (b.height - side) / 2, width: side, height: side)
pin.isHidden = !info.leadingPin
pin.frame = NSRect(x: inset * 2, y: (b.height - pinSide) / 2, width: pinSide, height: pinSide)
pin.frame = pinFrame
// Icon-only items keep their pill around the glyph (a full-width fill reads
// as a selected row); the profile avatar's pill and click fill its square.
let pillFrame = style.isIconOnly ? (showsAvatar ? b : iconFrame)
Expand Down Expand Up @@ -348,6 +349,27 @@ class SidebarItemRowView: NSView {
layoutActivity(onGlyph: wellFrame)
}

/// The cache key of the pin's ink (`SidebarGlyphInk`).
private static let pinGlyph = "pin|\(IconName.statePinned.rawValue)"

/// The leading pin's frame: its drawn ink, not its square, starts at `leading` (where an unpinned workspace
/// row's icon or title starts) and centers on the row, on the device pixel grid. The pin's square leaves a
/// margin on each side of its ink, so the square at the rows' inset drew the pin in from them (Leo 2026-10-21).
private func pinGlyphFrame(side: CGFloat, leading: CGFloat, height: CGFloat) -> NSRect {
let scale = window?.backingScaleFactor ?? 2
let snap = { (value: CGFloat) in (value * scale).rounded() / scale }
guard let image = pin.image, let ink = SidebarGlyphInk.shared.box(of: image, glyph: Self.pinGlyph) else {
return NSRect(x: leading, y: snap((height - side) / 2), width: side, height: side)
}
return NSRect(x: snap(leading - ink.minX), y: snap(height / 2 - ink.midY), width: side, height: side)
}

/// The pin's drawn ink in the row's coordinates; nil without a pin (tests).
var pinInkFrame: CGRect? {
guard !pin.isHidden, let image = pin.image, let ink = SidebarGlyphInk.shared.box(of: image, glyph: Self.pinGlyph) else { return nil }
return ink.offsetBy(dx: pin.frame.minX, dy: pin.frame.minY)
}

/// The item's registry icon at `side` points; without one, its SF Symbol at the matching text size.
private func glyphImage(side: CGFloat) -> NSImage? {
if let favicon = info.favicon { return NSImage(cgImage: favicon.image, size: NSSize(width: side, height: side)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import CoreGraphics
/// | horizontal inset | 6 | 8 | `space3`→`space4` |
/// | section gap | 8 | 12 | `space4`→`space5` |
/// | list top padding | 4 | 6 | `space2`→`space3` |
/// | group bottom padding | 6 | 8 | `space3`→`space4` |
///
/// Row title and detail text size and weight do not change (Interface Size
/// owns the text size); a group header's name keeps its existing rule and
/// follows its bar (`SidebarStyle.groupHeaderFont`: 11 pt on the 24 pt row,
/// 12 pt on the 28 pt row). Row spacing stays `space1` so selection fills
/// keep their rhythm. Values are read at layout time, never cached, so a
/// keep their rhythm, and it is the only gap between rows: a group's or the
/// Pinned block's last row adds no padding (Leo 2026-10-21). Values are read at layout time, never cached, so a
/// change applies live.
enum SidebarDensityTokens {
static var density: SidebarDensity { DesignSettings.shared.sidebarSections.density }
Expand All @@ -51,5 +51,4 @@ enum SidebarDensityTokens {
static var rowCornerRadius: CGFloat { Metrics.itemCornerRadius }
static var sectionSpacing: CGFloat { isComfortable ? Metrics.space5 : Metrics.space4 }
static var topPadding: CGFloat { isComfortable ? Metrics.space3 : Metrics.space2 }
static var groupBottomPadding: CGFloat { isComfortable ? Metrics.space4 : Metrics.space3 }
}
Loading
Loading