Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1,945 changes: 892 additions & 1,053 deletions Sources/ContentView.swift

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Sources/Debug/SidebarLazyContractProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ struct SidebarLazyContractProbe {
var workspaceRowBodyEnd: (() -> Void)?
var groupHeaderRowBody: (() -> Void)?
var workspaceSnapshotBuild: (() -> Void)?
var workspaceRowInputProjection: (() -> Void)?
}
#endif
4 changes: 2 additions & 2 deletions Sources/Sidebar/SidebarBonsplitTabDropDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ struct SidebarBonsplitTabDropDelegate: DropDelegate {
let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID?
let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool
let syncSidebarSelectionAfterDrop: @MainActor () -> Void
@Binding var selectedTabIds: Set<UUID>
let selectTargetAfterDrop: @MainActor () -> Void

func validateDrop(info: DropInfo) -> Bool {
guard isEnabled else { return false }
Expand Down Expand Up @@ -36,7 +36,7 @@ struct SidebarBonsplitTabDropDelegate: DropDelegate {
return false
}

selectedTabIds = [targetWorkspaceId]
selectTargetAfterDrop()
syncSidebarSelectionAfterDrop()
return true
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Sidebar/SidebarBonsplitWorkspaceRowDropModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct SidebarBonsplitWorkspaceRowDropModifier: ViewModifier {
let bonsplitSourceWorkspaceId: @MainActor (UUID) -> UUID?
let moveBonsplitTabToWorkspace: @MainActor (BonsplitTabDragPayload.Transfer, UUID) -> Bool
let syncSidebarSelectionAfterDrop: @MainActor () -> Void
@Binding var selectedTabIds: Set<UUID>
let selectTargetAfterDrop: @MainActor () -> Void

func body(content: Content) -> some View {
let delegate = SidebarBonsplitTabDropDelegate(
Expand All @@ -16,7 +16,7 @@ struct SidebarBonsplitWorkspaceRowDropModifier: ViewModifier {
bonsplitSourceWorkspaceId: bonsplitSourceWorkspaceId,
moveBonsplitTabToWorkspace: moveBonsplitTabToWorkspace,
syncSidebarSelectionAfterDrop: syncSidebarSelectionAfterDrop,
selectedTabIds: $selectedTabIds
selectTargetAfterDrop: selectTargetAfterDrop
)
return content.onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: delegate)
}
Expand Down
25 changes: 25 additions & 0 deletions Sources/SidebarWorkspaceContextMenuSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Immutable context-menu inputs for one workspace row.
///
/// The live workspace graph is resolved above the sidebar's `LazyVStack` and
/// reduced to this value. Menu actions are supplied separately as closures, so
/// opening a menu cannot subscribe a lazy row to `TabManager`, `Workspace`, or
/// the notification store.
struct SidebarWorkspaceContextMenuSnapshot: Equatable {
let targetWorkspaceIds: [UUID]
let remoteTargetWorkspaceIds: [UUID]
let allRemoteTargetsConnecting: Bool
let allRemoteTargetsDisconnected: Bool
let pinState: WorkspaceActionDispatcher.PinState?
let groupMenuSnapshot: WorkspaceGroupMenuSnapshot
let canCreateEmptyGroup: Bool
let eligibleGroupTargetIds: [UUID]
let allEligibleTargetsGroupId: UUID?
let hasGroupedEligibleTarget: Bool
let todoStatusLanes: [WorkspaceTodoStatusLane]
let canMarkRead: Bool
let canMarkUnread: Bool
let hasLatestNotification: Bool
let notifications: [TerminalNotification]
}
71 changes: 71 additions & 0 deletions Sources/SidebarWorkspaceContextMenuTargetAggregate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import CmuxCore
import Foundation

/// Shared context-menu facts derived from a target workspace selection.
///
/// The sidebar computes the selected-set aggregate once above `LazyVStack`.
/// Selected rows reuse this immutable value instead of repeating remote,
/// grouping, unread, and notification aggregation during row realization.
struct SidebarWorkspaceContextMenuTargetAggregate: Equatable {
let targetWorkspaceIds: [UUID]
let remoteTargetWorkspaceIds: [UUID]
let allRemoteTargetsConnecting: Bool
let allRemoteTargetsDisconnected: Bool
let eligibleGroupTargetIds: [UUID]
let allEligibleTargetsGroupId: UUID?
let hasGroupedEligibleTarget: Bool
let canMarkRead: Bool
let canMarkUnread: Bool
let hasLatestNotification: Bool
let notifications: [TerminalNotification]

@MainActor
init(
targetWorkspaceIds: [UUID],
workspaceRowsById: [UUID: SidebarWorkspaceRowInput],
anchorWorkspaceIds: Set<UUID>,
notificationIndex: SidebarWorkspaceNotificationIndex
) {
self.targetWorkspaceIds = targetWorkspaceIds
remoteTargetWorkspaceIds = targetWorkspaceIds.filter {
workspaceRowsById[$0]?.isRemoteContextMenuEligible == true
}
allRemoteTargetsConnecting = !remoteTargetWorkspaceIds.isEmpty
&& remoteTargetWorkspaceIds.allSatisfy {
guard let state = workspaceRowsById[$0]?.remoteConnectionState else { return false }
return state == .connecting || state == .reconnecting
}
allRemoteTargetsDisconnected = !remoteTargetWorkspaceIds.isEmpty
&& remoteTargetWorkspaceIds.allSatisfy {
workspaceRowsById[$0]?.remoteConnectionState == .disconnected
}
eligibleGroupTargetIds = targetWorkspaceIds.filter {
!anchorWorkspaceIds.contains($0) && workspaceRowsById[$0] != nil
}
let eligibleGroupIds = eligibleGroupTargetIds.map { workspaceRowsById[$0]?.groupId }
allEligibleTargetsGroupId = Self.commonGroupId(eligibleGroupIds)
hasGroupedEligibleTarget = eligibleGroupTargetIds.contains {
workspaceRowsById[$0]?.groupId != nil
}
canMarkRead = targetWorkspaceIds.contains {
(workspaceRowsById[$0]?.unreadCount ?? 0) > 0
}
canMarkUnread = targetWorkspaceIds.contains {
(workspaceRowsById[$0]?.unreadCount ?? 0) == 0
}
hasLatestNotification = notificationIndex.hasNotification(
workspaceIds: targetWorkspaceIds
)
notifications = notificationIndex.contextMenuNotifications(
workspaceIds: targetWorkspaceIds
)
}

private static func commonGroupId(_ groupIds: [UUID?]) -> UUID? {
guard let first = groupIds.first,
groupIds.allSatisfy({ $0 == first }) else {
return nil
}
return first
}
}
41 changes: 41 additions & 0 deletions Sources/SidebarWorkspaceGroupRowSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import AppKit
import CmuxSettings
import Foundation

/// Immutable presentation and menu state for one workspace-group row.
///
/// Live group, notification, config, drag, and pointer models are reduced to
/// this value before the lazy-list boundary. Only action closures are bound
/// when SwiftUI realizes the row.
struct SidebarWorkspaceGroupRowSnapshot {
let groupId: UUID
let anchorWorkspaceId: UUID
let name: String
let iconSymbol: String
let tintHex: String?
let isCollapsed: Bool
let isPinned: Bool
let isAnchorActive: Bool
let memberCount: Int
let anchorUnreadCount: Int
let canMarkRead: Bool
let canMarkUnread: Bool
let hasLatestNotifications: Bool
let canMarkAllRead: Bool
let canMarkAllUnread: Bool
let shortcutDigit: Int?
let shortcutModifierSymbol: String?
let showsShortcutHint: Bool
let isPointerHovering: Bool
let shortcutHintXOffset: Double
let shortcutHintYOffset: Double
let fontScale: CGFloat
let cwdContextMenuItems: [CmuxResolvedConfigContextMenuItem]
let newWorkspacePlacement: WorkspaceGroupNewPlacement?
let rowSpacing: CGFloat
let isFirstRow: Bool
let isBeingDragged: Bool
let topDropIndicatorVisible: Bool
let bottomDropIndicatorVisible: Bool
let shouldCollectWorkspaceDropTargets: Bool
}
27 changes: 27 additions & 0 deletions Sources/SidebarWorkspaceGroupRowView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation
import SwiftUI

/// Mounts one immutable workspace-group projection below the lazy-list boundary.
struct SidebarWorkspaceGroupRowView: View {
let header: SidebarWorkspaceGroupHeaderView
let groupId: UUID
let anchorWorkspaceId: UUID
let shouldCollectWorkspaceDropTargets: Bool
let onPointerFrameChange: (CGRect) -> Void
let onPointerFrameDisappear: () -> Void

var body: some View {
header
.equatable()
.id(anchorWorkspaceId)
.accessibilityIdentifier("sidebarWorkspaceGroup.\(groupId.uuidString)")
.sidebarWorkspaceFrameAnchor(
id: anchorWorkspaceId,
isEnabled: shouldCollectWorkspaceDropTargets
)
.sidebarPointerFrameReporting(
onFrameChange: onPointerFrameChange,
onDisappear: onPointerFrameDisappear
)
}
}
76 changes: 76 additions & 0 deletions Sources/SidebarWorkspaceNotificationIndex.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

/// Immutable notification projection shared by all rows in one sidebar pass.
///
/// The parent groups and sorts the flat store snapshot once. Lazy rows perform
/// dictionary presence checks or a bounded merge of the requested workspace
/// buckets; they never rescan the full notification history.
@MainActor
struct SidebarWorkspaceNotificationIndex {
static let contextMenuNotificationLimit = 50

private let notificationsByWorkspaceId: [UUID: [TerminalNotification]]

init(notifications: [TerminalNotification]) {
var grouped: [UUID: [TerminalNotification]] = [:]
grouped.reserveCapacity(min(notifications.count, 64))
for notification in notifications {
grouped[notification.tabId, default: []].append(notification)
}
for workspaceId in Array(grouped.keys) {
guard let bucket = grouped[workspaceId] else { continue }
grouped[workspaceId] = Array(
bucket
.sorted(by: TerminalNotificationStore.notificationSortPrecedes)
.prefix(Self.contextMenuNotificationLimit)
)
}
notificationsByWorkspaceId = grouped
}

func hasNotification(workspaceId: UUID) -> Bool {
notificationsByWorkspaceId[workspaceId]?.isEmpty == false
}

func hasNotification(workspaceIds: [UUID]) -> Bool {
workspaceIds.contains { hasNotification(workspaceId: $0) }
}

func contextMenuNotifications(workspaceIds: [UUID]) -> [TerminalNotification] {
var seenWorkspaceIds = Set<UUID>()
let activeWorkspaceIds = workspaceIds.filter { workspaceId in
seenWorkspaceIds.insert(workspaceId).inserted
&& notificationsByWorkspaceId[workspaceId]?.isEmpty == false
}
guard !activeWorkspaceIds.isEmpty else { return [] }

var offsetsByWorkspaceId: [UUID: Int] = [:]
offsetsByWorkspaceId.reserveCapacity(activeWorkspaceIds.count)
var result: [TerminalNotification] = []
result.reserveCapacity(Self.contextMenuNotificationLimit)

while result.count < Self.contextMenuNotificationLimit {
var newestWorkspaceId: UUID?
var newestNotification: TerminalNotification?
for workspaceId in activeWorkspaceIds {
let offset = offsetsByWorkspaceId[workspaceId, default: 0]
guard let bucket = notificationsByWorkspaceId[workspaceId],
offset < bucket.count else {
continue
}
let candidate = bucket[offset]
if newestNotification.map({
TerminalNotificationStore.notificationSortPrecedes(candidate, $0)
}) ?? true {
newestWorkspaceId = workspaceId
newestNotification = candidate
}
}
guard let newestWorkspaceId, let newestNotification else { break }
result.append(newestNotification)
offsetsByWorkspaceId[newestWorkspaceId, default: 0] += 1
}

return result
}
}
53 changes: 33 additions & 20 deletions Sources/SidebarWorkspaceRenderItem.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
import CmuxWorkspaces
import Foundation

/// One drawable item in the workspace sidebar.
/// Stable value identity for one drawable item in the workspace sidebar.
///
/// Keep live `Workspace` / `WorkspaceGroup` references out of this value. A
/// `LazyVStack` copies and diffs its `ForEach` data while placing rows; carrying
/// the models through that path made scrolling copy the live sidebar graph and
/// blurred the ownership boundary between layout data and observed state.
/// Models are resolved from the parent-owned render context only when SwiftUI
/// asks to realize a row.
@MainActor
enum SidebarWorkspaceRenderItem {
case groupHeader(WorkspaceGroup, memberWorkspaceIds: [UUID])
case workspace(Workspace)
case groupHeader(groupId: UUID, anchorWorkspaceId: UUID)
case workspace(workspaceId: UUID)

var id: SidebarWorkspaceRenderItemID {
switch self {
case .groupHeader(let group, _):
return .group(group.id)
case .workspace(let workspace):
return .workspace(workspace.id)
case .groupHeader(let groupId, _):
return .group(groupId)
case .workspace(let workspaceId):
return .workspace(workspaceId)
}
}

var rowWorkspaceId: UUID {
switch self {
case .groupHeader(let group, _):
return group.anchorWorkspaceId
case .workspace(let workspace):
return workspace.id
case .groupHeader(_, let anchorWorkspaceId):
return anchorWorkspaceId
case .workspace(let workspaceId):
return workspaceId
}
}

Expand All @@ -30,12 +37,6 @@ enum SidebarWorkspaceRenderItem {
groupsById: [UUID: WorkspaceGroup]
) -> [SidebarWorkspaceRenderItem] {
guard !tabs.isEmpty else { return [] }
var memberWorkspaceIdsByGroupId: [UUID: [UUID]] = [:]
for tab in tabs {
if let gid = tab.groupId {
memberWorkspaceIdsByGroupId[gid, default: []].append(tab.id)
}
}
var items: [SidebarWorkspaceRenderItem] = []
items.reserveCapacity(tabs.count + groupsById.count)
var lastEmittedGroupId: UUID? = nil
Expand All @@ -49,8 +50,10 @@ enum SidebarWorkspaceRenderItem {
skipChildrenUntilNextGroup = false
if let groupId, let group = groupsById[groupId] {
if !emittedHeaders.contains(groupId) {
let memberWorkspaceIds = memberWorkspaceIdsByGroupId[groupId] ?? []
items.append(.groupHeader(group, memberWorkspaceIds: memberWorkspaceIds))
items.append(.groupHeader(
groupId: group.id,
anchorWorkspaceId: group.anchorWorkspaceId
))
emittedHeaders.insert(groupId)
collapsedByGroupId[groupId] = group.isCollapsed
}
Expand All @@ -64,9 +67,19 @@ enum SidebarWorkspaceRenderItem {
continue
}
if groupId == nil || !skipChildrenUntilNextGroup {
items.append(.workspace(tab))
items.append(.workspace(workspaceId: tab.id))
}
}
return items
}

static func memberWorkspaceIdsByGroupId(tabs: [Workspace]) -> [UUID: [UUID]] {
var result: [UUID: [UUID]] = [:]
for tab in tabs {
if let groupId = tab.groupId {
result[groupId, default: []].append(tab.id)
}
}
return result
}
}
Loading