diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6aa212979192..2c28401b5897 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12328,7 +12328,7 @@ struct VerticalTabsSidebar: View { dragState.beginDragging(tabId: tabId) return SidebarTabDragPayload.provider(for: tabId) } - let tabDropDelegateFactory: (CGFloat) -> SidebarTabDropDelegate = { [ + let tabDropDelegateFactory: (CGFloat?) -> SidebarTabDropDelegate = { [ tabId = tab.id, selectedTabIds = $selectedTabIds, lastSidebarSelectionIndex = $lastSidebarSelectionIndex @@ -13356,10 +13356,10 @@ struct TabItemView: View, Equatable { let isBeingDragged: Bool let topDropIndicatorVisible: Bool let onDragStart: () -> NSItemProvider - /// Factory invoked from `body` with the row's measured `rowHeight`. Closure + /// Factory invoked from `body` with a stable drop-hit height. Closure /// captures the parent's `dragState`, so TabItemView itself never holds an - /// `@Observable` store reference (snapshot-boundary rule). - let tabDropDelegateFactory: (CGFloat) -> SidebarTabDropDelegate + /// `@Observable` store reference or layout-driven state (snapshot-boundary rule). + let tabDropDelegateFactory: (CGFloat?) -> SidebarTabDropDelegate let contextMenuWorkspaceIds: [UUID] let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool @@ -13376,8 +13376,9 @@ struct TabItemView: View, Equatable { @State private var workspaceSnapshotStorage: SidebarWorkspaceSnapshotBuilder.Snapshot? @StateObject private var contextMenuState = SidebarTabItemContextMenuState() @State private var rowInteractionState = SidebarWorkspaceRowInteractionState() - @State private var rowHeight: CGFloat = 1 - @State private var workspaceFinderDirectoryOpenRequest: WorkspaceFinderDirectoryOpenRequest? + @State var workspaceFinderDirectoryOpenRequest: WorkspaceFinderDirectoryOpenRequest? + @State var metadataRowsExpanded = false + @State var metadataBlocksExpanded = false var isMultiSelected: Bool { selectedTabIds.contains(tab.id) @@ -13610,18 +13611,6 @@ struct TabItemView: View, Equatable { } } - private var rowHeightProbe: some View { - GeometryReader { proxy in - Color.clear - .onAppear { - rowHeight = max(proxy.size.height, 1) - } - .onChange(of: proxy.size.height) { newHeight in - rowHeight = max(newHeight, 1) - } - } - } - @ViewBuilder private var remoteWorkspaceSection: some View { let workspaceSnapshot = self.workspaceSnapshot @@ -13713,6 +13702,13 @@ struct TabItemView: View, Equatable { : nil let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle let detailVisibility = visibleAuxiliaryDetails + let titleLineLimit = settings.wrapsWorkspaceTitles + ? SidebarWorkspaceRowDropMetrics.maxWrappedTitleLines + : 1 + let dropTargetHeight = workspaceDropTargetHeight( + snapshot: workspaceSnapshot, + effectiveSubtitle: effectiveSubtitle + ) let scaledUnreadBadgeSize = 16 * fontScale let scaledCloseButtonHitSize = max(16, 16 * fontScale) let scaledCloseButtonWidth = max( @@ -13743,7 +13739,7 @@ struct TabItemView: View, Equatable { Text(workspaceSnapshot.title) .font(.system(size: scaledFontSize(12.5), weight: titleFontWeight)) .foregroundColor(activePrimaryTextColor) - .lineLimit(settings.wrapsWorkspaceTitles ? nil : 1) + .lineLimit(titleLineLimit) .truncationMode(.tail) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -13756,10 +13752,7 @@ struct TabItemView: View, Equatable { // row. (Matches the group-header plus-button pattern.) if canCloseWorkspace { Button(action: { - #if DEBUG - cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) + closeWorkspace(method: "button") }) { Image(systemName: "xmark") .font(.system(size: scaledFontSize(9), weight: .medium)) @@ -13801,6 +13794,7 @@ struct TabItemView: View, Equatable { if !metadataEntries.isEmpty { SidebarMetadataRows( entries: metadataEntries, + isExpanded: $metadataRowsExpanded, isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.95), activeSecondaryForegroundColor: activeSecondaryColor(0.65), @@ -13812,6 +13806,7 @@ struct TabItemView: View, Equatable { if !metadataBlocks.isEmpty { SidebarMetadataMarkdownBlocks( blocks: metadataBlocks, + isExpanded: $metadataBlocksExpanded, isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.8), activeSecondaryForegroundColor: activeSecondaryColor(0.65), @@ -14045,7 +14040,6 @@ struct TabItemView: View, Equatable { ) .shortcutHintVisibilityAnimation(value: showsWorkspaceShortcutHint) .padding(.horizontal, 6) - .background { rowHeightProbe } .contentShape(Rectangle()) .opacity(isBeingDragged ? 0.6 : 1) .overlay { @@ -14053,10 +14047,7 @@ struct TabItemView: View, Equatable { } .overlay { MiddleClickCapture { - #if DEBUG - cmuxDebugLog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) + closeWorkspace(method: "middleClick") } } .overlay(alignment: .top) { @@ -14070,26 +14061,13 @@ struct TabItemView: View, Equatable { refreshWorkspaceSnapshot(force: true) } .task(id: workspaceFinderDirectoryOpenRequest) { - guard let request = workspaceFinderDirectoryOpenRequest else { return } - await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) - guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } - workspaceFinderDirectoryOpenRequest = nil + await openPendingFinderDirectoryRequest() } .onReceive( tab.sidebarImmediateObservationPublisher .receive(on: RunLoop.main) ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=immediate " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - refreshWorkspaceSnapshot() + refreshWorkspaceSnapshotAfterObservation(source: "immediate") } .onReceive( tab.sidebarObservationPublisher @@ -14099,24 +14077,14 @@ struct TabItemView: View, Equatable { // row redraws once with the settled state instead of blinking. .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=debounced " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - refreshWorkspaceSnapshot() + refreshWorkspaceSnapshotAfterObservation(source: "debounced") } .onChange(of: settings) { _ in refreshWorkspaceSnapshot(force: true) } .onDrag(onDragStart) .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(rowHeight)) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(dropTargetHeight)) .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( targetWorkspaceId: tab.id, tabManager: tabManager, @@ -14152,7 +14120,7 @@ struct TabItemView: View, Equatable { } } - private func refreshWorkspaceSnapshot(force: Bool = false) { + func refreshWorkspaceSnapshot(force: Bool = false) { let nextSnapshot = makeWorkspaceSnapshot() let decision = SidebarWorkspaceSnapshotRefreshPolicy.decision( current: workspaceSnapshotStorage, @@ -15251,6 +15219,7 @@ private struct SidebarWorkspaceDescriptionText: View { .font(.system(size: 10.5 * fontScale)) .foregroundColor(foregroundColor) .multilineTextAlignment(.leading) + .lineLimit(SidebarWorkspaceRowDropMetrics.maxDescriptionLines) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("SidebarWorkspaceDescriptionText") @@ -15297,15 +15266,13 @@ private struct SidebarWorkspaceDescriptionText: View { private struct SidebarMetadataRows: View { let entries: [SidebarStatusEntry] + @Binding var isExpanded: Bool let isActive: Bool let activeForegroundColor: Color let activeSecondaryForegroundColor: Color let fontScale: CGFloat let onFocus: () -> Void - @State private var isExpanded: Bool = false - private let collapsedEntryLimit = 3 - var body: some View { VStack(alignment: .leading, spacing: 2) { ForEach(visibleEntries, id: \.key) { entry in @@ -15335,8 +15302,10 @@ private struct SidebarMetadataRows: View { } private var visibleEntries: [SidebarStatusEntry] { - guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } - return Array(entries.prefix(collapsedEntryLimit)) + guard !isExpanded, entries.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataEntryLimit else { + return entries + } + return Array(entries.prefix(SidebarWorkspaceRowDropMetrics.collapsedMetadataEntryLimit)) } private var helpText: String { @@ -15348,7 +15317,7 @@ private struct SidebarMetadataRows: View { } private var shouldShowToggle: Bool { - entries.count > collapsedEntryLimit + entries.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataEntryLimit } } @@ -15453,15 +15422,13 @@ private struct SidebarMetadataEntryRow: View { private struct SidebarMetadataMarkdownBlocks: View { let blocks: [SidebarMetadataBlock] + @Binding var isExpanded: Bool let isActive: Bool let activeForegroundColor: Color let activeSecondaryForegroundColor: Color let fontScale: CGFloat let onFocus: () -> Void - @State private var isExpanded: Bool = false - private let collapsedBlockLimit = 1 - var body: some View { VStack(alignment: .leading, spacing: 3) { ForEach(visibleBlocks, id: \.key) { block in @@ -15490,12 +15457,14 @@ private struct SidebarMetadataMarkdownBlocks: View { } private var visibleBlocks: [SidebarMetadataBlock] { - guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } - return Array(blocks.prefix(collapsedBlockLimit)) + guard !isExpanded, blocks.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataBlockLimit else { + return blocks + } + return Array(blocks.prefix(SidebarWorkspaceRowDropMetrics.collapsedMetadataBlockLimit)) } private var shouldShowToggle: Bool { - blocks.count > collapsedBlockLimit + blocks.count > SidebarWorkspaceRowDropMetrics.collapsedMetadataBlockLimit } } @@ -15523,6 +15492,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { } .font(.system(size: 10 * fontScale)) .multilineTextAlignment(.leading) + .lineLimit(SidebarWorkspaceRowDropMetrics.maxMetadataBlockLines) .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) .onTapGesture { onFocus() } @@ -16823,4 +16793,3 @@ enum SidebarPresetOption: String, CaseIterable, Identifiable { } } } - diff --git a/Sources/SidebarWorkspaceGroupHeaderMetrics.swift b/Sources/SidebarWorkspaceGroupHeaderMetrics.swift index ca746eb87e87..6ef20d892109 100644 --- a/Sources/SidebarWorkspaceGroupHeaderMetrics.swift +++ b/Sources/SidebarWorkspaceGroupHeaderMetrics.swift @@ -73,4 +73,9 @@ struct SidebarWorkspaceGroupHeaderMetrics: Equatable { var plusFontSize: CGFloat { Self.basePlusFontSize * fontScale } /// Scaled plus-button frame edge. var plusFrame: CGFloat { Self.basePlusFrame * fontScale } + /// Stable drop-hit height for the group header, without reading SwiftUI layout. + var dropTargetHeight: CGFloat { + let contentHeight = max(chevronFrame, iconFrame, plusFrame, nameFontSize + 4) + return max(24 * fontScale, contentHeight + 10) + } } diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index f52dd701d388..575dbc5f2a0f 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -57,6 +57,9 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let isBeingDragged: Bool let topDropIndicatorVisible: Bool let onDragStart: () -> NSItemProvider + /// Factory invoked from `body` with a stable drop-hit height. Closure + /// captures the parent's drag state while this LazyVStack row stays free of + /// layout-driven state writes. let tabDropDelegateFactory: (CGFloat) -> SidebarWorkspaceGroupHeaderDropDelegate let onToggleCollapsed: () -> Void let onFocusAnchor: () -> Void @@ -70,7 +73,6 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let onOpenDocs: () -> Void @State private var isHovered = false - @State private var rowHeight: CGFloat = 1 private var metrics: SidebarWorkspaceGroupHeaderMetrics { SidebarWorkspaceGroupHeaderMetrics(fontScale: fontScale) @@ -94,18 +96,6 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { return "\(shortcutModifierSymbol)\(shortcutDigit)" } - private var rowHeightProbe: some View { - GeometryReader { proxy in - Color.clear - .onAppear { - rowHeight = max(proxy.size.height, 1) - } - .onChange(of: proxy.size.height) { _, newHeight in - rowHeight = max(newHeight, 1) - } - } - } - var body: some View { HStack(spacing: 4) { Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") @@ -227,7 +217,6 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { offsetY: shortcutHintYOffset ) .padding(.horizontal, 6) - .background { rowHeightProbe } .shortcutHintVisibilityAnimation(value: showsShortcutHint) .opacity(isBeingDragged ? 0.6 : 1) .overlay(alignment: .top) { @@ -239,7 +228,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { } .onDrag(onDragStart) .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(rowHeight)) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(metrics.dropTargetHeight)) .onHover { hovering in isHovered = hovering } diff --git a/Sources/SidebarWorkspaceRowDropMetrics.swift b/Sources/SidebarWorkspaceRowDropMetrics.swift new file mode 100644 index 000000000000..7d864978e1fb --- /dev/null +++ b/Sources/SidebarWorkspaceRowDropMetrics.swift @@ -0,0 +1,208 @@ +import CoreGraphics +import CmuxSidebar + +struct SidebarWorkspaceRowDropMetrics { + static let collapsedMetadataEntryLimit = 3 + static let collapsedMetadataBlockLimit = 1 + static let maxWrappedTitleLines = 8 + static let maxDescriptionLines = 12 + static let maxMetadataBlockLines = 12 + private static let estimatedCharactersPerLine = 42 + + static func targetHeight( + fontScale: CGFloat, + titleLineCount: Int, + descriptionLineCount: Int, + hasSubtitle: Bool, + hasRemoteStatus: Bool, + metadataEntryCount: Int, + metadataEntryIsExpanded: Bool, + metadataBlockLineCounts: [Int], + hasMetadataBlockToggle: Bool, + hasLog: Bool, + hasProgress: Bool, + branchDirectoryRowCount: Int, + pullRequestRowCount: Int, + hasPorts: Bool + ) -> CGFloat { + let scale = max(fontScale, 0.5) + var height = 16 + CGFloat(max(titleLineCount, 1)) * 16 * scale + if descriptionLineCount > 0 { + height += (CGFloat(descriptionLineCount) * 13 + 2) * scale + } + if hasSubtitle { + height += 24 * scale + } + if hasRemoteStatus { + height += 18 * scale + } + height += metadataEntriesHeight( + entryCount: metadataEntryCount, + isExpanded: metadataEntryIsExpanded, + scale: scale + ) + height += metadataBlocksHeight( + lineCounts: metadataBlockLineCounts, + hasToggle: hasMetadataBlockToggle, + scale: scale + ) + if hasLog { + height += 16 * scale + } + if hasProgress { + height += 16 * scale + } + if branchDirectoryRowCount > 0 { + height += (CGFloat(branchDirectoryRowCount) * 13 + CGFloat(max(branchDirectoryRowCount - 1, 0))) * scale + } + if pullRequestRowCount > 0 { + height += (CGFloat(pullRequestRowCount) * 14 + CGFloat(max(pullRequestRowCount - 1, 0))) * scale + } + if hasPorts { + height += 16 * scale + } + return max(34, height.rounded(.up)) + } + + static func estimatedMetadataBlockLineCounts( + _ blocks: [SidebarMetadataBlock], + isExpanded: Bool + ) -> [Int] { + let visibleBlocks = isExpanded ? blocks : Array(blocks.prefix(collapsedMetadataBlockLimit)) + return visibleBlocks.map { estimatedLineCount($0.markdown) } + } + + static func estimatedTitleLineCount(_ title: String, wraps: Bool) -> Int { + wraps ? estimatedLineCount(title, maxLines: maxWrappedTitleLines) : 1 + } + + static func estimatedDescriptionLineCount(_ description: String?) -> Int { + guard let description else { return 0 } + return estimatedLineCount(description, maxLines: maxDescriptionLines) + } + + static func shouldUsePointerEdgeHeight( + wrapsWorkspaceTitles: Bool, + hasDescription: Bool, + hasMetadataBlocks: Bool + ) -> Bool { + !wrapsWorkspaceTitles && !hasDescription && !hasMetadataBlocks + } + + private static func metadataEntriesHeight( + entryCount: Int, + isExpanded: Bool, + scale: CGFloat + ) -> CGFloat { + guard entryCount > 0 else { return 0 } + let visibleCount = isExpanded ? entryCount : min(entryCount, collapsedMetadataEntryLimit) + let toggleCount = entryCount > collapsedMetadataEntryLimit ? 1 : 0 + let rowCount = visibleCount + toggleCount + return (CGFloat(rowCount) * 13 + CGFloat(max(rowCount - 1, 0)) * 2) * scale + } + + private static func metadataBlocksHeight( + lineCounts: [Int], + hasToggle: Bool, + scale: CGFloat + ) -> CGFloat { + guard !lineCounts.isEmpty || hasToggle else { return 0 } + let visibleLineCount = lineCounts.reduce(0) { $0 + max($1, 1) } + let toggleCount = hasToggle ? 1 : 0 + let blockSpacingCount = max(lineCounts.count + toggleCount - 1, 0) + return (CGFloat(visibleLineCount) * 13 + CGFloat(toggleCount) * 13 + CGFloat(blockSpacingCount) * 3) * scale + } + + private static func estimatedLineCount(_ text: String, maxLines: Int = maxMetadataBlockLines) -> Int { + let boundedText = String(text.prefix(estimatedCharactersPerLine * maxLines)) + let lines = boundedText.components(separatedBy: .newlines) + let lineCount = lines.reduce(0) { count, line in + let characterCount = line.trimmingCharacters(in: .whitespacesAndNewlines).count + return count + max(1, Int(ceil(Double(characterCount) / Double(estimatedCharactersPerLine)))) + } + return min(max(lineCount, 1), maxLines) + } + + private static func branchDirectoryRowCount( + snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + settings: SidebarTabItemSettingsSnapshot + ) -> Int { + guard settings.visibleAuxiliaryDetails.showsBranchDirectory else { return 0 } + if settings.usesVerticalBranchLayout { + return snapshot.branchDirectoryLines.reduce(0) { count, line in + if settings.stacksBranchAndDirectory { + let branchCount = line.branch == nil ? 0 : 1 + let directoryCount = line.directoryCandidates.isEmpty ? 0 : 1 + return count + max(branchCount + directoryCount, 1) + } + return count + 1 + } + } + if settings.stacksBranchAndDirectory, + (snapshot.compactGitBranchSummaryText != nil || !snapshot.compactDirectoryCandidates.isEmpty) { + let branchCount = snapshot.compactGitBranchSummaryText == nil ? 0 : 1 + let directoryCount = snapshot.compactDirectoryCandidates.isEmpty ? 0 : 1 + return max(branchCount + directoryCount, 1) + } + if !snapshot.compactBranchDirectoryCandidates.isEmpty { + return 1 + } + return 0 + } + + static func targetHeight( + snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + settings: SidebarTabItemSettingsSnapshot, + effectiveSubtitle: String?, + metadataEntryIsExpanded: Bool, + metadataBlocksAreExpanded: Bool + ) -> CGFloat { + let visibleDetails = settings.visibleAuxiliaryDetails + let metadataEntryCount = visibleDetails.showsMetadata ? snapshot.metadataEntries.count : 0 + let metadataBlockLineCounts = visibleDetails.showsMetadata + ? estimatedMetadataBlockLineCounts(snapshot.metadataBlocks, isExpanded: metadataBlocksAreExpanded) + : [] + let hasMetadataBlockToggle = visibleDetails.showsMetadata && + snapshot.metadataBlocks.count > collapsedMetadataBlockLimit + return targetHeight( + fontScale: settings.sidebarFontScale, + titleLineCount: estimatedTitleLineCount(snapshot.title, wraps: settings.wrapsWorkspaceTitles), + descriptionLineCount: estimatedDescriptionLineCount(snapshot.customDescription), + hasSubtitle: effectiveSubtitle != nil, + hasRemoteStatus: !settings.hidesAllDetails && settings.showsSSH && snapshot.remoteWorkspaceSidebarText != nil, + metadataEntryCount: metadataEntryCount, + metadataEntryIsExpanded: metadataEntryIsExpanded, + metadataBlockLineCounts: metadataBlockLineCounts, + hasMetadataBlockToggle: hasMetadataBlockToggle, + hasLog: visibleDetails.showsLog && snapshot.latestLog != nil, + hasProgress: visibleDetails.showsProgress && snapshot.progress != nil, + branchDirectoryRowCount: branchDirectoryRowCount(snapshot: snapshot, settings: settings), + pullRequestRowCount: visibleDetails.showsPullRequests ? snapshot.pullRequestRows.count : 0, + hasPorts: visibleDetails.showsPorts && !snapshot.listeningPorts.isEmpty + ) + } + + static func dropTargetHeight( + snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + settings: SidebarTabItemSettingsSnapshot, + effectiveSubtitle: String?, + metadataEntryIsExpanded: Bool, + metadataBlocksAreExpanded: Bool + ) -> CGFloat? { + let visibleDetails = settings.visibleAuxiliaryDetails + guard shouldUsePointerEdgeHeight( + wrapsWorkspaceTitles: settings.wrapsWorkspaceTitles, + hasDescription: snapshot.customDescription != nil, + hasMetadataBlocks: visibleDetails.showsMetadata && !snapshot.metadataBlocks.isEmpty + ) else { + return nil + } + return targetHeight( + snapshot: snapshot, + settings: settings, + effectiveSubtitle: effectiveSubtitle, + metadataEntryIsExpanded: metadataEntryIsExpanded, + metadataBlocksAreExpanded: metadataBlocksAreExpanded + ) + } +} diff --git a/Sources/TabItemViewDropSupport.swift b/Sources/TabItemViewDropSupport.swift new file mode 100644 index 000000000000..73f19c743461 --- /dev/null +++ b/Sources/TabItemViewDropSupport.swift @@ -0,0 +1,67 @@ +import CoreGraphics +import Foundation + +extension TabItemView { + func workspaceDropTargetHeight( + snapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, + effectiveSubtitle: String? + ) -> CGFloat? { + SidebarWorkspaceRowDropMetrics.dropTargetHeight( + snapshot: snapshot, + settings: settings, + effectiveSubtitle: effectiveSubtitle, + metadataEntryIsExpanded: metadataRowsExpanded, + metadataBlocksAreExpanded: metadataBlocksExpanded + ) + } + + func closeWorkspace(method: StaticString) { + #if DEBUG + let workspaceDebugID = tab.id.uuidString.prefix(5) + cmuxDebugLog("sidebar.close workspace=\(workspaceDebugID) method=\(method)") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + } + + func refreshWorkspaceSnapshotAfterObservation(source: StaticString) { + logWorkspaceObservationInvalidation(source: source) + refreshWorkspaceSnapshot() + } + + func openPendingFinderDirectoryRequest() async { + guard let request = workspaceFinderDirectoryOpenRequest else { return } + await WorkspaceFinderDirectoryOpener.openInFinder(request.directoryURL) + guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } + workspaceFinderDirectoryOpenRequest = nil + } + + private func logWorkspaceObservationInvalidation(source: StaticString) { + #if DEBUG + let description = tab.customDescription ?? "" + let workspaceDebugID = tab.id.uuidString.prefix(8) + let titlePreview = debugSidebarTextPreview(tab.title) + let descriptionLength = (description as NSString).length + let descriptionPreview = debugSidebarTextPreview(description) + cmuxDebugLog( + "sidebar.row.invalidate workspace=\(workspaceDebugID) " + + "source=\(source) " + + "title=\"\(titlePreview)\" " + + "descLen=\(descriptionLength) " + + "desc=\"\(descriptionPreview)\"" + ) + #endif + } + + private func debugSidebarTextPreview(_ text: String, limit: Int = 120) -> String { + let escaped = text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + if escaped.count <= limit { + return escaped + } + let prefix = escaped.prefix(limit) + return "\(prefix)..." + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 27eff27109bf..88707785603c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -658,6 +658,7 @@ F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; D7AB34300000000000000105 /* SidebarTabDropIndicatorPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; + C9A57403C9A57403C9A57403 /* SidebarWorkspaceDropMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57404C9A57404C9A57404 /* SidebarWorkspaceDropMetricsTests.swift */; }; D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; }; C9A57103C9A57103C9A57103 /* SidebarWorkspaceGroupConfigOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */; }; C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */; }; @@ -667,9 +668,10 @@ C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */; }; C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */; }; C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */; }; - C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; - D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */; }; - C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */; }; + C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; + C9A57401C9A57401C9A57401 /* SidebarWorkspaceRowDropMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57402C9A57402C9A57402 /* SidebarWorkspaceRowDropMetrics.swift */; }; + D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */; }; + C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */; }; C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */; }; 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */; }; 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; @@ -684,6 +686,7 @@ D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; + C9A57405C9A57405C9A57405 /* TabItemViewDropSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57406C9A57406C9A57406 /* TabItemViewDropSupport.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */; }; @@ -1503,6 +1506,7 @@ D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabDropIndicatorPredicateTests.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; + C9A57404C9A57404C9A57404 /* SidebarWorkspaceDropMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropMetricsTests.swift; sourceTree = ""; }; D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropPlannerTests.swift; sourceTree = ""; }; C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpener.swift; sourceTree = ""; }; C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpenerTests.swift; sourceTree = ""; }; @@ -1512,9 +1516,10 @@ C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderMetricsTests.swift; sourceTree = ""; }; C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderView.swift; sourceTree = ""; }; C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupingMetrics.swift; sourceTree = ""; }; - C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; - D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceRowHoverTracker.swift; sourceTree = ""; }; - C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceScrollLayoutTests.swift; sourceTree = ""; }; + C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; + C9A57402C9A57402C9A57402 /* SidebarWorkspaceRowDropMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowDropMetrics.swift; sourceTree = ""; }; + D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceRowHoverTracker.swift; sourceTree = ""; }; + C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceScrollLayoutTests.swift; sourceTree = ""; }; C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSelectionAnchorPolicyTests.swift; sourceTree = ""; }; D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift; sourceTree = ""; }; F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotRefreshPolicyTests.swift; sourceTree = ""; }; @@ -1527,6 +1532,7 @@ D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; + C9A57406C9A57406C9A57406 /* TabItemViewDropSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemViewDropSupport.swift; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; E3B7A400000000000000000C /* TabManager+FocusHistoryHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+FocusHistoryHosting.swift"; sourceTree = ""; }; @@ -2027,11 +2033,13 @@ C9A57302C9A57302C9A57302 /* SidebarWorkspaceGroupHeaderMetrics.swift */, C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */, D5037010000000000000002 /* RenderableSystemSymbol.swift */, - C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, - C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, - C9A57502C9A57502C9A57502 /* SidebarRowsFillLayout.swift */, - C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, - C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, + C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, + C9A57402C9A57402C9A57402 /* SidebarWorkspaceRowDropMetrics.swift */, + C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, + C9A57502C9A57502C9A57502 /* SidebarRowsFillLayout.swift */, + C9A57406C9A57406C9A57406 /* TabItemViewDropSupport.swift */, + C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, + C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */, C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */, 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */, @@ -2645,6 +2653,7 @@ C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */, C7A5090000000000000005A1 /* CmuxTopSnapshotScopeCacheTests.swift */, C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */, + C9A57404C9A57404C9A57404 /* SidebarWorkspaceDropMetricsTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */, A47E00010000000000000002 /* AuthEnvironmentTests.swift */, @@ -3486,16 +3495,18 @@ C9A57107C9A57107C9A57107 /* SidebarWorkspaceGroupDialogs.swift in Sources */, C9A57301C9A57301C9A57301 /* SidebarWorkspaceGroupHeaderMetrics.swift in Sources */, C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */, - C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, - C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, - D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */, - 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */, + C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, + C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, + C9A57401C9A57401C9A57401 /* SidebarWorkspaceRowDropMetrics.swift in Sources */, + D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */, + 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */, A5001226 /* SocketControlMode+Display.swift in Sources */, E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */, D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */, + C9A57405C9A57405C9A57405 /* TabItemViewDropSupport.swift in Sources */, D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, E3B7A400000000000000000B /* TabManager+FocusHistoryHosting.swift in Sources */, @@ -3878,6 +3889,7 @@ C9A57513C9A57513C9A57513 /* SidebarScrollViewConfiguratorTests.swift in Sources */, D7AB34300000000000000105 /* SidebarTabDropIndicatorPredicateTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, + C9A57403C9A57403C9A57403 /* SidebarWorkspaceDropMetricsTests.swift in Sources */, D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */, C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */, diff --git a/cmuxTests/SidebarWorkspaceDropMetricsTests.swift b/cmuxTests/SidebarWorkspaceDropMetricsTests.swift new file mode 100644 index 000000000000..c9331f30c15f --- /dev/null +++ b/cmuxTests/SidebarWorkspaceDropMetricsTests.swift @@ -0,0 +1,147 @@ +import CoreGraphics +import CmuxSidebar +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +struct SidebarWorkspaceDropMetricsTests { + @Test + func testWorkspaceGroupHeaderDropTargetHeightScalesWithoutLayoutMeasurement() { + #expect(SidebarWorkspaceGroupHeaderMetrics(fontScale: 1).dropTargetHeight == 28) + #expect(SidebarWorkspaceGroupHeaderMetrics(fontScale: 2).dropTargetHeight == 48) + } + + @Test + func testWorkspaceRowDropTargetHeightScalesWithContentWithoutLayoutMeasurement() { + let base = workspaceRowHeight() + let rich = workspaceRowHeight( + titleLineCount: 2, + descriptionLineCount: 2, + hasSubtitle: true, + hasRemoteStatus: true, + metadataEntryCount: 4, + metadataEntryIsExpanded: true, + metadataBlockLineCounts: [2, 3], + hasMetadataBlockToggle: true, + hasLog: true, + hasProgress: true, + branchDirectoryRowCount: 2, + pullRequestRowCount: 2, + hasPorts: true + ) + let scaled = workspaceRowHeight(fontScale: 1.5) + + #expect(base == 34) + #expect(rich > base) + #expect(scaled > base) + } + + @Test + func testWorkspaceRowDropTargetHeightTracksExpandedMetadataRows() { + let collapsed = workspaceRowHeight(metadataEntryCount: 6, metadataEntryIsExpanded: false) + let expanded = workspaceRowHeight(metadataEntryCount: 6, metadataEntryIsExpanded: true) + + #expect(expanded > collapsed) + } + + @Test + func testWorkspaceRowDropTargetHeightTracksExpandedMetadataBlocks() { + let collapsed = workspaceRowHeight( + metadataBlockLineCounts: [1], + hasMetadataBlockToggle: true + ) + let expanded = workspaceRowHeight( + metadataBlockLineCounts: [1, 4, 2], + hasMetadataBlockToggle: true + ) + + #expect(expanded > collapsed) + } + + @Test + func testMetadataBlockLineEstimationOnlyScansVisibleCollapsedBlock() { + let blocks = [ + SidebarMetadataBlock(key: "visible", markdown: "one", priority: 0, timestamp: Date()), + SidebarMetadataBlock( + key: "hidden", + markdown: String(repeating: "hidden\n", count: 100), + priority: 0, + timestamp: Date() + ), + ] + + #expect(SidebarWorkspaceRowDropMetrics.estimatedMetadataBlockLineCounts(blocks, isExpanded: false) == [1]) + #expect(SidebarWorkspaceRowDropMetrics.estimatedMetadataBlockLineCounts(blocks, isExpanded: true) == [1, 12]) + } + + @Test + func testWorkspaceRowDropTargetHeightUsesLineAwareTitleAndDescriptionEstimates() { + let short = workspaceRowHeight(titleLineCount: 1, descriptionLineCount: 1) + let tall = workspaceRowHeight(titleLineCount: 4, descriptionLineCount: 5) + + #expect(tall > short) + } + + @Test + func testPointerEdgeHeightIsOnlyUsedForWidthIndependentRows() { + #expect(SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( + wrapsWorkspaceTitles: false, + hasDescription: false, + hasMetadataBlocks: false + )) + #expect(!SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( + wrapsWorkspaceTitles: true, + hasDescription: false, + hasMetadataBlocks: false + )) + #expect(!SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( + wrapsWorkspaceTitles: false, + hasDescription: true, + hasMetadataBlocks: false + )) + #expect(!SidebarWorkspaceRowDropMetrics.shouldUsePointerEdgeHeight( + wrapsWorkspaceTitles: false, + hasDescription: false, + hasMetadataBlocks: true + )) + } + + private func workspaceRowHeight( + fontScale: CGFloat = 1, + titleLineCount: Int = 1, + descriptionLineCount: Int = 0, + hasSubtitle: Bool = false, + hasRemoteStatus: Bool = false, + metadataEntryCount: Int = 0, + metadataEntryIsExpanded: Bool = false, + metadataBlockLineCounts: [Int] = [], + hasMetadataBlockToggle: Bool = false, + hasLog: Bool = false, + hasProgress: Bool = false, + branchDirectoryRowCount: Int = 0, + pullRequestRowCount: Int = 0, + hasPorts: Bool = false + ) -> CGFloat { + SidebarWorkspaceRowDropMetrics.targetHeight( + fontScale: fontScale, + titleLineCount: titleLineCount, + descriptionLineCount: descriptionLineCount, + hasSubtitle: hasSubtitle, + hasRemoteStatus: hasRemoteStatus, + metadataEntryCount: metadataEntryCount, + metadataEntryIsExpanded: metadataEntryIsExpanded, + metadataBlockLineCounts: metadataBlockLineCounts, + hasMetadataBlockToggle: hasMetadataBlockToggle, + hasLog: hasLog, + hasProgress: hasProgress, + branchDirectoryRowCount: branchDirectoryRowCount, + pullRequestRowCount: pullRequestRowCount, + hasPorts: hasPorts + ) + } +}