diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1ebf08a926ad..3147e878afc2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10677,7 +10677,6 @@ struct VerticalTabsSidebar: View { // row sitting behind the open menu. See `SidebarShortcutHintFreezePolicy`. @State private var frozenShortcutHintsTabId: UUID? @State private var frozenShortcutHintsValue: Bool = false - @State private var workspaceRowsMeasurement: SidebarWorkspaceRowsMeasurement? @State private var pendingSelectedWorkspaceScrollId: UUID? @State private var collapsedExtensionSidebarSectionIds: Set = [] @State private var extensionSidebarWorktreeCreationInFlightSectionIds: Set = [] @@ -11201,27 +11200,11 @@ struct VerticalTabsSidebar: View { viewportHeight: geometryProxy.size.height, insets: scrollInsets ) - let measuredWorkspaceRowsHeight = workspaceRowsMeasurement?.rowsHeight( - for: renderContext.visibleWorkspaceRowIds - ) - // The empty drop/tap area below the last row is sized to the - // remaining viewport so the scroll content fills (but never - // exceeds) the visible height when the rows fit. Sizing it to a - // finite remainder — instead of the old `maxHeight: .infinity` — - // is what keeps the document view from always overflowing, so the - // overlay scroller stays hidden when there is nothing to scroll - // (https://github.com/manaflow-ai/cmux/issues/3241). - let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaHeight( - contentMinHeight: contentMinHeight, - rowsHeight: measuredWorkspaceRowsHeight - ) - ScrollViewReader { scrollProxy in ScrollView(.vertical) { workspaceScrollContent( renderContext: renderContext, - minHeight: contentMinHeight, - emptyAreaHeight: emptyAreaHeight + minHeight: contentMinHeight ) } .background( @@ -11320,12 +11303,6 @@ struct VerticalTabsSidebar: View { } requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext) } - .onChange(of: renderContext.visibleWorkspaceRowIds) { _, _ in - // Drop the stale rows-height measurement so the empty-area - // sizing recomputes for the new row set. The measurement is - // also keyed by workspace ids, so this is belt-and-suspenders. - workspaceRowsMeasurement = nil - } .onReceive(NotificationCenter.default.publisher(for: .workspaceOrderDidChange)) { notification in requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(notification) } @@ -11374,21 +11351,6 @@ struct VerticalTabsSidebar: View { lastSidebarSelectionIndex = index } } - .onPreferenceChange(SidebarWorkspaceRowsHeightPreferenceKey.self) { measurement in - guard let measurement else { - workspaceRowsMeasurement = nil - return - } - let nextMeasurement = SidebarWorkspaceRowsMeasurement( - workspaceIds: measurement.workspaceIds, - rowsHeight: max(0, measurement.rowsHeight) - ) - if let workspaceRowsMeasurement, - workspaceRowsMeasurement.isEquivalent(to: nextMeasurement) { - return - } - workspaceRowsMeasurement = nextMeasurement - } } } } @@ -12498,10 +12460,21 @@ struct VerticalTabsSidebar: View { private func workspaceScrollContent( renderContext: WorkspaceListRenderContext, - minHeight: CGFloat, - emptyAreaHeight: CGFloat + minHeight: CGFloat ) -> some View { - VStack(spacing: 0) { + // Rows take their natural height; the empty drop/tap area stretches to + // fill the remaining viewport. SidebarRowsFillLayout sizes that + // remainder from the explicit viewport height (`minHeight`, the floored + // content height from the scroll geometry) rather than from a layout + // proposal, which a vertical ScrollView leaves unspecified in the + // scroll axis. So we never measure the LazyVStack's whole-content height + // into @State. That measurement (a .background GeometryReader writing a + // PreferenceKey) fed a non-converging relayout loop (#2586 / #5764 / + // #5845). The empty area fills exactly to the viewport when rows fit and + // collapses to 0 when they overflow, so the overlay scroller stays + // hidden (#3241) and the blank area below the last row stays a + // drop/tap target. + SidebarRowsFillLayout(viewportHeight: minHeight) { workspaceRows(renderContext: renderContext) SidebarEmptyArea( @@ -12513,8 +12486,7 @@ struct VerticalTabsSidebar: View { topDropIndicatorVisible: emptyAreaTopDropIndicatorVisible(), tabDropDelegate: emptyAreaTabDropDelegate(renderContext: renderContext), bonsplitDropIndicator: dropIndicatorBinding, - expandsVertically: false, - minimumHeight: emptyAreaHeight + expandsVertically: false ) } .frame(minHeight: minHeight, alignment: .top) @@ -12552,18 +12524,11 @@ struct VerticalTabsSidebar: View { } .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) - let measuredRows = rows - .background { - GeometryReader { proxy in - Color.clear.preference( - key: SidebarWorkspaceRowsHeightPreferenceKey.self, - value: SidebarWorkspaceRowsMeasurement( - workspaceIds: renderContext.visibleWorkspaceRowIds, - rowsHeight: proxy.size.height - ) - ) - } - } + // No whole-content height measurement here. Reading the LazyVStack's + // total height via a .background GeometryReader (to size the empty area) + // fed a non-converging relayout loop (#2586 / #5764 / #5845). The + // empty-area fill is now handled geometrically by SidebarRowsFillLayout + // in workspaceScrollContent without measuring these rows. // Gate ONLY the per-row frame-anchor *reader* (the virtualization-defeating // work) behind the drag-active check, and keep the Bonsplit drop-capture @@ -12573,7 +12538,7 @@ struct VerticalTabsSidebar: View { // the drop NSView, orphaning the in-flight drag. Applying it at the stable outer // level keeps the NSView identity-stable across gate flips. (#5325 review) rowsWithGatedDropTargetReader( - rows: measuredRows, + rows: rows, renderContext: renderContext, shouldCollect: shouldCollectWorkspaceDropTargets ) @@ -15762,7 +15727,7 @@ struct TabItemView: View, Equatable { fontScale: fontScale, onFocus: { updateSelection() } ) - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(.opacity) } if !metadataBlocks.isEmpty { SidebarMetadataMarkdownBlocks( @@ -15773,7 +15738,7 @@ struct TabItemView: View, Equatable { fontScale: fontScale, onFocus: { updateSelection() } ) - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(.opacity) } } @@ -15788,7 +15753,7 @@ struct TabItemView: View, Equatable { .lineLimit(1) .truncationMode(.tail) } - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(.opacity) } if detailVisibility.showsProgress, let progress = workspaceSnapshot.progress { @@ -15811,7 +15776,7 @@ struct TabItemView: View, Equatable { .lineLimit(1) } } - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(.opacity) } // Branch + directory row @@ -15965,9 +15930,12 @@ struct TabItemView: View, Equatable { .lineLimit(1) } } - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.latestLog) - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.progress != nil) - .animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.metadataBlocks.count) + // No implicit .animation(value:) on agent-mutable fields: animating a + // row-height change interpolates the LazyVStack's measured height over + // every frame of the 0.2s curve, and with dozens of agent sessions some + // row is always animating, so the sidebar-wide layout re-runs at display + // refresh rate (#5764 / #5845). Lazy rows must be height-stable after + // they appear; content changes now apply in one discrete layout pass. .padding(.horizontal, 10) .padding(.vertical, 8) .background( @@ -17467,9 +17435,12 @@ private struct SidebarMetadataMarkdownBlockRow: View { let fontScale: CGFloat let onFocus: () -> Void - @State private var renderedMarkdown: AttributedString? - var body: some View { + // Render inline (memoized) so the FIRST render is already attributed. + // Parsing in onAppear into @State performed a guaranteed nil -> + // attributed swap on every first appearance, changing the row's height + // mid-scroll and re-feeding the sidebar-wide layout cycle (#5764). + let renderedMarkdown = SidebarMetadataMarkdownRenderer.rendered(block.markdown) Group { if let renderedMarkdown { Text(renderedMarkdown) @@ -17484,22 +17455,11 @@ private struct SidebarMetadataMarkdownBlockRow: View { .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) .onTapGesture { onFocus() } - .onAppear(perform: renderMarkdown) - .onChange(of: block.markdown) { _ in - renderMarkdown() - } } private var foregroundColor: Color { isActive ? activeForegroundColor : .secondary } - - private func renderMarkdown() { - renderedMarkdown = try? AttributedString( - markdown: block.markdown, - options: .init(interpretedSyntax: .full) - ) - } } enum SidebarAutoScrollDirection: Equatable { diff --git a/Sources/SidebarMetadataMarkdownRenderer.swift b/Sources/SidebarMetadataMarkdownRenderer.swift new file mode 100644 index 000000000000..2fb2cfdd61b5 --- /dev/null +++ b/Sources/SidebarMetadataMarkdownRenderer.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Renders sidebar metadata-block markdown with a bounded memo cache so the +/// FIRST render of a row is already attributed. +/// +/// The previous flow parsed in the row's `onAppear` into `@State`: every first +/// appearance of every metadata block performed a guaranteed nil -> attributed +/// swap, changing the row's intrinsic height mid-scroll and re-feeding the +/// sidebar-wide layout/measurement cycle +/// (https://github.com/manaflow-ai/cmux/issues/5764, +/// https://github.com/manaflow-ai/cmux/issues/5845). Lazy rows must be +/// height-stable after they appear, and row state belongs in the initializer or +/// the model, not `onAppear`. +/// +/// Parsing inline from `body` matches the `SidebarWorkspaceDescriptionText` +/// sibling; the cache keeps repeat body evaluations cheap (agent-heavy rows +/// re-evaluate often) and is bounded so long sessions with churning metadata +/// cannot grow it without limit. +@MainActor +enum SidebarMetadataMarkdownRenderer { + private static var cache: [String: AttributedString?] = [:] + private static var insertionOrder: [String] = [] + private static let capacity = 512 + /// Only small blocks are rendered as markdown. Metadata markdown is + /// agent/control-socket supplied and uncapped at this boundary. Caching + /// large values would retain hundreds of big payloads after the workspace + /// metadata is overwritten or cleared (memory), and parsing them inline on + /// every body eval would re-run main-actor Markdown parsing under agent + /// churn (CPU). Above this size the block falls back to plain text (the row + /// renders `Text(block.markdown)` on the nil return): no parse, no + /// retention, and still height-stable because the result never changes for + /// a given block. Cached small blocks bound total retained bytes to + /// `capacity * maxCacheableBytes`. A >4 KB sidebar metadata block is already + /// pathological, so plain text is an acceptable degradation. + private static let maxCacheableBytes = 4096 + + static func rendered(_ markdown: String) -> AttributedString? { + guard markdown.utf8.count <= maxCacheableBytes else { + return nil + } + if let hit = cache[markdown] { + return hit + } + let parsed = parse(markdown) + if insertionOrder.count >= capacity, let oldest = insertionOrder.first { + insertionOrder.removeFirst() + cache.removeValue(forKey: oldest) + } + // updateValue, not subscript assignment: with an Optional value type, + // `cache[markdown] = nil` removes the key instead of caching the failed + // parse, so unparseable blocks would re-parse on every body eval and + // append phantom keys to insertionOrder. + cache.updateValue(parsed, forKey: markdown) + insertionOrder.append(markdown) + return parsed + } + + private static func parse(_ markdown: String) -> AttributedString? { + try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .full) + ) + } +} diff --git a/Sources/SidebarRowsFillLayout.swift b/Sources/SidebarRowsFillLayout.swift new file mode 100644 index 000000000000..f824822b7f12 --- /dev/null +++ b/Sources/SidebarRowsFillLayout.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// Lays the sidebar workspace rows out at their natural height, then stretches a +/// trailing empty drop/tap area to fill the remaining viewport — in one geometry +/// pass, with no whole-content height measurement. +/// +/// The previous approach measured the `LazyVStack`'s total height via a +/// `.background` `GeometryReader` and routed it through a `PreferenceKey` into +/// `@State` to size a fixed-height empty area. That preference write during +/// layout fed a non-converging relayout transaction +/// (https://github.com/manaflow-ai/cmux/issues/2586, +/// https://github.com/manaflow-ai/cmux/issues/5764, +/// https://github.com/manaflow-ai/cmux/issues/5845). +/// +/// This `Layout` takes the viewport height as an explicit input +/// (`viewportHeight`, the floored content height the call site already computes +/// from the scroll geometry) and sizes the empty area from it directly. It does +/// NOT derive the viewport from the layout proposal: a vertical `ScrollView` +/// leaves the scroll-axis height unspecified, and +/// `ProposedViewSize.replacingUnspecifiedDimensions()` would then fall back to a +/// 10pt placeholder, collapsing the empty area to `0` and dropping the blank +/// area below the last row out of the drop/tap target. With the explicit +/// viewport: when the rows fit, rows + empty area exactly fill the viewport (no +/// overflow, overlay scroller stays hidden — +/// https://github.com/manaflow-ai/cmux/issues/3241); when the rows overflow, the +/// empty area is `0` and the document view scrolls. The rows are never measured +/// into SwiftUI state. +/// +/// Expects exactly two subviews in order: `[rows, emptyArea]`. +struct SidebarRowsFillLayout: Layout { + /// The floored viewport height available to the scroll content. The empty + /// area fills the remainder of this height below the rows. + let viewportHeight: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.replacingUnspecifiedDimensions().width + let rowsHeight = subviews.first?.sizeThatFits( + ProposedViewSize(width: width, height: nil) + ).height ?? 0 + // Fill the viewport when the rows are shorter; grow to the rows' natural + // height when they overflow it. Driven by the explicit viewport, not the + // (unspecified in a vertical ScrollView) proposed height. + return CGSize(width: width, height: max(rowsHeight, viewportHeight)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let rows = subviews.first else { return } + let rowsHeight = rows.sizeThatFits( + ProposedViewSize(width: bounds.width, height: nil) + ).height + rows.place( + at: CGPoint(x: bounds.minX, y: bounds.minY), + proposal: ProposedViewSize(width: bounds.width, height: rowsHeight) + ) + guard subviews.count > 1 else { return } + // Size the empty area against the explicit viewport (or the rows' height + // when they overflow it), never against `bounds.height` — which could be + // the rows' natural height alone if the parent placed us at our content + // size. + let emptyHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + viewportHeight: viewportHeight, + rowsHeight: rowsHeight + ) + subviews[1].place( + at: CGPoint(x: bounds.minX, y: bounds.minY + rowsHeight), + proposal: ProposedViewSize(width: bounds.width, height: emptyHeight) + ) + } +} diff --git a/Sources/SidebarWorkspaceRenderItem.swift b/Sources/SidebarWorkspaceRenderItem.swift index 36080cea6d5c..f854a2b2fbef 100644 --- a/Sources/SidebarWorkspaceRenderItem.swift +++ b/Sources/SidebarWorkspaceRenderItem.swift @@ -1,17 +1,31 @@ import Foundation +/// Stable, allocation-free identity for a `SidebarWorkspaceRenderItem`. +/// +/// ForEach gathers row identifiers on every list diff, so the id must be cheap +/// to create and hash. The previous `String` form +/// (`"workspace.\(uuid.uuidString)"`) allocated and formatted a fresh string on +/// every getter call; with the sidebar re-diffing all rows per update it was the +/// hottest app-owned frame in the +/// https://github.com/manaflow-ai/cmux/issues/5764 livelock spindump. The case +/// keeps group headers and workspace rows from ever colliding on the same UUID. +enum SidebarWorkspaceRenderItemID: Hashable { + case group(UUID) + case workspace(UUID) +} + /// One drawable item in the workspace sidebar. @MainActor enum SidebarWorkspaceRenderItem { case groupHeader(WorkspaceGroup, memberWorkspaceIds: [UUID]) case workspace(Workspace) - var id: String { + var id: SidebarWorkspaceRenderItemID { switch self { case .groupHeader(let group, _): - return "group.\(group.id.uuidString)" + return .group(group.id) case .workspace(let workspace): - return "workspace.\(workspace.id.uuidString)" + return .workspace(workspace.id) } } diff --git a/Sources/SidebarWorkspaceRowsHeightPreferenceKey.swift b/Sources/SidebarWorkspaceRowsHeightPreferenceKey.swift deleted file mode 100644 index 8abd21f95535..000000000000 --- a/Sources/SidebarWorkspaceRowsHeightPreferenceKey.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import SwiftUI - -/// Carries the single whole-content `SidebarWorkspaceRowsMeasurement` of the -/// sidebar workspace rows up to the scroll container, which uses it to size -/// the empty drop/tap area to the remaining viewport (#3241). -struct SidebarWorkspaceRowsHeightPreferenceKey: PreferenceKey { - static let defaultValue: SidebarWorkspaceRowsMeasurement? = nil - - static func reduce( - value: inout SidebarWorkspaceRowsMeasurement?, - nextValue: () -> SidebarWorkspaceRowsMeasurement? - ) { - guard let next = nextValue() else { return } - guard let current = value else { - value = next - return - } - value = current.rowsHeight >= next.rowsHeight ? current : next - } -} diff --git a/Sources/SidebarWorkspaceRowsMeasurement.swift b/Sources/SidebarWorkspaceRowsMeasurement.swift deleted file mode 100644 index 6488a272c182..000000000000 --- a/Sources/SidebarWorkspaceRowsMeasurement.swift +++ /dev/null @@ -1,29 +0,0 @@ -import CoreGraphics - -/// A single whole-content height measurement of the sidebar workspace rows, -/// keyed by the workspace row identity that produced it. -/// -/// The sidebar sizes the empty drop/tap area below the last row to the -/// remaining viewport (`SidebarWorkspaceScrollLayout.emptyAreaHeight`) from -/// one measurement of the laid-out rows. Keying by `workspaceIds` lets a stale -/// measurement be ignored after rows are added, removed, or reordered, and -/// `isEquivalent(to:tolerance:)` dedupes sub-pixel height jitter so constant -/// agent-driven row re-renders do not write `@State` and re-feed a -/// preference/layout transaction cycle -/// (the https://github.com/manaflow-ai/cmux/issues/2586 class of livelock). -struct SidebarWorkspaceRowsMeasurement: Equatable { - let workspaceIds: [ID] - let rowsHeight: CGFloat - - nonisolated func rowsHeight(for currentWorkspaceIds: [ID]) -> CGFloat? { - guard workspaceIds == currentWorkspaceIds else { return nil } - return max(0, rowsHeight) - } - - nonisolated func isEquivalent( - to other: SidebarWorkspaceRowsMeasurement, - tolerance: CGFloat = 0.5 - ) -> Bool { - workspaceIds == other.workspaceIds && abs(rowsHeight - other.rowsHeight) <= tolerance - } -} diff --git a/Sources/WindowChromeMetrics.swift b/Sources/WindowChromeMetrics.swift index b558b81b775d..9e6c29fda04a 100644 --- a/Sources/WindowChromeMetrics.swift +++ b/Sources/WindowChromeMetrics.swift @@ -90,27 +90,51 @@ enum SidebarWorkspaceScrollLayout { return max(0, (viewportHeight - insets.total).rounded(.down)) } - /// Height of the empty drop/tap area placed below the last workspace row. + /// Height of the empty drop/tap area `SidebarRowsFillLayout` places below the + /// last workspace row: the space remaining in the layout's concrete container. /// - /// The area fills the remaining viewport (so the content fills the visible - /// height when the rows fit) but is clamped to `0` once the rows already - /// reach or exceed `contentMinHeight`. Sizing it to this finite remainder — - /// rather than `maxHeight: .infinity` — is what keeps the document view - /// from perpetually overflowing, so the overlay scroller stays hidden when - /// there is nothing to scroll - /// (https://github.com/manaflow-ai/cmux/issues/3241). + /// The container height is the viewport floor (`.frame(minHeight:)`) when the + /// rows fit, or the rows' natural height when they overflow it. Because it is + /// derived from the layout's own bounds rather than a measured whole-content + /// height, the rows are never read into SwiftUI `@State` — which is what fed + /// the relayout loop + /// (https://github.com/manaflow-ai/cmux/issues/2586, + /// https://github.com/manaflow-ai/cmux/issues/5764). When the rows fit, rows + + /// empty area exactly fill the viewport, so there is no overflow and the + /// overlay scroller stays hidden (https://github.com/manaflow-ai/cmux/issues/3241); + /// when the rows overflow, this is `0` and the document view genuinely scrolls. /// /// - Parameters: - /// - contentMinHeight: The viewport height available to the scroll content. - /// - rowsHeight: The measured height of the laid-out rows, or `nil` when - /// no measurement is available yet (in which case the area collapses to - /// `0` and the content's `minHeight` frame still fills the viewport). + /// - containerHeight: The layout's resolved container height. + /// - rowsHeight: The rows' natural height. /// - Returns: The non-negative height for the empty area. - nonisolated static func emptyAreaHeight( - contentMinHeight: CGFloat, - rowsHeight: CGFloat? + nonisolated static func emptyAreaFillHeight( + containerHeight: CGFloat, + rowsHeight: CGFloat ) -> CGFloat { - guard let rowsHeight, rowsHeight > 0 else { return 0 } - return max(0, contentMinHeight - rowsHeight) + return max(0, containerHeight - rowsHeight) + } + + /// Empty-area height from the explicit viewport, the way `SidebarRowsFillLayout` + /// computes it. The container is the viewport when the rows fit, or the rows' + /// height when they overflow it, so the empty area fills the remaining + /// viewport below the rows (keeping the blank area a drop/tap target) and + /// collapses to `0` once the rows overflow. Driven by the explicit viewport, + /// not a layout proposal — a vertical `ScrollView` leaves the scroll-axis + /// height unspecified, so deriving it from the proposal would collapse the + /// area to a placeholder height when the rows fit. + /// + /// - Parameters: + /// - viewportHeight: The floored viewport height available to the content. + /// - rowsHeight: The rows' natural height. + /// - Returns: The non-negative height for the empty area. + nonisolated static func emptyAreaFillHeight( + viewportHeight: CGFloat, + rowsHeight: CGFloat + ) -> CGFloat { + return emptyAreaFillHeight( + containerHeight: max(viewportHeight, rowsHeight), + rowsHeight: rowsHeight + ) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 026dc12e411e..f5d0016e4f83 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -590,12 +590,14 @@ B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */; }; 385C6BA7E78DB87460E5D930 /* SidebarMarkdownRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */; }; + C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000002 /* SidebarPathFormatter.swift */; }; C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; }; C0DE48000000000000000001 /* SidebarProviderMenuRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE48000000000000000002 /* SidebarProviderMenuRegressionTests.swift */; }; B9000130A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000131A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; + C9A57501C9A57501C9A57501 /* SidebarRowsFillLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57502C9A57502C9A57502 /* SidebarRowsFillLayout.swift */; }; C0DE35010000000000000001 /* SidebarScrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35010000000000000002 /* SidebarScrim.swift */; }; C9A57511C9A57511C9A57511 /* SidebarScrollViewConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57512C9A57512C9A57512 /* SidebarScrollViewConfigurator.swift */; }; C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */; }; @@ -615,8 +617,6 @@ C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */; }; C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */; }; - C9A57501C9A57501C9A57501 /* SidebarWorkspaceRowsHeightPreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57502C9A57502C9A57502 /* SidebarWorkspaceRowsHeightPreferenceKey.swift */; }; - C9A57503C9A57503C9A57503 /* SidebarWorkspaceRowsMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57504C9A57504C9A57504 /* SidebarWorkspaceRowsMeasurement.swift */; }; C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */; }; C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */; }; 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */; }; @@ -1394,12 +1394,14 @@ B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = ""; }; F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMarkdownRendererTests.swift; sourceTree = ""; }; + C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMetadataMarkdownRenderer.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; EA1F00000000000000000002 /* SidebarPathFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPathFormatter.swift; sourceTree = ""; }; C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPortDisplayText.swift; sourceTree = ""; }; C0DE48000000000000000002 /* SidebarProviderMenuRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarProviderMenuRegressionTests.swift; sourceTree = ""; }; B9000131A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPullRequestInteractivityUITests.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; + C9A57502C9A57502C9A57502 /* SidebarRowsFillLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarRowsFillLayout.swift; sourceTree = ""; }; C0DE35010000000000000002 /* SidebarScrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrim.swift; sourceTree = ""; }; C9A57512C9A57512C9A57512 /* SidebarScrollViewConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrollViewConfigurator.swift; sourceTree = ""; }; C9A57514C9A57514C9A57514 /* SidebarScrollViewConfiguratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrollViewConfiguratorTests.swift; sourceTree = ""; }; @@ -1419,8 +1421,6 @@ C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupingMetrics.swift; sourceTree = ""; }; C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceRowHoverTracker.swift; sourceTree = ""; }; - C9A57502C9A57502C9A57502 /* SidebarWorkspaceRowsHeightPreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowsHeightPreferenceKey.swift; sourceTree = ""; }; - C9A57504C9A57504C9A57504 /* SidebarWorkspaceRowsMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowsMeasurement.swift; sourceTree = ""; }; C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceScrollLayoutTests.swift; sourceTree = ""; }; C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSelectionAnchorPolicyTests.swift; sourceTree = ""; }; D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift; sourceTree = ""; }; @@ -1876,10 +1876,10 @@ C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */, D5037010000000000000002 /* RenderableSystemSymbol.swift */, C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, + C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, C9A57512C9A57512C9A57512 /* SidebarScrollViewConfigurator.swift */, C9A57516C9A57516C9A57516 /* SidebarScrollViewResolverView.swift */, - C9A57502C9A57502C9A57502 /* SidebarWorkspaceRowsHeightPreferenceKey.swift */, - C9A57504C9A57504C9A57504 /* SidebarWorkspaceRowsMeasurement.swift */, + C9A57502C9A57502C9A57502 /* SidebarRowsFillLayout.swift */, C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */, @@ -2818,7 +2818,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; + shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nmkdir -p \"$BIN_DEST\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; }; C0DEFF100000000000000003 /* Build Command Palette Nucleo FFI */ = { isa = PBXShellScriptBuildPhase; @@ -3209,8 +3209,10 @@ D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */, + C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */, EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */, + C9A57501C9A57501C9A57501 /* SidebarRowsFillLayout.swift in Sources */, C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, C9A57511C9A57511C9A57511 /* SidebarScrollViewConfigurator.swift in Sources */, C9A57515C9A57515C9A57515 /* SidebarScrollViewResolverView.swift in Sources */, @@ -3224,8 +3226,6 @@ C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */, - C9A57501C9A57501C9A57501 /* SidebarWorkspaceRowsHeightPreferenceKey.swift in Sources */, - C9A57503C9A57503C9A57503 /* SidebarWorkspaceRowsMeasurement.swift in Sources */, 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */, A5001226 /* SocketControlMode+Display.swift in Sources */, E3309A0D /* SplitEqualizer.swift in Sources */, diff --git a/cmuxTests/SidebarWorkspaceScrollLayoutTests.swift b/cmuxTests/SidebarWorkspaceScrollLayoutTests.swift index 6e268fcc8a8a..6f40c3a79292 100644 --- a/cmuxTests/SidebarWorkspaceScrollLayoutTests.swift +++ b/cmuxTests/SidebarWorkspaceScrollLayoutTests.swift @@ -10,62 +10,20 @@ import Testing @MainActor @Suite("Sidebar workspace scroll layout") struct SidebarWorkspaceScrollLayoutTests { - @Test func unmeasuredRowsCollapseEmptyAreaWithoutForcingOverflow() { - // Before a measurement arrives, the empty area collapses to 0. The - // content's `minHeight` frame still fills the viewport, so the document - // view does not overflow and the overlay scroller stays hidden. - let contentMinHeight: CGFloat = 480 - let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaHeight( - contentMinHeight: contentMinHeight, - rowsHeight: nil - ) - - #expect(abs(emptyAreaHeight) <= 0.001) - // The content frame is pinned to contentMinHeight, so it never exceeds - // the viewport: no overflow, overlay scroller stays hidden. - #expect(max(contentMinHeight, emptyAreaHeight) <= contentMinHeight) - } - - @Test func rowsMeasurementIgnoresStaleWorkspaceIds() { - let measurement = SidebarWorkspaceRowsMeasurement( - workspaceIds: ["a", "b"], - rowsHeight: 240 + @Test func contentMinHeightSubtractsInsetsFromViewport() { + let contentMinHeight = SidebarWorkspaceScrollLayout.contentMinHeight( + viewportHeight: 720, + insets: SidebarWorkspaceScrollInsets(top: 28, bottom: 48) ) - - #expect(measurement.rowsHeight(for: ["b", "c"]) == nil) - #expect(abs((measurement.rowsHeight(for: ["a", "b"]) ?? -1) - 240) <= 0.001) + #expect(abs(contentMinHeight - (720 - 76)) <= 0.001) } - @Test func rowsMeasurementDedupesSubPixelJitterForSameWorkspaceIds() { - // The livelock-safety invariant: the single whole-content measurement - // is deduped so sub-pixel height jitter from constant agent-driven row - // re-renders does NOT write @State, so it cannot re-feed a - // preference/layout transaction cycle (the #2586 class of bug). - let base = SidebarWorkspaceRowsMeasurement(workspaceIds: ["a", "b"], rowsHeight: 240) - let jittered = SidebarWorkspaceRowsMeasurement(workspaceIds: ["a", "b"], rowsHeight: 240.3) - let moved = SidebarWorkspaceRowsMeasurement(workspaceIds: ["a", "b"], rowsHeight: 256) - let differentRows = SidebarWorkspaceRowsMeasurement(workspaceIds: ["a"], rowsHeight: 240) - - #expect(base.isEquivalent(to: jittered)) - #expect(!base.isEquivalent(to: moved)) - #expect(!base.isEquivalent(to: differentRows)) - } - - @Test func emptyAreaFillsOnlyRemainingViewportSpaceWhenRowsFit() { + @Test func contentMinHeightNeverGoesNegative() { let contentMinHeight = SidebarWorkspaceScrollLayout.contentMinHeight( - viewportHeight: 720, + viewportHeight: 20, insets: SidebarWorkspaceScrollInsets(top: 28, bottom: 48) ) - let rowsHeight: CGFloat = 96 - let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaHeight( - contentMinHeight: contentMinHeight, - rowsHeight: rowsHeight - ) - - #expect(abs(emptyAreaHeight - (contentMinHeight - rowsHeight)) <= 0.001) - // Rows + filled empty area exactly equals the viewport: content fits, - // so the overlay scroller stays hidden (the #3241 phantom-scrollbar fix). - #expect(abs((rowsHeight + emptyAreaHeight) - contentMinHeight) <= 0.001) + #expect(contentMinHeight == 0) } @Test func contentHeightStaysWithinViewportAfterPixelRounding() { @@ -101,17 +59,90 @@ struct SidebarWorkspaceScrollLayoutTests { } } - @Test func emptyAreaCollapsesWhenRowsAlreadyOverflowViewport() { - let contentMinHeight: CGFloat = 300 + @Test func emptyAreaFillsOnlyRemainingContainerSpaceWhenRowsFit() { + // SidebarRowsFillLayout places the empty area below the rows, sized to + // the space remaining in its concrete container. When the rows fit, rows + // + filled empty area exactly equal the container, so the content fits + // the viewport and the overlay scroller stays hidden (the #3241 + // phantom-scrollbar fix) — without ever measuring the rows into @State. + let containerHeight: CGFloat = 644 + let rowsHeight: CGFloat = 96 + let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + containerHeight: containerHeight, + rowsHeight: rowsHeight + ) + + #expect(abs(emptyAreaHeight - (containerHeight - rowsHeight)) <= 0.001) + #expect(abs((rowsHeight + emptyAreaHeight) - containerHeight) <= 0.001) + } + + @Test func emptyAreaCollapsesWhenRowsAlreadyFillContainer() { + // When the rows reach or exceed the container (the viewport), the empty + // area adds nothing, so the document view stays at the rows' natural + // height and genuinely scrolls. + let containerHeight: CGFloat = 300 let rowsHeight: CGFloat = 420 - let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaHeight( - contentMinHeight: contentMinHeight, + let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + containerHeight: containerHeight, rowsHeight: rowsHeight ) #expect(abs(emptyAreaHeight) <= 0.001) - // The empty area adds nothing, so the document view stays at the rows' - // natural height and genuinely overflows the viewport — a real scroll. - #expect(rowsHeight + emptyAreaHeight > contentMinHeight) + } + + @Test func emptyAreaIsZeroWhenRowsExactlyFillContainer() { + let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + containerHeight: 480, + rowsHeight: 480 + ) + #expect(abs(emptyAreaHeight) <= 0.001) + } + + @Test func emptyAreaFillsEntireContainerWhenNoRows() { + // No workspaces: the empty drop/tap area fills the entire viewport so + // the empty sidebar is still a drop/tap target and the content fills the + // visible height (no phantom scrollbar). + let containerHeight: CGFloat = 612 + let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + containerHeight: containerHeight, + rowsHeight: 0 + ) + #expect(abs(emptyAreaHeight - containerHeight) <= 0.001) + } + + // SidebarRowsFillLayout sizes the empty area from the *explicit* viewport, + // not from a layout proposal. A vertical ScrollView leaves the scroll-axis + // height unspecified, so deriving the viewport from the proposal would + // collapse the empty area to a placeholder height when the rows fit, dropping + // the blank area below the last row out of the drop/tap target. These cover + // the viewport-based path directly (the regression Codex flagged on #6033). + + @Test func emptyAreaFromViewportFillsRemainderWhenRowsFit() { + // Mirrors the observed runtime values (viewport 628, rows 421 -> 207). + let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + viewportHeight: 628, + rowsHeight: 421 + ) + #expect(abs(emptyAreaHeight - 207) <= 0.001) + // rows + empty exactly fill the viewport, so the blank area below the + // last row stays a drop/tap target and the overlay scroller stays hidden. + #expect(abs((421 + emptyAreaHeight) - 628) <= 0.001) + } + + @Test func emptyAreaFromViewportCollapsesWhenRowsOverflow() { + // Mirrors the observed overflow values (viewport 628, rows 676 -> 0). + let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + viewportHeight: 628, + rowsHeight: 676 + ) + #expect(abs(emptyAreaHeight) <= 0.001) + } + + @Test func emptyAreaFromViewportFillsEntireViewportWhenNoRows() { + let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight( + viewportHeight: 612, + rowsHeight: 0 + ) + #expect(abs(emptyAreaHeight - 612) <= 0.001) } }